diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index b7d70ef1..1d7325b9 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -31,6 +31,101 @@ include: - template/automagic.gitlab-ci.yaml + +Docker Container: + extends: .build_docker_container + resource_group: build + needs: [] + script: + - update-binfmts --display + - | + + echo "[DEBUG] building multiarch/specified arch image"; + + docker buildx build --platform=$DOCKER_IMAGE_BUILD_TARGET_PLATFORMS . \ + --label org.opencontainers.image.created="$(date '+%Y-%m-%d %H:%M:%S%:z')" \ + --label org.opencontainers.image.documentation="$CI_PROJECT_URL" \ + --label org.opencontainers.image.source="$CI_PROJECT_URL" \ + --label org.opencontainers.image.revision="$CI_COMMIT_SHA" \ + --push \ + --build-arg CI_PROJECT_URL=$CI_PROJECT_URL \ + --build-arg CI_COMMIT_SHA=$CI_COMMIT_SHA \ + --build-arg CI_COMMIT_TAG=$CI_COMMIT_TAG \ + --file $DOCKER_DOCKERFILE \ + --tag $DOCKER_IMAGE_BUILD_REGISTRY/$DOCKER_IMAGE_BUILD_NAME:$DOCKER_IMAGE_BUILD_TAG; + + docker buildx imagetools inspect $DOCKER_IMAGE_BUILD_REGISTRY/$DOCKER_IMAGE_BUILD_NAME:$DOCKER_IMAGE_BUILD_TAG; + + # during docker multi platform build there are >=3 additional unknown images added to gitlab container registry. cleanup + + DOCKER_MULTI_ARCH_IMAGES=$(docker buildx imagetools inspect "$DOCKER_IMAGE_BUILD_REGISTRY/$DOCKER_IMAGE_BUILD_NAME:$DOCKER_IMAGE_BUILD_TAG" --format "{{ range .Manifest.Manifests }}{{ if ne (print .Platform) \"&{unknown unknown [] }\" }}$DOCKER_IMAGE_BUILD_REGISTRY/$DOCKER_IMAGE_BUILD_NAME:$DOCKER_IMAGE_BUILD_TAG@{{ println .Digest }}{{end}} {{end}}"); + + docker buildx imagetools create $DOCKER_MULTI_ARCH_IMAGES --tag $DOCKER_IMAGE_BUILD_REGISTRY/$DOCKER_IMAGE_BUILD_NAME:$DOCKER_IMAGE_BUILD_TAG; + + docker buildx imagetools inspect $DOCKER_IMAGE_BUILD_REGISTRY/$DOCKER_IMAGE_BUILD_NAME:$DOCKER_IMAGE_BUILD_TAG; + + rules: # rules manually synced from docker/publish.gitlab-ci.yaml removing git tag + + # - if: # condition_master_branch_push + # $CI_COMMIT_BRANCH == "master" && + # $CI_PIPELINE_SOURCE == "push" + # exists: + # - '{dockerfile,dockerfile.j2}' + # when: always + + - if: # condition_not_master_or_dev_push + $CI_COMMIT_BRANCH != "master" && + $CI_COMMIT_BRANCH != "development" && + $CI_PIPELINE_SOURCE == "push" + exists: + - '{dockerfile,dockerfile.j2}' + changes: + paths: + - '{dockerfile,dockerfile.j2,includes/**/*}' + compare_to: 'development' + when: always + + + - if: # condition_dev_branch_push + ( + $CI_COMMIT_BRANCH == "development" + || + $CI_COMMIT_BRANCH == "master" + ) + && + $CI_PIPELINE_SOURCE == "push" + exists: + - '{dockerfile,dockerfile.j2}' + allow_failure: true + when: on_success + + - when: never + + +Docker.Hub.Branch.Publish: + extends: .publish-docker-hub + needs: [ "Docker Container" ] + resource_group: build + rules: # rules manually synced from docker/publish.gitlab-ci.yaml removing git tag + + # - if: # condition_master_branch_push + # $CI_COMMIT_BRANCH == "master" && + # $CI_PIPELINE_SOURCE == "push" + # exists: + # - '{dockerfile,dockerfile.j2}' + # when: always + + - if: # condition_dev_branch_push + $CI_COMMIT_BRANCH == "development" && + $CI_PIPELINE_SOURCE == "push" + exists: + - '{dockerfile,dockerfile.j2}' + allow_failure: true + when: on_success + + - when: never + + Website.Submodule.Deploy: extends: .submodule_update_trigger variables: diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 9f68f78a..9ba499f3 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,6 +1,7 @@ { "recommendations": [ "ms-python.python", + "ms-python.debugpy", "njpwerner.autodocstring", "streetsidesoftware.code-spell-checker-australian-english", "streetsidesoftware.code-spell-checker", diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..a5269777 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Debug: Django", + "type": "debugpy", + "request": "launch", + "args": [ + "runserver", + "0.0.0.0:8002" + ], + "django": true, + "autoStartBrowser": false, + "program": "${workspaceFolder}/app/manage.py" + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index dd6b128f..717ff9af 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,4 +4,9 @@ "cSpell.enableFiletypes": [ "!python" ], + "python.testing.pytestArgs": [ + "app" + ], + "python.testing.unittestEnabled": true, + "python.testing.pytestEnabled": true, } \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 393621f6..d291225c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,7 +35,46 @@ python3 manage.py makemigrations --noinput 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. -## Running Tests + +## Tests + +!!! danger "Requirement" + All models **are** to have tests written for them, Including testing between dependent models. + +To ensure consistency and reliability of this application, tests are to be written. Each test is to test one item ONLY and no more. Each module is to contain a tests directory of the model being tested with a single file for grouping of what is being tested. for items that depend upon a parent model, the test file is to be within the child-models test directory named with format `test___` + +_example structure for the device model that relies upon access app model organization, core app model history and model notes._ + +``` text + +├── tests +│   ├── device +│   │   ├── test_device_access_organization.py +│   │   ├── test_device_api_permission.py +│   │   ├── test_device_core_history.py +│   │   ├── test_device_core_notes.py +│   │   ├── test_device_permission.py +│   │   └── test_device.py + + +``` + +Items to test include but are not limited to: + +- CRUD permissions admin site + +- CRUD permissions api site + +- CRUD permissions main site + +- can only access organization object + +- can access global object (still to require model CRUD permission) + +- parent models + + +### Running Tests test can be run by running the following: diff --git a/README.md b/README.md new file mode 100644 index 00000000..00ecbe6e --- /dev/null +++ b/README.md @@ -0,0 +1,8 @@ + +![GitLab Bugs](https://img.shields.io/gitlab/issues/open/nofusscomputing%2Fprojects%2Fdjango_template?labels=type%3A%3Abug&style=plastic&logo=gitlab&label=Bug%20Fixes%20Required&color=fc6d26) + + +![GitLab Issues](https://img.shields.io/gitlab/issues/open/nofusscomputing%2Fprojects%2Fdjango_template?style=plastic&logo=gitlab&label=Issues&color=fc6d26) + +![Docker Pulls](https://img.shields.io/docker/pulls/nofusscomputing/django-template?style=plastic&logo=docker&color=0db7ed) + diff --git a/app/access/migrations/0002_alter_team_organization.py b/app/access/migrations/0002_alter_team_organization.py new file mode 100644 index 00000000..fc091978 --- /dev/null +++ b/app/access/migrations/0002_alter_team_organization.py @@ -0,0 +1,19 @@ +# Generated by Django 5.0.6 on 2024-05-23 10:37 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('access', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='team', + name='organization', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization'), + ), + ] diff --git a/app/access/mixin.py b/app/access/mixin.py index c0e25d91..4efd3e80 100644 --- a/app/access/mixin.py +++ b/app/access/mixin.py @@ -1,5 +1,5 @@ -from django.contrib.auth.mixins import PermissionRequiredMixin +from django.contrib.auth.mixins import AccessMixin, PermissionRequiredMixin from django.contrib.auth.models import Group from django.core.exceptions import PermissionDenied from django.utils.functional import cached_property @@ -16,18 +16,28 @@ class OrganizationMixin(): def object_organization(self) -> int: - if 'access.models.Organization' in str(type(self.get_object())): + id = None - id = self.get_object().id + try: - else: + self.get_queryset() - id = self.get_object().organization.id + self.get_object() + + id = self.get_object().get_organization().id if self.get_object().is_global: id = 0 + except AttributeError: + + if self.request.method == 'POST': + + if self.request.POST.get("organization", ""): + + id = int(self.request.POST.get("organization", "")) + return id @@ -124,7 +134,7 @@ class OrganizationMixin(): assembled_permission = str(permission["content_type__app_label"]) + '.' + str(permission["codename"]) - if assembled_permission in self.get_permission_required()[0] and (team['organization_id'] == self.object_organization() or self.object_organization() == 0): + if assembled_permission in self.get_permission_required() and (team['organization_id'] == self.object_organization() or self.object_organization() == 0): return True @@ -132,13 +142,16 @@ class OrganizationMixin(): -class OrganizationPermission(OrganizationMixin): +class OrganizationPermission(AccessMixin, OrganizationMixin): """checking organization membership""" def dispatch(self, request, *args, **kwargs): self.request = request + if not request.user.is_authenticated: + return self.handle_no_permission() + if hasattr(self, 'get_object'): if not self.has_permission() and not request.user.is_superuser: diff --git a/app/access/models.py b/app/access/models.py index 26f37cb4..4be64a50 100644 --- a/app/access/models.py +++ b/app/access/models.py @@ -1,11 +1,14 @@ from django.conf import settings from django.db import models -from django.contrib.auth.models import Group, Permission +from django.contrib.auth.models import User, Group, Permission from .fields import * +from core.middleware.get_request import get_request +from core.mixin.history_save import SaveHistory -class Organization(models.Model): + +class Organization(SaveHistory): class Meta: verbose_name_plural = "Organizations" @@ -40,6 +43,10 @@ class Organization(models.Model): modified = AutoLastModifiedField() + def get_organization(self): + return self + + class TenancyObject(models.Model): class Meta: @@ -48,6 +55,8 @@ class TenancyObject(models.Model): organization = models.ForeignKey( Organization, on_delete=models.CASCADE, + blank = False, + null = True, ) is_global = models.BooleanField( @@ -55,8 +64,11 @@ class TenancyObject(models.Model): blank = False ) + def get_organization(self) -> Organization: + return self.organization -class Team(Group, TenancyObject): + +class Team(Group, TenancyObject, SaveHistory): class Meta: # proxy = True verbose_name_plural = "Teams" @@ -67,7 +79,6 @@ class Team(Group, TenancyObject): def save(self, *args, **kwargs): - self.name = self.organization.name.lower().replace(' ', '_') + '_' + self.team_name.lower().replace(' ', '_') super().save(*args, **kwargs) @@ -86,7 +97,7 @@ class Team(Group, TenancyObject): modified = AutoLastModifiedField() -class TeamUsers(models.Model): +class TeamUsers(SaveHistory): class Meta: # proxy = True @@ -118,3 +129,40 @@ class TeamUsers(models.Model): created = AutoCreatedField() modified = AutoLastModifiedField() + + + def delete(self, using=None, keep_parents=False): + """ Delete Team + + Overrides, post-action + As teams are an extension of Groups, remove the user to the team. + """ + + super().delete(using=using, keep_parents=keep_parents) + + group = Group.objects.get(pk=self.team.id) + + user = User.objects.get(pk=self.user_id) + + user.groups.remove(group) + + + def get_organization(self) -> Organization: + return self.team.organization + + + def save(self, *args, **kwargs): + """ Save Team + + Overrides, post-action + As teams are an extension of groups, add the user to the matching group. + """ + + super().save(*args, **kwargs) + + group = Group.objects.get(pk=self.team.id) + + user = User.objects.get(pk=self.user_id) + + user.groups.add(group) + diff --git a/app/access/templates/access/organization.html.j2 b/app/access/templates/access/organization.html.j2 index 022b5099..d7e10b64 100644 --- a/app/access/templates/access/organization.html.j2 +++ b/app/access/templates/access/organization.html.j2 @@ -10,6 +10,7 @@
+{% include 'icons/issue_link.html.j2' with issue=13 %}
@@ -25,7 +26,7 @@ {% for field in teams %} - {{ field.team_name }} + {{ field.team_name }} {{ field.created }} {{ field.modified }} diff --git a/app/access/templates/access/team.html.j2 b/app/access/templates/access/team.html.j2 index a7a0540a..0a2295ed 100644 --- a/app/access/templates/access/team.html.j2 +++ b/app/access/templates/access/team.html.j2 @@ -22,13 +22,14 @@ + {% include 'icons/issue_link.html.j2' with issue=13 %}

