Compare commits

...

132 Commits
0.5.0 ... 0.7.0

Author SHA1 Message Date
46c4419350 bump: version 0.6.0 → 0.7.0 2024-07-14 06:25:37 +00:00
Jon
1f35893db9 chore: footer text to be grey
does not need to be prominent

!35
2024-07-14 15:27:03 +09:30
Jon
935e10dc24 docs(development): add initial forms
!35
2024-07-14 04:56:11 +09:30
Jon
a4617c28f8 chore: complete footer layout
git icon from https://gitlab-org.gitlab.io/gitlab-svgs

!35 closes #25
2024-07-14 04:22:41 +09:30
Jon
d4aaea4dbb docs(development): update views, models and index
!35
2024-07-14 03:10:42 +09:30
Jon
a7168834ba chore: add new exception MissingAttribute
used for development

!35
2024-07-14 02:52:28 +09:30
Jon
329049e81d docs: roadmap update
!35
2024-07-14 01:51:53 +09:30
Jon
e25ec12cb0 ci: correct test report path
!35
2024-07-14 01:15:54 +09:30
Jon
5ae487cd3e fix(config_management): Don't allow a config group to assign itself as its parent
!35 fixes #122
2024-07-14 01:09:19 +09:30
Jon
4c42f77692 feat(core): Filter every form field if associated with an organization to users organizations only
!35 fixes #119
2024-07-14 01:08:00 +09:30
Jon
3aab7b57e8 fix(config_management): correct permission for deleting a host from config group
!35
2024-07-13 23:12:31 +09:30
Jon
931c9864db fix(config_management): use parent group details to work out permissions when adding a host
!35 fixes #120
2024-07-13 23:11:59 +09:30
Jon
65bf994619 fix(config_management): use parent group details to work out permissions
!35 fixes #121
2024-07-13 22:44:54 +09:30
Jon
367c4bebb6 refactor: adjust views missing add/change form to now use forms
!35 #15 #46 #74 #120 #121 fixes #118
2024-07-13 17:32:45 +09:30
Jon
8c1be67974 test: add test test_view_*_attribute_not_exists_fields for add and change views
!25 #15 #46
2024-07-13 17:25:20 +09:30
Jon
789c035a03 test: fix test_view_change_attribute_type_form_class to test if type class
!25 #15 #46
2024-07-13 17:23:56 +09:30
Jon
1cf15f7339 feat(core): add var template_name to common view template for all views that require it
!35
2024-07-13 16:01:12 +09:30
Jon
77ff580f19 fix(itam): Add missing permissions to software categories index view
!35 #74
2024-07-13 16:01:12 +09:30
Jon
423ff11d4c fix(itam): Add missing permissions to device types index view
!35 #74
2024-07-13 16:01:12 +09:30
Jon
9e4b5185b1 fix(itam): Add missing permissions to device model index view
!35 #74
2024-07-13 16:01:12 +09:30
Jon
020441c41a fix(settings): Add missing permissions to app settings view
!35 #74
2024-07-13 16:01:12 +09:30
Jon
d0a3b7b49d fix(itam): Add missing permissions to software index view
!35 #74
2024-07-13 16:01:12 +09:30
Jon
960fa5485d fix(itam): Add missing permissions to operating system index view
!35 #74
2024-07-13 16:01:12 +09:30
Jon
26db463044 fix(itam): Add missing permissions to device index view
!35 #74
2024-07-13 16:01:12 +09:30
Jon
1193f1d86d fix(config_management): Add missing permissions to group views
!35 #74
2024-07-13 16:01:12 +09:30
Jon
9bece0a811 test(views): add test cases for model views
!35  #46 #15 #118 #120 #121
2024-07-13 16:01:12 +09:30
Jon
f29ec63f46 test: Add Test case abstract classes to models
!35 #46 #15
2024-07-13 16:01:12 +09:30
Jon
e48278e6e9 Merge branch 'docs' into 'development'
docs(centurion_docs): update docs

See merge request nofusscomputing/projects/centurion_erp!41
2024-07-13 04:19:40 +00:00
c41c7ed1f0 docs: update mkdocs
Change Repo name
Update URL and URI

!41
2024-07-13 12:04:59 +09:30
c9190e9a7d docs: Update index
Slight re-word