- + Organization: +# return Organization.objects.create( +# name='Test org', +# ) + + +# @pytest.fixture +# def team() -> Team: +# return Team.objects.create( +# name='Team one', +# organization = Organization.objects.create( +# name='Test org', +# ), +# ) + + +@pytest.mark.skip(reason="to be written") +def test_authorization_user_permission_add_team_manager(user): + """Ensure user can be added when user is team manager + + user requires permissions team view and user add + """ + pass + + +@pytest.mark.skip(reason="to be written") +def test_authorization_user_permission_delete_team_manager(user): + """Ensure user can be deleted when user is team manager + + user requires permissions team view and user delete + """ + pass + + +# is_superuser to be able to view, add, change, delete for all objects + diff --git a/app/access/tests/team_user/test_team_user_permission.py b/app/access/tests/team_user/test_team_user_permission.py new file mode 100644 index 00000000..57739d7c --- /dev/null +++ b/app/access/tests/team_user/test_team_user_permission.py @@ -0,0 +1,538 @@ +# 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 + + + +class TeamUserPermissions(TestCase): + + model = TeamUsers + + model_name = 'teamusers' + app_label = 'access' + + @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.test_team = Team.objects.create( + team_name = 'test_team', + organization = organization, + ) + + self.team_user = User.objects.create_user(username="test_self.team_user", password="password") + + self.item = self.model.objects.create( + team = self.test_team, + user = self.team_user + ) + + view_permissions = Permission.objects.get( + codename = 'view_' + self.model_name, + content_type = ContentType.objects.get( + app_label = self.app_label, + model = self.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_name, + content_type = ContentType.objects.get( + app_label = self.app_label, + model = self.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_name, + content_type = ContentType.objects.get( + app_label = self.app_label, + model = self.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_name, + content_type = ContentType.objects.get( + app_label = self.app_label, + model = self.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 + ) + + + + @pytest.mark.skip(reason="feature does not exist") + def test_team_user_auth_view_user_anon_denied(self): + """ Check correct permission for view + + Attempt to view as anon user + """ + + client = Client() + url = reverse('Access:_team_user_view', kwargs={'pk': self.item.id}) + + response = client.get(url) + + assert response.status_code == 302 and response.url.startswith('/account/login') + + + @pytest.mark.skip(reason="feature does not exist") + def test_team_user_auth_view_no_permission_denied(self): + """ Check correct permission for view + + Attempt to view with user missing permission + """ + + client = Client() + url = reverse('Access:_team_user_view', kwargs={'pk': self.item.id}) + + + client.force_login(self.no_permissions_user) + response = client.get(url) + + assert response.status_code == 403 + + + @pytest.mark.skip(reason="feature does not exist") + def test_team_user_auth_view_different_organizaiton_denied(self): + """ Check correct permission for view + + Attempt to view with user from different organization + """ + + client = Client() + url = reverse('Access:_team_user_view', kwargs={'pk': self.item.id}) + + + client.force_login(self.different_organization_user) + response = client.get(url) + + assert response.status_code == 403 + + + @pytest.mark.skip(reason="feature does not exist") + def test_team_user_auth_view_has_permission(self): + """ Check correct permission for view + + Attempt to view as user with view permission + """ + + client = Client() + url = reverse('Access:_team_user_view', kwargs={'pk': self.item.id}) + + + client.force_login(self.view_user) + response = client.get(url) + + assert response.status_code == 200 + + + + def test_team_user_auth_add_user_anon_denied(self): + """ Check correct permission for add + + Attempt to add as anon user + """ + + client = Client() + url = reverse('Access:_team_user_add', kwargs={'organization_id': self.organization.id, 'pk': self.item.id}) + + + response = client.put(url, data={'device': 'device'}) + + assert response.status_code == 302 and response.url.startswith('/account/login') + + # @pytest.mark.skip(reason="ToDO: figure out why fails") + def test_team_user_auth_add_no_permission_denied(self): + """ Check correct permission for add + + Attempt to add as user with no permissions + """ + + client = Client() + url = reverse('Access:_team_user_add', kwargs={'organization_id': self.organization.id, 'pk': self.item.id}) + + + client.force_login(self.no_permissions_user) + response = client.post(url, data={'device': 'device'}) + + assert response.status_code == 403 + + + # @pytest.mark.skip(reason="ToDO: figure out why fails") + def test_team_user_auth_add_different_organization_denied(self): + """ Check correct permission for add + + attempt to add as user from different organization + """ + + client = Client() + url = reverse('Access:_team_user_add', kwargs={'organization_id': self.organization.id, 'pk': self.item.id}) + + + client.force_login(self.different_organization_user) + response = client.post(url, data={'name': 'device', 'organization': self.organization.id}) + + assert response.status_code == 403 + + + def test_team_user_auth_add_permission_view_denied(self): + """ Check correct permission for add + + Attempt to add a user with view permission + """ + + client = Client() + url = reverse('Access:_team_user_add', kwargs={'organization_id': self.organization.id, 'pk': self.item.id}) + + + client.force_login(self.view_user) + response = client.post(url, data={'device': 'device'}) + + assert response.status_code == 403 + + + def test_team_user_auth_add_has_permission(self): + """ Check correct permission for add + + Attempt to add as user with no permission + """ + + client = Client() + url = reverse('Access:_team_user_add', kwargs={'organization_id': self.organization.id, 'pk': self.item.id}) + + + client.force_login(self.add_user) + response = client.post(url, data={'device': 'device', 'organization': self.organization.id}) + + assert response.status_code == 200 + + + + @pytest.mark.skip(reason="feature does not exist") + def test_team_user_auth_change_user_anon_denied(self): + """ Check correct permission for change + + Attempt to change as anon + """ + + client = Client() + url = reverse('Access:_team_user_view', kwargs={'pk': self.item.id}) + + + response = client.patch(url, data={'device': 'device'}) + + assert response.status_code == 302 and response.url.startswith('/account/login') + + + @pytest.mark.skip(reason="feature does not exist") + def test_team_user_auth_change_no_permission_denied(self): + """ Ensure permission view cant make change + + Attempt to make change as user without permissions + """ + + client = Client() + url = reverse('Access:_team_user_view', kwargs={'pk': self.item.id}) + + + client.force_login(self.no_permissions_user) + response = client.post(url, data={'device': 'device'}) + + assert response.status_code == 403 + + + @pytest.mark.skip(reason="feature does not exist") + def test_team_user_auth_change_different_organization_denied(self): + """ Ensure permission view cant make change + + Attempt to make change as user from different organization + """ + + client = Client() + url = reverse('Access:_team_user_view', kwargs={'pk': self.item.id}) + + + client.force_login(self.different_organization_user) + response = client.post(url, data={'device': 'device'}) + + assert response.status_code == 403 + + + @pytest.mark.skip(reason="feature does not exist") + def test_team_user_auth_change_permission_view_denied(self): + """ Ensure permission view cant make change + + Attempt to make change as user with view permission + """ + + client = Client() + url = reverse('Access:_team_user_view', kwargs={'pk': self.item.id}) + + + client.force_login(self.view_user) + response = client.post(url, data={'device': 'device'}) + + assert response.status_code == 403 + + + @pytest.mark.skip(reason="feature does not exist") + def test_team_user_auth_change_permission_add_denied(self): + """ Ensure permission view cant make change + + Attempt to make change as user with add permission + """ + + client = Client() + url = reverse('Access:_team_user_view', kwargs={'pk': self.item.id}) + + + client.force_login(self.add_user) + response = client.post(url, data={'device': 'device'}) + + assert response.status_code == 403 + + + @pytest.mark.skip(reason="feature does not exist") + def test_team_user_auth_change_has_permission(self): + """ Check correct permission for change + + Make change with user who has change permission + """ + + client = Client() + url = reverse('Access:_team_user_view', kwargs={'pk': self.item.id}) + + + client.force_login(self.change_user) + response = client.post(url, data={'device': 'device'}) + + assert response.status_code == 200 + + + + def test_team_user_auth_delete_user_anon_denied(self): + """ Check correct permission for delete + + Attempt to delete item as anon user + """ + + client = Client() + url = reverse('Access:_team_user_delete', kwargs={'organization_id': self.organization.id, 'team_id': self.item.team.id, 'pk': self.item.id}) + + + response = client.delete(url, data={'device': 'device'}) + + assert response.status_code == 302 and response.url.startswith('/account/login') + + + def test_team_user_auth_delete_no_permission_denied(self): + """ Check correct permission for delete + + Attempt to delete as user with no permissons + """ + + client = Client() + url = reverse('Access:_team_user_delete', kwargs={'organization_id': self.organization.id, 'team_id': self.item.team.id, 'pk': self.item.id}) + + + client.force_login(self.no_permissions_user) + response = client.delete(url, data={'device': 'device'}) + + assert response.status_code == 403 + + + def test_team_user_auth_delete_different_organization_denied(self): + """ Check correct permission for delete + + Attempt to delete as user from different organization + """ + + client = Client() + url = reverse('Access:_team_user_delete', kwargs={'organization_id': self.organization.id, 'team_id': self.item.team.id, 'pk': self.item.id}) + + + client.force_login(self.different_organization_user) + response = client.delete(url, data={'device': 'device'}) + + assert response.status_code == 403 + + + def test_team_user_auth_delete_permission_view_denied(self): + """ Check correct permission for delete + + Attempt to delete as user with veiw permission only + """ + + client = Client() + url = reverse('Access:_team_user_delete', kwargs={'organization_id': self.organization.id, 'team_id': self.item.team.id, 'pk': self.item.id}) + + + client.force_login(self.view_user) + response = client.delete(url, data={'device': 'device'}) + + assert response.status_code == 403 + + + def test_team_user_auth_delete_permission_add_denied(self): + """ Check correct permission for delete + + Attempt to delete as user with add permission only + """ + + client = Client() + url = reverse('Access:_team_user_delete', kwargs={'organization_id': self.organization.id, 'team_id': self.item.team.id, 'pk': self.item.id}) + + + client.force_login(self.add_user) + response = client.delete(url, data={'device': 'device'}) + + assert response.status_code == 403 + + + def test_team_user_auth_delete_permission_change_denied(self): + """ Check correct permission for delete + + Attempt to delete as user with change permission only + """ + + client = Client() + url = reverse('Access:_team_user_delete', kwargs={'organization_id': self.organization.id, 'team_id': self.item.team.id, 'pk': self.item.id}) + + + client.force_login(self.change_user) + response = client.delete(url, data={'device': 'device'}) + + assert response.status_code == 403 + + + def test_team_user_auth_delete_has_permission(self): + """ Check correct permission for delete + + Delete item as user with delete permission + """ + + client = Client() + url = reverse('Access:_team_user_delete', + kwargs={ + 'organization_id': self.organization.id, + 'team_id': self.test_team.id, + 'pk': self.item.id + } + ) + + + client.force_login(self.delete_user) + response = client.delete(url, data={'device': 'device'}) + + assert response.status_code == 302 and response.url == reverse('Access:_team_view', + kwargs={ + 'organization_id': self.organization.id, + 'pk': self.test_team.id + } + ) diff --git a/app/access/tests/team_user/test_team_user_permission_api.py b/app/access/tests/team_user/test_team_user_permission_api.py new file mode 100644 index 00000000..1ae35338 --- /dev/null +++ b/app/access/tests/team_user/test_team_user_permission_api.py @@ -0,0 +1,32 @@ +# from django.conf import settings +# from django.shortcuts import reverse +from django.test import TestCase, Client + +import pytest +import unittest +import requests + + + +@pytest.mark.skip(reason="to be written") +def test_team_user_auth_view_api(user): + """ Check correct permission for view """ + pass + + +@pytest.mark.skip(reason="to be written") +def test_team_user_auth_add_api(user): + """ Check correct permission for add """ + pass + + +@pytest.mark.skip(reason="to be written") +def test_team_user_auth_change_api(user): + """ Check correct permission for change """ + pass + + +@pytest.mark.skip(reason="to be written") +def test_team_user_auth_delete_api(user): + """ Check correct permission for delete """ + pass diff --git a/app/access/tests/test_auth_app_structure.py b/app/access/tests/test_auth_app_structure.py deleted file mode 100644 index 7650f253..00000000 --- a/app/access/tests/test_auth_app_structure.py +++ /dev/null @@ -1,122 +0,0 @@ -from django.conf import settings -from django.shortcuts import reverse -from django.test import TestCase, Client - -import pytest -import unittest -import requests - -from django.contrib.auth import get_user_model -from django.core.exceptions import ValidationError -from access.models import Organization - -# class Test_app_structure_auth(unittest.TestCase): -User = get_user_model() - - -@pytest.fixture -def user() -> User: - return User.objects.create_user(username="testuser", password="testpassword") - - -@pytest.fixture -def organization() -> Organization: - return Organization.objects.create( - name='Test org', - ) - - -@pytest.mark.django_db -def test_require_login_organizations(): - """Some docstring defining what the test is checking.""" - client = Client() - url = reverse('Access:Organizations') - - response = client.get(url) - - assert response.status_code == 302 - -@pytest.mark.skip(reason="to be re-written for orgmixin") -@pytest.mark.django_db -def test_require_login_organization_pk(organization): - """Ensure login is required to view an organization""" - client = Client() - url = reverse('Access:_organization', kwargs={'organization_id': 1}) - - response = client.get(url) - - assert response.status_code == 302 - - -@pytest.mark.django_db -def test_login_view_organizations_no_permission(user): - """Some docstring defining what the test is checking.""" - client = Client() - url = reverse('Access:Organizations') - client.force_login(user) - - response = client.get(url) - - assert response.status_code == 403 - - -@pytest.mark.skip(reason="to be written") -def test_organizations_permission_change(user): - """ensure user with permission can change organization - - Args: - user (_type_): _description_ - """ - pass - - -@pytest.mark.skip(reason="to be written") -def test_organizations_permission_delete_denied(user): - """ensure non-admin user cant delete organization - - Args: - user (_type_): _description_ - """ - pass - - -@pytest.mark.skip(reason="to be written") -def test_team_permission_add_in_org(user): - """ensure user with add permission to an organization can add team - - Args: - user (_type_): _description_ - """ - pass - - -@pytest.mark.skip(reason="to be written") -def test_team_permission_add_not_in_org(user): - """ensure user with add permission to an organization can add team - - Args: - user (_type_): _description_ - """ - pass - - -@pytest.mark.skip(reason="to be written") -def test_team_permission_change(user): - """ensure user can change a team - - Args: - user (_type_): _description_ - """ - pass - - -@pytest.mark.skip(reason="to be written") -def test_team_permission_delete(user): - """ensure user can delete a team - - Args: - user (_type_): _description_ - """ - pass - - diff --git a/app/access/tests/test_model_app_structure.py b/app/access/tests/test_model_app_structure.py deleted file mode 100644 index 7763cd9e..00000000 --- a/app/access/tests/test_model_app_structure.py +++ /dev/null @@ -1,205 +0,0 @@ -from django.test import TestCase - -import pytest -import requests -import unittest - -from django.contrib.auth import get_user_model -from django.core.exceptions import ValidationError -from access.models import Organization, Team - - -# @pytest.fixture -# def organization() -> Organization: -# return Organization.objects.create( -# name='Test org', -# ) - - -# @pytest.fixture -# def team() -> Team: -# return Team.objects.create( -# name='Team one', -# organization = Organization.objects.create( -# name='Test org', -# ), -# ) - - -###################################################################### -# SoF for loop for tests -# for test in ['organization','team', 'users'] -# -# permissions for each item as per the action plus view of the parent item -###################################################################### -@pytest.mark.skip(reason="to be written") -def test_authorization_organization_view(user): - """User of organization can view - - user requires permissions organization view - """ - pass - - -@pytest.mark.skip(reason="to be written") -def test_authorization_organization_no_view(user): - """User not part of organization cant view - - user requires permissions organization view - """ - pass - - -################################################################### - -@pytest.mark.skip(reason="to be written") -def test_authorization_team_permission_view(user): - """ Ensure team can be viewed when user has correct permissions - - user requires permissions organization view and team view - """ - pass - -@pytest.mark.skip(reason="to be written") -def test_authorization_team_permission_no_view(user): - """ Ensure team can't be viewed when user is missing permissions - - user requires permissions organization view and team view - """ - pass - - - -@pytest.mark.skip(reason="to be written") -def test_authorization_team_permission_add(user): - """Ensure team can be added when user has correct permissions - - user requires permissions organization view and team add - """ - pass - -@pytest.mark.skip(reason="to be written") -def test_authorization_team_permission_no_view(user): - """Ensure team can't be added when user is missing permissions - - user requires permissions organization view and team add - """ - pass - - -@pytest.mark.skip(reason="to be written") -def test_authorization_team_permission_change(user): - """Ensure team can be changed when user has correct permissions - - user requires permissions organization view and team change - """ - pass - -@pytest.mark.skip(reason="to be written") -def test_authorization_team_permission_no_change(user): - """Ensure team can't be change when user is missing permissions - - user requires permissions organization view and team change - """ - pass - - - -@pytest.mark.skip(reason="to be written") -def test_authorization_team_permission_delete(user): - """Ensure team can be deleted when user has correct permissions - - user requires permissions organization view and team delete - """ - pass - -@pytest.mark.skip(reason="to be written") -def test_authorization_team_permission_no_delete(user): - """Ensure team can't be deleted when user is missing permissions - - user requires permissions organization view and team delete - """ - pass - - - - -################################################################### - - - -@pytest.mark.skip(reason="to be written") -def test_authorization_user_permission_add(user): - """Ensure user can be added when user has correct permissions - - user requires permissions team view and user add - """ - pass - -@pytest.mark.skip(reason="to be written") -def test_authorization_user_permission_no_add(user): - """Ensure user can't be added when user is missing permissions - - user requires permissions team view and user add - """ - pass - -@pytest.mark.skip(reason="to be written") -def test_authorization_user_permission_add_team_manager(user): - """Ensure user can be added when user is team manager - - user requires permissions team view and user add - """ - pass - - - -@pytest.mark.skip(reason="to be written") -def test_authorization_user_permission_change(user): - """Ensure user can be changed when user has correct permissions - - user requires permissions team view and user change - """ - pass - -@pytest.mark.skip(reason="to be written") -def test_authorization_user_permission_no_change(user): - """Ensure user can't be change when user is missing permissions - - user requires permissions team view and user change - """ - pass - - - -@pytest.mark.skip(reason="to be written") -def test_authorization_user_permission_delete(user): - """Ensure user can be deleted when user has correct permissions - - user requires permissions team view and user delete - """ - pass - -@pytest.mark.skip(reason="to be written") -def test_authorization_user_permission_no_delete(user): - """Ensure user can't be deleted when user is missing permissions - - user requires permissions team view and user delete - """ - pass - -@pytest.mark.skip(reason="to be written") -def test_authorization_user_permission_delete_team_manager(user): - """Ensure user can be deleted when user is team manager - - user requires permissions team view and user delete - """ - pass - -###################################################################### -# EoF for loop for tests -# for test in ['organization','team'] -###################################################################### - -# is_superuser to be able to view, add, change, delete for all objects - diff --git a/app/access/urls.py b/app/access/urls.py index 4062f7dc..df132e1e 100644 --- a/app/access/urls.py +++ b/app/access/urls.py @@ -6,11 +6,10 @@ from .views import team, organization, user app_name = "Access" urlpatterns = [ path("", organization.IndexView.as_view(), name="Organizations"), - path("/", organization.View.as_view(), name="_organization"), - path("/edit", organization.Change.as_view(), name="_organization_change"), - path("/team//", team.View.as_view(), name="_team"), + path("/", organization.View.as_view(), name="_organization_view"), + # path("/edit", organization.Change.as_view(), name="_organization_change"), + path("/team//", team.View.as_view(), name="_team_view"), path("/team/add", team.Add.as_view(), name="_team_add"), - path("/team//edit", team.Change.as_view(), name="_team_change"), path("/team//delete", team.Delete.as_view(), name="_team_delete"), path("/team//user/add", user.Add.as_view(), name="_team_user_add"), path("/team//user//delete", user.Delete.as_view(), name="_team_user_delete"), diff --git a/app/access/views/organization.py b/app/access/views/organization.py index 10e37f9d..441575e5 100644 --- a/app/access/views/organization.py +++ b/app/access/views/organization.py @@ -1,4 +1,5 @@ -from django.contrib.auth.mixins import PermissionRequiredMixin, LoginRequiredMixin +from django.contrib.auth import decorators as auth_decorator +from django.utils.decorators import method_decorator from django.views import generic from access.mixin import * @@ -6,8 +7,10 @@ from access.models import * -class IndexView(PermissionRequiredMixin, OrganizationPermission, generic.ListView): - permission_required = 'access.view_organization' +class IndexView(OrganizationPermission, generic.ListView): + permission_required = [ + 'access.view_organization' + ] template_name = 'access/index.html.j2' context_object_name = "organization_list" @@ -24,9 +27,12 @@ class IndexView(PermissionRequiredMixin, OrganizationPermission, generic.ListVie -class View(LoginRequiredMixin, OrganizationPermission, generic.UpdateView): +class View(OrganizationPermission, generic.UpdateView): model = Organization - permission_required = 'access.view_organization' + permission_required = [ + 'access.view_organization', + 'access.change_organization', + ] template_name = "access/organization.html.j2" fields = ["name", 'id'] @@ -46,16 +52,25 @@ class View(LoginRequiredMixin, OrganizationPermission, generic.UpdateView): context['teams'] = Team.objects.filter(organization=self.kwargs['pk']) + context['model_pk'] = self.kwargs['pk'] + context['model_name'] = self.model._meta.verbose_name.replace(' ', '') + return context + @method_decorator(auth_decorator.permission_required("access.change_organization", raise_exception=True)) + def post(self, request, *args, **kwargs): -class Change(LoginRequiredMixin, OrganizationPermission, generic.DetailView): + return super().post(request, *args, **kwargs) + + + +class Change(OrganizationPermission, generic.DetailView): pass -class Delete(LoginRequiredMixin, OrganizationPermission, generic.DetailView): +class Delete(OrganizationPermission, generic.DetailView): pass diff --git a/app/access/views/team.py b/app/access/views/team.py index e905c864..6479482e 100644 --- a/app/access/views/team.py +++ b/app/access/views/team.py @@ -1,5 +1,7 @@ -from django.contrib.auth.mixins import PermissionRequiredMixin, LoginRequiredMixin +from django.contrib.auth import decorators as auth_decorator from django.contrib.auth.models import Permission +from django.utils.decorators import method_decorator +from django.urls import reverse from django.views import generic from access.models import Team, TeamUsers, Organization @@ -10,7 +12,7 @@ from access.mixin import * class View(OrganizationPermission, generic.UpdateView): model = Team permission_required = [ - 'access.add_team', + 'access.view_team', 'access.change_team', ] template_name = 'access/team.html.j2' @@ -37,14 +39,23 @@ class View(OrganizationPermission, generic.UpdateView): context['teamusers'] = teamusers context['permissions'] = Permission.objects.filter() + context['model_pk'] = self.kwargs['pk'] + context['model_name'] = self.model._meta.verbose_name.replace(' ', '') + return context def get_success_url(self, **kwargs): - return f"/organization/{self.kwargs['organization_id']}/team/{self.kwargs['pk']}/" + return reverse('Access:_team_view', args=(self.kwargs['organization_id'], self.kwargs['pk'],)) + + + @method_decorator(auth_decorator.permission_required("access.change_team", raise_exception=True)) + def post(self, request, *args, **kwargs): + + return super().post(request, *args, **kwargs) -class Add(PermissionRequiredMixin, OrganizationPermission, generic.CreateView): +class Add(OrganizationPermission, generic.CreateView): model = Team permission_required = [ 'access.add_team', @@ -66,36 +77,16 @@ class Add(PermissionRequiredMixin, OrganizationPermission, generic.CreateView): 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'] = 'Add Team' return context -class Change(PermissionRequiredMixin, OrganizationPermission, generic.UpdateView): - model = Team - permission_required = [ - 'access.change_team', - ] - template_name = 'form.html.j2' - fields = [ - 'team_name', - 'permissions', - 'organization' - ] - def get_success_url(self, **kwargs): - return f"/organization/{self.kwargs['pk']}/" - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - - context['content_title'] = 'Edit Team' - - return context - - - -class Delete(PermissionRequiredMixin, OrganizationPermission, generic.DeleteView): +class Delete(OrganizationPermission, generic.DeleteView): model = Team permission_required = [ 'access.delete_team' @@ -115,6 +106,9 @@ class Delete(PermissionRequiredMixin, OrganizationPermission, generic.DeleteView 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 Team' return context diff --git a/app/access/views/user.py b/app/access/views/user.py index 8f3391b6..f4c15467 100644 --- a/app/access/views/user.py +++ b/app/access/views/user.py @@ -1,5 +1,6 @@ -from django.contrib.auth.mixins import PermissionRequiredMixin, LoginRequiredMixin -from django.contrib.auth.models import User, Group +from django.contrib.auth import decorators as auth_decorator +from django.urls import reverse +from django.utils.decorators import method_decorator from django.views import generic from access.mixin import OrganizationPermission @@ -7,7 +8,7 @@ from access.models import Team, TeamUsers -class Add(PermissionRequiredMixin, OrganizationPermission, generic.CreateView): +class Add(OrganizationPermission, generic.CreateView): model = TeamUsers permission_required = [ 'access.view_team', @@ -24,15 +25,17 @@ class Add(PermissionRequiredMixin, OrganizationPermission, generic.CreateView): team = Team.objects.get(pk=self.kwargs['pk']) form.instance.team = team - group = Group.objects.get(pk=team.group_ptr_id) - user = User.objects.get(pk=self.request.POST['user'][0]) - user.groups.add(group) - return super().form_valid(form) def get_success_url(self, **kwargs): - return f"/organization/{self.kwargs['organization_id']}/team/{self.kwargs['pk']}" + + return reverse('Access:_team_view', + kwargs={ + 'organization_id': self.kwargs['organization_id'], + 'pk': self.kwargs['pk'] + } + ) def get_context_data(self, **kwargs): @@ -43,31 +46,22 @@ class Add(PermissionRequiredMixin, OrganizationPermission, generic.CreateView): return context -class Delete(PermissionRequiredMixin, OrganizationPermission, generic.DeleteView): +class Delete(OrganizationPermission, generic.DeleteView): model = TeamUsers permission_required = [ - 'access.view_team', 'access.delete_teamusers' ] template_name = 'form.html.j2' - def form_valid(self, form): - - team = Team.objects.get(pk=self.kwargs['team_id']) - teamuser = TeamUsers.objects.get(pk=self.kwargs['pk']) - - group = Group.objects.get(pk=team.group_ptr_id) - - user = User.objects.get(pk=teamuser.user_id) - - user.groups.remove(group) - - return super().form_valid(form) - - def get_success_url(self, **kwargs): - return f"/organization/{self.kwargs['organization_id']}/team/{self.kwargs['team_id']}" + + return reverse('Access:_team_view', + kwargs={ + 'organization_id': self.kwargs['organization_id'], + 'pk': self.kwargs['team_id'] + } + ) def get_context_data(self, **kwargs): diff --git a/app/api/serializers/access.py b/app/api/serializers/access.py index a32834ca..287dd135 100644 --- a/app/api/serializers/access.py +++ b/app/api/serializers/access.py @@ -1,15 +1,48 @@ -from rest_framework import serializers +from rest_framework import serializers, request +from rest_framework.reverse import reverse from access.models import Organization, Team +from django.contrib.auth.models import Permission -class TeamSerializer(serializers.ModelSerializer): + + +class TeamSerializerBase(serializers.ModelSerializer): + + view_name="_api_team" + + url = serializers.SerializerMethodField('get_url') class Meta: model = Team fields = ( - "group_ptr_id", + "id", "name", + 'organization', + 'url', + ) + + + def get_url(self, obj): + + request = self.context.get('request') + + return request.build_absolute_uri(reverse(self.view_name, args=[obj.organization.id,obj.pk])) + + + +class TeamSerializer(TeamSerializerBase): + + + class Meta: + model = Team + depth = 1 + fields = ( + "id", + "name", + 'organization', + 'permissions', + 'url', ) @@ -20,11 +53,16 @@ class OrganizationSerializer(serializers.ModelSerializer): view_name="_api_organization", format="html" ) + teams = TeamSerializerBase(source='team_set', many=True, read_only=False) + + view_name="_api_organization" + class Meta: model = Organization fields = ( "id", "name", + 'teams', 'url', ) diff --git a/app/api/serializers/itam/device.py b/app/api/serializers/itam/device.py index 019529c7..f9fee314 100644 --- a/app/api/serializers/itam/device.py +++ b/app/api/serializers/itam/device.py @@ -25,6 +25,7 @@ class DeviceSerializer(serializers.ModelSerializer): fields = '__all__' read_only_fields = [ + 'inventorydate', 'is_global', 'organization', ] diff --git a/app/api/tests/test_api_access.py b/app/api/tests/test_api_access.py index ed019401..99eb4e0d 100644 --- a/app/api/tests/test_api_access.py +++ b/app/api/tests/test_api_access.py @@ -22,188 +22,6 @@ def test_api_access_home(user): pass -@pytest.mark.skip(reason="to be written") -def test_api_access_model_view_organization(user): - """Ensure api model access - - test_api_access_model_view_organization = test_api_access_model__ - - Test to ensure that action can only occur when authenticated and against the model - """ - pass - - - -@pytest.mark.skip(reason="to be written") -def test_api_access_model_view_team(user): - """Ensure api model access - - test_api_access_model_view_organization = test_api_access_model__ - - Test to ensure that action can only occur when authenticated and against the model - """ - pass - - -@pytest.mark.skip(reason="to be written") -def test_api_access_model_add_organization(user): - """Ensure api model access - - test_api_access_model_view_organization = test_api_access_model__ - - Test to ensure that action can only occur when authenticated and against the model - """ - pass - - - -@pytest.mark.skip(reason="to be written") -def test_api_access_model_add_team(user): - """Ensure api model access - - test_api_access_model_view_organization = test_api_access_model__ - - Test to ensure that action can only occur when authenticated and against the model - """ - pass - - -@pytest.mark.skip(reason="to be written") -def test_api_access_model_change_organization(user): - """Ensure api model access - - test_api_access_model_view_organization = test_api_access_model__ - - Test to ensure that action can only occur when authenticated and against the model - """ - pass - - - -@pytest.mark.skip(reason="to be written") -def test_api_access_model_change_team(user): - """Ensure api model access - - test_api_access_model_view_organization = test_api_access_model__ - - Test to ensure that action can only occur when authenticated and against the model - """ - pass - - -@pytest.mark.skip(reason="to be written") -def test_api_access_model_delete_organization(user): - """Ensure api model access - - test_api_access_model_view_organization = test_api_access_model__ - - Test to ensure that action can only occur when authenticated and against the model - """ - pass - - - -@pytest.mark.skip(reason="to be written") -def test_api_access_model_delete_team(user): - """Ensure api model access - - test_api_access_model_view_organization = test_api_access_model__ - - Test to ensure that action can only occur when authenticated and against the model - """ - pass - - -@pytest.mark.skip(reason="to be written") -def test_api_access_model_view_device(user): - """Ensure api model access - - test_api_access_model_view_organization = test_api_access_model__ - - Test to ensure that action can only occur when authenticated and against the model - """ - pass - - -@pytest.mark.skip(reason="to be written") -def test_api_access_model_add_device(user): - """Ensure api model access - - test_api_access_model_view_organization = test_api_access_model__ - - Test to ensure that action can only occur when authenticated and against the model - """ - pass - - -@pytest.mark.skip(reason="to be written") -def test_api_access_model_change_device(user): - """Ensure api model access - - test_api_access_model_view_organization = test_api_access_model__ - - Test to ensure that action can only occur when authenticated and against the model - """ - pass - - -@pytest.mark.skip(reason="to be written") -def test_api_access_model_delete_device(user): - """Ensure api model access - - test_api_access_model_view_organization = test_api_access_model__ - - Test to ensure that action can only occur when authenticated and against the model - """ - pass - - -@pytest.mark.skip(reason="to be written") -def test_api_access_model_view_software(user): - """Ensure api model access - - test_api_access_model_view_organization = test_api_access_model__ - - Test to ensure that action can only occur when authenticated and against the model - """ - pass - - -@pytest.mark.skip(reason="to be written") -def test_api_access_model_add_software(user): - """Ensure api model access - - test_api_access_model_view_organization = test_api_access_model__ - - Test to ensure that action can only occur when authenticated and against the model - """ - pass - - -@pytest.mark.skip(reason="to be written") -def test_api_access_model_change_software(user): - """Ensure api model access - - test_api_access_model_view_organization = test_api_access_model__ - - Test to ensure that action can only occur when authenticated and against the model - """ - pass - - -@pytest.mark.skip(reason="to be written") -def test_api_access_model_delete_software(user): - """Ensure api model access - - test_api_access_model_view_organization = test_api_access_model__ - - Test to ensure that action can only occur when authenticated and against the model - """ - pass - - - - diff --git a/app/api/tests/test_api_inventory.py b/app/api/tests/test_api_inventory.py new file mode 100644 index 00000000..95f5c3a2 --- /dev/null +++ b/app/api/tests/test_api_inventory.py @@ -0,0 +1,151 @@ +from django.shortcuts import reverse +from django.test import TestCase, Client + +import pytest +import unittest + + + +@pytest.mark.skip(reason="to be written") +def test_api_inventory_device_added(): + """ Device is created """ + pass + + + +@pytest.mark.skip(reason="to be written") +def test_api_inventory_operating_system_added(): + """ Operating System is created """ + pass + + + +@pytest.mark.skip(reason="to be written") +def test_api_inventory_operating_system_version_added(): + """ Operating System version is created """ + pass + + + +@pytest.mark.skip(reason="to be written") +def test_api_inventory_device_has_operating_system_added(): + """ Operating System version linked to device """ + pass + + + +@pytest.mark.skip(reason="to be written") +def test_api_inventory_device_operating_system_version_is_semver(): + """ Operating System version is full semver + + Operating system versions name is the major version number of semver. + The device version is to be full semver + """ + pass + + + +@pytest.mark.skip(reason="to be written") +def test_api_inventory_software_no_version_cleaned(): + """ Check softare cleaned up + + As part of the inventory upload the software versions of software found on the device is set to null + and before the processing is completed, the version=null software is supposed to be cleaned up. + """ + pass + + + +@pytest.mark.skip(reason="to be written") +def test_api_inventory_software_category_added(): + """ Software category exists """ + pass + + + +@pytest.mark.skip(reason="to be written") +def test_api_inventory_software_added(): + """ Test software exists """ + pass + + + +@pytest.mark.skip(reason="to be written") +def test_api_inventory_software_category_linked_to_software(): + """ Software category linked to software """ + pass + + + +@pytest.mark.skip(reason="to be written") +def test_api_inventory_software_version_added(): + """ Test software version exists """ + pass + + + +@pytest.mark.skip(reason="to be written") +def test_api_inventory_software_version_returns_semver(): + """ Software Version from inventory returns semver if within version string """ + pass + + + +@pytest.mark.skip(reason="to be written") +def test_api_inventory_software_version_returns_original_version(): + """ Software Version from inventory returns inventoried version if no semver found """ + pass + + + +@pytest.mark.skip(reason="to be written") +def test_api_inventory_software_version_linked_to_software(): + """ Test software version linked to software it belongs too """ + pass + + + +@pytest.mark.skip(reason="to be written") +def test_api_inventory_device_has_software_version(): + """ Inventoried software is linked to device and it's the corret one""" + pass + + + +@pytest.mark.skip(reason="to be written") +def test_api_inventory_device_software_has_installed_date(): + """ Inventoried software version has install date """ + pass + + + +@pytest.mark.skip(reason="to be written") +def test_api_inventory_device_software_blank_installed_date_is_updated(): + """ A blank installed date of software is updated if the software was already attached to the device """ + pass + + + +@pytest.mark.skip(reason="to be written") +def test_api_inventory_valid_status_created(): + """ Successful inventory upload returns 201 """ + pass + + + +@pytest.mark.skip(reason="to be written") +def test_api_inventory_invalid_status_bad_request(): + """ Incorrectly formated inventory upload returns 400 """ + pass + + + +@pytest.mark.skip(reason="to be written") +def test_api_inventory_exeception_status_sever_error(): + """ if the method throws an exception 500 must be returned. + + idea to test: add a random key to the report that is not documented + and perform some action against it that will cause a python exception. + """ + pass + diff --git a/app/api/urls.py b/app/api/urls.py index 2eb1a6a5..f4ffe80c 100644 --- a/app/api/urls.py +++ b/app/api/urls.py @@ -3,7 +3,9 @@ from rest_framework.urlpatterns import format_suffix_patterns from .views import access, index -from .views.itam import device as itam_device, software as itam_software, config as itam_config +from .views.itam import software as itam_software, config as itam_config +from .views.itam.device import detail as itam_device +from .views.itam.device import inventory urlpatterns = [ path("", index.IndexView.as_view(), name='_api_home'), @@ -21,6 +23,8 @@ urlpatterns = [ path("software/", itam_software.List.as_view(), name="_api_softwares"), path("software//", itam_software.Detail.as_view(), name="_api_software_view"), + path("device/inventory/", inventory.Collect.as_view(), name="_api_device_inventory"), + ] urlpatterns = format_suffix_patterns(urlpatterns) diff --git a/app/api/views/access.py b/app/api/views/access.py index d954f24c..dbfcc4ee 100644 --- a/app/api/views/access.py +++ b/app/api/views/access.py @@ -21,6 +21,7 @@ class OrganizationList(generics.ListCreateAPIView): class OrganizationDetail(generics.RetrieveUpdateDestroyAPIView): permission_required = 'access.view_organization' queryset = Organization.objects.all() + lookup_field = 'pk' serializer_class = OrganizationSerializer diff --git a/app/api/views/index.py b/app/api/views/index.py index 46c4faf9..1e5c5034 100644 --- a/app/api/views/index.py +++ b/app/api/views/index.py @@ -27,9 +27,9 @@ class IndexView(routers.APIRootView): def get(self, request, *args, **kwargs): return Response( { - "organizations": reverse("_api_orgs", request=request), - "teams": reverse("_api_teams", request=request), + # "teams": reverse("_api_teams", request=request), "devices": reverse("_api_devices", request=request), + "organizations": reverse("_api_orgs", request=request), "software": reverse("_api_softwares", request=request), } ) diff --git a/app/api/views/itam/device/__init__.py b/app/api/views/itam/device/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/api/views/itam/device.py b/app/api/views/itam/device/detail.py similarity index 100% rename from app/api/views/itam/device.py rename to app/api/views/itam/device/detail.py diff --git a/app/api/views/itam/device/inventory.py b/app/api/views/itam/device/inventory.py new file mode 100644 index 00000000..5ce3d8fc --- /dev/null +++ b/app/api/views/itam/device/inventory.py @@ -0,0 +1,280 @@ +# from django.contrib.auth.mixins import PermissionRequiredMixin, LoginRequiredMixin +import json +import re + +from django.http import JsonResponse +from django.utils import timezone + +from rest_framework import generics, views +from rest_framework.response import Response + +from access.models import Organization + +from core.http.common import Http + +from itam.models.device import Device, DeviceType, DeviceOperatingSystem, DeviceSoftware +from itam.models.operating_system import OperatingSystem, OperatingSystemVersion +from itam.models.software import Software, SoftwareCategory, SoftwareVersion + +from settings.models.app_settings import AppSettings +from settings.models.user_settings import UserSettings + + + +class Collect(views.APIView): + + def post(self, request, *args, **kwargs): + + data = json.loads(request.body) + + status = Http.Status.BAD_REQUEST + + device = None + device_operating_system = None + operating_system = None + operating_system_version = None + + try: + + default_organization = UserSettings.objects.get(user=request.user).default_organization + + app_settings = AppSettings.objects.get(owner_organization = None) + + if Device.objects.filter(name=data['details']['name']).exists(): + + device = Device.objects.get(name=data['details']['name']) + + else: # Create the device + + device = Device.objects.create( + name = data['details']['name'], + device_type = None, + serial_number = data['details']['serial_number'], + uuid = data['details']['uuid'], + organization = default_organization, + ) + + status = Http.Status.CREATED + + + if OperatingSystem.objects.filter( slug=data['os']['name'] ).exists(): + + operating_system = OperatingSystem.objects.get( slug=data['os']['name'] ) + + else: # Create Operating System + + operating_system = OperatingSystem.objects.create( + name = data['os']['name'], + organization = default_organization, + is_global = True + ) + + + if OperatingSystemVersion.objects.filter( name=data['os']['version_major'], operating_system=operating_system ).exists(): + + operating_system_version = OperatingSystemVersion.objects.get( + organization = default_organization, + is_global = True, + name = data['os']['version_major'], + operating_system = operating_system + ) + + else: # Create Operating System Version + + operating_system_version = OperatingSystemVersion.objects.create( + organization = default_organization, + is_global = True, + name = data['os']['version_major'], + operating_system = operating_system, + ) + + + if DeviceOperatingSystem.objects.filter( version=data['os']['version'], device=device, operating_system_version=operating_system_version ).exists(): + + device_operating_system = DeviceOperatingSystem.objects.get( + device=device, + version = data['os']['version'], + operating_system_version = operating_system_version, + ) + + if not device_operating_system.installdate: # Only update install date if empty + + device_operating_system.installdate = timezone.now() + + device_operating_system.save() + + else: # Create Operating System Version + + device_operating_system = DeviceOperatingSystem.objects.create( + organization = default_organization, + device=device, + version = data['os']['version'], + operating_system_version = operating_system_version, + installdate = timezone.now() + ) + + + if app_settings.software_is_global: + + software_organization = app_settings.global_organization + + else: + + software_organization = device.organization + + + if app_settings.software_categories_is_global: + + software_category_organization = app_settings.global_organization + + else: + + software_category_organization = device.organization + + + + for inventory in list(data['software']): + + software = None + software_category = None + software_version = None + + device_software = None + + + if SoftwareCategory.objects.filter( name = inventory['category'] ).exists(): + + software_category = SoftwareCategory.objects.get( + name = inventory['category'] + ) + + else: # Create Software Category + + software_category = SoftwareCategory.objects.create( + organization = software_category_organization, + is_global = True, + name = inventory['category'], + ) + + + if Software.objects.filter( name = inventory['name'] ).exists(): + + software = Software.objects.get( + name = inventory['name'] + ) + + if not software.category: + + software.category = software_category + software.save() + + else: # Create Software + + software = Software.objects.create( + organization = software_organization, + is_global = True, + name = inventory['name'], + category = software_category, + ) + + + pattern = r"^(\d+:)?(?P\d+\.\d+(\.\d+)?)" + + semver = re.search(pattern, str(inventory['version']), re.DOTALL) + + + if semver: + + semver = semver['semver'] + + else: + semver = inventory['version'] + + + if SoftwareVersion.objects.filter( name = semver, software = software ).exists(): + + software_version = SoftwareVersion.objects.get( + name = semver, + software = software, + ) + + else: # Create Software Category + + software_version = SoftwareVersion.objects.create( + organization = default_organization, + is_global = True, + name = semver, + software = software, + ) + + + if DeviceSoftware.objects.filter( software = software, device=device ).exists(): + + device_software = DeviceSoftware.objects.get( + device = device, + software = software + ) + + else: # Create Software + + device_software = DeviceSoftware.objects.create( + organization = default_organization, + is_global = True, + installedversion = software_version, + software = software, + device = device, + action=None + ) + + + if device_software: # Update the Inventoried software + + clear_installed_software = DeviceSoftware.objects.filter( + device = device, + software = software + ) + + # Clear installed version of all installed software + # any found later with no version to be removed + clear_installed_software.update(installedversion=None) + + + if not device_software.installed: # Only update install date if blank + + device_software.installed = timezone.now() + + device_software.save() + + device_software.installedversion = software_version + + device_software.save() + + + if device and operating_system and operating_system_version and device_operating_system: + + # Remove software no longer installed + DeviceSoftware.objects.filter( + device = device, + software = software, + ).delete() + + device.inventorydate = timezone.now() + + device.save() + + status = Http.Status.OK + + + except Exception as e: + + print(f'An error occured{e}') + + status = Http.Status.SERVER_ERROR + + + return Response(data='OK',status=status) + + + + def get_view_name(self): + return "Inventory" diff --git a/app/app/context_processors.py b/app/app/context_processors/base.py similarity index 68% rename from app/app/context_processors.py rename to app/app/context_processors/base.py index 90826a56..34680942 100644 --- a/app/app/context_processors.py +++ b/app/app/context_processors/base.py @@ -1,13 +1,49 @@ import re -from .urls import urlpatterns +from app.urls import urlpatterns +from django.conf import settings from django.urls import URLPattern, URLResolver +from settings.models.user_settings import UserSettings + + +def build_details(context) -> dict: + + return { + 'project_url': settings.BUILD_REPO, + 'sha': settings.BUILD_SHA, + 'version': settings.BUILD_VERSION, + } + def request(request): return request.get_full_path() + +def user_settings(context) -> int: + """ Provides the settings ID for the current user. + + If user settings object doesn't exist, it's probably a new user. So create their settings row. + + Returns: + int: model usersettings Primary Key + """ + if context.user.is_authenticated: + + settings = UserSettings.objects.filter(user=context.user) + + if not settings.exists(): + + UserSettings.objects.create(user=context.user) + + settings = UserSettings.objects.filter(user=context.user) + + return settings[0].pk + + return None + + def nav_items(context) -> list(dict()): """ Fetch All Project URLs @@ -61,7 +97,7 @@ def nav_items(context) -> list(dict()): url = '/' + str(nav_group.pattern) + str(pattern.pattern) - if str(context.path) == url: + if str(context.path).startswith(url): is_active = True @@ -94,7 +130,10 @@ def nav_items(context) -> list(dict()): return dnav -def navigation(context): +def common(context): + return { - 'nav_items': nav_items(context) + 'build_details': build_details(context), + 'nav_items': nav_items(context), + 'user_settings': user_settings(context), } diff --git a/app/app/settings.py b/app/app/settings.py index 897bfc00..a2a7340a 100644 --- a/app/app/settings.py +++ b/app/app/settings.py @@ -9,6 +9,7 @@ https://docs.djangoproject.com/en/5.0/topics/settings/ For the full list of settings and their values, see https://docs.djangoproject.com/en/5.0/ref/settings/ """ + import os from pathlib import Path @@ -18,6 +19,11 @@ from split_settings.tools import optional, include BASE_DIR = Path(__file__).resolve().parent.parent SETTINGS_DIR = '/etc/itsm' # Primary Settings Directory + +BUILD_REPO = os.getenv('CI_PROJECT_URL') +BUILD_SHA = os.getenv('CI_COMMIT_SHA') +BUILD_VERSION = os.getenv('CI_COMMIT_TAG') + # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/ @@ -46,6 +52,7 @@ INSTALLED_APPS = [ 'core.apps.CoreConfig', 'access.apps.AccessConfig', 'itam.apps.ItamConfig', + 'settings.apps.SettingsConfig', ] MIDDLEWARE = [ @@ -56,6 +63,7 @@ MIDDLEWARE = [ 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'core.middleware.get_request.RequestMiddleware', ] @@ -74,7 +82,7 @@ TEMPLATES = [ 'django.contrib.messages.context_processors.messages', 'social_django.context_processors.backends', 'social_django.context_processors.login_redirect', - 'app.context_processors.navigation', + 'app.context_processors.base.common', ], }, }, @@ -217,3 +225,9 @@ if DEBUG: "127.0.0.1", ] + # Apps Under Development + INSTALLED_APPS += [ + 'information.apps.InformationConfig', + 'config_management.apps.ConfigManagementConfig', + 'project_management.apps.ProjectManagementConfig', + ] diff --git a/app/app/tests/test_01_settings.py b/app/app/tests/test_01_settings.py deleted file mode 100644 index de159715..00000000 --- a/app/app/tests/test_01_settings.py +++ /dev/null @@ -1,41 +0,0 @@ -from app import settings - -import pytest -import unittest - -class Test_aa_settings_default(unittest.TestCase): - - # @pytest.mark.django_db - # def test_setting_api_disabled_default(self): - # """ As the API is only partially developed, it must be disabled. - - # This test can be removed when the API has been fully developed and functioning as it should. - # """ - - # assert not settings.API_ENABLED - - - @pytest.mark.django_db - def test_setting_login_required_default(self): - """ By default login should be required - """ - - assert settings.LOGIN_REQUIRED - - - @pytest.mark.django_db - def test_setting_use_tz_default(self): - """ Ensure that 'USE_TZ = True' is within settings - """ - - assert settings.USE_TZ - - - @pytest.mark.django_db - def test_setting_debug_off(self): - """ Ensure that debug is off within settings by default - - Debug is only required during development with this setting must always remain off within the committed code. - """ - - assert not settings.DEBUG diff --git a/app/app/tests/test_auth.py b/app/app/tests/test_auth.py deleted file mode 100644 index 9a463175..00000000 --- a/app/app/tests/test_auth.py +++ /dev/null @@ -1,38 +0,0 @@ -from django.conf import settings -from django.shortcuts import reverse -from django.test import TestCase, Client - -import pytest -import unittest - -from access.models import Organization - - -@pytest.mark.django_db -def test_setting_login_required(): - """Some docstring defining what the test is checking.""" - client = Client() - url = reverse('home') - # client.force_login(user) - # default_settings = settings - settings.LOGIN_REQUIRED = True - - response = client.get(url) - - assert response.status_code == 302 - - # settings = default_settings - - - -@pytest.mark.django_db -def test_setting_login_required_not(): - """Some docstring defining what the test is checking.""" - client = Client() - url = reverse('home') - - settings.LOGIN_REQUIRED = False - - response = client.get(url) - - assert response.status_code == 200 diff --git a/app/app/tests/test_context_processor_base.py b/app/app/tests/test_context_processor_base.py new file mode 100644 index 00000000..e1e0c2a9 --- /dev/null +++ b/app/app/tests/test_context_processor_base.py @@ -0,0 +1,18 @@ +from django.test import TestCase, Client + +import pytest +import unittest +import requests + + + +@pytest.mark.skip(reason="to be written") +def test_context_processor_base_user_settings_if_authenticated_only(): + """ Context Processor base to only provide `user_settings` for an authenticated user """ + pass + + +@pytest.mark.skip(reason="to be written") +def test_context_processor_base_user_settings_is_logged_in_user(): + """ Context Processor base to only provide `user_settings` for the current logged in user """ + pass diff --git a/app/app/tests/test_settings.py b/app/app/tests/test_settings.py new file mode 100644 index 00000000..c34f2ff8 --- /dev/null +++ b/app/app/tests/test_settings.py @@ -0,0 +1,66 @@ +from django.conf import settings as django_settings +from django.shortcuts import reverse +from django.test import TestCase, Client + +from app import settings + + +import pytest +import unittest + + +class SettingsDefault(TestCase): + """ Test Settings file default values """ + + + def test_setting_default_login_required(self): + """ By default login should be required + """ + + assert settings.LOGIN_REQUIRED + + + def test_setting_default_use_tz(self): + """ Ensure that 'USE_TZ = True' is within settings + """ + + assert settings.USE_TZ + + + def test_setting_default_debug_off(self): + """ Ensure that debug is off within settings by default + + Debug is only required during development with this setting must always remain off within the committed code. + """ + + assert not settings.DEBUG + + + +class SettingsValues(TestCase): + """ Test Each setting that offers different functionality """ + + + def test_setting_value_login_required(self): + """Some docstring defining what the test is checking.""" + client = Client() + url = reverse('home') + + django_settings.LOGIN_REQUIRED = True + + response = client.get(url) + + assert response.status_code == 302 and response.url.startswith('/account/login') + + + + def test_setting_value_login_required_not(self): + """Some docstring defining what the test is checking.""" + client = Client() + url = reverse('home') + + django_settings.LOGIN_REQUIRED = False + + response = client.get(url) + + assert response.status_code == 200 diff --git a/app/app/urls.py b/app/app/urls.py index c8edcb94..0df7c4f0 100644 --- a/app/app/urls.py +++ b/app/app/urls.py @@ -17,19 +17,29 @@ Including another URLconf from django.conf import settings from django.contrib import admin from django.contrib.auth import views as auth_views -from django.urls import include, path +from django.views.static import serve +from django.urls import include, path, re_path + +from .views import home + +from core.views import history + +from settings.views import user_settings + -from .views import HomeView urlpatterns = [ - path('', HomeView.as_view(), name='home'), + path('', home.HomeView.as_view(), name='home'), path('admin/', admin.site.urls, name='_administration'), - path('account/password_change/', auth_views.PasswordChangeView.as_view(template_name="password_change.html.j2"), - name="change_password"), + + path('account/password_change/', auth_views.PasswordChangeView.as_view(template_name="password_change.html.j2"), name="change_password"), + path('account/settings/', user_settings.View.as_view(), name="_settings_user"), path("account/", include("django.contrib.auth.urls")), + path("organization/", include("access.urls")), path("itam/", include("itam.urls")), - + path("history//", history.View.as_view(), name='_history'), + re_path(r'^static/(?P.*)$', serve,{'document_root': settings.STATIC_ROOT}) ] if settings.API_ENABLED: @@ -43,4 +53,15 @@ if settings.DEBUG: urlpatterns += [ path("__debug__/", include("debug_toolbar.urls"), name='_debug'), + # Apps Under Development + path("information/", include("information.urls")), + path("config_management/", include("config_management.urls")), + path("project_management/", include("project_management.urls")), ] + +# must be after above +urlpatterns += [ + + path("settings/", include("settings.urls")), + +] diff --git a/app/config_management/__init__.py b/app/config_management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/config_management/apps.py b/app/config_management/apps.py new file mode 100644 index 00000000..e334de89 --- /dev/null +++ b/app/config_management/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ConfigManagementConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'config_management' diff --git a/app/config_management/migrations/__init__.py b/app/config_management/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/config_management/models.py b/app/config_management/models.py new file mode 100644 index 00000000..137941ff --- /dev/null +++ b/app/config_management/models.py @@ -0,0 +1 @@ +from django.db import models diff --git a/app/config_management/urls.py b/app/config_management/urls.py new file mode 100644 index 00000000..518b51e2 --- /dev/null +++ b/app/config_management/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from .views import ConfigIndex + +app_name = "Config Management" +urlpatterns = [ + path('', ConfigIndex.as_view(), name='Config Management'), + +] diff --git a/app/config_management/views.py b/app/config_management/views.py new file mode 100644 index 00000000..5cb69d88 --- /dev/null +++ b/app/config_management/views.py @@ -0,0 +1,18 @@ +from django.shortcuts import render +from django.views import generic + + +class ConfigIndex(generic.View): + + permission_required = 'itam.view_device' + + template_name = 'form.html.j2' + + + def get(self, request): + + context = {} + + context['content_title'] = 'Config Management' + + return render(request, self.template_name, context) diff --git a/app/core/forms/comment.py b/app/core/forms/comment.py new file mode 100644 index 00000000..f34e57a3 --- /dev/null +++ b/app/core/forms/comment.py @@ -0,0 +1,15 @@ +from django import forms + +from app import settings +from core.models.notes import Notes + + +class AddNoteForm(forms.ModelForm): + + prefix = 'note' + + class Meta: + model = Notes + fields = [ + 'note' + ] diff --git a/app/core/http/common.py b/app/core/http/common.py new file mode 100644 index 00000000..1f5e4c8a --- /dev/null +++ b/app/core/http/common.py @@ -0,0 +1,17 @@ +from enum import IntEnum + + + +class Http(): + """Common HTTP Related objects""" + + + class Status(IntEnum): + """HTTP server status codes.""" + + OK = 200 + CREATED = 201 + + BAD_REQUEST = 400 + + SERVER_ERROR = 500 diff --git a/app/core/management/commands/manufacturer.py b/app/core/management/commands/manufacturer.py new file mode 100644 index 00000000..a0adcaf8 --- /dev/null +++ b/app/core/management/commands/manufacturer.py @@ -0,0 +1,64 @@ +from django.core.management.base import BaseCommand +from django.db.models import Q +from django.utils import timezone + +from core.models.manufacturer import Manufacturer + +from settings.models.app_settings import AppSettings + + + +class Command(BaseCommand): + help = 'Manage Common item Manufacturer for the entire application.' + + + def add_arguments(self, parser): + parser.add_argument('-g', '--global', action='store_true', help='Sets all manufacturer to be global (manufacturers will be migrated to global organization if set)') + parser.add_argument('-m', '--migrate', action='store_true', help='Migrate existing global manufacturers to global organization') + + + def handle(self, *args, **kwargs): + + if kwargs['global']: + + softwares = Manufacturer.objects.filter(is_global = False) + + self.stdout.write('Running global') + + self.stdout.write(f'found manufacturer {str(len(softwares))} to set as global') + + for software in softwares: + + software.clean() + software.save() + + self.stdout.write(f"Setting {software} as global") + + self.stdout.write('Global finished') + + + if kwargs['migrate']: + + app_settings = AppSettings.objects.get(owner_organization=None) + + self.stdout.write('Running Migrate') + self.stdout.write(f'Global organization: {app_settings.global_organization}') + + softwares = Manufacturer.objects.filter( + ~Q(organization = app_settings.global_organization) + | + Q(is_global = False) + & + Q(organization=app_settings.global_organization), + ) + + self.stdout.write(f'found manufacturer {str(len(softwares))} to migrate') + + for software in softwares: + + software.clean() + software.save() + + self.stdout.write(f"Migrating manufacturer {software} to organization {app_settings.global_organization.name}") + + self.stdout.write('Migrate finished') diff --git a/app/core/middleware/__init__.py b/app/core/middleware/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/core/middleware/get_request.py b/app/core/middleware/get_request.py new file mode 100644 index 00000000..d4464658 --- /dev/null +++ b/app/core/middleware/get_request.py @@ -0,0 +1,21 @@ +import threading + +request_local = threading.local() + +def get_request(): + return getattr(request_local, 'request', None) + +class RequestMiddleware(): + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + request_local.request = request + return self.get_response(request) + + def process_exception(self, request, exception): + request_local.request = None + + def process_template_response(self, request, response): + request_local.request = None + return response \ No newline at end of file diff --git a/app/core/migrations/0001_initial.py b/app/core/migrations/0001_initial.py new file mode 100644 index 00000000..300d1c9e --- /dev/null +++ b/app/core/migrations/0001_initial.py @@ -0,0 +1,41 @@ +# Generated by Django 5.0.6 on 2024-05-20 15:44 + +import access.fields +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('access', '0001_initial'), + ('itam', '0007_device_inventorydate'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Notes', + fields=[ + ('is_global', models.BooleanField(default=False)), + ('id', models.AutoField(primary_key=True, serialize=False, unique=True)), + ('created', access.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False)), + ('modified', access.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False)), + ('serial_number', models.CharField(blank=True, default=None, max_length=50, null=True, verbose_name='Serial Number')), + ('note', models.TextField(default=None, null=True, verbose_name='Note')), + ('device', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, to='itam.device')), + ('operatingsystem', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, to='itam.operatingsystem')), + ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='access.organization')), + ('software', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, to='itam.software')), + ('usercreated', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='usercreated', to=settings.AUTH_USER_MODEL, verbose_name='Added By')), + ('usermodified', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='usermodified', to=settings.AUTH_USER_MODEL, verbose_name='Edited By')), + ], + options={ + 'ordering': ['-created'], + }, + ), + ] diff --git a/app/core/migrations/0002_remove_notes_serial_number_alter_notes_device_and_more.py b/app/core/migrations/0002_remove_notes_serial_number_alter_notes_device_and_more.py new file mode 100644 index 00000000..0b9e71c5 --- /dev/null +++ b/app/core/migrations/0002_remove_notes_serial_number_alter_notes_device_and_more.py @@ -0,0 +1,34 @@ +# Generated by Django 5.0.6 on 2024-05-20 16:06 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0001_initial'), + ('itam', '0007_device_inventorydate'), + ] + + operations = [ + migrations.RemoveField( + model_name='notes', + name='serial_number', + ), + migrations.AlterField( + model_name='notes', + name='device', + field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='itam.device'), + ), + migrations.AlterField( + model_name='notes', + name='operatingsystem', + field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='itam.operatingsystem'), + ), + migrations.AlterField( + model_name='notes', + name='software', + field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='itam.software'), + ), + ] diff --git a/app/core/migrations/0003_alter_notes_note_history.py b/app/core/migrations/0003_alter_notes_note_history.py new file mode 100644 index 00000000..786b3add --- /dev/null +++ b/app/core/migrations/0003_alter_notes_note_history.py @@ -0,0 +1,41 @@ +# Generated by Django 5.0.6 on 2024-05-23 03:59 + +import access.fields +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0002_remove_notes_serial_number_alter_notes_device_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name='notes', + name='note', + field=models.TextField(blank=True, default=None, null=True, verbose_name='Note'), + ), + migrations.CreateModel( + name='History', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False, unique=True)), + ('created', access.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False)), + ('before', models.TextField(blank=True, default=None, help_text='JSON Object before Change', null=True)), + ('after', models.TextField(blank=True, default=None, help_text='JSON Object After Change', null=True)), + ('action', models.IntegerField(choices=[('1', 'Create'), ('2', 'Update'), ('3', 'Delete')], default=None, null=True)), + ('item_pk', models.IntegerField(default=None, null=True)), + ('item_class', models.CharField(default=None, max_length=50, null=True)), + ('item_parent_pk', models.IntegerField(default=None, null=True)), + ('item_parent_class', models.CharField(default=None, max_length=50, null=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created'], + }, + ), + ] diff --git a/app/core/migrations/0004_notes_is_null.py b/app/core/migrations/0004_notes_is_null.py new file mode 100644 index 00000000..b9ee8ffb --- /dev/null +++ b/app/core/migrations/0004_notes_is_null.py @@ -0,0 +1,20 @@ +# Generated by Django 5.0.6 on 2024-05-23 10:37 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('access', '0002_alter_team_organization'), + ('core', '0003_alter_notes_note_history'), + ] + + operations = [ + migrations.AlterField( + model_name='notes', + name='organization', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization'), + ), + ] diff --git a/app/core/migrations/0005_manufacturer.py b/app/core/migrations/0005_manufacturer.py new file mode 100644 index 00000000..64a5ed12 --- /dev/null +++ b/app/core/migrations/0005_manufacturer.py @@ -0,0 +1,32 @@ +# Generated by Django 5.0.6 on 2024-05-23 10:58 + +import access.fields +import django.db.models.deletion +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('access', '0002_alter_team_organization'), + ('core', '0004_notes_is_null'), + ] + + operations = [ + migrations.CreateModel( + name='Manufacturer', + fields=[ + ('is_global', models.BooleanField(default=False)), + ('id', models.AutoField(primary_key=True, serialize=False, unique=True)), + ('created', access.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False)), + ('modified', access.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False)), + ('name', models.CharField(max_length=50, unique=True)), + ('slug', access.fields.AutoSlugField()), + ('organization', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization')), + ], + options={ + 'ordering': ['name'], + }, + ), + ] diff --git a/app/core/migrations/0006_alter_history_user.py b/app/core/migrations/0006_alter_history_user.py new file mode 100644 index 00000000..4a234b82 --- /dev/null +++ b/app/core/migrations/0006_alter_history_user.py @@ -0,0 +1,21 @@ +# Generated by Django 5.0.6 on 2024-05-25 05:21 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0005_manufacturer'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name='history', + name='user', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.DO_NOTHING, to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/app/core/migrations/__init__.py b/app/core/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/core/mixin/history_save.py b/app/core/mixin/history_save.py new file mode 100644 index 00000000..7393f4ec --- /dev/null +++ b/app/core/mixin/history_save.py @@ -0,0 +1,162 @@ +import json + +from django.db import models + +from core.middleware.get_request import get_request +from core.models.history import History + + +class SaveHistory(models.Model): + + class Meta: + abstract = True + + @property + def fields(self): + return [ f.name for f in self._meta.fields + self._meta.many_to_many ] + + + def save(self, *args, **kwargs): + """ OverRides save for keeping model history. + + Not a Full-Override as this is just to add to existing. + + Before to fetch from DB to ensure the changed value is the actual changed value and the after + is the data that was saved to the DB. + """ + + remove_keys = [ + '_state', + 'created', + 'modified' + ] + before = {} + + try: + before = self.__class__.objects.get(pk=self.pk).__dict__.copy() + except Exception: + pass + + clean = {} + for entry in before: + + if type(before[entry]) == type(int()): + + value = int(before[entry]) + + elif type(before[entry]) == type(bool()): + + value = bool(before[entry]) + + else: + + value = str(before[entry]) + + + if entry not in remove_keys: + clean[entry] = value + + before_json = json.dumps(clean) + + # Process the save + super().save(*args, **kwargs) + + after = self.__dict__.copy() + + clean = {} + for entry in after: + + if type(after[entry]) == type(int()): + + value = int(after[entry]) + + elif type(after[entry]) == type(bool()): + + value = bool(after[entry]) + + else: + + value = str(after[entry]) + + + if entry not in remove_keys and str(before) != '{}': + + if after[entry] != before[entry]: + clean[entry] = value + + elif entry not in remove_keys: + + clean[entry] = value + + + after = json.dumps(clean) + + item_parent_pk = None + item_parent_class = None + + if self._meta.model_name == 'deviceoperatingsystem': + + item_parent_pk = self.device.pk + item_parent_class = self.device._meta.model_name + + if self._meta.model_name == 'devicesoftware': + + item_parent_pk = self.device.pk + item_parent_class = self.device._meta.model_name + + if self._meta.model_name == 'operatingsystemversion': + + item_parent_pk = self.operating_system_id + item_parent_class = self.operating_system._meta.model_name + + + if self._meta.model_name == 'softwareversion': + + item_parent_pk = self.software.pk + item_parent_class = self.software._meta.model_name + + if self._meta.model_name == 'team': + + item_parent_pk = self.organization.pk + item_parent_class = self.organization._meta.model_name + + if self._meta.model_name == 'teamusers': + + item_parent_pk = self.team.pk + item_parent_class = self.team._meta.model_name + + + if not before: + + action = History.Actions.ADD + + elif before != after: + + action = History.Actions.UPDATE + + elif not after: + + action = History.Actions.DELETE + + current_user = None + if get_request() is not None: + + current_user = get_request().user + + if current_user.is_anonymous: + current_user = None + + + if before != after and after != '{}': + entry = History.objects.create( + before = before_json, + after = after, + user = current_user, + action = action, + item_pk = self.pk, + item_class = self._meta.model_name, + item_parent_pk = item_parent_pk, + item_parent_class = item_parent_class, + ) + + entry.save() diff --git a/app/core/models/history.py b/app/core/models/history.py new file mode 100644 index 00000000..9acd9817 --- /dev/null +++ b/app/core/models/history.py @@ -0,0 +1,95 @@ +from django.contrib.auth.models import User +from django.db import models + +from access.fields import * + + +class HistoryCommonFields(models.Model): + + class Meta: + abstract = True + + id = models.AutoField( + primary_key=True, + unique=True, + blank=False + ) + + created = AutoCreatedField() + + + +class History(HistoryCommonFields): + + + class Meta: + + ordering = [ + '-created' + ] + + + class Actions(models.TextChoices): + ADD = '1', 'Create' + UPDATE = '2', 'Update' + DELETE = '3', 'Delete' + + + before = models.TextField( + help_text = 'JSON Object before Change', + blank = True, + default = None, + null = True + ) + + + after = models.TextField( + help_text = 'JSON Object After Change', + blank = True, + default = None, + null = True + ) + + + action = models.IntegerField( + choices=Actions, + default=None, + null=True, + blank = False, + ) + + + user = models.ForeignKey( + User, + on_delete=models.DO_NOTHING, + null = True, + blank= False, + ) + + item_pk = models.IntegerField( + default=None, + null = True, + blank = False, + ) + + item_class = models.CharField( + blank = False, + default=None, + null = True, + max_length = 50, + unique = False, + ) + + item_parent_pk = models.IntegerField( + default=None, + null = True, + blank = False, + ) + + item_parent_class = models.CharField( + blank = False, + default=None, + null = True, + max_length = 50, + unique = False, + ) diff --git a/app/core/models/manufacturer.py b/app/core/models/manufacturer.py new file mode 100644 index 00000000..8f831eea --- /dev/null +++ b/app/core/models/manufacturer.py @@ -0,0 +1,59 @@ +from django.contrib.auth.models import User +from django.db import models + +from access.fields import * +from access.models import TenancyObject + +from core.mixin.history_save import SaveHistory + +from settings.models.app_settings import AppSettings + +class ManufacturerCommonFields(models.Model): + + class Meta: + abstract = True + + id = models.AutoField( + primary_key=True, + unique=True, + blank=False + ) + + created = AutoCreatedField() + + modified = AutoCreatedField() + + + +class Manufacturer(TenancyObject, ManufacturerCommonFields, SaveHistory): + + + class Meta: + + ordering = [ + 'name' + ] + + name = models.CharField( + blank = False, + max_length = 50, + unique = True, + ) + + + slug = AutoSlugField() + + + def clean(self): + + app_settings = AppSettings.objects.get(owner_organization=None) + + if app_settings.manufacturer_is_global: + + self.organization = app_settings.global_organization + self.is_global = app_settings.manufacturer_is_global + + + def __str__(self): + + return self.name diff --git a/app/core/models/notes.py b/app/core/models/notes.py new file mode 100644 index 00000000..3bb36a75 --- /dev/null +++ b/app/core/models/notes.py @@ -0,0 +1,100 @@ +from django.contrib.auth.models import User +from django.db import models + +from access.fields import * +from access.models import TenancyObject + +from itam.models.device import Device +from itam.models.software import Software +from itam.models.operating_system import OperatingSystem + + +class NotesCommonFields(TenancyObject, models.Model): + + class Meta: + abstract = True + + id = models.AutoField( + primary_key=True, + unique=True, + blank=False + ) + + created = AutoCreatedField() + + modified = AutoLastModifiedField() + + + +class Notes(NotesCommonFields): + """ Notes that can be left against a model + + Currently supported models are: + - Device + - Operating System + - Software + """ + + class Meta: + + ordering = [ + '-created' + ] + + + note = models.TextField( + verbose_name = 'Note', + blank = True, + default = None, + null = True + ) + + + usercreated = models.ForeignKey( + User, + verbose_name = 'Added By', + related_name = 'usercreated', + on_delete=models.SET_DEFAULT, + default = None, + null = True, + blank= True + ) + + usermodified = models.ForeignKey( + User, + verbose_name = 'Edited By', + related_name = 'usermodified', + on_delete=models.SET_DEFAULT, + default = None, + null = True, + blank= True + ) + + device = models.ForeignKey( + Device, + on_delete=models.CASCADE, + default = None, + null = True, + blank= True + ) + + software = models.ForeignKey( + Software, + on_delete=models.CASCADE, + default = None, + null = True, + blank= True + ) + + operatingsystem = models.ForeignKey( + OperatingSystem, + on_delete=models.CASCADE, + default = None, + null = True, + blank= True + ) + + + def __str__(self): + + return 'Note ' + str(self.id) diff --git a/app/core/templates/history.html.j2 b/app/core/templates/history.html.j2 new file mode 100644 index 00000000..ace9a14c --- /dev/null +++ b/app/core/templates/history.html.j2 @@ -0,0 +1,60 @@ +{% extends 'base.html.j2' %} +{% load json %} + +{% block body %} + + + + + + + + + + + + {% for entry in history %} + + + + + + + + + + + + + + + {% endfor %} +
CreatedActionItemUser
{{ entry.created }} + {% if entry.action == 1 %} + Create + {% elif entry.action == 2 %} + Update + {% elif entry.action == 3 %} + Delete + {% else %} + fuck knows + {% endif %} + + {{ entry.item_class}} + {{ entry.user }}
+ +{% endblock %} \ No newline at end of file diff --git a/app/core/templates/note.html.j2 b/app/core/templates/note.html.j2 new file mode 100644 index 00000000..af4d1d4f --- /dev/null +++ b/app/core/templates/note.html.j2 @@ -0,0 +1,35 @@ +{% load markdown %} +
+ +
+ + + + {{ note.created }} + + + + {{ note.usercreated }} +   +
+ +
{{ note.note | markdown | safe }}
+ + + +
diff --git a/app/core/templatetags/json.py b/app/core/templatetags/json.py new file mode 100644 index 00000000..f2b60def --- /dev/null +++ b/app/core/templatetags/json.py @@ -0,0 +1,13 @@ +from django import template +from django.template.defaultfilters import stringfilter + +import json + +register = template.Library() + + +@register.filter() +@stringfilter +def json_pretty(value): + + return json.dumps(json.loads(value), indent=4, sort_keys=True) diff --git a/app/core/templatetags/markdown.py b/app/core/templatetags/markdown.py new file mode 100644 index 00000000..d303dcc7 --- /dev/null +++ b/app/core/templatetags/markdown.py @@ -0,0 +1,12 @@ +from django import template +from django.template.defaultfilters import stringfilter + +import markdown as md + +register = template.Library() + + +@register.filter() +@stringfilter +def markdown(value): + return md.markdown(value, extensions=['markdown.extensions.fenced_code']) \ No newline at end of file diff --git a/app/core/tests/test_history.py b/app/core/tests/test_history.py new file mode 100644 index 00000000..a6f96630 --- /dev/null +++ b/app/core/tests/test_history.py @@ -0,0 +1,31 @@ + +from django.test import TestCase, Client + +import pytest +import unittest +import requests + + + +@pytest.mark.skip(reason="to be written") +def test_history_auth_view_super_admin(): + """ Super Admin can view history without requiring permission """ + pass + + +@pytest.mark.skip(reason="to be written") +def test_history_no_entry_without_item(): + """ A history entry cant be created without an item + + fields required `item_pk` and `item_class` + """ + pass + + +@pytest.mark.skip(reason="to be written") +def test_history_no_entry_without_parent_item(): + """ A history entry cant be created without a parent item + + fields required `parent_item_pk` and `parent_item_class + """ + pass diff --git a/app/core/tests/test_notes.py b/app/core/tests/test_notes.py new file mode 100644 index 00000000..9c2885a8 --- /dev/null +++ b/app/core/tests/test_notes.py @@ -0,0 +1,21 @@ + +from django.test import TestCase, Client + +import pytest +import unittest +import requests + + + +@pytest.mark.skip(reason="to be written") +def test_note_new_correct_usercreated(): + """ The user who added the note must be added to the note """ + pass + + +@pytest.mark.skip(reason="to be written") +def test_note_new_correct_usermodified(): + """ The user who edited the note must be added to the note """ + pass + + diff --git a/app/core/views/history.py b/app/core/views/history.py new file mode 100644 index 00000000..d95660d7 --- /dev/null +++ b/app/core/views/history.py @@ -0,0 +1,40 @@ +import markdown + +from django.contrib.auth.mixins import PermissionRequiredMixin +from django.db.models import Q +from django.http import HttpResponseRedirect +from django.shortcuts import redirect, render +from django.views import generic + +from access.mixin import OrganizationPermission + +from core.models.history import History + +from itam.models.device import Device, DeviceSoftware, DeviceOperatingSystem +from itam.models.software import Software + + +class View(OrganizationPermission, generic.View): + + permission_required = [ + 'itam.view_history' + ] + + template_name = 'history.html.j2' + + + def get(self, request, model_name, model_pk): + if not request.user.is_authenticated and settings.LOGIN_REQUIRED: + return redirect(f"{settings.LOGIN_URL}?next={request.path}") + + context = {} + + context['history'] = History.objects.filter( + Q(item_pk = model_pk, item_class = model_name) + | + Q(item_parent_pk = model_pk, item_parent_class = model_name) + ) + + context['content_title'] = 'History' + + return render(request, self.template_name, context) diff --git a/app/information/__init__.py b/app/information/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/information/apps.py b/app/information/apps.py new file mode 100644 index 00000000..4812bad7 --- /dev/null +++ b/app/information/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class InformationConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'information' diff --git a/app/information/urls.py b/app/information/urls.py new file mode 100644 index 00000000..835103fc --- /dev/null +++ b/app/information/urls.py @@ -0,0 +1,13 @@ +from django.urls import path + +from . import views +from .views import knowledge_base, playbooks + +app_name = "Information" + +urlpatterns = [ + + path("kb/", knowledge_base.Index.as_view(), name="Knowledge Base"), + path("playbook/", playbooks.Index.as_view(), name="Playbooks"), + +] diff --git a/app/information/views/knowledge_base.py b/app/information/views/knowledge_base.py new file mode 100644 index 00000000..7aace078 --- /dev/null +++ b/app/information/views/knowledge_base.py @@ -0,0 +1,32 @@ +import json + +from django.contrib.auth.mixins import PermissionRequiredMixin +from django.db.models import Q +from django.shortcuts import render +from django.template import Template, Context +from django.views import generic + +from access.mixin import OrganizationPermission + + + +class Index(generic.View): + + # permission_required = [ + # 'itil.view_knowledge_base' + # ] + + template_name = 'form.html.j2' + + + 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) + + + context['content_title'] = 'Knowledge Base' + + return render(request, self.template_name, context) diff --git a/app/information/views/playbooks.py b/app/information/views/playbooks.py new file mode 100644 index 00000000..c52b3e04 --- /dev/null +++ b/app/information/views/playbooks.py @@ -0,0 +1,30 @@ +import json + +from django.contrib.auth.mixins import PermissionRequiredMixin +from django.db.models import Q +from django.shortcuts import render +from django.template import Template, Context +from django.views import generic + +from access.mixin import OrganizationPermission + + + +class Index(generic.View): + + # permission_required = [ + # 'itil.view_playbook' + # ] + + template_name = 'form.html.j2' + + def get(self, request): + context = {} + + user_string = Template("{% include 'icons/issue_link.html.j2' with issue=11 %}") + user_context = Context(context) + context['form'] = user_string.render(user_context) + + context['content_title'] = 'Playbooks' + + return render(request, self.template_name, context) diff --git a/app/itam/forms/device/device.py b/app/itam/forms/device/device.py index 2d1f22b3..9bf9ac5a 100644 --- a/app/itam/forms/device/device.py +++ b/app/itam/forms/device/device.py @@ -1,6 +1,7 @@ from django import forms from django.db.models import Q +from app import settings from itam.models.device import Device @@ -13,7 +14,21 @@ class DeviceForm(forms.ModelForm): fields = [ 'id', 'name', + 'device_model', 'serial_number', 'uuid', 'device_type', + 'organization' ] + + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.fields['_lastinventory'] = forms.DateTimeField( + label="Last Inventory Date", + input_formats=settings.DATETIME_FORMAT, + initial=kwargs['instance'].inventorydate, + disabled=True, + required=False, + ) diff --git a/app/itam/forms/device/operating_system.py b/app/itam/forms/device/operating_system.py index dd61aa7b..43938309 100644 --- a/app/itam/forms/device/operating_system.py +++ b/app/itam/forms/device/operating_system.py @@ -1,6 +1,8 @@ from django import forms from django.db.models import Q +from app import settings + from itam.models.device import DeviceOperatingSystem @@ -16,3 +18,17 @@ class Update(forms.ModelForm): 'operating_system_version', ] + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + if 'instance' in kwargs.keys(): + + if kwargs['instance'] is not None: + + self.fields['_created'] = forms.DateTimeField( + label="Install Date", + input_formats=settings.DATETIME_FORMAT, + initial=kwargs['instance'].installdate, + disabled=True + ) + diff --git a/app/itam/forms/operating_system/update.py b/app/itam/forms/operating_system/update.py index 3816deff..a00687b6 100644 --- a/app/itam/forms/operating_system/update.py +++ b/app/itam/forms/operating_system/update.py @@ -11,6 +11,7 @@ class Update(forms.ModelForm): model = OperatingSystem fields = [ "name", + 'publisher', 'slug', 'id', 'organization', diff --git a/app/itam/forms/software/update.py b/app/itam/forms/software/update.py index 3d3b37bb..b9337778 100644 --- a/app/itam/forms/software/update.py +++ b/app/itam/forms/software/update.py @@ -10,6 +10,7 @@ class Update(forms.ModelForm): model = Software fields = [ "name", + 'publisher', 'slug', 'id', 'organization', diff --git a/app/itam/migrations/0006_alter_devicesoftware_options_and_more.py b/app/itam/migrations/0006_alter_devicesoftware_options_and_more.py new file mode 100644 index 00000000..93bb65a2 --- /dev/null +++ b/app/itam/migrations/0006_alter_devicesoftware_options_and_more.py @@ -0,0 +1,38 @@ +# Generated by Django 5.0.6 on 2024-05-20 06:36 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('itam', '0005_alter_operatingsystemversion_name_and_more'), + ] + + operations = [ + migrations.AlterModelOptions( + name='devicesoftware', + options={'ordering': ['-action', 'software']}, + ), + migrations.AddField( + model_name='deviceoperatingsystem', + name='installdate', + field=models.DateTimeField(blank=True, default=None, null=True, verbose_name='Install Date'), + ), + migrations.AddField( + model_name='devicesoftware', + name='installed', + field=models.DateTimeField(blank=True, null=True, verbose_name='Install Date'), + ), + migrations.AddField( + model_name='devicesoftware', + name='installedversion', + field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='installedversion', to='itam.softwareversion'), + ), + migrations.AlterField( + model_name='devicesoftware', + name='action', + field=models.CharField(blank=True, choices=[('1', 'Install'), ('0', 'Remove')], default=None, max_length=1, null=True), + ), + ] diff --git a/app/itam/migrations/0007_device_inventorydate.py b/app/itam/migrations/0007_device_inventorydate.py new file mode 100644 index 00000000..0cb81014 --- /dev/null +++ b/app/itam/migrations/0007_device_inventorydate.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.6 on 2024-05-20 09:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('itam', '0006_alter_devicesoftware_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='device', + name='inventorydate', + field=models.DateTimeField(blank=True, null=True, verbose_name='Last Inventory Date'), + ), + ] diff --git a/app/itam/migrations/0008_alter_device_organization_and_more.py b/app/itam/migrations/0008_alter_device_organization_and_more.py new file mode 100644 index 00000000..6ea99488 --- /dev/null +++ b/app/itam/migrations/0008_alter_device_organization_and_more.py @@ -0,0 +1,60 @@ +# Generated by Django 5.0.6 on 2024-05-23 10:37 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('access', '0002_alter_team_organization'), + ('itam', '0007_device_inventorydate'), + ] + + operations = [ + migrations.AlterField( + model_name='device', + name='organization', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization'), + ), + migrations.AlterField( + model_name='deviceoperatingsystem', + name='organization', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization'), + ), + migrations.AlterField( + model_name='devicesoftware', + name='organization', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization'), + ), + migrations.AlterField( + model_name='devicetype', + name='organization', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization'), + ), + migrations.AlterField( + model_name='operatingsystem', + name='organization', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization'), + ), + migrations.AlterField( + model_name='operatingsystemversion', + name='organization', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization'), + ), + migrations.AlterField( + model_name='software', + name='organization', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization'), + ), + migrations.AlterField( + model_name='softwarecategory', + name='organization', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization'), + ), + migrations.AlterField( + model_name='softwareversion', + name='organization', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization'), + ), + ] diff --git a/app/itam/migrations/0009_devicemodel_device_device_model.py b/app/itam/migrations/0009_devicemodel_device_device_model.py new file mode 100644 index 00000000..7da6665f --- /dev/null +++ b/app/itam/migrations/0009_devicemodel_device_device_model.py @@ -0,0 +1,39 @@ +# Generated by Django 5.0.6 on 2024-05-23 12:05 + +import access.fields +import django.db.models.deletion +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('access', '0002_alter_team_organization'), + ('core', '0005_manufacturer'), + ('itam', '0008_alter_device_organization_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='DeviceModel', + fields=[ + ('is_global', models.BooleanField(default=False)), + ('id', models.AutoField(primary_key=True, serialize=False, unique=True)), + ('created', access.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False)), + ('modified', access.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False)), + ('name', models.CharField(max_length=50, unique=True)), + ('slug', access.fields.AutoSlugField()), + ('manufacturer', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='core.manufacturer')), + ('organization', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization')), + ], + options={ + 'ordering': ['manufacturer', 'name'], + }, + ), + migrations.AddField( + model_name='device', + name='device_model', + field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='itam.devicemodel'), + ), + ] diff --git a/app/itam/migrations/0010_operatingsystem_publisher_software_publisher.py b/app/itam/migrations/0010_operatingsystem_publisher_software_publisher.py new file mode 100644 index 00000000..1f5df1d6 --- /dev/null +++ b/app/itam/migrations/0010_operatingsystem_publisher_software_publisher.py @@ -0,0 +1,20 @@ +# Generated by Django 5.0.6 on 2024-05-23 12:48 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0005_manufacturer'), + ('itam', '0009_devicemodel_device_device_model'), + ] + + operations = [ + migrations.AddField( + model_name='operatingsystem', + name='publisher', + field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='core.manufacturer'), + ), + ] diff --git a/app/itam/migrations/0011_software_publisher.py b/app/itam/migrations/0011_software_publisher.py new file mode 100644 index 00000000..7f4cf073 --- /dev/null +++ b/app/itam/migrations/0011_software_publisher.py @@ -0,0 +1,20 @@ +# Generated by Django 5.0.6 on 2024-05-23 12:49 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0005_manufacturer'), + ('itam', '0010_operatingsystem_publisher_software_publisher'), + ] + + operations = [ + migrations.AddField( + model_name='software', + name='publisher', + field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='core.manufacturer'), + ), + ] diff --git a/app/itam/migrations/0012_alter_device_serial_number_alter_device_uuid.py b/app/itam/migrations/0012_alter_device_serial_number_alter_device_uuid.py new file mode 100644 index 00000000..d05767a3 --- /dev/null +++ b/app/itam/migrations/0012_alter_device_serial_number_alter_device_uuid.py @@ -0,0 +1,23 @@ +# Generated by Django 5.0.6 on 2024-05-28 06:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('itam', '0011_software_publisher'), + ] + + operations = [ + migrations.AlterField( + model_name='device', + name='serial_number', + field=models.CharField(blank=True, default=None, max_length=50, null=True, unique=True, verbose_name='Serial Number'), + ), + migrations.AlterField( + model_name='device', + name='uuid', + field=models.CharField(blank=True, default=None, max_length=50, null=True, unique=True, verbose_name='UUID'), + ), + ] diff --git a/app/itam/models/device.py b/app/itam/models/device.py index 43bdf06d..8642271a 100644 --- a/app/itam/models/device.py +++ b/app/itam/models/device.py @@ -2,58 +2,45 @@ from django.db import models from access.fields import * from access.models import TenancyObject + +from core.mixin.history_save import SaveHistory + + +from itam.models.device_common import DeviceCommonFields, DeviceCommonFieldsName +from itam.models.device_models import DeviceModel from itam.models.software import Software, SoftwareVersion from itam.models.operating_system import OperatingSystemVersion - -class DeviceCommonFields(TenancyObject, models.Model): - - class Meta: - abstract = True - - id = models.AutoField( - primary_key=True, - unique=True, - blank=False - ) - - created = AutoCreatedField() - - modified = AutoLastModifiedField() - - - -class DeviceCommonFieldsName(DeviceCommonFields): - - class Meta: - abstract = True - - name = models.CharField( - blank = False, - max_length = 50, - unique = True, - ) - - slug = AutoSlugField() - - +from settings.models.app_settings import AppSettings class DeviceType(DeviceCommonFieldsName): + + def clean(self): + + app_settings = AppSettings.objects.get(owner_organization=None) + + if app_settings.device_type_is_global: + + self.organization = app_settings.global_organization + self.is_global = app_settings.device_type_is_global + + def __str__(self): return self.name -class Device(DeviceCommonFieldsName): +class Device(DeviceCommonFieldsName, SaveHistory): serial_number = models.CharField( verbose_name = 'Serial Number', max_length = 50, default = None, null = True, - blank = True + blank = True, + unique = True, ) @@ -62,10 +49,18 @@ class Device(DeviceCommonFieldsName): max_length = 50, default = None, null = True, - blank = True + blank = True, + unique = True, ) + device_model = models.ForeignKey( + DeviceModel, + on_delete=models.CASCADE, + default = None, + null = True, + blank= True + ) device_type = models.ForeignKey( DeviceType, @@ -76,10 +71,19 @@ class Device(DeviceCommonFieldsName): ) + + inventorydate = models.DateTimeField( + verbose_name = 'Last Inventory Date', + null = True, + blank = True + ) + + def __str__(self): return self.name + def get_configuration(self, id): softwares = DeviceSoftware.objects.filter(device=id) @@ -89,33 +93,40 @@ class Device(DeviceCommonFieldsName): } for software in softwares: + + if software.action: - if int(software.action) == 1: + if int(software.action) == 1: - state = 'present' + state = 'present' - elif int(software.action) == 0: + elif int(software.action) == 0: - state = 'absent' + state = 'absent' - software_action = { - "name": software.software.slug, - "state": state - } + software_action = { + "name": software.software.slug, + "state": state + } - if software.version: - software_action['version'] = software.version.name + if software.version: + software_action['version'] = software.version.name - config['software'] = config['software'] + [ software_action ] + config['software'] = config['software'] + [ software_action ] return config - -class DeviceSoftware(DeviceCommonFields): +class DeviceSoftware(DeviceCommonFields, SaveHistory): """ A way for the device owner to configure software to install/remove """ + class Meta: + ordering = [ + '-action', + 'software' + ] + class Actions(models.TextChoices): INSTALL = '1', 'Install' @@ -128,7 +139,6 @@ class DeviceSoftware(DeviceCommonFields): default = None, null = False, blank= False - ) software = models.ForeignKey( @@ -137,13 +147,14 @@ class DeviceSoftware(DeviceCommonFields): default = None, null = False, blank= False - ) action = models.CharField( max_length=1, choices=Actions, default=None, + null=True, + blank = True, ) version = models.ForeignKey( @@ -152,12 +163,27 @@ class DeviceSoftware(DeviceCommonFields): default = None, null = True, blank= True - + ) + + + installedversion = models.ForeignKey( + SoftwareVersion, + related_name = 'installedversion', + on_delete=models.CASCADE, + default = None, + null = True, + blank= True + ) + + installed = models.DateTimeField( + verbose_name = 'Install Date', + null = True, + blank = True ) -class DeviceOperatingSystem(DeviceCommonFields): +class DeviceOperatingSystem(DeviceCommonFields, SaveHistory): device = models.ForeignKey( Device, @@ -184,3 +210,10 @@ class DeviceOperatingSystem(DeviceCommonFields): null = False, blank = False, ) + + installdate = models.DateTimeField( + verbose_name = 'Install Date', + null = True, + blank = True, + default = None, + ) diff --git a/app/itam/models/device_common.py b/app/itam/models/device_common.py new file mode 100644 index 00000000..159fcc3b --- /dev/null +++ b/app/itam/models/device_common.py @@ -0,0 +1,35 @@ +from django.db import models + +from access.fields import * +from access.models import TenancyObject + + +class DeviceCommonFields(TenancyObject, models.Model): + + class Meta: + abstract = True + + id = models.AutoField( + primary_key=True, + unique=True, + blank=False + ) + + created = AutoCreatedField() + + modified = AutoLastModifiedField() + + + +class DeviceCommonFieldsName(DeviceCommonFields): + + class Meta: + abstract = True + + name = models.CharField( + blank = False, + max_length = 50, + unique = True, + ) + + slug = AutoSlugField() diff --git a/app/itam/models/device_models.py b/app/itam/models/device_models.py new file mode 100644 index 00000000..0acb361a --- /dev/null +++ b/app/itam/models/device_models.py @@ -0,0 +1,44 @@ +from django.contrib.auth.models import User +from django.db import models + +from itam.models.device_common import DeviceCommonFieldsName + +from access.models import TenancyObject + +from core.mixin.history_save import SaveHistory +from core.models.manufacturer import Manufacturer + +from settings.models.app_settings import AppSettings + +class DeviceModel(DeviceCommonFieldsName, SaveHistory): + + + class Meta: + + ordering = [ + 'manufacturer', + 'name', + ] + + manufacturer = models.ForeignKey( + Manufacturer, + on_delete=models.CASCADE, + default = None, + null = True, + blank= True + ) + + + def clean(self): + + app_settings = AppSettings.objects.get(owner_organization=None) + + if app_settings.device_model_is_global: + + self.organization = app_settings.global_organization + self.is_global = app_settings.device_model_is_global + + + def __str__(self): + + return self.manufacturer.name + ' ' + self.name diff --git a/app/itam/models/operating_system.py b/app/itam/models/operating_system.py index 63b8b992..00af2ebe 100644 --- a/app/itam/models/operating_system.py +++ b/app/itam/models/operating_system.py @@ -3,6 +3,9 @@ from django.db import models from access.fields import * from access.models import TenancyObject +from core.mixin.history_save import SaveHistory +from core.models.manufacturer import Manufacturer + class OperatingSystemCommonFields(TenancyObject, models.Model): @@ -37,14 +40,22 @@ class OperatingSystemFieldsName(OperatingSystemCommonFields): -class OperatingSystem(OperatingSystemFieldsName): +class OperatingSystem(OperatingSystemFieldsName, SaveHistory): + + publisher = models.ForeignKey( + Manufacturer, + on_delete=models.CASCADE, + default = None, + null = True, + blank= True + ) def __str__(self): return self.name -class OperatingSystemVersion(OperatingSystemCommonFields): +class OperatingSystemVersion(OperatingSystemCommonFields, SaveHistory): operating_system = models.ForeignKey( OperatingSystem, diff --git a/app/itam/models/software.py b/app/itam/models/software.py index fa38eca3..5378160c 100644 --- a/app/itam/models/software.py +++ b/app/itam/models/software.py @@ -3,6 +3,10 @@ from django.db import models from access.fields import * from access.models import TenancyObject +from core.mixin.history_save import SaveHistory +from core.models.manufacturer import Manufacturer + +from settings.models.app_settings import AppSettings class SoftwareCommonFields(TenancyObject, models.Model): @@ -30,7 +34,18 @@ class SoftwareCommonFields(TenancyObject, models.Model): -class SoftwareCategory(SoftwareCommonFields): +class SoftwareCategory(SoftwareCommonFields, SaveHistory): + + + def clean(self): + + app_settings = AppSettings.objects.get(owner_organization=None) + + if app_settings.software_categories_is_global: + + self.organization = app_settings.global_organization + self.is_global = app_settings.software_categories_is_global + def __str__(self): @@ -38,7 +53,15 @@ class SoftwareCategory(SoftwareCommonFields): -class Software(SoftwareCommonFields): +class Software(SoftwareCommonFields, SaveHistory): + + publisher = models.ForeignKey( + Manufacturer, + on_delete=models.CASCADE, + default = None, + null = True, + blank= True + ) category = models.ForeignKey( SoftwareCategory, @@ -46,16 +69,27 @@ class Software(SoftwareCommonFields): default = None, null = True, blank= True - + ) + + def clean(self): + + app_settings = AppSettings.objects.get(owner_organization=None) + + if app_settings.software_is_global: + + self.organization = app_settings.global_organization + self.is_global = app_settings.software_is_global + + def __str__(self): return self.name -class SoftwareVersion(SoftwareCommonFields): +class SoftwareVersion(SoftwareCommonFields, SaveHistory): software = models.ForeignKey( Software, diff --git a/app/itam/templates/itam/device.html.j2 b/app/itam/templates/itam/device.html.j2 index f1439390..c3a47eb5 100644 --- a/app/itam/templates/itam/device.html.j2 +++ b/app/itam/templates/itam/device.html.j2 @@ -39,6 +39,7 @@ + @@ -52,6 +53,7 @@ {% include 'icons/issue_link.html.j2' with issue=6 %} {{ form.as_p }} + {% include 'icons/issue_link.html.j2' with issue=13 %}