nofusscomputing/projects/centurion_erp!41
2024-07-13 11:58:15 +09:30
0294f5ed65 docs(centurion): replace Django ITSM -> Centurion ERP
nofusscomputing/projects/centurion_erp!41
2024-07-13 11:57:34 +09:30
Jon
ae4fdcfc58 chore: clean-up readme
!35
2024-07-12 13:50:34 +09:30
Jon
a395f30bd4 fix(security) update djangorestframework 3.15.1 -> 3.15.2
[CVE-2024-21520](https://cwe.mitre.org/data/definitions/79.html)

!35
2024-07-12 13:48:06 +09:30
Jon
c057ffdc9c feat(core): add Display view to common forms abstract class
intended to display generic data

!35
2024-07-12 12:47:17 +09:30
Jon
6837c38303 feat(navigation): always show every menu for super admin
!35
2024-07-12 12:44:41 +09:30
Jon
ee8920a464 fix(navigation): always show settings menu entry
!35
2024-07-12 09:27:29 +09:30
Jon
ccfdf005f7 chore: remove issue#13 links for model notes
!35 closes #13
2024-07-12 09:23:12 +09:30
Jon
0276f9454b refactor: add navigation menu expand arrows
!35 closes #21
2024-07-12 09:19:13 +09:30
Jon
45cc34284a feat(core): only display navigation menu item if use can view model
!35 fixes #114
2024-07-12 07:16:05 +09:30
Jon
7329a65ae7 docs: update roadmap
!35
2024-07-12 05:56:22 +09:30
Jon
9a529a64e2 docs: add bug count badge
!35
2024-07-12 05:53:21 +09:30
Jon
f2640df0d3 feat(django): update 5.0.6 -> 5.0.7
!35
2024-07-12 05:51:21 +09:30
Jon
7d172fb4af refactor: migrate views to use new abstract model view classes
!35 fixes #111
2024-07-12 05:50:36 +09:30
Jon
f848d01b34 refactor: migrate forms to use new abstract model form class
!35
2024-07-12 05:47:49 +09:30
Jon
44f20b28be feat(core): add common forms abstract class
form class for inclusion in our forms

!35
2024-07-12 05:45:39 +09:30
Jon
2e22a484a0 feat(core): add common views abstract class
class for our views

!35
2024-07-12 05:03:11 +09:30
Jon
a62a36ba82 fix(itam): cater for fields that are prefixed
!35 fixes #112
2024-07-11 18:27:07 +09:30
Jon
c00cf16bc8 fix(itam): Ability to view software category
Ensure organization filters to list of orgs

!35
2024-07-11 18:12:07 +09:30
Jon
7784dfede9 fix(itam): correct view permission
!35
2024-07-11 18:01:10 +09:30
Jon
03d350e302 fix(access): When adding a new team to org ensure parent model is fetched
!35
2024-07-11 17:56:12 +09:30
Jon
9b79c9d7ff docs: update readme
!35
2024-07-11 16:57:21 +09:30
Jon
1d5c86f13b fix(access): enable org manager to view orgs
corrects http/500

!35 fixes #105
2024-07-11 16:34:19 +09:30
Jon
9e336d368d fix(settings): restrict user visible organizations to ones they are part of
this includes if they are the org manager

!35 fixes #99
2024-07-11 15:53:50 +09:30
Jon
937e935949 fix(access): enable org manager to view orgs
!35 fixes #105
2024-07-11 15:37:16 +09:30
Jon
860eaa6749 fix(access): fetch object if method exists
enables the setting og self.model with get_object method

!35 fixes #105
2024-07-11 14:25:53 +09:30
Jon
aab94431a9 fix(docs): update docs link to new path
!35 fixes #103
2024-07-11 14:08:18 +09:30
Jon
7cfede45b8 refactor(access): Rename Team Button "new user" -> "Assign User"
!35 fixes #110
2024-07-11 14:00:55 +09:30
Jon
65de93715d refactor(access): model pk and name not required context for adding a device
!35
2024-07-11 13:59:58 +09:30
Jon
fea7ea3119 refactor: rename field "model notes" -> "Notes"
!35 fixes #102 #104
2024-07-11 13:58:14 +09:30
Jon
524a70ba18 fix(access): correctly set team user parent model to team
!35 fixes #109
2024-07-11 13:43:27 +09:30
Jon
29c4b4a0ca fix(access): fallback to django permissions if org permissions check is false
!35 #109 fixes #101
2024-07-11 13:41:36 +09:30
Jon
f5ae01b08d fix(access): Correct logic so that org managers can see orgs they manage
!35 fixes #100
2024-07-10 17:23:24 +09:30
Jon
ee3dd68cfe fix(base): add missing content_title to context
!35 #74
2024-07-10 15:58:18 +09:30
Jon
25efa31493 fix(access): Enable Organization Manager to view organisations they are assigned to
!35 fixes #100
2024-07-10 15:14:53 +09:30
Jon
4a6ce35332 fix(api): correct logic for adding inventory UUID and serial number to device
!35
2024-07-10 14:45:46 +09:30
Jon
332810ffd6 feat: add postgreSQL database support
!35
2024-07-10 14:05:34 +09:30
Jon
f0bbd22cf4 refactor: remove settings model
wasnt required, so removed

!35
2024-07-10 12:02:06 +09:30
Jon
6bf681530d chore: fix docs indent
!35
2024-07-10 03:04:04 +09:30
Jon
9dd2f6a341 docs: fix mkdocs navigation
!35
2024-07-10 02:13:32 +09:30
Jon
23c640a460 docs: add roadmap
!35
2024-07-10 02:13:12 +09:30
Jon
3c6092f776 chore: rename to Centurion ERP
!35
2024-07-10 01:58:19 +09:30
Jon
cb66b9303a feat(ui): add config groups navigation icon
!35
2024-07-09 22:50:38 +09:30
Jon
2d80f02634 fix(ui): navigation alignment and software icon
!35
2024-07-09 22:34:35 +09:30
Jon
abe1ce6948 fix(ui): display organization manager name instead of ID
!35
2024-07-09 22:31:55 +09:30
Jon
fb907283b0 refactor(ui): increase indentation to sub-menu items
!35
2024-07-09 17:32:29 +09:30
Jon
86ed7318ec fix(access): ensure name param exists before attempting to access
!35
2024-07-09 17:29:48 +09:30
Jon
a2a8e12046 feat(ui): add some navigation icons
!35 #21 #22 #23
2024-07-09 17:21:46 +09:30
Jon
c1a8ee65f2 refactor(itam): rename old inventory status icon for use with security
!35
2024-07-09 16:24:52 +09:30
Jon
6a14f78bf7 feat(itam): update inventory status icon
!35
2024-07-09 16:24:10 +09:30
Jon
90a01911da fix(itam): dont show none/nil for device fields containing no value
!35
2024-07-09 15:47:27 +09:30
Jon
de3ed3a881 fix(itam): show device model name instead of ID
!35
2024-07-09 15:42:46 +09:30
Jon
656807e410 feat(itam): ensure device software pagination links keep interface on software tab
!35 closes #81
2024-07-09 14:33:22 +09:30
Jon
f64be2ea33 fix(api): Ensure if serial number from inventory is null that it's not used
!35 fixes #78
2024-07-09 13:21:19 +09:30
Jon
ef9c596ec7 fix(api): ensure checked uuid and serial number is used for updating
!35
2024-07-09 05:28:48 +09:30
Jon
f22e886d92 Merge branch '76-background-worker' into 'development'
feat: "Migrate inventory processing to background worker"

See merge request nofusscomputing/projects/django_template!39
2024-07-08 18:34:33 +00:00
Jon
a2c67541ec test(inventory): add mocks?? for calling background worker
!39 #76
2024-07-09 03:51:21 +09:30
Jon
5f4231ab04 test(view): view permission checks
!39 #76
2024-07-09 02:29:35 +09:30
Jon
b0405c8fd0 test(inventory): update tests for background worker changes
!39 #76
2024-07-09 02:27:24 +09:30
Jon
b42bb3a30e feat(access): enable non-organization django permission checks
!39 #76
2024-07-09 02:26:21 +09:30
Jon
27eb54cc37 docs(api): update swagger docs with inventory changes
!39 #76
2024-07-08 23:10:34 +09:30
Jon
a8e2c687b1 docs(administration): notate rabbitMQ setup
!39 #76
2024-07-08 23:06:51 +09:30
Jon
7aeba34787 refactor(api): migrate inventory processing to background worker
!39 #76
2024-07-08 22:54:34 +09:30
Jon
090c4a5425 feat(settings): Add celery task results index and view page
!39 #76
2024-07-08 22:52:34 +09:30
Jon
87a1f2aa20 feat(base): Add background worker
!39 #76
2024-07-08 22:38:06 +09:30
Jon
70135eaa91 Merge branch 'fixes-inventory' into 'development'
fix: inventory

See merge request nofusscomputing/projects/django_template!38
2024-07-06 06:17:54 +00:00
Jon
f47b97e2a0 refactor(itam): only perform actions on device inventory if DB matches inventory item
!38 #75
2024-07-06 15:45:48 +09:30
Jon
67f20ecb66 fix(itam): only remove device software when not found during inventory upload
!38 #75
2024-07-06 15:45:48 +09:30
Jon
3bceb66600 fix(itam): only update software version if different
!38 #75
2024-07-06 15:45:48 +09:30
Jon
fe34b8274d chore: update submodules to head
!35
2024-07-01 04:03:05 +09:30
Jon
a235aa7ec3 ci: add submodule update job
!35
2024-07-01 04:02:51 +09:30
Jon
f69f883439 Merge branch '66-fix-inventory-uuid-update' into 'development'
fix: existing device without uuid not updated when uploading an inventory

Closes #66

See merge request nofusscomputing/projects/django_template!37
2024-06-30 15:06:02 +00:00
Jon
7b4ed7b135 feat(itam): Update Serial Number from inventory if present and Serial Number not set
!37
2024-07-01 00:27:46 +09:30
Jon
b801c9a49e feat(itam): Update UUID from inventory if present and UUID not set
!37 fixes #66
2024-07-01 00:27:28 +09:30
Jon
583e1767a1 Merge branch '67-fix-device-software-pagination' into 'development'
fix: Device Software tab pagination does not work

Closes #67

See merge request nofusscomputing/projects/django_template!36
2024-06-30 14:33:19 +00:00
Jon
241ba47c80 fix(itam): correct device software pagination
!36 fixes #67
2024-07-01 00:01:28 +09:30
c2d673ca1b bump: version 0.5.0 → 0.6.0 2024-06-30 07:13:40 +00:00
Jon
05c46df0a9 Merge branch '63-feat-user-api-token' into 'development'
feat: user api token

Closes #63 and #65

See merge request nofusscomputing/projects/django_template!34
2024-06-30 06:51:16 +00:00
Jon
53284d456f test(token_auth): test authentication method token
!34 closes #63
2024-06-30 16:16:29 +09:30
Jon
6cfcf1580c fix(user_token): conduct user check on token view access
!34 #63
2024-06-30 16:05:31 +09:30
Jon
4d3a238583 docs: Add user settings documentation
!34 #63
2024-06-30 12:35:58 +09:30
Jon
47d6a3beff docs(api): API Token authentication
!34 #63
2024-06-29 16:24:18 +09:30
Jon
111791438a feat(api): API token authentication
!34 #63
2024-06-29 15:09:30 +09:30
Jon
ce2c6f3b13 feat(api): abilty for user to create/delete api token
!34 #63
2024-06-28 15:52:55 +09:30
Jon
e655f22fac feat(api): create token model
!34 #63
2024-06-28 03:57:09 +09:30
Jon
66b8d9362d refactor(settings): use seperate change/view views
!34
2024-06-28 02:06:30 +09:30
Jon
37d277e149 refactor(settings): use form for user settings
!34
2024-06-28 01:18:48 +09:30
Jon
f686691232 fix(itam): use same form for edit and add
!34 fixes #65
2024-06-27 17:53:38 +09:30
Jon
802f2c410d fix(itam): dont add field inventorydate if adding new item
!34
2024-06-27 17:52:18 +09:30
Jon
be559d3d9d Merge branch 'testing' into 'development'
test: more tests

See merge request nofusscomputing/projects/django_template!33
2024-06-19 19:47:33 +00:00
Jon
d6cfef3a0b test: add .coveragerc to remove non-code files from coverage report
!33 #15
2024-06-20 05:12:04 +09:30
Jon
4fdb3df06e test: Unit Tests TenancyObjects
!33 #15
2024-06-20 03:12:43 +09:30
Jon
7eb0651b89 test: Test Cases for TenancyObjects
!33 #15
2024-06-20 03:07:39 +09:30
Jon
6d3984f6e1 test: tests for checking links from rendered templetes
!33 #15
2024-06-20 02:09:15 +09:30
Jon
58b134ae30 refactor(tests): move unit tests to unit test sub-directory
!33 #15
2024-06-19 22:58:50 +09:30
Jon
50384044c8 test(core): test cases for notes permissions
!33 #15
2024-06-19 17:08:42 +09:30
Jon
2cda4228ce test(config_management): config groups history permissions
!33 #15
2024-06-19 16:09:23 +09:30
Jon
67585b9f89 test(api): Majority of Inventory upload tests
!33 #15
2024-06-19 15:48:10 +09:30
Jon
4e42856027 fix(api): inventory upload requires sanitization
!33
2024-06-19 15:00:30 +09:30
Jon
58051f297f test(access): TenancyObject field tests
!33 #15
2024-06-19 11:51:03 +09:30
Jon
0a9a5b20fa test(access): remove skipped api tests for team users
teamusers not accessible from api

!33 #15
2024-06-19 10:52:24 +09:30
Jon
a0874356fd ci(git_sync): sync on push ro feature branch 14-feat-project-management
!29 !31
2024-06-18 08:55:56 +09:30
Jon
5d8f5e3a51 ci: remove dockerhub publish on bot push
!29
2024-06-18 01:21:30 +09:30
324 changed files with 6577 additions and 1731 deletions

View File

@ -3,5 +3,5 @@ commitizen:
prerelease_offset: 1
tag_format: $version
update_changelog_on_bump: false
version: 0.5.0
version: 0.7.0
version_scheme: semver

5
.gitignore vendored
View File

@ -1,10 +1,11 @@
venv/**
*/static/**
__pycache__
**db.sqlite3
**.sqlite3
**.sqlite
**.coverage
artifacts/
**.tmp.*
volumes/
build/
pages/
pages/

View File

@ -2,21 +2,21 @@
variables:
MY_PROJECT_ID: "57560288"
GIT_SYNC_URL: "https://$GITHUB_USERNAME_ROBOT:$GITHUB_TOKEN_ROBOT@github.com/NoFussComputing/django_template.git"
GIT_SYNC_URL: "https://$GITHUB_USERNAME_ROBOT:$GITHUB_TOKEN_ROBOT@github.com/NoFussComputing/centurion_erp.git"
# Docker Build / Publish
DOCKER_IMAGE_BUILD_TARGET_PLATFORMS: "linux/amd64,linux/arm64"
DOCKER_IMAGE_BUILD_NAME: django-template
DOCKER_IMAGE_BUILD_NAME: centurion-erp
DOCKER_IMAGE_BUILD_REGISTRY: $CI_REGISTRY_IMAGE
DOCKER_IMAGE_BUILD_TAG: $CI_COMMIT_SHA
# Docker Publish
DOCKER_IMAGE_PUBLISH_NAME: django-template
DOCKER_IMAGE_PUBLISH_NAME: centurion-erp
DOCKER_IMAGE_PUBLISH_REGISTRY: docker.io/nofusscomputing
DOCKER_IMAGE_PUBLISH_URL: https://hub.docker.com/r/nofusscomputing/$DOCKER_IMAGE_PUBLISH_NAME
# Docs NFC
PAGES_ENVIRONMENT_PATH: projects/django-template/
PAGES_ENVIRONMENT_PATH: projects/centurion_erp/
# RELEASE_ADDITIONAL_ACTIONS_BUMP: ./.gitlab/additional_actions_bump.sh
@ -31,6 +31,9 @@ include:
- template/automagic.gitlab-ci.yaml
Update Git Submodules:
extends: .ansible_playbook_git_submodule
Docker Container:
extends: .build_docker_container
@ -125,6 +128,12 @@ Docker.Hub.Branch.Publish:
# - '{dockerfile,dockerfile.j2}'
# when: always
- if:
$CI_COMMIT_AUTHOR =='nfc_bot <helpdesk@nofusscomputing.com>'
&&
$CI_COMMIT_BRANCH == "development"
when: never
- if: $CI_COMMIT_TAG
exists:
- '{dockerfile,dockerfile.j2}'
@ -141,6 +150,31 @@ Docker.Hub.Branch.Publish:
- when: never
Github (Push --mirror):
extends:
- .git_push_mirror
needs: []
rules:
- if: '$JOB_STOP_GIT_PUSH_MIRROR'
when: never
- if: $GIT_SYNC_URL == null
when: never
- if: # condition_master_or_dev_push
(
$CI_COMMIT_BRANCH == "master"
||
$CI_COMMIT_BRANCH == "development"
||
$CI_COMMIT_BRANCH == "14-feat-project-management"
) &&
$CI_PIPELINE_SOURCE == "push"
when: always
- when: never
Website.Submodule.Deploy:
extends: .submodule_update_trigger
variables:

View File

@ -1,29 +1,21 @@
Unit:
.pytest:
stage: test
image: python:3.11-alpine3.19
needs: []
script:
before_script:
- pip install -r requirements.txt
- pip install -r requirements_test.txt
- cd app
- pytest --cov --cov-report term --cov-report xml:../artifacts/coverage.xml --cov-report html:../artifacts/coverage/ --junit-xml=../artifacts/unit.JUnit.xml
coverage: '/(?i)total.*? (100(?:\.0+)?\%|[1-9]?\d(?:\.\d+)?\%)$/'
artifacts:
expire_in: "30 days"
when: always
reports:
coverage_report:
coverage_format: cobertura
path: artifacts/coverage.xml
junit:
- artifacts/*.JUnit.xml
paths:
- artifacts/
environment:
name: PyTest Coverage Report
url: https://nofusscomputing.gitlab.io/-/projects/django_template/-/jobs/${CI_JOB_ID}/artifacts/artifacts/coverage/index.html
rules:
- if: # Occur on merge
@ -38,3 +30,52 @@ Unit:
- when: never
Unit:
extends: .pytest
script:
- pytest --cov --cov-report term --cov-report xml:../artifacts/coverage.xml --cov-report html:../artifacts/coverage/ --junit-xml=../artifacts/unit.JUnit.xml **/tests/unit
coverage: '/(?i)total.*? (100(?:\.0+)?\%|[1-9]?\d(?:\.\d+)?\%)$/'
artifacts:
expire_in: "30 days"
when: always
reports:
coverage_report:
coverage_format: cobertura
path: artifacts/coverage.xml
junit:
- artifacts/*.JUnit.xml
paths:
- artifacts/
environment:
name: Unit Test Coverage Report
url: https://nofusscomputing.gitlab.io/-/projects/centurion_erp/-/jobs/${CI_JOB_ID}/artifacts/artifacts/coverage/index.html
UI:
extends: .pytest
script:
- apk update
- apk add chromium-chromedriver
- pytest --junit-xml=../artifacts/ui.JUnit.xml **/tests/ui
artifacts:
expire_in: "30 days"
when: always
reports:
junit:
- artifacts/*.JUnit.xml
paths:
- artifacts/
rules:
- if: # Occur on merge
$CI_COMMIT_BRANCH
&&
(
$CI_PIPELINE_SOURCE == "push"
||
$CI_PIPELINE_SOURCE == "web"
)
allow_failure: true
when: always
- when: never

17
.vscode/launch.json vendored
View File

@ -15,6 +15,23 @@
"django": true,
"autoStartBrowser": false,
"program": "${workspaceFolder}/app/manage.py"
},
{
"name": "Debug: Celery",
"type": "python",
"request": "launch",
"module": "celery",
"console": "integratedTerminal",
"args": [
"-A",
"app",
"worker",
"-l",
"INFO",
"-n",
"debug-itsm@%h"
],
"cwd": "${workspaceFolder}/app"
}
]
}

View File

@ -13,4 +13,8 @@
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true,
"testing.coverageToolbarEnabled": true,
"cSpell.words": [
"ITSM"
],
"cSpell.language": "en-AU",
}

View File

@ -1,3 +1,134 @@
## 0.7.0 (2024-07-14)
### Bug Fixes
- **config_management**: [5ae487cd](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/5ae487cd3eac2f5273d3b2a9e7642e714bdbde68) - Don't allow a config group to assign itself as its parent [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) [#122](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues/122) ]
- **config_management**: [3aab7b57](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/3aab7b57e80b48e1f4671413034c1c71dfad4c66) - correct permission for deleting a host from config group [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) ]
- **config_management**: [931c9864](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/931c9864db8e14072a0a7e331d525aeedd19eb2a) - use parent group details to work out permissions when adding a host [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) [#120](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues/120) ]
- **config_management**: [65bf9946](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/65bf994619949c4d42bfa92e06e8c63f67acabca) - use parent group details to work out permissions [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) [#121](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues/121) ]
- **itam**: [77ff580f](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/77ff580f19e41a430cfa7d3bf1ba3870d7993cf9) - Add missing permissions to software categories index view [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) [#74](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues/74) ]
- **itam**: [423ff11d](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/423ff11d4c30670cb8c7832b452af78cf54a5fd3) - Add missing permissions to device types index view [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) [#74](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues/74) ]
- **itam**: [9e4b5185](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/9e4b5185b144ade67b98aeb6e909e1af39b62545) - Add missing permissions to device model index view [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) [#74](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues/74) ]
- **settings**: [020441c4](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/020441c41aae4ccc3453becdf451918bc3a432f7) - Add missing permissions to app settings view [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) [#74](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues/74) ]
- **itam**: [d0a3b7b4](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/d0a3b7b49dfa8c0468106652af723b824c7ecf89) - Add missing permissions to software index view [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) [#74](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues/74) ]
- **itam**: [960fa548](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/960fa5485d3198c1e4868957e6d57f3c8bd65cf8) - Add missing permissions to operating system index view [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) [#74](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues/74) ]
- **itam**: [26db4630](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/26db4630445ceff412a6d12f97f661e95c165a3b) - Add missing permissions to device index view [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) [#74](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues/74) ]
- **config_management**: [1193f1d8](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/1193f1d86d247e5df77dc44bae36a5534c0f0c88) - Add missing permissions to group views [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) [#74](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues/74) ]
- **navigation**: [ee8920a4](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/ee8920a464017e2ec7ef714530a105197eaae75b) - always show settings menu entry [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) ]
- **itam**: [a62a36ba](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/a62a36ba82252257646325679180c68632971c52) - cater for fields that are prefixed [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) [#112](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues/112) ]
- **itam**: [c00cf16b](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/c00cf16bc8ac85f5c5bf19d30cf78ae9a838d00f) - Ability to view software category [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) ]
- **itam**: [7784dfed](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/7784dfede98f94bd1a5e4df5155130e91876fe67) - correct view permission [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) ]
- **access**: [03d350e3](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/03d350e302c443ff53fc71e32bc32f489af1409a) - When adding a new team to org ensure parent model is fetched [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) ]
- **access**: [1d5c86f1](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/1d5c86f13b2df36e0562e1fe3b6aca7dfa04a7ec) - enable org manager to view orgs [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) [#105](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues/105) ]
- **settings**: [9e336d36](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/9e336d368d51381701b358287853f9ceab61b49f) - restrict user visible organizations to ones they are part of [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) [#99](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues/99) ]
- **access**: [937e9359](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/937e9359498da9388fd730c69e591679458c331e) - enable org manager to view orgs [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) [#105](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues/105) ]
- **access**: [860eaa67](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/860eaa674937dc9ff1690615bafcddaab38d890d) - fetch object if method exists [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) [#105](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues/105) ]
- **docs**: [aab94431](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/aab94431a9d3864ace91af70e29b5dc59b61fd6f) - update docs link to new path [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) [#103](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues/103) ]
- **access**: [524a70ba](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/524a70ba184c0af1125636f5923492ba65765f1e) - correctly set team user parent model to team [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) [#109](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues/109) ]
- **access**: [29c4b4a0](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/29c4b4a0caaa37866774235d56312f2b9ede8148) - fallback to django permissions if org permissions check is false [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) [#109](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues/109) [#101](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues/101) ]
- **access**: [f5ae01b0](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/f5ae01b08d0ce91e4af86a042effff79db489d6a) - Correct logic so that org managers can see orgs they manage [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) [#100](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues/100) ]
- **base**: [ee3dd68c](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/ee3dd68cfe78c71a4431e88f6d1f6429dd2af8c0) - add missing content_title to context [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) [#74](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues/74) ]
- **access**: [25efa314](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/25efa31493f71eca5d553d46136d3a261a9d3612) - Enable Organization Manager to view organisations they are assigned to [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) [#100](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues/100) ]
- **api**: [4a6ce353](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/4a6ce353325148f66e3216e61feee7ad96b45cbc) - correct logic for adding inventory UUID and serial number to device [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) ]
- **ui**: [2d80f026](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/2d80f026341910eecd23560ef970323ac275b112) - navigation alignment and software icon [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) ]
- **ui**: [abe1ce69](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/abe1ce69480de5c831d5183845987bc9a3264fc3) - display organization manager name instead of ID [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) ]
- **access**: [86ed7318](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/86ed7318ecd43c15cfccdb344af4ea2ac05c8a87) - ensure name param exists before attempting to access [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) ]
- **itam**: [90a01911](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/90a01911dacd698d0b7832a05e24eec6fe8310eb) - dont show none/nil for device fields containing no value [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) ]
- **itam**: [de3ed3a8](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/de3ed3a881bd53e533b9b35f37ce17419c6d75f2) - show device model name instead of ID [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) ]
- **api**: [f64be2ea](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/f64be2ea33ebf0ae1ba735a7259caf880bcddad5) - Ensure if serial number from inventory is `null` that it's not used [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) [#78](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues/78) ]
- **api**: [ef9c596e](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/ef9c596ec79decec268ffb700e62d5bc35e49019) - ensure checked uuid and serial number is used for updating [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) ]
- **itam**: [67f20ecb](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/67f20ecb661b039092ad34b491c3a8a7296534db) - only remove device software when not found during inventory upload [ [!38](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/38) [#75](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues/75) ]
- **itam**: [3bceb666](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/3bceb66600404919ac32498ffa5cbb4f24fcced4) - only update software version if different [ [!38](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/38) [#75](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues/75) ]
- **itam**: [241ba47c](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/241ba47c80805dbd648392ef0b6b26793d3f55ff) - correct device software pagination [ [!36](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/36) [#67](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues/67) ]
### Code Refactor
- [367c4beb](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/367c4bebb67c24ffcc2ade19abfeac6089a1e702) - adjust views missing add/change form to now use forms [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) [#15](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues/15) [#46](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues/46) [#74](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues/74) [#120](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues/120) [#121](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues/121) [#118](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues/118) ]
- [0276f945](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/0276f9454b3999ec147314327b25945a69213250) - add navigation menu expand arrows [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) [#21](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues/21) ]
- [7d172fb4](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/7d172fb4afa284e67231d3d24c7f1bdc533f922a) - migrate views to use new abstract model view classes [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) [#111](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues/111) ]
- [f848d01b](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/f848d01b347af5615613e8bfdb0d9d7324e1daec) - migrate forms to use new abstract model form class [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) ]
- **access**: [7cfede45](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/7cfede45b89dd5d5ce5b2d2fa8e4ef0c64d31ab3) - Rename Team Button "new user" -> "Assign User" [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) [#110](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues/110) ]
- **access**: [65de9371](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/65de93715d505d5c71b150175e31471b5a07bb8f) - model pk and name not required context for adding a device [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) ]
- [fea7ea31](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/fea7ea31198190bf115dc89595b00d7e034aa991) - rename field "model notes" -> "Notes" [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) [#102](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues/102) [#104](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues/104) ]
- [f0bbd22c](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/f0bbd22cf441cb3c3f5b01c8108a7a0fd8357938) - remove settings model [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) ]
- **ui**: [fb907283](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/fb907283b036454aa2afd41bbc658c8feb1a44d8) - increase indentation to sub-menu items [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) ]
- **itam**: [c1a8ee65](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/c1a8ee65f2bc892c2ca8bfacc5f95094a0130b24) - rename old inventory status icon for use with security [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) ]
- **api**: [7aeba347](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/7aeba347875cbefb6d5eae112804ae6f0097d264) - migrate inventory processing to background worker [ [!39](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/39) [#76](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues/76) ]
- **itam**: [f47b97e2](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/f47b97e2a084e1acbfba733c910f3b8f4f764a36) - only perform actions on device inventory if DB matches inventory item [ [!38](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/38) [#75](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues/75) ]
### Continious Integration
- [e25ec12c](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/e25ec12cb02f8f48853806d9cc97959d3e757115) - correct test report path [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) ]
- [a235aa7e](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/a235aa7ec37a6dda69bc96ab854df41eacba255b) - add submodule update job [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) ]
### Documentaton / Guides
- **development**: [935e10dc](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/935e10dc24d10c2d99b52a185730d676b4978908) - add initial forms [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) ]
- **development**: [d4aaea4d](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/d4aaea4dbb250f23850d02d8c5cb9ecbc147a9da) - update views, models and index [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) ]
- [329049e8](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/329049e81dd50ca01512f75323669c7953be2199) - roadmap update [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) ]
- [c41c7ed1](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/c41c7ed1f09a7a13ac93043bee4e3f3ce0613245) - update mkdocs [ [!41](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/41) ]
- [c9190e9a](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/c9190e9a7dd12725df323811816ea603315331e9) - Update index [ [!41](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/41) ]
- **centurion**: [0294f5ed](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/0294f5ed65868fadd9f27a8a9c22ca861418061b) - replace Django ITSM -> Centurion ERP [ [!41](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/41) ]
- [7329a65a](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/7329a65ae7f30b6e89d0c284609aac2063c76ea6) - update roadmap [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) ]
- [9a529a64](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/9a529a64e2e5deaeebec872e3cbe91a6eccebcbe) - add bug count badge [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) ]
- [9b79c9d7](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/9b79c9d7ffaacc6ecd9a8d379bc26a303d2951c4) - update readme [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) ]
- [9dd2f6a3](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/9dd2f6a341c4e4c78de4e80769552914a4b23bc9) - fix mkdocs navigation [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) ]
- [23c640a4](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/23c640a4602948d1b6ec0d3a88473aa26d359997) - add roadmap [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) ]
- **api**: [27eb54cc](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/27eb54cc3729bcf3ac4b74c84b2def8086685b34) - update swagger docs with inventory changes [ [!39](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/39) [#76](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues/76) ]
- **administration**: [a8e2c687](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/a8e2c687b11186f922abd57bb46e98de9f9bf985) - notate rabbitMQ setup [ [!39](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/39) [#76](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues/76) ]
### Features
- **core**: [4c42f776](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/4c42f776924dc2f8c52c237b9facd35f10e85e28) - Filter every form field if associated with an organization to users organizations only [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) [#119](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues/119) ]
- **core**: [1cf15f73](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/1cf15f7339a50d9aa1a7940feb84a3128a573ea6) - add var `template_name` to common view template for all views that require it [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) ]
- **core**: [c057ffdc](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/c057ffdc9c57b399206579f13c51bf4d28120e88) - add Display view to common forms abstract class [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) ]
- **navigation**: [6837c383](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/6837c383034b962973248cbdd6ae6a5a8a758a41) - always show every menu for super admin [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) ]
- **core**: [45cc3428](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/45cc34284a7b89aa3d61e61cfa3a12c73165127b) - only display navigation menu item if use can view model [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) [#114](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues/114) ]
- **django**: [f2640df0](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/f2640df0d3737a5fc7a416cd692c37690786e7d1) - update 5.0.6 -> 5.0.7 [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) ]
- **core**: [44f20b28](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/44f20b28be8c54978b53f00fac9664a5c403ed50) - add common forms abstract class [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) ]
- **core**: [2e22a484](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/2e22a484a0f4416f41dffb49c68db703c177fe0d) - add common views abstract class [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) ]
- [332810ff](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/332810ffd6cf6024a0a917024eafed21ec8d2139) - add postgreSQL database support [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) ]
- **ui**: [cb66b930](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/cb66b9303aa752ee131e3fd6edb9d670e37c3b0e) - add config groups navigation icon [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) ]
- **ui**: [a2a8e120](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/a2a8e1204649a27481862e85f8292a229e85fa97) - add some navigation icons [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) [#21](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues/21) [#22](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues/22) [#23](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues/23) ]
- **itam**: [6a14f78b](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/6a14f78bf7fcda8509d1b0c6b477711b4da59180) - update inventory status icon [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) ]
- **itam**: [656807e4](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/656807e410c4dcc0d049cc61ce67c07114313925) - ensure device software pagination links keep interface on software tab [ [!35](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/35) [#81](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues/81) ]
- **access**: [b42bb3a3](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/b42bb3a30e613ed9701c95c6ad10fa1890d17dac) - enable non-organization django permission checks [ [!39](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/39) [#76](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues/76) ]
- **settings**: [090c4a54](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/090c4a542544cb61356bc00ce2258463d5647f67) - Add celery task results index and view page [ [!39](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/39) [#76](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues/76) ]
- **base**: [87a1f2aa](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/87a1f2aa20fdcbbff1863a751a0c5e7d91b269bb) - Add background worker [ [!39](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/39) [#76](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues/76) ]
- **itam**: [7b4ed7b1](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/7b4ed7b13537de064680f3c19a703b37c9d2bb83) - Update Serial Number from inventory if present and Serial Number not set [ [!37](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/37) ]
- **itam**: [b801c9a4](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/commit/b801c9a49e70ca5640c65bafdd05c29daed40798) - Update UUID from inventory if present and UUID not set [ [!37](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests/37) [#66](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues/66) ]
## 0.6.0 (2024-06-30)
### Bug Fixes
- **user_token**: [6cfcf158](https://gitlab.com/nofusscomputing/projects/django_template/-/commit/6cfcf1580c669c046e4dd6d547b99c8b9814a078) - conduct user check on token view access [ [!34](https://gitlab.com/nofusscomputing/projects/django_template/-/merge_requests/34) [#63](https://gitlab.com/nofusscomputing/projects/django_template/-/issues/63) ]
- **itam**: [f6866912](https://gitlab.com/nofusscomputing/projects/django_template/-/commit/f6866912329fd2ea5f1bce6014db53605e1fee55) - use same form for edit and add [ [!34](https://gitlab.com/nofusscomputing/projects/django_template/-/merge_requests/34) [#65](https://gitlab.com/nofusscomputing/projects/django_template/-/issues/65) ]
- **itam**: [802f2c41](https://gitlab.com/nofusscomputing/projects/django_template/-/commit/802f2c410da1d4810005991f6da27963621adc25) - dont add field inventorydate if adding new item [ [!34](https://gitlab.com/nofusscomputing/projects/django_template/-/merge_requests/34) ]
- **api**: [4e428560](https://gitlab.com/nofusscomputing/projects/django_template/-/commit/4e428560274fc2a82d927338c66b4641a1c93986) - inventory upload requires sanitization [ [!33](https://gitlab.com/nofusscomputing/projects/django_template/-/merge_requests/33) ]
### Code Refactor
- **settings**: [66b8d936](https://gitlab.com/nofusscomputing/projects/django_template/-/commit/66b8d9362d815e7f54ae402e4689c0a38f65c14d) - use seperate change/view views [ [!34](https://gitlab.com/nofusscomputing/projects/django_template/-/merge_requests/34) ]
- **settings**: [37d277e1](https://gitlab.com/nofusscomputing/projects/django_template/-/commit/37d277e1493ab708b8861fa8d0de3191da24d2f2) - use form for user settings [ [!34](https://gitlab.com/nofusscomputing/projects/django_template/-/merge_requests/34) ]
- **tests**: [58b134ae](https://gitlab.com/nofusscomputing/projects/django_template/-/commit/58b134ae30866b2ca207cef2cf17158d54517044) - move unit tests to unit test sub-directory [ [!33](https://gitlab.com/nofusscomputing/projects/django_template/-/merge_requests/33) [#15](https://gitlab.com/nofusscomputing/projects/django_template/-/issues/15) ]
### Continious Integration
- **git_sync**: [a0874356](https://gitlab.com/nofusscomputing/projects/django_template/-/commit/a0874356fd59978864664d4c25217dca527ee667) - sync on push ro feature branch 14-feat-project-management [ [!29](https://gitlab.com/nofusscomputing/projects/django_template/-/merge_requests/29) [!31](https://gitlab.com/nofusscomputing/projects/django_template/-/merge_requests/31) ]
- [5d8f5e3a](https://gitlab.com/nofusscomputing/projects/django_template/-/commit/5d8f5e3a518bea520a4b6159623c60a3eaade051) - remove dockerhub publish on bot push [ [!29](https://gitlab.com/nofusscomputing/projects/django_template/-/merge_requests/29) ]
### Documentaton / Guides
- [4d3a2385](https://gitlab.com/nofusscomputing/projects/django_template/-/commit/4d3a2385831c4db99bb9f3e70411b3d2d4d624f0) - Add user settings documentation [ [!34](https://gitlab.com/nofusscomputing/projects/django_template/-/merge_requests/34) [#63](https://gitlab.com/nofusscomputing/projects/django_template/-/issues/63) ]
- **api**: [47d6a3be](https://gitlab.com/nofusscomputing/projects/django_template/-/commit/47d6a3beffa7bb3d5b822c54440fe8b31ad18e02) - API Token authentication [ [!34](https://gitlab.com/nofusscomputing/projects/django_template/-/merge_requests/34) [#63](https://gitlab.com/nofusscomputing/projects/django_template/-/issues/63) ]
### Features
- **api**: [11179143](https://gitlab.com/nofusscomputing/projects/django_template/-/commit/111791438a45a8eb0cf4c175e4a1439cd56c84da) - API token authentication [ [!34](https://gitlab.com/nofusscomputing/projects/django_template/-/merge_requests/34) [#63](https://gitlab.com/nofusscomputing/projects/django_template/-/issues/63) ]
- **api**: [ce2c6f3b](https://gitlab.com/nofusscomputing/projects/django_template/-/commit/ce2c6f3b135ec9110682db3b77c80d6dde26a3c2) - abilty for user to create/delete api token [ [!34](https://gitlab.com/nofusscomputing/projects/django_template/-/merge_requests/34) [#63](https://gitlab.com/nofusscomputing/projects/django_template/-/issues/63) ]
- **api**: [e655f22f](https://gitlab.com/nofusscomputing/projects/django_template/-/commit/e655f22fac4d7de2ef42f16f33c8427528b63481) - create token model [ [!34](https://gitlab.com/nofusscomputing/projects/django_template/-/merge_requests/34) [#63](https://gitlab.com/nofusscomputing/projects/django_template/-/issues/63) ]
## 0.5.0 (2024-06-17)
### Bug Fixes

View File

@ -15,7 +15,7 @@ pip install -r requirements.txt
```
To setup the django test server run the following
To setup the centurion erp test server run the following
``` bash
@ -50,9 +50,9 @@ See [Documentation](https://nofusscomputing.com/projects/django-template/develop
cd app
docker build . --tag django-app:dev
docker build . --tag centurion-erp:dev
docker run -d --rm -v ${PWD}/db.sqlite3:/app/db.sqlite3 -p 8002:8000 --name app django-app:dev
docker run -d --rm -v ${PWD}/db.sqlite3:/app/db.sqlite3 -p 8002:8000 --name app centurion-erp:dev
```

View File

@ -1,21 +1,65 @@
<span style="text-align: center;">
![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)
# No Fuss Computing - Centurion ERP
<br>
![Project Status - Active](https://img.shields.io/badge/Project%20Status-Active-green?logo=gitlab&style=plastic)
![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)
![Gitlab Code Coverage](https://img.shields.io/gitlab/pipeline-coverage/nofusscomputing%2Fprojects%2Fdjango_template?branch=master&style=plastic&logo=gitlab&label=Test%20Coverage)
[![Docker Pulls](https://img.shields.io/docker/pulls/nofusscomputing/centurion-erp?style=plastic&logo=docker&color=0db7ed)](https://hub.docker.com/r/nofusscomputing/centurion-erp)
artifacts
----
<br>
![Gitlab forks count](https://img.shields.io/badge/dynamic/json?label=Forks&query=%24.forks_count&url=https%3A%2F%2Fgitlab.com%2Fapi%2Fv4%2Fprojects%2F57560288%2F&color=ff782e&logo=gitlab&style=plastic) ![Gitlab stars](https://img.shields.io/badge/dynamic/json?label=Stars&query=%24.star_count&url=https%3A%2F%2Fgitlab.com%2Fapi%2Fv4%2Fprojects%2F57560288%2F&color=ff782e&logo=gitlab&style=plastic) [![Open Issues](https://img.shields.io/badge/dynamic/json?color=ff782e&logo=gitlab&style=plastic&label=Open%20Issues&query=%24.statistics.counts.opened&url=https%3A%2F%2Fgitlab.com%2Fapi%2Fv4%2Fprojects%2F57560288%2Fissues_statistics)](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues) [![GitLab Bugs](https://img.shields.io/gitlab/issues/open/nofusscomputing%2Fprojects%2Fcenturion_erp?labels=type%3A%3Abug&style=plastic&logo=gitlab&label=Bug%20Fixes%20Required&color=fc6d26)](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues/?sort=created_date&state=opened&label_name%5B%5D=type%3A%3Abug)
dont work to file
https://gitlab.com/nofusscomputing/projects/django_template/-/jobs/artifacts/master/browse/artifacts/coverage/index.html?job=Unit
works to dir
https://gitlab.com/nofusscomputing/projects/django_template/-/jobs/artifacts/master/browse/artifacts/coverage/?job=Unit
![GitHub forks](https://img.shields.io/github/forks/NofussComputing/centurion_erp?logo=github&style=plastic&color=000000&labell=Forks) ![GitHub stars](https://img.shields.io/github/stars/NofussComputing/centurion_erp?color=000000&logo=github&style=plastic) ![Github Watchers](https://img.shields.io/github/watchers/NofussComputing/centurion_erp?color=000000&label=Watchers&logo=github&style=plastic)
<br>
This project is hosted on [gitlab](https://gitlab.com/nofusscomputing/projects/centurion_erp) and has a read-only copy hosted on [Github](https://github.com/NofussComputing/centurion_erp).
----
**Stable Branch**
![Gitlab build status - stable](https://img.shields.io/badge/dynamic/json?color=ff782e&label=Build&query=0.status&url=https%3A%2F%2Fgitlab.com%2Fapi%2Fv4%2Fprojects%2F57560288%2Fpipelines%3Fref%3Dmaster&logo=gitlab&style=plastic) ![branch release version](https://img.shields.io/badge/dynamic/yaml?color=ff782e&logo=gitlab&style=plastic&label=Release&query=%24.commitizen.version&url=https%3A//gitlab.com/nofusscomputing/projects/centurion_erp%2F-%2Fraw%2Fmaster%2F.cz.yaml) [![Gitlab Code Coverage](https://img.shields.io/gitlab/pipeline-coverage/nofusscomputing%2Fprojects%2Fcenturion_erp?branch=master&style=plastic&logo=gitlab&label=Test%20Coverage)](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/jobs/artifacts/master/browse/artifacts/coverage/?job=Unit)
----
**Development Branch**
![Gitlab build status - development](https://img.shields.io/badge/dynamic/json?color=ff782e&label=Build&query=0.status&url=https%3A%2F%2Fgitlab.com%2Fapi%2Fv4%2Fprojects%2F57560288%2Fpipelines%3Fref%3Ddevelopment&logo=gitlab&style=plastic) ![branch release version](https://img.shields.io/badge/dynamic/yaml?color=ff782e&logo=gitlab&style=plastic&label=Release&query=%24.commitizen.version&url=https%3A//gitlab.com/nofusscomputing/projects/centurion_erp%2F-%2Fraw%2Fdevelopment%2F.cz.yaml) [![Gitlab Code Coverage](https://img.shields.io/gitlab/pipeline-coverage/nofusscomputing%2Fprojects%2Fcenturion_erp?branch=development&style=plastic&logo=gitlab&label=Test%20Coverage)](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/jobs/artifacts/development/browse/artifacts/coverage/?job=Unit)
----
<br>
</div>
links:
- [Issues](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues)
- [Merge Requests (Pull Requests)](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests)
An ERP with a large emphasis on the IT Service Management (ITSM) and Automation.
## Contributing
All contributions for this project must conducted from [Gitlab](https://gitlab.com/nofusscomputing/projects/centurion_erp).
For further details on contributing please refer to the [contribution guide](CONTRIBUTING.md).
## Other
This repo is release under this [license](LICENSE)

17
app/.coveragerc Normal file
View File

@ -0,0 +1,17 @@
[run]
source = .
omit =
*migrations/*
*tests/*/*
[report]
omit =
*/tests/*/*
*/migrations/*
*apps.py
*manage.py
*__init__.py
*asgi*
*wsgi*
*admin.py
*urls.py

View File

@ -5,8 +5,9 @@ from app import settings
from access.models import Organization
from core.forms.common import CommonModelForm
class OrganizationForm(forms.ModelForm):
class OrganizationForm(CommonModelForm):
class Meta:
model = Organization

View File

@ -6,8 +6,10 @@ from django.forms import inlineformset_factory
from app import settings
from .team_users import TeamUsersForm, TeamUsers
from access.models import Team
from core.forms.common import CommonModelForm
TeamUserFormSet = inlineformset_factory(
model=TeamUsers,
@ -19,7 +21,19 @@ TeamUserFormSet = inlineformset_factory(
]
)
class TeamForm(forms.ModelForm):
class TeamFormAdd(CommonModelForm):
class Meta:
model = Team
fields = [
'name',
]
class TeamForm(CommonModelForm):
class Meta:
model = Team
@ -55,12 +69,15 @@ class TeamForm(forms.ModelForm):
'access',
'config_management',
'core',
'django_celery_results',
'itam',
'settings',
]
exclude_models = [
'appsettings',
'chordcounter',
'groupresult',
'organization'
'settings',
'usersettings',
@ -68,8 +85,11 @@ class TeamForm(forms.ModelForm):
exclude_permissions = [
'add_organization',
'add_taskresult',
'change_organization',
'change_taskresult',
'delete_organization',
'delete_taskresult',
]
self.fields['permissions'].queryset = Permission.objects.filter(

View File

@ -1,12 +1,12 @@
from django import forms
from django.db.models import Q
from app import settings
from access.models import TeamUsers
from core.forms.common import CommonModelForm
class TeamUsersForm(forms.ModelForm):
class TeamUsersForm(CommonModelForm):
class Meta:
model = TeamUsers

View File

@ -0,0 +1,23 @@
# Generated by Django 5.0.6 on 2024-07-11 04:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('access', '0005_organization_manager_organization_model_notes'),
]
operations = [
migrations.AlterField(
model_name='organization',
name='model_notes',
field=models.TextField(blank=True, default=None, null=True, verbose_name='Notes'),
),
migrations.AlterField(
model_name='team',
name='model_notes',
field=models.TextField(blank=True, default=None, null=True, verbose_name='Notes'),
),
]

View File

@ -4,8 +4,6 @@ from django.contrib.auth.models import Group
from django.core.exceptions import PermissionDenied
from django.utils.functional import cached_property
from .models import Organization, Team
@ -57,9 +55,11 @@ class OrganizationMixin():
id = obj.get_organization().id
if obj.is_global:
if hasattr(obj, 'is_global'):
id = 0
if obj.is_global:
id = 0
except AttributeError:
@ -70,6 +70,18 @@ class OrganizationMixin():
id = int(self.request.POST.get("organization", ""))
for field in self.request.POST.dict(): # cater for fields prefixed '<prefix>-<field name>'
a_field = str(field).split('-')
if len(a_field) == 2:
if a_field[1] == 'organization':
id = int(self.request.POST.get(field))
return id
@ -191,8 +203,26 @@ class OrganizationMixin():
is_organization_manager = False
queryset = None
if hasattr(self, 'get_queryset'):
queryset = self.get_queryset()
obj = None
if hasattr(self, 'get_object'):
try:
obj = self.get_object()
except:
pass
if hasattr(self, 'model'):
if self.model._meta.label_lower in organization_manager_models:
@ -203,10 +233,32 @@ class OrganizationMixin():
is_organization_manager = True
if not self.has_organization_permission() and not request.user.is_superuser and not is_organization_manager:
return False
return True
return True
if request.user.is_superuser:
return True
perms = self.get_permission_required()
if self.has_organization_permission():
return True
if self.request.user.has_perms(perms) and len(self.kwargs) == 0 and str(self.request.method).lower() == 'get':
return True
for required_permission in self.permission_required:
if required_permission.replace(
'view_', ''
) == 'access.organization' and len(self.kwargs) == 0:
return True
return False
@ -276,7 +328,33 @@ class OrganizationPermission(AccessMixin, OrganizationMixin):
if len(self.permission_required) > 0:
non_organization_models = [
'TaskResult'
]
if hasattr(self, 'model'):
if hasattr(self.model, '__name__'):
if self.model.__name__ in non_organization_models:
if hasattr(self, 'get_object'):
self.get_object()
perms = self.get_permission_required()
if not self.request.user.has_perms(perms):
return self.handle_no_permission()
return super().dispatch(self.request, *args, **kwargs)
if not self.permission_check(request):
raise PermissionDenied('You are not part of this organization')
raise PermissionDenied('You are not part of this organization')
return super().dispatch(self.request, *args, **kwargs)

View File

@ -49,6 +49,7 @@ class Organization(SaveHistory):
blank = True,
default = None,
null= True,
verbose_name = 'Notes',
)
slug = AutoSlugField()
@ -91,6 +92,7 @@ class TenancyObject(models.Model):
blank = True,
default = None,
null= True,
verbose_name = 'Notes',
)
def get_organization(self) -> Organization:

View File

@ -1,6 +1,5 @@
{% extends 'base.html.j2' %}
{% block title %}Organizations{% endblock %}
{% block content_header_icon %}{% endblock %}
{% block content %}

View File

@ -57,7 +57,7 @@ form div .helptext {
<div class="detail-view-field">
<label>{{ form.manager.label }}</label>
<span>{{ form.manager.value }}</span>
<span>{{ organization.manager }}</span>
</div>
<div class="detail-view-field">

View File

@ -8,7 +8,6 @@
{{ form.as_div }}
{% include 'icons/issue_link.html.j2' with issue=13 %}<br>
<input style="display:unset;" type="submit" value="Submit">
</form>
@ -18,7 +17,7 @@
<input type="button" value="<< Back" onclick="window.location='{% url 'Access:_organization_view' pk=organization.id %}';">
<input type="button" value="Delete Team"
onclick="window.location='{% url 'Access:_team_delete' organization_id=organization.id pk=team.id %}';">
<input type="button" value="New User"
<input type="button" value="Assign User"
onclick="window.location='{% url 'Access:_team_user_add' organization_id=organization.id pk=team.id %}';">
{{ formset.management_form }}

View File

@ -0,0 +1,68 @@
import pytest
import unittest
class TenancyObject:
""" Tests for checking TenancyObject """
model = None
""" Model to be tested """
def test_has_attr_get_organization(self):
""" TenancyObject attribute check
TenancyObject has function get_organization
"""
assert hasattr(self.model, 'get_organization')
def test_has_attr_is_global(self):
""" TenancyObject attribute check
TenancyObject has field is_global
"""
assert hasattr(self.model, 'is_global')
def test_has_attr_model_notes(self):
""" TenancyObject attribute check
TenancyObject has field model_notes
"""
assert hasattr(self.model, 'model_notes')
def test_has_attr_organization(self):
""" TenancyObject attribute check
TenancyObject has field organization
"""
assert hasattr(self.model, 'organization')
@pytest.mark.skip(reason="to be written")
def test_create_no_organization_fails(self):
""" Devices must be assigned an organization
Must not be able to create an item without an organization
"""
pass
@pytest.mark.skip(reason="to be written")
def test_edit_no_organization_fails(self):
""" Devices must be assigned an organization
Must not be able to edit an item without an organization
"""
pass

View File

@ -1,15 +0,0 @@
import pytest
import unittest
from django.test import TestCase, Client
from access.models import Organization, Team, TeamUsers, Permission
from api.tests.abstract.api_permissions import APIPermissions
@pytest.mark.skip(reason="to be written")
class TeamUsersPermissionsAPI(TestCase, APIPermissions):
model = TeamUsers

View File

@ -1,24 +0,0 @@
import pytest
import unittest
from django.test import TestCase
class TenancyObject(TestCase):
# @classmethod
# def setUpTestData(self):
# """ Setup Test """
# pass
@pytest.mark.skip(reason="to be written")
def test_function_save_attributes(self):
""" Ensure save Attributes function match django default
the save method is overridden. the function attributes must match default django method
"""
pass

View File

View File

@ -0,0 +1,21 @@
import pytest
import unittest
import requests
from django.test import TestCase
from app.tests.abstract.models import ModelDisplay, ModelIndex
class OrganizationViews(
TestCase,
ModelDisplay,
ModelIndex
):
display_module = 'access.views.organization'
display_view = 'View'
index_module = display_module
index_view = 'IndexView'

View File

@ -0,0 +1,17 @@
import pytest
import unittest
import requests
from django.test import TestCase, Client
from access.models import Team
from access.tests.abstract.tenancy_object import TenancyObject
class TeamTenancyObject(
TestCase,
TenancyObject
):
model = Team

View File

@ -0,0 +1,29 @@
import pytest
import unittest
import requests
from django.test import TestCase
from app.tests.abstract.models import ModelAdd, ModelDelete, ModelDisplay
class TeamViews(
TestCase,
ModelAdd,
ModelDelete,
ModelDisplay,
):
add_module = 'access.views.team'
add_view = 'Add'
# change_module = add_module
# change_view = 'Change'
delete_module = add_module
delete_view = 'Delete'
display_module = add_module
display_view = 'View'

View File

@ -0,0 +1,30 @@
import pytest
import unittest
import requests
from django.test import TestCase
from app.tests.abstract.models import AddView, DeleteView
class TeamUserViews(
TestCase,
AddView,
DeleteView
):
add_module = 'access.views.user'
add_view = 'Add'
# change_module = add_module
# change_view = 'GroupView'
delete_module = add_module
delete_view = 'Delete'
# display_module = add_module
# display_view = 'GroupView'
# index_module = add_module
# index_view = 'GroupIndexView'

View File

@ -0,0 +1,45 @@
import pytest
import unittest
from django.test import TestCase
from access.models import TenancyObject
from unittest.mock import patch
class TenancyObject(TestCase):
item = TenancyObject
def test_has_attribute_organization(self):
""" Field organization exists """
assert hasattr(self.item, 'organization')
def test_has_attribute_is_global(self):
""" Field organization exists """
assert hasattr(self.item, 'is_global')
def test_has_attribute_model_notes(self):
""" Field organization exists """
assert hasattr(self.item, 'model_notes')
def test_has_attribute_get_organization(self):
""" Function 'get_organization' Exists """
assert hasattr(self.item, 'get_organization')
def test_is_function_get_organization(self):
""" Attribute 'get_organization' is a function """
assert callable(self.item.get_organization)

View File

@ -1,4 +1,5 @@
from django.contrib.auth import decorators as auth_decorator
from django.db.models import Q
from django.utils.decorators import method_decorator
from django.views import generic
@ -7,9 +8,12 @@ from access.models import *
from access.forms.organization import OrganizationForm
from core.views.common import ChangeView, IndexView
class IndexView(OrganizationPermission, generic.ListView):
class IndexView(IndexView):
model = Organization
permission_required = [
'access.view_organization'
]
@ -17,6 +21,14 @@ class IndexView(OrganizationPermission, generic.ListView):
context_object_name = "organization_list"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['content_title'] = 'Organizations'
return context
def get_queryset(self):
if self.request.user.is_superuser:
@ -25,11 +37,15 @@ class IndexView(OrganizationPermission, generic.ListView):
else:
return Organization.objects.filter(pk__in=self.user_organizations())
return Organization.objects.filter(
Q(pk__in=self.user_organizations())
|
Q(manager=self.request.user.id)
)
class View(OrganizationPermission, generic.UpdateView):
class View(ChangeView):
context_object_name = "organization"
@ -70,6 +86,8 @@ class View(OrganizationPermission, generic.UpdateView):
context['model_pk'] = self.kwargs['pk']
context['model_name'] = self.model._meta.verbose_name.replace(' ', '')
context['content_title'] = 'Organization - ' + context[self.context_object_name].name
return context

View File

@ -2,16 +2,15 @@ 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.forms.team import TeamForm
# from access.forms.team_users import TeamUsersForm
from access.forms.team import TeamForm, TeamFormAdd
from access.models import Team, TeamUsers, Organization
from access.mixin import *
from core.views.common import AddView, ChangeView, DeleteView
class View(OrganizationPermission, generic.UpdateView):
class View(ChangeView):
context_object_name = "team"
@ -79,15 +78,19 @@ class View(OrganizationPermission, generic.UpdateView):
class Add(OrganizationPermission, generic.CreateView):
class Add(AddView):
form_class = TeamFormAdd
model = Team
parent_model = Organization
permission_required = [
'access.add_team',
]
template_name = 'form.html.j2'
fields = [
'team_name',
]
def form_valid(self, form):
form.instance.organization = Organization.objects.get(pk=self.kwargs['pk'])
@ -101,8 +104,6 @@ class Add(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'
@ -110,7 +111,7 @@ class Add(OrganizationPermission, generic.CreateView):
class Delete(OrganizationPermission, generic.DeleteView):
class Delete(DeleteView):
model = Team
permission_required = [
'access.delete_team'

View File

@ -1,15 +1,13 @@
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.forms.team_users import TeamUsersForm
from access.mixin import OrganizationPermission
from access.models import Team, TeamUsers
from core.views.common import AddView, DeleteView
class Add(OrganizationPermission, generic.CreateView):
class Add(AddView):
context_object_name = "teamuser"
@ -17,10 +15,9 @@ class Add(OrganizationPermission, generic.CreateView):
model = TeamUsers
parent_model = TeamUsers
parent_model = Team
permission_required = [
'access.view_team',
'access.add_teamusers'
]
@ -52,7 +49,7 @@ class Add(OrganizationPermission, generic.CreateView):
return context
class Delete(OrganizationPermission, generic.DeleteView):
class Delete(DeleteView):
model = TeamUsers
permission_required = [
'access.delete_teamusers'

79
app/api/auth.py Normal file
View File

@ -0,0 +1,79 @@
import datetime
from rest_framework import exceptions
from rest_framework.authentication import BaseAuthentication, get_authorization_header
from api.models.tokens import AuthToken
class TokenAuthentication(BaseAuthentication):
""" API Token Authentication
Provides the ability to use the API by using a token to authenticate.
"""
def authenticate_header(self, request):
return 'Token'
def authenticate(self, request):
""" Authentication the API session using the supplied token
Args:
request (object): API Request Object
Raises:
exceptions.AuthenticationFailed: 'Token header invalid' - Authorization Header Value is not in format `Token <auth-token>`
exceptions.AuthenticationFailed: 'Token header invalid. Possibly incorrectly formatted' - Authentication header value has >1 space
exceptions.AuthenticationFailed: 'Invalid token header. Token string should not contain invalid characters.' - Authorization header contains non-unicode chars
Returns:
None (None): User not authenticated
tuple(user,token): User authenticated
"""
auth = get_authorization_header(request).split()
if not auth:
return None
if len(auth) == 1:
raise exceptions.AuthenticationFailed('Token header invalid')
elif len(auth) > 2:
raise exceptions.AuthenticationFailed('Token header invalid. Possibly incorrectly formatted')
elif len(auth) == 2:
try:
decoded_token: str = auth[1].decode("utf-8")
for token in AuthToken.objects.filter():
provided_token: str = token.token_hash(decoded_token)
if token.token == provided_token:
if datetime.datetime.strptime(str(token.expires),'%Y-%m-%d %H:%M:%S%z') > datetime.datetime.now(datetime.timezone.utc):
user = token.user
return (user, provided_token)
else:
expired_token = AuthToken.objects.get(id=token.id)
expired_token.delete()
except UnicodeError:
raise exceptions.AuthenticationFailed('Invalid token header. Token string should not contain invalid characters.')
return None

View File

@ -0,0 +1,49 @@
import datetime
from django import forms
from api.models.tokens import AuthToken
from app import settings
from core.forms.common import CommonModelForm
class AuthTokenForm(CommonModelForm):
prefix = 'user_token'
class Meta:
fields = [
'note',
'expires',
]
model = AuthToken
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['expires'].widget = forms.widgets.DateTimeInput(attrs={'type': 'datetime-local', 'format': "%Y-%m-%dT%H:%M"})
self.fields['expires'].input_formats = settings.DATETIME_FORMAT
self.fields['expires'].format="%Y-%m-%dT%H:%M"
self.fields['expires'].initial= datetime.datetime.now() + datetime.timedelta(days=90)
if self.prefix + '-gen_token' not in self.data:
generated_token = self.instance.generate()
else:
generated_token = self.data[self.prefix + '-gen_token']
self.fields['gen_token'] = forms.CharField(
label="Generated Token",
initial=generated_token,
empty_value= generated_token,
required=False,
help_text = 'Ensure you save this token somewhere as you will never be able to obtain it again',
)
self.fields['gen_token'].widget.attrs['readonly'] = True

View File

@ -0,0 +1,31 @@
# Generated by Django 5.0.6 on 2024-06-27 18:25
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 = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='AuthToken',
fields=[
('id', models.AutoField(primary_key=True, serialize=False, unique=True)),
('note', models.CharField(blank=True, default=None, max_length=50, null=True)),
('token', models.CharField(db_index=True, max_length=64, unique=True, verbose_name='Auth Token')),
('expires', models.DateTimeField(verbose_name='Expiry Date')),
('created', access.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False)),
('modified', access.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]

View File

View File

76
app/api/models/tokens.py Normal file
View File

@ -0,0 +1,76 @@
import hashlib
import random
import string
from django.conf import settings
from django.contrib.auth.models import User
from django.db import models
from access.fields import *
from access.models import TenancyObject
class AuthToken(models.Model):
id = models.AutoField(
primary_key=True,
unique=True,
blank=False
)
note = models.CharField(
blank = True,
max_length = 50,
default = None,
null= True,
)
token = models.CharField(
verbose_name = 'Auth Token',
db_index=True,
max_length = 64,
null = False,
blank = False,
unique = True,
)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE
)
expires = models.DateTimeField(
verbose_name = 'Expiry Date',
null = False,
blank = False
)
created = AutoCreatedField()
modified = AutoLastModifiedField()
def generate(self) -> str:
return str(hashlib.sha256(str(self.randomword()).encode('utf-8')).hexdigest())
def token_hash(self, token:str) -> str:
salt = settings.SECRET_KEY
return str(hashlib.sha256(str(token + salt).encode('utf-8')).hexdigest())
def randomword(self) -> str:
return ''.join(random.choice(string.ascii_letters) for i in range(120))
def __str__(self):
return self.token

View File

View File

@ -0,0 +1,168 @@
from django.core.exceptions import ValidationError
from django.utils.html import escape
class Inventory:
""" Inventory Object
Pass in an Inventory dict that a device has provided and sanitize ready for use.
Raises:
ValidationError: Malformed inventory data.
"""
class Details:
_name: str
_serial_number: str
_uuid: str
def __init__(self, details: dict):
self._name = escape(details['name'])
self._serial_number = escape(details['serial_number'])
self._uuid = escape(details['uuid'])
@property
def name(self) -> str:
return str(self._name)
@property
def serial_number(self) -> str:
return str(self._serial_number)
@property
def uuid(self) -> str:
return str(self._uuid)
class OperatingSystem:
_name: str
_version_major: str
_version: str
def __init__(self, operating_system: dict):
self._name = escape(operating_system['name'])
self._version_major = escape(operating_system['version_major'])
self._version = escape(operating_system['version'])
@property
def name(self) -> str:
return str(self._name)
@property
def version_major(self) -> str:
return str(self._version_major)
@property
def version(self) -> str:
return str(self._version)
class Software:
_name: str
_category: str
_version: str
def __init__(self, software: dict):
self._name = escape(software['name'])
self._category = escape(software['category'])
self._version = escape(software['version'])
@property
def name(self) -> str:
return str(self._name)
@property
def category(self) -> str:
return str(self._category)
@property
def version(self) -> str:
return str(self._version)
_details: Details = None
_operating_system: OperatingSystem = None
_software: list[Software] = []
def __init__(self, inventory: dict):
if (
type(inventory['details']) is dict and
type(inventory['os']) is dict and
type(inventory['software']) is list
):
self._details = self.Details(inventory['details'])
self._operating_system = self.OperatingSystem(inventory['os'])
for software in inventory['software']:
self._software += [ self.Software(software) ]
else:
raise ValidationError('Inventory File is invalid')
@property
def details(self) -> Details:
return self._details
@property
def operating_system(self) -> OperatingSystem:
return self._operating_system
@property
def software(self) -> list[Software]:
return list(self._software)

323
app/api/tasks.py Normal file
View File

@ -0,0 +1,323 @@
import json
import re
from django.utils import timezone
from celery import shared_task, current_task
from celery.utils.log import get_task_logger
from celery import states
from access.models import Organization
from api.serializers.inventory import Inventory
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
logger = get_task_logger(__name__)
@shared_task(bind=True)
def process_inventory(self, data, organization: int):
device = None
device_operating_system = None
operating_system = None
operating_system_version = None
try:
logger.info('Begin Processing Inventory')
data = json.loads(data)
data = Inventory(data)
organization = Organization.objects.get(id=organization)
if Device.objects.filter(slug=str(data.details.name).lower()).exists():
device = Device.objects.get(slug=str(data.details.name).lower())
# device = self.obj
app_settings = AppSettings.objects.get(owner_organization = None)
device_serial_number = None
device_uuid = None
if data.details.serial_number and str(data.details.serial_number).lower() != 'na':
device_serial_number = str(data.details.serial_number)
if data.details.uuid and str(data.details.uuid).lower() != 'na':
device_uuid = str(data.details.uuid)
if not device: # Create the device
device = Device.objects.create(
name = data.details.name,
device_type = None,
serial_number = device_serial_number,
uuid = device_uuid,
organization = organization,
)
if device:
logger.info(f"Device: {device.name}, Serial: {device.serial_number}, UUID: {device.uuid}")
if not device.uuid and device_uuid:
device.uuid = device_uuid
device.save()
if not device.serial_number and device_serial_number:
device.serial_number = data.details.serial_number
device.save()
if OperatingSystem.objects.filter( slug=data.operating_system.name ).exists():
operating_system = OperatingSystem.objects.get( slug=data.operating_system.name )
else: # Create Operating System
operating_system = OperatingSystem.objects.create(
name = data.operating_system.name,
organization = organization,
is_global = True
)
if OperatingSystemVersion.objects.filter( name=data.operating_system.version_major, operating_system=operating_system ).exists():
operating_system_version = OperatingSystemVersion.objects.get(
organization = organization,
is_global = True,
name = data.operating_system.version_major,
operating_system = operating_system
)
else: # Create Operating System Version
operating_system_version = OperatingSystemVersion.objects.create(
organization = organization,
is_global = True,
name = data.operating_system.version_major,
operating_system = operating_system,
)
if DeviceOperatingSystem.objects.filter( version=data.operating_system.version, device=device, operating_system_version=operating_system_version ).exists():
device_operating_system = DeviceOperatingSystem.objects.get(
device=device,
version = data.operating_system.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 = organization,
device=device,
version = data.operating_system.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
inventoried_software: list = []
for inventory in list(data.software):
software = None
software_category = None
software_version = None
device_software = None
software_category = SoftwareCategory.objects.filter( name = inventory.category )
if software_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_category.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,
)
if software.name == inventory.name:
pattern = r"^(\d+:)?(?P<semver>\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 = organization,
is_global = True,
name = semver,
software = software,
)
if software_version.name == semver:
if DeviceSoftware.objects.filter( software = software, device=device ).exists():
device_software = DeviceSoftware.objects.get(
device = device,
software = software
)
logger.debug(f"Select Existing Device Software: {device_software.software.name}")
else: # Create Software
device_software = DeviceSoftware.objects.create(
organization = organization,
is_global = True,
installedversion = software_version,
software = software,
device = device,
action=None
)
logger.debug(f"Create Device Software: {device_software.software.name}")
if device_software: # Update the Inventoried software
inventoried_software += [ device_software.id ]
if not device_software.installed: # Only update install date if blank
device_software.installed = timezone.now()
device_software.save()
logger.debug(f"Update Device Software (installed): {device_software.software.name}")
if device_software.installedversion.name != software_version.name:
device_software.installedversion = software_version
device_software.save()
logger.debug(f"Update Device Software (installedversion): {device_software.software.name}")
for not_installed in DeviceSoftware.objects.filter( device=device ):
if not_installed.id not in inventoried_software:
not_installed.delete()
logger.debug(f"Remove Device Software: {not_installed.software.name}")
if device and operating_system and operating_system_version and device_operating_system:
device.inventorydate = timezone.now()
device.save()
logger.info('Finish Processing Inventory')
return str('finished...')
except Exception as e:
logger.critical('Exception')
raise Exception(e)
return str(f'Exception Occured: {e}')

View File

@ -1,251 +0,0 @@
import pytest
import unittest
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.shortcuts import reverse
from django.test import TestCase, Client
from unittest.mock import patch
from access.models import Organization, Team, TeamUsers, Permission
from api.views.mixin import OrganizationPermissionAPI
from itam.models.device import Device
from settings.models.user_settings import UserSettings
class InventoryAPI(TestCase):
model = Device
model_name = 'device'
app_label = 'itam'
inventory = {
"details": {
"name": "device_name",
"serial_number": "a serial number",
"uuid": "string"
},
"os": {
"name": "os_name",
"version_major": "12",
"version": "12.1"
},
"software": [
{
"name": "software_name",
"category": "category_name",
"version": "1.2.3"
}
]
}
@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
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])
self.add_user = User.objects.create_user(username="test_user_add", password="password")
add_user_settings = UserSettings.objects.get(user=self.add_user)
add_user_settings.default_organization = organization
add_user_settings.save()
@patch.object(OrganizationPermissionAPI, 'permission_check')
def test_inventory_function_called_permission_check(self, permission_check):
""" Inventory Upload checks permissions
Function 'permission_check' is the function that checks permissions
As the non-established way of authentication an API permission is being done
confimation that the permissions are still checked is required.
"""
client = Client()
url = reverse('API:_api_device_inventory')
client.force_login(self.add_user)
response = client.post(url, data=self.inventory, content_type='application/json')
assert permission_check.called
@pytest.mark.skip(reason="to be written")
def test_api_inventory_device_added(self):
""" Device is created """
pass
@pytest.mark.skip(reason="to be written")
def test_api_inventory_operating_system_added(self):
""" Operating System is created """
pass
@pytest.mark.skip(reason="to be written")
def test_api_inventory_operating_system_version_added(self):
""" Operating System version is created """
pass
@pytest.mark.skip(reason="to be written")
def test_api_inventory_device_has_operating_system_added(self):
""" Operating System version linked to device """
pass
@pytest.mark.skip(reason="to be written")
def test_api_inventory_device_operating_system_version_is_semver(self):
""" 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(self):
""" 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(self):
""" Software category exists """
pass
@pytest.mark.skip(reason="to be written")
def test_api_inventory_software_added(self):
""" Test software exists """
pass
@pytest.mark.skip(reason="to be written")
def test_api_inventory_software_category_linked_to_software(self):
""" Software category linked to software """
pass
@pytest.mark.skip(reason="to be written")
def test_api_inventory_software_version_added(self):
""" Test software version exists """
pass
@pytest.mark.skip(reason="to be written")
def test_api_inventory_software_version_returns_semver(self):
""" 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(self):
""" 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(self):
""" 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(self):
""" 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(self):
""" 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(self):
""" 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(self):
""" Successful inventory upload returns 201 """
pass
@pytest.mark.skip(reason="to be written")
def test_api_inventory_invalid_status_bad_request(self):
""" Incorrectly formated inventory upload returns 400 """
pass
@pytest.mark.skip(reason="to be written")
def test_api_inventory_exeception_status_sever_error(self):
""" 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

View File

View File

@ -0,0 +1,426 @@
import datetime
import json
import pytest
import unittest
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.shortcuts import reverse
from django.test import TestCase, Client
from django.test.utils import override_settings
from unittest.mock import patch
from access.models import Organization, Team, TeamUsers, Permission
from api.views.mixin import OrganizationPermissionAPI
from api.serializers.inventory import Inventory
from api.tasks import process_inventory
from itam.models.device import Device, DeviceOperatingSystem, DeviceSoftware
from itam.models.operating_system import OperatingSystem, OperatingSystemVersion
from itam.models.software import Software, SoftwareCategory, SoftwareVersion
from settings.models.user_settings import UserSettings
class InventoryAPI(TestCase):
model = Device
model_name = 'device'
app_label = 'itam'
inventory = {
"details": {
"name": "device_name",
"serial_number": "a serial number",
"uuid": "string"
},
"os": {
"name": "os_name",
"version_major": "12",
"version": "12.1"
},
"software": [
{
"name": "software_name",
"category": "category_name",
"version": "1.2.3"
},
{
"name": "software_name_not_semver",
"category": "category_name",
"version": "2024.4"
},
{
"name": "software_name_semver_contained",
"category": "category_name",
"version": "1.2.3-rc1"
},
]
}
@classmethod
def setUpTestData(self):
"""Setup Test
1. Create an organization for user
2. Create a team for user with correct permissions
3. add user to the teeam
4. upload the inventory
5. conduct queries for tests
"""
organization = Organization.objects.create(name='test_org')
self.organization = organization
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])
self.add_user = User.objects.create_user(username="test_user_add", password="password")
add_user_settings = UserSettings.objects.get(user=self.add_user)
add_user_settings.default_organization = organization
add_user_settings.save()
teamuser = TeamUsers.objects.create(
team = add_team,
user = self.add_user
)
# upload the inventory
process_inventory(json.dumps(self.inventory), organization.id)
self.device = Device.objects.get(name=self.inventory['details']['name'])
self.operating_system = OperatingSystem.objects.get(name=self.inventory['os']['name'])
self.operating_system_version = OperatingSystemVersion.objects.get(name=self.inventory['os']['version_major'])
self.device_operating_system = DeviceOperatingSystem.objects.get(version=self.inventory['os']['version'])
self.software = Software.objects.get(name=self.inventory['software'][0]['name'])
self.software_category = SoftwareCategory.objects.get(name=self.inventory['software'][0]['category'])
self.software_version = SoftwareVersion.objects.get(
name = self.inventory['software'][0]['version'],
software = self.software,
)
self.software_not_semver = Software.objects.get(name=self.inventory['software'][1]['name'])
self.software_version_not_semver = SoftwareVersion.objects.get(
name = self.inventory['software'][1]['version'],
software = self.software_not_semver
)
self.software_is_semver = Software.objects.get(name=self.inventory['software'][2]['name'])
self.software_version_is_semver = SoftwareVersion.objects.get(
software = self.software_is_semver
)
self.device_software = DeviceSoftware.objects.get(device=self.device,software=self.software)
@override_settings(CELERY_TASK_ALWAYS_EAGER=True,
CELERY_TASK_EAGER_PROPOGATES=True)
@patch.object(OrganizationPermissionAPI, 'permission_check')
def test_inventory_function_called_permission_check(self, permission_check):
""" Inventory Upload checks permissions
Function 'permission_check' is the function that checks permissions
As the non-established way of authentication an API permission is being done
confimation that the permissions are still checked is required.
"""
client = Client()
url = reverse('API:_api_device_inventory')
client.force_login(self.add_user)
response = client.post(url, data=self.inventory, content_type='application/json')
assert permission_check.called
@override_settings(CELERY_TASK_ALWAYS_EAGER=True,
CELERY_TASK_EAGER_PROPOGATES=True)
@patch.object(Inventory, '__init__')
def test_inventory_serializer_inventory_called(self, serializer):
""" Inventory Upload checks permissions
Function 'permission_check' is the function that checks permissions
As the non-established way of authentication an API permission is being done
confimation that the permissions are still checked is required.
"""
client = Client()
url = reverse('API:_api_device_inventory')
client.force_login(self.add_user)
response = client.post(url, data=self.inventory, content_type='application/json')
assert serializer.called
@override_settings(CELERY_TASK_ALWAYS_EAGER=True,
CELERY_TASK_EAGER_PROPOGATES=True)
@patch.object(Inventory.Details, '__init__')
def test_inventory_serializer_inventory_details_called(self, serializer):
""" Inventory Upload uses Inventory serializer
Details Serializer is called for inventory details dict.
"""
client = Client()
url = reverse('API:_api_device_inventory')
client.force_login(self.add_user)
response = client.post(url, data=self.inventory, content_type='application/json')
assert serializer.called
@override_settings(CELERY_TASK_ALWAYS_EAGER=True,
CELERY_TASK_EAGER_PROPOGATES=True)
@patch.object(Inventory.OperatingSystem, '__init__')
def test_inventory_serializer_inventory_operating_system_called(self, serializer):
""" Inventory Upload uses Inventory serializer
Operating System Serializer is called for inventory Operating system dict.
"""
client = Client()
url = reverse('API:_api_device_inventory')
client.force_login(self.add_user)
response = client.post(url, data=self.inventory, content_type='application/json')
assert serializer.called
@override_settings(CELERY_TASK_ALWAYS_EAGER=True,
CELERY_TASK_EAGER_PROPOGATES=True)
@patch.object(Inventory.Software, '__init__')
def test_inventory_serializer_inventory_software_called(self, serializer):
""" Inventory Upload uses Inventory serializer
Software Serializer is called for inventory software list.
"""
client = Client()
url = reverse('API:_api_device_inventory')
client.force_login(self.add_user)
response = client.post(url, data=self.inventory, content_type='application/json')
assert serializer.called
def test_api_inventory_device_added(self):
""" Device is created """
assert self.device.name == self.inventory['details']['name']
def test_api_inventory_operating_system_added(self):
""" Operating System is created """
assert self.operating_system.name == self.inventory['os']['name']
def test_api_inventory_operating_system_version_added(self):
""" Operating System version is created """
assert self.operating_system_version.name == self.inventory['os']['version_major']
def test_api_inventory_device_has_operating_system_added(self):
""" Operating System version linked to device """
assert self.device_operating_system.version == self.inventory['os']['version']
@pytest.mark.skip(reason="to be written")
def test_api_inventory_device_operating_system_version_is_semver(self):
""" 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(self):
""" 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
def test_api_inventory_software_category_added(self):
""" Software category exists """
assert self.software_category.name == self.inventory['software'][0]['category']
def test_api_inventory_software_added(self):
""" Test software exists """
assert self.software.name == self.inventory['software'][0]['name']
def test_api_inventory_software_category_linked_to_software(self):
""" Software category linked to software """
assert self.software.category == self.software_category
def test_api_inventory_software_version_added(self):
""" Test software version exists """
assert self.software_version.name == self.inventory['software'][0]['version']
def test_api_inventory_software_version_returns_semver(self):
""" Software Version from inventory returns semver if within version string """
assert self.software_version_is_semver.name == str(self.inventory['software'][2]['version']).split('-')[0]
def test_api_inventory_software_version_returns_original_version(self):
""" Software Version from inventory returns inventoried version if no semver found """
assert self.software_version_not_semver.name == self.inventory['software'][1]['version']
def test_api_inventory_software_version_linked_to_software(self):
""" Test software version linked to software it belongs too """
assert self.software_version.software == self.software
def test_api_inventory_device_has_software_version(self):
""" Inventoried software is linked to device and it's the corret one"""
assert self.software_version.name == self.inventory['software'][0]['version']
def test_api_inventory_device_software_has_installed_date(self):
""" Inventoried software version has install date """
assert self.device_software.installed is not None
def test_api_inventory_device_software_installed_date_type(self):
""" Inventoried software version has install date """
assert type(self.device_software.installed) is datetime.datetime
@pytest.mark.skip(reason="to be written")
def test_api_inventory_device_software_blank_installed_date_is_updated(self):
""" A blank installed date of software is updated if the software was already attached to the device """
pass
@override_settings(CELERY_TASK_ALWAYS_EAGER=True,
CELERY_TASK_EAGER_PROPOGATES=True)
def test_api_inventory_valid_status_ok_existing_device(self):
""" Successful inventory upload returns 200 for existing device"""
client = Client()
url = reverse('API:_api_device_inventory')
client.force_login(self.add_user)
response = client.post(url, data=self.inventory, content_type='application/json')
assert response.status_code == 200
@override_settings(CELERY_TASK_ALWAYS_EAGER=True,
CELERY_TASK_EAGER_PROPOGATES=True)
def test_api_inventory_invalid_status_bad_request(self):
""" Incorrectly formated inventory upload returns 400 """
client = Client()
url = reverse('API:_api_device_inventory')
mod_inventory = self.inventory.copy()
mod_inventory.update({
'details': {
'name': 'test_api_inventory_invalid_status_bad_request'
},
'software': {
'not_within_a': 'list'
}
})
client.force_login(self.add_user)
response = client.post(url, data=mod_inventory, content_type='application/json')
assert response.status_code == 400
@pytest.mark.skip(reason="to be written")
def test_api_inventory_exeception_status_sever_error(self):
""" 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

View File

@ -1,3 +1,4 @@
import celery
import pytest
import unittest
import requests
@ -7,6 +8,9 @@ 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
from django.test.utils import override_settings
from unittest.mock import patch
from access.models import Organization, Team, TeamUsers, Permission
@ -188,6 +192,8 @@ class InventoryPermissionsAPI(TestCase):
@override_settings(CELERY_TASK_ALWAYS_EAGER=True,
CELERY_TASK_EAGER_PROPOGATES=True)
def test_device_auth_add_user_anon_denied(self):
""" Check correct permission for add
@ -203,6 +209,8 @@ class InventoryPermissionsAPI(TestCase):
assert response.status_code == 401
@override_settings(CELERY_TASK_ALWAYS_EAGER=True,
CELERY_TASK_EAGER_PROPOGATES=True)
def test_device_auth_add_no_permission_denied(self):
""" Check correct permission for add
@ -219,6 +227,8 @@ class InventoryPermissionsAPI(TestCase):
assert response.status_code == 403
@override_settings(CELERY_TASK_ALWAYS_EAGER=True,
CELERY_TASK_EAGER_PROPOGATES=True)
def test_device_auth_add_different_organization_denied(self):
""" Check correct permission for add
@ -235,6 +245,8 @@ class InventoryPermissionsAPI(TestCase):
assert response.status_code == 403
@override_settings(CELERY_TASK_ALWAYS_EAGER=True,
CELERY_TASK_EAGER_PROPOGATES=True)
def test_device_auth_add_permission_view_denied(self):
""" Check correct permission for add
@ -251,6 +263,8 @@ class InventoryPermissionsAPI(TestCase):
assert response.status_code == 403
@override_settings(CELERY_TASK_ALWAYS_EAGER=True,
CELERY_TASK_EAGER_PROPOGATES=True)
def test_device_auth_add_has_permission(self):
""" Check correct permission for add
@ -264,6 +278,6 @@ class InventoryPermissionsAPI(TestCase):
client.force_login(self.add_user)
response = client.post(url, data=self.inventory, content_type='application/json')
assert response.status_code == 201
assert response.status_code == 200

View File

@ -0,0 +1,326 @@
import hashlib
import json
import pytest
import requests
import unittest
from datetime import datetime, timedelta
from django.contrib.auth.models import AnonymousUser, User
from django.shortcuts import reverse
from django.test import TestCase, Client
from access.models import Organization, Team, TeamUsers, Permission
from api.models.tokens import AuthToken
from settings.models.user_settings import UserSettings
class APIAuthToken(TestCase):
@classmethod
def setUpTestData(self):
"""Setup Test
1. Create an organization for user
3. create user
4. create user settings
5. create API key (valid)
6. generate an API key that does not exist
5. create API key (expired)
"""
organization = Organization.objects.create(name='test_org')
self.organization = organization
self.add_user = User.objects.create_user(username="test_user_add", password="password")
add_user_settings = UserSettings.objects.get(user=self.add_user)
add_user_settings.default_organization = organization
add_user_settings.save()
expires = datetime.utcnow() + timedelta(days = 10)
expires = expires.strftime('%Y-%m-%d %H:%M:%S%z')
token = AuthToken.objects.create(
user = self.add_user,
expires=expires
)
self.api_token_valid = token.generate()
self.hashed_token = token.token_hash(self.api_token_valid)
token.token = self.hashed_token
token.save()
self.api_token_does_not_exist = hashlib.sha256(str('a random string').encode('utf-8')).hexdigest()
expires = datetime.utcnow() + timedelta(days = -10)
expires = expires.strftime('%Y-%m-%d %H:%M:%S%z')
self.api_token_expired = token.generate()
self.hashed_token_expired = token.token_hash(self.api_token_expired)
token = AuthToken.objects.create(
user = self.add_user,
expires=expires,
token = self.hashed_token_expired
)
def test_token_create_own(self):
""" Check correct permission for add
User can only create token for self.
"""
client = Client()
client.force_login(self.add_user)
url = reverse('_user_auth_token_add', kwargs={'user_id': self.add_user.id})
response = client.post(url, kwargs={'user_id': self.add_user.id})
assert response.status_code == 200
def test_token_create_other_user(self):
""" Check correct permission for add
User can not create token for another user.
"""
client = Client()
client.force_login(self.add_user)
url = reverse('_user_auth_token_add', kwargs={'user_id': 999})
response = client.post(url, kwargs={'user_id': 999})
assert response.status_code == 403
def test_token_delete_own(self):
""" Check correct permission for delete
User can only delete token for self.
"""
client = Client()
client.force_login(self.add_user)
url = reverse('_user_auth_token_delete', kwargs={'user_id': self.add_user.id, 'pk': 1})
response = client.post(url, kwargs={'user_id': self.add_user.id, 'pk': 1})
assert response.status_code == 302 and response.url == '/account/settings/1'
def test_token_delete_other_user(self):
""" Check correct permission for delete
User can not delete another users token.
"""
client = Client()
client.force_login(self.add_user)
url = reverse('_user_auth_token_delete', kwargs={'user_id': 999, 'pk': 1})
response = client.post(url, data={'id': 1}, kwargs={'user_id': 999, 'pk': 1})
assert response.status_code == 403
def test_auth_invalid_token(self):
""" Check token authentication
Invalid token does not allow login
"""
client = Client()
url = reverse('home') + 'api/'
response = client.get(
url,
content_type='application/json',
headers = {
'Accept': 'application/json',
'Authorization': 'Token ' + self.api_token_does_not_exist,
}
)
assert response.status_code == 401
def test_auth_no_token(self):
""" Check token authentication
providing no token does not allow login
"""
client = Client()
url = reverse('home') + 'api/'
response = client.get(
url,
content_type='application/json',
headers = {
'Accept': 'application/json'
}
)
assert response.status_code == 401
def test_auth_expired_token(self):
""" Check token authentication
expired token does not allow login
"""
client = Client()
url = reverse('home') + 'api/'
response = client.get(
url,
content_type='application/json',
headers = {
'Accept': 'application/json',
'Authorization': 'Token ' + self.api_token_expired,
}
)
assert response.status_code == 401
def test_auth_valid_token(self):
""" Check token authentication
Valid token allows login
"""
client = Client()
url = reverse('home') + 'api/'
response = client.get(
url,
content_type='application/json',
headers = {
'Accept': 'application/json',
'Authorization': 'Token ' + self.api_token_valid,
}
)
assert response.status_code == 200
def test_feat_expired_token_is_removed(self):
""" token feature confirmation
expired token is deleted
"""
client = Client()
url = reverse('home') + 'api/'
response = client.get(
url,
content_type='application/json',
headers = {
'Accept': 'application/json',
'Authorization': 'Token ' + self.api_token_expired,
}
)
db_query = AuthToken.objects.filter(
token = self.hashed_token_expired
)
assert not db_query.exists()
def test_token_not_saved_to_db(self):
""" confirm generated token not saved to the database """
db_query = AuthToken.objects.filter(
token = self.api_token_valid
)
assert not db_query.exists()
def test_header_format_invalid_token(self):
""" token header format check
header missing 'Token' prefix reports invalid
"""
client = Client()
url = reverse('home') + 'api/'
response = client.get(
url,
content_type='application/json',
headers = {
'Accept': 'application/json',
'Authorization': '' + self.api_token_valid,
}
)
content: dict = json.loads(response.content.decode('utf-8'))
assert response.status_code == 401 and content['detail'] == 'Token header invalid'
def test_header_format_invalid_token_spaces(self):
""" token header format check
auth header with extra spaces reports invalid
"""
client = Client()
url = reverse('home') + 'api/'
response = client.get(
url,
content_type='application/json',
headers = {
'Accept': 'application/json',
'Authorization': 'Token A space ' + self.api_token_valid,
}
)
content: dict = json.loads(response.content.decode('utf-8'))
assert response.status_code == 401 and content['detail'] == 'Token header invalid. Possibly incorrectly formatted'

View File

@ -1,30 +1,25 @@
# from django.contrib.auth.mixins import PermissionRequiredMixin, LoginRequiredMixin
import json
import re
from django.http import Http404, JsonResponse
from django.utils import timezone
from django.core.exceptions import ValidationError, PermissionDenied
from drf_spectacular.utils import extend_schema, OpenApiExample, OpenApiTypes, OpenApiResponse, OpenApiParameter
from drf_spectacular.utils import extend_schema, OpenApiResponse
from rest_framework import generics, views
from rest_framework.response import Response
from access.mixin import OrganizationMixin
from access.models import Organization
from api.views.mixin import OrganizationPermissionAPI
from api.serializers.itam.inventory import InventorySerializer
from api.serializers.inventory import Inventory
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 itam.models.device import Device
from settings.models.app_settings import AppSettings
from settings.models.user_settings import UserSettings
from api.tasks import process_inventory
class InventoryPermissions(OrganizationPermissionAPI):
@ -33,7 +28,7 @@ class InventoryPermissions(OrganizationPermissionAPI):
data = view.request.data
self.obj = Device.objects.get(slug=str(data['details']['name']).lower())
self.obj = Device.objects.get(slug=str(data.details.name).lower())
return super().permission_check(request, view, obj=None)
@ -66,9 +61,7 @@ this setting populated, no device will be created and the endpoint will return H
tags = ['device', 'inventory',],
request = InventorySerializer,
responses = {
200: OpenApiResponse(description='Inventory updated an existing device'),
201: OpenApiResponse(description='Inventory created a new device'),
400: OpenApiResponse(description='Inventory is invalid'),
200: OpenApiResponse(description='Inventory upload successful'),
401: OpenApiResponse(description='User Not logged in'),
403: OpenApiResponse(description='User is missing permission or in different organization'),
500: OpenApiResponse(description='Exception occured. View server logs for the Stack Trace'),
@ -76,267 +69,53 @@ this setting populated, no device will be created and the endpoint will return H
)
def post(self, request, *args, **kwargs):
data = json.loads(request.body)
device = None
self.default_organization = UserSettings.objects.get(user=request.user).default_organization
if Device.objects.filter(slug=str(data['details']['name']).lower()).exists():
self.obj = Device.objects.get(slug=str(data['details']['name']).lower())
device = self.obj
if not self.permission_check(request=request, view=self, obj=device):
raise Http404
status = Http.Status.BAD_REQUEST
device_operating_system = None
operating_system = None
operating_system_version = None
status = Http.Status.OK
response_data = 'OK'
try:
app_settings = AppSettings.objects.get(owner_organization = None)
data = json.loads(request.body)
data = Inventory(data)
if not device: # Create the device
device = None
device = Device.objects.create(
name = data['details']['name'],
device_type = None,
serial_number = data['details']['serial_number'],
uuid = data['details']['uuid'],
organization = self.default_organization,
)
status = Http.Status.CREATED
self.default_organization = UserSettings.objects.get(user=request.user).default_organization
if Device.objects.filter(slug=str(data.details.name).lower()).exists():
if OperatingSystem.objects.filter( slug=data['os']['name'] ).exists():
self.obj = Device.objects.get(slug=str(data.details.name).lower())
operating_system = OperatingSystem.objects.get( slug=data['os']['name'] )
device = self.obj
else: # Create Operating System
operating_system = OperatingSystem.objects.create(
name = data['os']['name'],
organization = self.default_organization,
is_global = True
)
if not self.permission_check(request=request, view=self, obj=device):
raise Http404
if OperatingSystemVersion.objects.filter( name=data['os']['version_major'], operating_system=operating_system ).exists():
task = process_inventory.delay(request.body, self.default_organization.id)
operating_system_version = OperatingSystemVersion.objects.get(
organization = self.default_organization,
is_global = True,
name = data['os']['version_major'],
operating_system = operating_system
)
response_data: dict = {"task_id": f"{task.id}"}
else: # Create Operating System Version
except PermissionDenied as e:
operating_system_version = OperatingSystemVersion.objects.create(
organization = self.default_organization,
is_global = True,
name = data['os']['version_major'],
operating_system = operating_system,
)
status = Http.Status.FORBIDDEN
response_data = ''
except ValidationError as e:
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 = self.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<semver>\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 = self.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 = self.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()
if status != Http.Status.CREATED:
status = Http.Status.OK
status = Http.Status.BAD_REQUEST
response_data = e.message
except Exception as e:
print(f'An error occured{e}')
status = Http.Status.SERVER_ERROR
response_data = 'Unknown Server Error occured'
return Response(data='OK',status=status)
return Response(data=response_data,status=status)

View File

@ -0,0 +1,3 @@
from .celery import worker as celery_app
__all__ = ('celery_app',)

18
app/app/celery.py Normal file
View File

@ -0,0 +1,18 @@
import os
from django.conf import settings
from celery import Celery
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings')
worker = Celery('app')
worker.config_from_object(f'django.conf:settings', namespace='CELERY')
worker.autodiscover_tasks()
@worker.task(bind=True, ignore_result=True)
def debug_task(self):
print(f'Request: {self!r}')

View File

@ -5,6 +5,8 @@ from app.urls import urlpatterns
from django.conf import settings
from django.urls import URLPattern, URLResolver
from access.models import Organization
from settings.models.user_settings import UserSettings
@ -88,7 +90,7 @@ def nav_items(context) -> list(dict()):
is_active: {bool} if this link is the active URL
Returns:
_type_: _description_
list: Items user has view access to
"""
dnav = []
@ -142,11 +144,45 @@ def nav_items(context) -> list(dict()):
name = str(pattern.name)
nav_items = nav_items + [ {
'name': name,
'url': url,
'is_active': is_active
} ]
if hasattr(pattern.callback.view_class, 'permission_required'):
permissions_required = pattern.callback.view_class.permission_required
user_has_perm = False
if type(permissions_required) is list:
user_has_perm = context.user.has_perms(permissions_required)
else:
user_has_perm = context.user.has_perm(permissions_required)
if hasattr(pattern.callback.view_class, 'model'):
if pattern.callback.view_class.model is Organization and context.user.is_authenticated:
organizations = Organization.objects.filter(manager = context.user)
if len(organizations) > 0:
user_has_perm = True
if str(nav_group.app_name).lower() == 'settings':
user_has_perm = True
if context.user.is_superuser:
user_has_perm = True
if user_has_perm:
nav_items = nav_items + [ {
'name': name,
'url': url,
'is_active': is_active
} ]
if len(nav_items) > 0:

View File

@ -24,14 +24,61 @@ 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')
DOCS_ROOT = 'https://nofusscomputing.com/projects/django-template/user/'
DOCS_ROOT = 'https://nofusscomputing.com/projects/centurion_erp/user/'
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/
# Celery settings
CELERY_ACCEPT_CONTENT = ['json']
CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True # broker_connection_retry_on_startup
CELERY_BROKER_URL = 'amqp://guest:guest@172.16.10.102:30712/itsm'
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#broker-use-ssl
# import ssl
# broker_use_ssl = {
# 'keyfile': '/var/ssl/private/worker-key.pem',
# 'certfile': '/var/ssl/amqp-server-cert.pem',
# 'ca_certs': '/var/ssl/myca.pem',
# 'cert_reqs': ssl.CERT_REQUIRED
# }
CELERY_BROKER_POOL_LIMIT = 3 # broker_pool_limit
CELERY_CACHE_BACKEND = 'django-cache'
CELERY_ENABLE_UTC = True
CELERY_RESULT_BACKEND = 'django-db'
CELERY_RESULT_EXTENDED = True
CELERY_TASK_SERIALIZER = 'json'
CELERY_TIMEZONE = 'UTC'
CELERY_TASK_DEFAULT_EXCHANGE = 'ITSM' # task_default_exchange
CELERY_TASK_DEFAULT_PRIORITY = 10 # 1-10=LOW-HIGH task_default_priority
# CELERY_TASK_DEFAULT_QUEUE = 'background'
CELERY_TASK_TIME_LIMIT = 3600 # task_time_limit
CELERY_TASK_TRACK_STARTED = True # task_track_started
# dont set concurrency for docer as it defaults to CPU count
CELERY_WORKER_CONCURRENCY = 2 # worker_concurrency - Default: Number of CPU cores
CELERY_WORKER_DEDUPLICATE_SUCCESSFUL_TASKS = True # worker_deduplicate_successful_tasks
CELERY_WORKER_MAX_TASKS_PER_CHILD = 1 # worker_max_tasks_per_child
# CELERY_WORKER_MAX_MEMORY_PER_CHILD = 10000 # 10000=10mb worker_max_memory_per_child - Default: No limit. Type: int (kilobytes)
# CELERY_TASK_SEND_SENT_EVENT = True
CELERY_WORKER_SEND_TASK_EVENTS = True # worker_send_task_events
# django setting.
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.db.DatabaseCache',
'LOCATION': 'my_cache_table',
}
}
#
# Defaults
#
ALLOWED_HOSTS = [ '*' ] # Site host to serve
DEBUG = False # SECURITY WARNING: don't run with debug turned on in production!
SITE_URL = 'http://127.0.0.1' # domain with HTTP method for the sites URL
@ -61,8 +108,8 @@ INSTALLED_APPS = [
'django.contrib.staticfiles',
'rest_framework',
'rest_framework_json_api',
'rest_framework.authtoken',
'social_django',
'django_celery_results',
'core.apps.CoreConfig',
'access.apps.AccessConfig',
'itam.apps.ItamConfig',
@ -170,7 +217,7 @@ STATICFILES_DIRS = [
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
SITE_TITLE = "Site Title"
SITE_TITLE = "Centurion ERP"
API_ENABLED = True
@ -188,7 +235,7 @@ if API_ENABLED:
'rest_framework.permissions.IsAuthenticated',
),
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework.authentication.TokenAuthentication',
'api.auth.TokenAuthentication',
'rest_framework.authentication.SessionAuthentication',
],
'DEFAULT_PAGINATION_CLASS':
@ -228,11 +275,15 @@ if API_ENABLED:
## Authentication
Authentication with the api is via Token. The token is placed in header `Authorization` with a value of `Token <Your Token>`.
Access to the API is restricted and requires authentication. Available authentication methods are:
## Token Generation
- Session
- Token
To generate a token, run `python3 manage.py drf_create_token <username>` from the CLI.
Session authentication is made available after logging into the application via the login interface.
Token authentication is via an API token that a user will generate within their
[settings panel](https://nofusscomputing.com/projects/django-template/user/user_settings/#api-tokens).
## Examples

View File

@ -118,6 +118,16 @@ class ModelPermissionsAdd:
add_data: dict = None
@pytest.mark.skip(reason="ToDO: write test")
def test_model_requires_attribute_parent_model(self):
""" Child model requires 'django view' attribute 'parent_model'
When a child-model is added the parent model is required so that the organization can be detrmined.
"""
pass
def test_model_add_user_anon_denied(self):
""" Check correct permission for add

View File

@ -0,0 +1,63 @@
import pytest
import unittest
from app.tests.abstract.views import AddView, ChangeView, DeleteView, DisplayView, IndexView
class ModelAdd(
AddView
):
""" Unit Tests for Model Add """
class ModelChange(
ChangeView
):
""" Unit Tests for Model Change """
class ModelDelete(
DeleteView
):
""" Unit Tests for Model delete """
class ModelDisplay(
DisplayView
):
""" Unit Tests for Model display """
class ModelIndex(
IndexView
):
""" Unit Tests for Model index """
class ModelCommon(
ModelAdd,
ModelChange,
ModelDelete,
ModelDisplay
):
""" Unit Tests for all models """
class PrimaryModel(
ModelCommon,
ModelIndex
):
""" Tests for Primary Models
A Primary model is a model that is deemed a model that has the following views:
- Add
- Change
- Delete
- Display
- Index
"""

View File

@ -0,0 +1,565 @@
import inspect
import pytest
import unittest
class AddView:
""" Testing of Display view """
add_module: str = None
""" Full module path to test """
add_view: str = None
""" View Class name to test """
def test_view_add_attribute_not_exists_fields(self):
""" Attribute does not exists test
Ensure that `fields` attribute is not defined as the expectation is that a form will be used.
"""
module = __import__(self.add_module, fromlist=[self.add_view])
assert hasattr(module, self.add_view)
viewclass = getattr(module, self.add_view)
assert viewclass.fields is None
def test_view_add_attribute_exists_form_class(self):
""" Attribute exists test
Ensure that `form_class` attribute is defined as it's required.
"""
module = __import__(self.add_module, fromlist=[self.add_view])
assert hasattr(module, self.add_view)
viewclass = getattr(module, self.add_view)
assert hasattr(viewclass, 'form_class')
def test_view_add_attribute_type_form_class(self):
""" Attribute Type Test
Ensure that `form_class` attribute is a class.
"""
module = __import__(self.add_module, fromlist=[self.add_view])
assert hasattr(module, self.add_view)
viewclass = getattr(module, self.add_view)
assert inspect.isclass(viewclass.form_class)
def test_view_add_attribute_exists_model(self):
""" Attribute exists test
Ensure that `model` attribute is defined as it's required .
"""
module = __import__(self.add_module, fromlist=[self.add_view])
assert hasattr(module, self.add_view)
viewclass = getattr(module, self.add_view)
assert hasattr(viewclass, 'model')
def test_view_add_attribute_exists_permission_required(self):
""" Attribute exists test
Ensure that `permission_required` attribute is defined as it's required.
"""
module = __import__(self.add_module, fromlist=[self.add_view])
assert hasattr(module, self.add_view)
viewclass = getattr(module, self.add_view)
assert hasattr(viewclass, 'permission_required')
def test_view_add_attribute_type_permission_required(self):
""" Attribute Type Test
Ensure that `permission_required` attribute is a list
"""
module = __import__(self.add_module, fromlist=[self.add_view])
assert hasattr(module, self.add_view)
viewclass = getattr(module, self.add_view)
assert type(viewclass.permission_required) is list
def test_view_add_attribute_exists_template_name(self):
""" Attribute exists test
Ensure that `template_name` attribute is defined as it's required.
"""
module = __import__(self.add_module, fromlist=[self.add_view])
assert hasattr(module, self.add_view)
viewclass = getattr(module, self.add_view)
assert hasattr(viewclass, 'template_name')
def test_view_add_attribute_type_template_name(self):
""" Attribute Type Test
Ensure that `template_name` attribute is a string.
"""
module = __import__(self.add_module, fromlist=[self.add_view])
assert hasattr(module, self.add_view)
viewclass = getattr(module, self.add_view)
assert type(viewclass.template_name) is str
class ChangeView:
""" Testing of Display view """
change_module: str = None
""" Full module path to test """
change_view: str = None
""" Change Class name to test """
def test_view_change_attribute_not_exists_fields(self):
""" Attribute does not exists test
Ensure that `fields` attribute is not defined as the expectation is that a form will be used.
"""
module = __import__(self.change_module, fromlist=[self.change_view])
assert hasattr(module, self.change_view)
viewclass = getattr(module, self.change_view)
assert viewclass.fields is None
def test_view_change_attribute_exists_form_class(self):
""" Attribute exists test
Ensure that `form_class` attribute is defined as it's required.
"""
module = __import__(self.change_module, fromlist=[self.change_view])
assert hasattr(module, self.change_view)
viewclass = getattr(module, self.change_view)
assert hasattr(viewclass, 'form_class')
def test_view_change_attribute_type_form_class(self):
""" Attribute Type Test
Ensure that `form_class` attribute is a string.
"""
module = __import__(self.change_module, fromlist=[self.change_view])
assert hasattr(module, self.change_view)
viewclass = getattr(module, self.change_view)
assert inspect.isclass(viewclass.form_class)
def test_view_change_attribute_exists_model(self):
""" Attribute exists test
Ensure that `model` attribute is defined as it's required .
"""
module = __import__(self.change_module, fromlist=[self.change_view])
assert hasattr(module, self.change_view)
viewclass = getattr(module, self.change_view)
assert hasattr(viewclass, 'model')
def test_view_change_attribute_exists_permission_required(self):
""" Attribute exists test
Ensure that `permission_required` attribute is defined as it's required.
"""
module = __import__(self.change_module, fromlist=[self.change_view])
assert hasattr(module, self.change_view)
viewclass = getattr(module, self.change_view)
assert hasattr(viewclass, 'permission_required')
def test_view_change_attribute_type_permission_required(self):
""" Attribute Type Test
Ensure that `permission_required` attribute is a list
"""
module = __import__(self.change_module, fromlist=[self.change_view])
assert hasattr(module, self.change_view)
viewclass = getattr(module, self.change_view)
assert type(viewclass.permission_required) is list
def test_view_change_attribute_exists_template_name(self):
""" Attribute exists test
Ensure that `template_name` attribute is defined as it's required.
"""
module = __import__(self.change_module, fromlist=[self.change_view])
assert hasattr(module, self.change_view)
viewclass = getattr(module, self.change_view)
assert hasattr(viewclass, 'template_name')
def test_view_change_attribute_type_template_name(self):
""" Attribute Type Test
Ensure that `template_name` attribute is a string.
"""
module = __import__(self.change_module, fromlist=[self.change_view])
assert hasattr(module, self.change_view)
viewclass = getattr(module, self.change_view)
assert type(viewclass.template_name) is str
class DeleteView:
""" Testing of Display view """
delete_module: str = None
""" Full module path to test """
delete_view: str = None
""" Delete Class name to test """
def test_view_delete_attribute_exists_model(self):
""" Attribute exists test
Ensure that `model` attribute is defined as it's required .
"""
module = __import__(self.delete_module, fromlist=[self.delete_view])
assert hasattr(module, self.delete_view)
viewclass = getattr(module, self.delete_view)
assert hasattr(viewclass, 'model')
def test_view_delete_attribute_exists_permission_required(self):
""" Attribute exists test
Ensure that `model` attribute is defined as it's required .
"""
module = __import__(self.delete_module, fromlist=[self.delete_view])
assert hasattr(module, self.delete_view)
viewclass = getattr(module, self.delete_view)
assert hasattr(viewclass, 'permission_required')
def test_view_delete_attribute_type_permission_required(self):
""" Attribute Type Test
Ensure that `permission_required` attribute is a list
"""
module = __import__(self.delete_module, fromlist=[self.delete_view])
assert hasattr(module, self.delete_view)
viewclass = getattr(module, self.delete_view)
assert type(viewclass.permission_required) is list
def test_view_delete_attribute_exists_template_name(self):
""" Attribute exists test
Ensure that `template_name` attribute is defined as it's required.
"""
module = __import__(self.delete_module, fromlist=[self.delete_view])
assert hasattr(module, self.delete_view)
viewclass = getattr(module, self.delete_view)
assert hasattr(viewclass, 'template_name')
def test_view_delete_attribute_type_template_name(self):
""" Attribute Type Test
Ensure that `template_name` attribute is a string.
"""
module = __import__(self.delete_module, fromlist=[self.delete_view])
assert hasattr(module, self.delete_view)
viewclass = getattr(module, self.delete_view)
assert type(viewclass.template_name) is str
class DisplayView:
""" Testing of Display view """
display_module: str = None
""" Full module path to test """
display_view: str = None
""" Change Class name to test """
def test_view_display_attribute_exists_model(self):
""" Attribute exists test
Ensure that `model` attribute is defined as it's required .
"""
module = __import__(self.display_module, fromlist=[self.display_view])
assert hasattr(module, self.display_view)
viewclass = getattr(module, self.display_view)
assert hasattr(viewclass, 'model')
def test_view_display_attribute_exists_permission_required(self):
""" Attribute exists test
Ensure that `permission_required` attribute is defined as it's required.
"""
module = __import__(self.display_module, fromlist=[self.display_view])
assert hasattr(module, self.display_view)
viewclass = getattr(module, self.display_view)
assert hasattr(viewclass, 'permission_required')
def test_view_display_attribute_type_permission_required(self):
""" Attribute Type Test
Ensure that `permission_required` attribute is a list
"""
module = __import__(self.display_module, fromlist=[self.display_view])
assert hasattr(module, self.display_view)
viewclass = getattr(module, self.display_view)
assert type(viewclass.permission_required) is list
def test_view_display_attribute_exists_template_name(self):
""" Attribute exists test
Ensure that `template_name` attribute is defined as it's required.
"""
module = __import__(self.display_module, fromlist=[self.display_view])
assert hasattr(module, self.display_view)
viewclass = getattr(module, self.display_view)
assert hasattr(viewclass, 'template_name')
def test_view_display_attribute_type_template_name(self):
""" Attribute Type Test
Ensure that `template_name` attribute is a string.
"""
module = __import__(self.display_module, fromlist=[self.display_view])
assert hasattr(module, self.display_view)
viewclass = getattr(module, self.display_view)
assert type(viewclass.template_name) is str
class IndexView:
""" Testing of Display view """
index_module: str = None
""" Full module path to test """
index_view: str = None
""" Index Class name to test """
def test_view_index_attribute_exists_model(self):
""" Attribute exists test
Ensure that `model` attribute is defined as it's required .
"""
module = __import__(self.index_module, fromlist=[self.index_view])
assert hasattr(module, self.index_view)
viewclass = getattr(module, self.index_view)
assert hasattr(viewclass, 'model')
def test_view_index_attribute_exists_permission_required(self):
""" Attribute exists test
Ensure that `model` attribute is defined as it's required .
"""
module = __import__(self.index_module, fromlist=[self.index_view])
assert hasattr(module, self.index_view)
viewclass = getattr(module, self.index_view)
assert hasattr(viewclass, 'permission_required')
def test_view_index_attribute_type_permission_required(self):
""" Attribute Type Test
Ensure that `permission_required` attribute is a list
"""
module = __import__(self.index_module, fromlist=[self.index_view])
assert hasattr(module, self.index_view)
viewclass = getattr(module, self.index_view)
assert type(viewclass.permission_required) is list
def test_view_index_attribute_exists_template_name(self):
""" Attribute exists test
Ensure that `template_name` attribute is defined as it's required.
"""
module = __import__(self.index_module, fromlist=[self.index_view])
assert hasattr(module, self.index_view)
viewclass = getattr(module, self.index_view)
assert hasattr(viewclass, 'template_name')
def test_view_index_attribute_type_template_name(self):
""" Attribute Type Test
Ensure that `template_name` attribute is a string.
"""
module = __import__(self.index_module, fromlist=[self.index_view])
assert hasattr(module, self.index_view)
viewclass = getattr(module, self.index_view)
assert type(viewclass.template_name) is str
class AllViews(
AddView,
ChangeView,
DeleteView,
DisplayView,
IndexView
):
""" Abstract test class containing ALL view tests """
add_module: str = None
""" Full module path to test """
add_view: str = None
""" View Class name to test """
change_module: str = None
""" Full module path to test """
change_view: str = None
""" Change Class name to test """
delete_module: str = None
""" Full module path to test """
delete_view: str = None
""" Delete Class name to test """
display_module: str = None
""" Full module path to test """
display_view: str = None
""" Change Class name to test """
index_module: str = None
""" Full module path to test """
index_view: str = None
""" Index Class name to test """

View File

@ -0,0 +1,86 @@
from app.urls import urlpatterns
class Data:
def parse_urls(self, patterns, parent_route = None) -> list:
urls = []
root_paths = [
'access',
# 'account',
# 'api',
'config_management',
'history',
'itam',
'organization',
'settings'
]
for url in patterns:
if hasattr(url, 'pattern'):
route = None
if hasattr(url.pattern, '_route'):
if parent_route:
route = parent_route + url.pattern._route
route = str(route).replace('<int:device_id>', '1')
route = str(route).replace('<int:group_id>', '1')
route = str(route).replace('<int:operating_system_id>', '1')
route = str(route).replace('<int:organization_id>', '1')
route = str(route).replace('<int:pk>', '1')
route = str(route).replace('<int:software_id>', '1')
route = str(route).replace('<int:team_id>', '1')
if route != '' and route not in urls:
urls += [ route ]
else:
route = url.pattern._route
route = str(route).replace('<int:device_id>', '1')
route = str(route).replace('<int:group_id>', '1')
route = str(route).replace('<int:operating_system_id>', '1')
route = str(route).replace('<int:organization_id>', '1')
route = str(route).replace('<int:pk>', '1')
route = str(route).replace('<int:software_id>', '1')
route = str(route).replace('<int:team_id>', '1')
if str(url.pattern._route).replace('/', '') in root_paths:
if route != '' and route not in urls:
urls += [ route ]
if hasattr(url, 'url_patterns'):
if str(url.pattern._route).replace('/', '') in root_paths:
urls += self.parse_urls(patterns=url.url_patterns, parent_route=url.pattern._route)
return urls
def __init__(self):
urls = []
patterns = urlpatterns
urls_found = self.parse_urls(patterns=patterns)
for url in urls_found:
if url not in urls:
urls += [ url ]
self.urls = urls

View File

@ -0,0 +1,141 @@
import pytest
import re
import requests
import unittest
from django.test import LiveServerTestCase
from app.urls import urlpatterns
from conftest import Data
@pytest.mark.skip(reason="test server required to be setup so tests work.")
class TestRenderedTemplateLinks:
"""UI Links tests """
server_host: str = '127.0.0.1'
# server_host: str = '192.168.1.172'
server_url: str = 'http://' + server_host + ':8002/'
data = Data()
driver = None
""" Chrome webdriver """
session = None
""" Client session that is logged into the dejango site """
def setup_class(self):
""" Set up the test
1. fetch session cookie
2. login to site
3. save session for use in tests
"""
self.session = requests.Session()
# fetch the csrf token
self.session.get(
url = self.server_url + 'account/login/',
)
# login
self.client = self.session.post(
url = self.server_url + 'account/login/',
data = {
'username': 'admin',
'password': 'admin',
'csrfmiddlewaretoken': self.session.cookies._cookies[self.server_host]['/']['csrftoken'].value
}
)
@pytest.mark.parametrize(
argnames='url',
argvalues=[link for link in data.urls],
ids=[link for link in data.urls]
)
def test_ui_no_http_forbidden(self, url):
""" Test Page Links
Scrape the page for links and ensure none return HTTP/403.
Test failure denotes a link on a page that should have been filtered out by testing for user
permissions within the template.
Args:
url (str): Page to test
"""
response = self.session.get(
url = str(self.server_url + url)
)
# Failsafe to ensure no redirection and that page exists
assert len(response.history) == 0
assert response.status_code == 200
page_urls = []
page = str(response.content)
links = re.findall('href=\"([a-z\/0-9]+)\"', page)
for link in links:
page_link_response = self.session.get(
url = str(self.server_url + link)
)
# Failsafe to ensure no redirection
assert len(response.history) == 0
assert page_link_response.status_code != 403
@pytest.mark.parametrize(
argnames='url',
argvalues=[link for link in data.urls],
ids=[link for link in data.urls]
)
def test_ui_no_http_not_found(self, url):
""" Test Page Links
Scrape the page for links and ensure none return HTTP/404.
Test failure denotes a link on a page that should not exist within the template.
Args:
url (str): Page to test
"""
response = self.session.get(
url = str(self.server_url + url)
)
# Failsafe to ensure no redirection and that page exists
assert len(response.history) == 0
assert response.status_code == 200
page_urls = []
page = str(response.content)
links = re.findall('href=\"([a-z\/0-9]+)\"', page)
for link in links:
page_link_response = self.session.get(
url = str(self.server_url + link)
)
# Failsafe to ensure no redirection
assert len(response.history) == 0
assert page_link_response.status_code != 404

View File

View File

@ -36,6 +36,9 @@ urlpatterns = [
path('account/password_change/', auth_views.PasswordChangeView.as_view(template_name="password_change.html.j2"), name="change_password"),
path('account/settings/<int:pk>', user_settings.View.as_view(), name="_settings_user"),
path('account/settings/<int:pk>/edit', user_settings.Change.as_view(), name="_settings_user_change"),
path('account/settings/<int:user_id>/token/add', user_settings.TokenAdd.as_view(), name="_user_auth_token_add"),
path('account/settings/<int:user_id>/token/<int:pk>/delete', user_settings.TokenDelete.as_view(), name="_user_auth_token_delete"),
path("account/", include("django.contrib.auth.urls")),
path("organization/", include("access.urls")),

View File

@ -1,11 +1,13 @@
from django import forms
from django.db.models import Q
from config_management.models.groups import ConfigGroupSoftware
from core.forms.common import CommonModelForm
from itam.models.software import Software
class SoftwareAdd(forms.ModelForm):
class SoftwareAdd(CommonModelForm):
class Meta:
model = ConfigGroupSoftware
@ -13,9 +15,3 @@ class SoftwareAdd(forms.ModelForm):
'software',
'action'
]
def __init__(self, *args, **kwargs):
organizations = kwargs.pop('organizations')
super().__init__(*args, **kwargs)
self.fields['software'].queryset = Software.objects.filter(Q(organization_id__in=organizations) | Q(is_global = True))

View File

@ -1,11 +1,13 @@
from django import forms
from django.db.models import Q
from config_management.models.groups import ConfigGroupSoftware
from core.forms.common import CommonModelForm
from itam.models.software import Software, SoftwareVersion
class SoftwareUpdate(forms.ModelForm):
class SoftwareUpdate(CommonModelForm):
class Meta:
model = ConfigGroupSoftware

View File

@ -1,11 +1,13 @@
from django import forms
from django.db.models import Q
from config_management.models.groups import ConfigGroups
from core.forms.common import CommonModelForm
from itam.models.software import Software, SoftwareVersion
class ConfigGroupForm(forms.ModelForm):
class ConfigGroupForm(CommonModelForm):
class Meta:
model = ConfigGroups
@ -13,5 +15,20 @@ class ConfigGroupForm(forms.ModelForm):
'name',
'parent',
'is_global',
'organization',
'model_notes',
'config',
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if 'parent' in kwargs['initial']:
self.fields['parent'].queryset = self.fields['parent'].queryset.filter(
).exclude(
id=int(kwargs['initial']['parent'])
)

View File

@ -1,11 +1,12 @@
from django import forms
from itam.models.device import Device
from config_management.models.groups import ConfigGroups, ConfigGroupHosts
from core.forms.common import CommonModelForm
class ConfigGroupHostsForm(forms.ModelForm):
class ConfigGroupHostsForm(CommonModelForm):
__name__ = 'asdsa'

View File

@ -0,0 +1,28 @@
# Generated by Django 5.0.6 on 2024-07-11 04:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('config_management', '0005_configgrouphosts_model_notes_and_more'),
]
operations = [
migrations.AlterField(
model_name='configgrouphosts',
name='model_notes',
field=models.TextField(blank=True, default=None, null=True, verbose_name='Notes'),
),
migrations.AlterField(
model_name='configgroups',
name='model_notes',
field=models.TextField(blank=True, default=None, null=True, verbose_name='Notes'),
),
migrations.AlterField(
model_name='configgroupsoftware',
name='model_notes',
field=models.TextField(blank=True, default=None, null=True, verbose_name='Notes'),
),
]

View File

@ -48,7 +48,7 @@
{% csrf_token %}
{{ form }}
{% include 'icons/issue_link.html.j2' with issue=13 %}<br>
<br>
<input type="submit" value="Submit">
<script>

View File

@ -0,0 +1,18 @@
import pytest
import unittest
import requests
from django.test import TestCase, Client
from access.tests.abstract.tenancy_object import TenancyObject
from config_management.models.groups import ConfigGroups
class ConfigGroupsTenancyObject(
TestCase,
TenancyObject
):
model = ConfigGroups

View File

@ -0,0 +1,29 @@
import pytest
import unittest
import requests
from django.test import TestCase
from app.tests.abstract.models import PrimaryModel
class ConfigManagementViews(
TestCase,
PrimaryModel
):
add_module = 'config_management.views.groups.groups'
add_view = 'GroupAdd'
change_module = add_module
change_view = 'GroupView'
delete_module = add_module
delete_view = 'GroupDelete'
display_module = add_module
display_view = 'GroupView'
index_module = add_module
index_view = 'GroupIndexView'

View File

@ -0,0 +1,18 @@
import pytest
import unittest
import requests
from django.test import TestCase, Client
from access.tests.abstract.tenancy_object import TenancyObject
from config_management.models.groups import ConfigGroupSoftware
class ConfigGroupSoftwareTenancyObject(
TestCase,
TenancyObject
):
model = ConfigGroupSoftware

View File

@ -14,9 +14,10 @@ from access.models import Organization, Team, TeamUsers, Permission
from config_management.models.groups import ConfigGroups
from core.models.history import History
from core.tests.abstract.history_permissions import HistoryPermissions
class ConfigGroupSoftwaresHistoryPermissions(TestCase):
class ConfigGroupSoftwaresHistoryPermissions(TestCase, HistoryPermissions):
item_model = ConfigGroups
@ -105,70 +106,3 @@ class ConfigGroupSoftwaresHistoryPermissions(TestCase):
team = different_organization_team,
user = self.different_organization_user
)
@pytest.mark.skip(reason="figure out best way to test")
def test_auth_view_history_user_anon_denied(self):
""" Check correct permission for view
Attempt to view as anon user
"""
client = Client()
url = reverse(self.namespace + self.name_view, kwargs={'model_name': self.history_model_name, 'model_pk': self.item.id})
response = client.get(url)
assert response.status_code == 302 and response.url.startswith('/account/login')
@pytest.mark.skip(reason="figure out best way to test")
def test_auth_view_history_no_permission_denied(self):
""" Check correct permission for view
Attempt to view with user missing permission
"""
client = Client()
url = reverse(self.namespace + self.name_view, kwargs={'model_name': self.history_model_name, 'model_pk': self.item.id})
client.force_login(self.no_permissions_user)
response = client.get(url)
assert response.status_code == 403
@pytest.mark.skip(reason="figure out best way to test")
def test_auth_view_history_different_organizaiton_denied(self):
""" Check correct permission for view
Attempt to view with user from different organization
"""
client = Client()
url = reverse(self.namespace + self.name_view, kwargs={'model_name': self.history_model_name, 'model_pk': self.item.id})
client.force_login(self.different_organization_user)
response = client.get(url)
assert response.status_code == 403
@pytest.mark.skip(reason="figure out best way to test")
def test_auth_view_history_has_permission(self):
""" Check correct permission for view
Attempt to view as user with view permission
"""
client = Client()
url = reverse(self.namespace + self.name_view, kwargs={'model_name': self.history_model_name, 'model_pk': self.item.id})
client.force_login(self.view_user)
response = client.get(url)
assert response.status_code == 200

View File

@ -0,0 +1,31 @@
import pytest
import unittest
import requests
from django.test import TestCase
from app.tests.abstract.models import AddView, ChangeView, DeleteView
class ConfigGroupsSoftwareViews(
TestCase,
AddView,
ChangeView,
DeleteView
):
add_module = 'config_management.views.groups.software'
add_view = 'GroupSoftwareAdd'
change_module = add_module
change_view = 'GroupSoftwareChange'
delete_module = add_module
delete_view = 'GroupSoftwareDelete'
# display_module = add_module
# display_view = 'GroupView'
# index_module = add_module
# index_view = 'GroupIndexView'

View File

@ -10,14 +10,14 @@ urlpatterns = [
path('group/add', GroupAdd.as_view(), name='_group_add'),
path('group/<int:pk>', GroupView.as_view(), name='_group_view'),
path('group/<int:group_id>/child', GroupAdd.as_view(), name='_group_add_child'),
path('group/<int:pk>/child', GroupAdd.as_view(), name='_group_add_child'),
path('group/<int:pk>/delete', GroupDelete.as_view(), name='_group_delete'),
path("group/<int:pk>/software/add", GroupSoftwareAdd.as_view(), name="_group_software_add"),
path("group/<int:group_id>/software/<int:pk>", GroupSoftwareChange.as_view(), name="_group_software_change"),
path("group/<int:group_id>/software/<int:pk>/delete", GroupSoftwareDelete.as_view(), name="_group_software_delete"),
path('group/<int:group_id>/host', GroupHostAdd.as_view(), name='_group_add_host'),
path('group/<int:pk>/host', GroupHostAdd.as_view(), name='_group_add_host'),
path('group/<int:group_id>/host/<int:pk>/delete', GroupHostDelete.as_view(), name='_group_delete_host'),
]

View File

@ -4,12 +4,10 @@ from django.contrib.auth import decorators as auth_decorator
from django.db.models import Count, Q
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.views import generic
from access.mixin import OrganizationPermission
from core.forms.comment import AddNoteForm
from core.models.notes import Notes
from core.views.common import AddView, ChangeView, DeleteView, IndexView
from itam.models.device import Device
@ -21,7 +19,7 @@ from config_management.models.groups import ConfigGroups, ConfigGroupHosts, Conf
class GroupIndexView(OrganizationPermission, generic.ListView):
class GroupIndexView(IndexView):
context_object_name = "groups"
@ -29,7 +27,9 @@ class GroupIndexView(OrganizationPermission, generic.ListView):
paginate_by = 10
permission_required = 'config_management.view_configgroups'
permission_required = [
'config_management.view_configgroups'
]
template_name = 'config_management/group_index.html.j2'
@ -57,13 +57,11 @@ class GroupIndexView(OrganizationPermission, generic.ListView):
class GroupAdd(OrganizationPermission, generic.CreateView):
class GroupAdd(AddView):
fields = [
'name',
'parent',
'organization',
]
organization_field = 'organization'
form_class = ConfigGroupForm
model = ConfigGroups
@ -80,11 +78,11 @@ class GroupAdd(OrganizationPermission, generic.CreateView):
'organization': UserSettings.objects.get(user = self.request.user).default_organization
}
if 'group_id' in self.kwargs:
if 'pk' in self.kwargs:
if self.kwargs['group_id']:
if self.kwargs['pk']:
initial.update({'parent': self.kwargs['group_id']})
initial.update({'parent': self.kwargs['pk']})
self.model.parent.field.hidden = True
@ -111,7 +109,7 @@ class GroupAdd(OrganizationPermission, generic.CreateView):
class GroupView(OrganizationPermission, generic.UpdateView):
class GroupView(ChangeView):
context_object_name = "group"
@ -195,7 +193,7 @@ class GroupView(OrganizationPermission, generic.UpdateView):
class GroupDelete(OrganizationPermission, generic.DeleteView):
class GroupDelete(DeleteView):
model = ConfigGroups
@ -220,12 +218,14 @@ class GroupDelete(OrganizationPermission, generic.DeleteView):
class GroupHostAdd(OrganizationPermission, generic.CreateView):
class GroupHostAdd(AddView):
model = ConfigGroupHosts
parent_model = ConfigGroups
permission_required = [
'config_management.add_hosts',
'config_management.add_configgrouphosts',
]
template_name = 'form.html.j2'
@ -235,7 +235,9 @@ class GroupHostAdd(OrganizationPermission, generic.CreateView):
def form_valid(self, form):
form.instance.group_id = self.kwargs['group_id']
form.instance.group_id = self.kwargs['pk']
form.instance.organization = self.parent_model.objects.get(pk=form.instance.group_id).organization
return super().form_valid(form)
@ -252,40 +254,31 @@ class GroupHostAdd(OrganizationPermission, generic.CreateView):
form_class = super().get_form(form_class=None)
group = ConfigGroups.objects.get(pk=self.kwargs['group_id'])
group = ConfigGroups.objects.get(pk=self.kwargs['pk'])
exsting_group_hosts = ConfigGroupHosts.objects.filter(group=group)
form_class.fields["host"].queryset = None
form_class.fields["host"].queryset = form_class.fields["host"].queryset.filter(
).exclude(
id__in=exsting_group_hosts.values_list('host', flat=True)
)
if group.is_global:
form_class.fields["host"].queryset = Device.objects.filter(
).exclude(
id__in=exsting_group_hosts.values_list('host', flat=True)
)
if form_class.fields["host"].queryset is None:
form_class.fields["host"].queryset = Device.objects.filter(
organization=group.organization.id,
).exclude(id__in=exsting_group_hosts.values_list('host', flat=True))
return form_class
def get_success_url(self, **kwargs):
return reverse('Config Management:_group_view', args=[self.kwargs['group_id'],])
return reverse('Config Management:_group_view', args=[self.kwargs['pk'],])
class GroupHostDelete(OrganizationPermission, generic.DeleteView):
class GroupHostDelete(DeleteView):
model = ConfigGroupHosts
permission_required = [
'config_management.delete_hosts',
'config_management.delete_configgrouphosts',
]
template_name = 'form.html.j2'

View File

@ -1,7 +1,4 @@
from django.urls import reverse
from django.views import generic
from access.mixin import OrganizationPermission
from itam.models.software import Software
@ -9,9 +6,10 @@ from config_management.forms.group.add_software import SoftwareAdd
from config_management.forms.group.change_software import SoftwareUpdate
from config_management.models.groups import ConfigGroups, ConfigGroupSoftware
from core.views.common import AddView, ChangeView, DeleteView
class GroupSoftwareAdd(OrganizationPermission, generic.CreateView):
class GroupSoftwareAdd(AddView):
form_class = SoftwareAdd
@ -53,13 +51,6 @@ class GroupSoftwareAdd(OrganizationPermission, generic.CreateView):
return super().form_valid(form)
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
obj = ConfigGroups.objects.get(pk=self.kwargs['pk'])
kwargs['organizations'] = [ obj.organization.id ]
return kwargs
def get_success_url(self, **kwargs):
return reverse('Config Management:_group_view', args=(self.kwargs['pk'],))
@ -74,7 +65,7 @@ class GroupSoftwareAdd(OrganizationPermission, generic.CreateView):
class GroupSoftwareChange(OrganizationPermission, generic.UpdateView):
class GroupSoftwareChange(ChangeView):
form_class = SoftwareUpdate
@ -113,7 +104,7 @@ class GroupSoftwareChange(OrganizationPermission, generic.UpdateView):
class GroupSoftwareDelete(OrganizationPermission, generic.DeleteView):
class GroupSoftwareDelete(DeleteView):
model = ConfigGroupSoftware

5
app/core/exceptions.py Normal file
View File

@ -0,0 +1,5 @@
class MissingAttribute(Exception):
""" An attribute is missing"""
pass

View File

View File

@ -1,10 +1,10 @@
from django import forms
from app import settings
from core.forms.common import CommonModelForm
from core.models.notes import Notes
class AddNoteForm(forms.ModelForm):
class AddNoteForm(CommonModelForm):
prefix = 'note'

100
app/core/forms/common.py Normal file
View File

@ -0,0 +1,100 @@
from django import forms
from django.db.models import Q
from access.models import Organization, TeamUsers
class CommonModelForm(forms.ModelForm):
""" Abstract Form class for form inclusion
This class exists so that common functions can be conducted against forms as they are loaded.
"""
organization_field: str = 'organization'
""" Organization Field
Name of the field that contains Organizations.
This field will be filtered to those that the user is part of.
"""
def __init__(self, *args, **kwargs):
"""Form initialization.
Initialize the form using the super classes first then continue to initialize the form using logic
contained within this method.
## Tenancy Objects
Fields that contain an attribute called `organization` will have the objects filtered to
the organizations the user is part of. If the object has `is_global=True`, that object will not be
filtered out.
"""
user = kwargs.pop('user', None)
user_organizations: list([str]) = []
user_organizations_id: list(int()) = []
for team_user in TeamUsers.objects.filter(user=user):
if team_user.team.organization.name not in user_organizations:
if not user_organizations:
self.user_organizations = []
user_organizations += [ team_user.team.organization.name ]
user_organizations_id += [ team_user.team.organization.id ]
new_kwargs: dict = {}
for key, value in kwargs.items():
if key != 'user':
new_kwargs.update({key: value})
super().__init__(*args, **new_kwargs)
if len(user_organizations_id) > 0:
for field_name in self.fields:
field = self.fields[field_name]
if hasattr(field, 'queryset'):
if hasattr(field.queryset.model, 'organization'):
if hasattr(field.queryset.model, 'is_global'):
self.fields[field_name].queryset = field.queryset.filter(
Q(organization__in=user_organizations_id)
|
Q(is_global = True)
)
else:
self.fields[field_name].queryset = field.queryset.filter(
Q(organization__in=user_organizations_id)
)
if self.Meta.fields:
if self.organization_field in self.Meta.fields:
self.fields[self.organization_field].queryset = self.fields[self.organization_field].queryset.filter(
Q(name__in=user_organizations)
|
Q(manager=user)
)

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