Compare commits

..

163 Commits

Author SHA1 Message Date
Jon
8d071c68df chore: add Merge/Pull request template
#226
2024-08-14 01:52:22 +09:30
Jon
3b1691ff62 docs(roadmap): update completed features
#226
2024-08-14 01:46:21 +09:30
Jon
a77c43d213 docs(base): detail view template
. #24 #226 closes #22
2024-08-14 01:33:48 +09:30
Jon
086959b431 refactor(itim): services now use details template
. #22 #226
2024-08-14 01:23:17 +09:30
Jon
3f117f9d83 feat(base): create detail view templates
purpose is to aid in the development of a detail form

#22 #24 #226
2024-08-14 00:02:25 +09:30
Jon
6a23845a4f docs: initial adding of template page
#22
2024-08-13 15:03:10 +09:30
Jon
b9c6d04e04 chore: add services navigation icon
!43 #69
2024-08-13 13:23:45 +09:30
Jon
32c0027ecf fix(itim): ensure that the service template config is also rendered as part of device config
!43 #69
2024-08-13 13:23:45 +09:30
Jon
dae52e8646 docs: fluff the port and services
!43 closes #69
2024-08-13 13:23:45 +09:30
Jon
890a5651a0 fix(itim): dont render link if no device
!43 #69
2024-08-13 13:23:45 +09:30
Jon
4cb37f8347 feat(itam): Render Service Config with device config
!43 #69
2024-08-13 13:23:45 +09:30
Jon
a2010b9517 feat(itam): Display deployed services for devices
!43 #69
2024-08-13 13:23:45 +09:30
Jon
c95736ce14 feat(itim): Prevent circular service dependencies
!43 #69
2024-08-13 13:23:45 +09:30
Jon
b46c61954c feat(itim): Port number validation to check for valid port numbers
!43 #69
2024-08-13 13:23:45 +09:30
Jon
afe4266600 feat(itim): Prevent Service template from being assigned as dependent service
!43 #69
2024-08-13 13:23:45 +09:30
Jon
0c8d1c8da1 feat(itim): Add service template support
!43 #69
2024-08-13 13:23:45 +09:30
Jon
eac998b5cc fix(itim): Dont show self within service dependencies
!43 #69
2024-08-13 13:23:45 +09:30
Jon
5914782252 feat(itim): Ports for service management
!43 #69
2024-08-13 13:23:45 +09:30
Jon
73d875c4ac feat(itim): Service Management
!43 #69
2024-08-13 13:23:45 +09:30
Jon
8f439f0675 fix(assistance): Only return distinct values when limiting KB articles
!43 #10
2024-08-13 13:23:45 +09:30
Jon
0f102c6aaf docs(assistance): document kb categories for user
!43 closes #10
2024-08-13 13:23:45 +09:30
Jon
4852c6caeb feat(assistance): Filter KB articles to target user
only intended to filter for users whom dont have change perm.

!43 #10
2024-08-13 13:23:45 +09:30
Jon
3fffba2eba feat(assistance): Add date picker to date fields for KB articles
!43 #10
2024-08-13 13:23:45 +09:30
Jon
a1293984ea feat(assistance): Dont display expired articles for "view" users
!43 #10
2024-08-13 13:23:45 +09:30
Jon
4876db50c1 docs(assistance): document kb for user
!43 #10
2024-08-13 13:23:45 +09:30
Jon
425cc066af feat(base): add code highlighting to markdown
!43 #10
2024-08-13 13:23:45 +09:30
Jon
1086f517fa feat(assistance): Categorised Knowledge base articles
!43 #10
2024-08-13 13:23:45 +09:30
Jon
2fdbf87ddd docs(assistance): added pages for knowledgebase
!43 #10
2024-08-13 13:23:45 +09:30
Jon
86228836c7 chore(base): rename information -> assistance
!43 #10
2024-08-13 13:23:45 +09:30
Jon
a6e6c948a5 feat(itim): Add menu entry
!43 #69 #71
2024-08-13 13:23:45 +09:30
Jon
dcdfa8feb7 feat(itam): Ability to add device configuration
!43 fixes #44
2024-08-13 13:23:45 +09:30
Jon
8388d2e695 test(external_link): add tests
!43 fixes #6
2024-08-13 13:23:45 +09:30
Jon
29f269050f feat(settings): New model to allow adding templated links to devices and software
!43 #6
2024-08-13 13:23:45 +09:30
Jon
93c4fc2009 docs: move settings pages into sub-directory
!43 #6
2024-08-13 13:23:45 +09:30
9d5464b5a9 build: bump version 1.0.0-b13 -> 1.0.0-b14 2024-08-12 06:02:07 +00:00
Jon
7848397ae2 Merge pull request #224 from nofusscomputing/223-fix-api-team-mnotes
CRUD operation for team notes
2024-08-12 15:29:21 +09:30
Jon
f298ce94bf test(access): test field model_notes
closes #223
2024-08-12 15:15:28 +09:30
Jon
3cace8943e fix(api): ensure model_notes is an available field
#223
2024-08-12 15:14:58 +09:30
Jon
aa40d68c88 build: add test to changelog
#209
2024-08-11 19:30:45 +09:30
c0f186db89 build: bump version 1.0.0-b12 -> 1.0.0-b13 2024-08-11 08:01:20 +00:00
Jon
6b35e7808c Merge pull request #222 from nofusscomputing/153-model-validations
fix: Audit models for validations
2024-08-11 17:21:20 +09:30
Jon
467f6fca6b fix(itam): Ensure device name is formatted according to RFC1035 2.3.1
see https://datatracker.ietf.org/doc/html/rfc1035#autoid-6

#222 closes #153
2024-08-11 17:09:06 +09:30
Jon
f86b2d5216 fix(itam): Ensure device UUID is correctly formatted
#153 #222
2024-08-11 17:08:56 +09:30
Jon
e29d8e1ec1 fix(config_management): Ensure that config group can't set self as parent
interface already filters self out, however check still to be done.

. #153 #222
2024-08-11 17:07:39 +09:30
Jon
0fc5f41391 fix(settings): ensure that the api token cant be saved to notes field
#153
2024-08-11 16:26:19 +09:30
Jon
4b29448d84 Merge pull request #220 from nofusscomputing/162-api-field-validtion
test: api field checks
2024-08-11 14:37:46 +09:30
Jon
e9fe4896df ci: mirror repo to gitlab
. #220 closes #214
2024-08-11 14:29:02 +09:30
Jon
b9d32a2c16 docs(tests): update testing docs explaining test types
#220 closes #162
2024-08-11 12:57:33 +09:30
Jon
d6bd99c5de docs: update project badges to reflect github hosted project
. #220
2024-08-11 12:38:20 +09:30
Jon
7de5ab12bf docs(readme): correct status badge icon to github
. #220
2024-08-11 12:37:52 +09:30
Jon
3fe09fb8f9 test(software): api field checks
. #162 #220
2024-08-11 12:27:57 +09:30
Jon
eb6b03f731 docs(development): added api field test note
. #220
2024-08-11 12:05:46 +09:30
40e3078a58 build: bump version 1.0.0-b11 -> 1.0.0-b12 2024-08-10 11:23:21 +00:00
Jon
4ba79c6ae9 Merge pull request #218 from nofusscomputing/162-api-field-validtion
test: api field checks

#128 #162
2024-08-10 20:51:51 +09:30
Jon
b5c31d81d3 fix(api): ensure org mixin is inherited by software view
. #218 fixes #219
2024-08-10 20:35:06 +09:30
Jon
c3b585d416 fix(base): correct project links to github
. #218
2024-08-10 20:24:43 +09:30
Jon
84d21f4af8 test(teams): api field checks
. #162 #218
2024-08-10 19:58:04 +09:30
Jon
262e431834 test(organization): api field checks
. #162
2024-08-10 19:39:28 +09:30
cde2562048 build: bump version 1.0.0-b10 -> 1.0.0-b11 2024-08-10 08:30:02 +00:00
Jon
67d853cf25 Merge pull request #215 from nofusscomputing/dependabot/pip/django-5.0.8
chore(deps): bump django from 5.0.7 to 5.0.8

#209
2024-08-10 17:54:55 +09:30
Jon
3ba6bb5b4b docs(readme): correct build badge
#209 #214
2024-08-10 17:35:53 +09:30
84d4f48c63 chore(deps): bump django from 5.0.7 to 5.0.8
Bumps [django](https://github.com/django/django) from 5.0.7 to 5.0.8.
- [Commits](https://github.com/django/django/compare/5.0.7...5.0.8)

---
updated-dependencies:
- dependency-name: django
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-08-10 17:22:47 +09:30
Jon
5e8bebbeb1 build(python): update installed packages as part of the build
#209
2024-08-10 17:22:07 +09:30
Jon
b66a8644a0 Merge pull request #217 from nofusscomputing/ci-adjustments
ci: migration items
2024-08-10 15:03:45 +09:30
Jon
f0ae185fc5 docs(readme): fix version badges
#217 #214
2024-08-10 14:02:27 +09:30
Jon
43b7e413a6 ci(project): add issue/pr project triage
https://github.com/nofusscomputing/action_project/pull/1 https://github.com/nofusscomputing/centurion_erp/issues/214 #217
2024-08-10 13:45:58 +09:30
Jon
04dc00d79d ci(gitlab): fix includes
https://github.com/nofusscomputing/centurion_erp/issues/214
2024-08-10 13:35:37 +09:30
8e6fd58107 build: bump version 1.0.0-b9 -> 1.0.0-b10 2024-08-09 12:53:39 +00:00
Jon
bfe9a95038 ci: remove docker.io from image publish name
#209
2024-08-09 22:23:03 +09:30
33687791ec build: bump version 1.0.0-b8 -> 1.0.0-b9 2024-08-09 12:43:24 +00:00
Jon
57bc972b0f ci: use correct docker.io credentials
#209
2024-08-09 22:12:19 +09:30
Jon
f437eeccb8 ci: use full docker.io as publish image name
#209
2024-08-09 22:12:19 +09:30
Jon
4e11ad67d0 ci: use docker.io as publish registry
#209
2024-08-09 22:12:19 +09:30
40ba645a35 build: bump version 1.0.0-b7 -> 1.0.0-b8 2024-08-09 12:26:45 +00:00
Jon
27e73e21d1 ci: add org to docker publish registry
#209
2024-08-09 21:55:41 +09:30
a6c0785de0 build: bump version 1.0.0-b6 -> 1.0.0-b7 2024-08-09 12:14:12 +00:00
Jon
83328be22e ci: fix publish registry
#209
2024-08-09 21:39:53 +09:30
c6ed5c8279 build: bump version 1.0.0-b5 -> 1.0.0-b6 2024-08-09 11:58:50 +00:00
Jon
a4dc7f479a Merge pull request #216 from nofusscomputing/gitlab-migration
chore: gitlab migration tasks

#216 #214
2024-08-09 21:16:01 +09:30
Jon
71726035dc ci: Add Bump workflow
#216 #214
2024-08-09 21:05:38 +09:30
Jon
c624a3617c ci: Add Python workflow
#216 #214
2024-08-09 21:05:16 +09:30
Jon
cf00ab6234 ci: Add Docker workflow
#216 #214
2024-08-09 21:05:05 +09:30
Jon
e8684c5206 ci: Add PR checks workflow
#216 #214
2024-08-09 21:04:25 +09:30
Jon
bb388a1969 ci: remove temp workflows
#216 #214
2024-08-09 21:03:58 +09:30
Jon
d99f2d3c6f docs: update readme to reflect Github as project home
. #216
2024-08-09 21:02:48 +09:30
Jon
81a72773cb ci: remove gitlab pipelines
#216
2024-08-09 15:15:54 +09:30
Jon
5fa88a5209 ci(github): add coverage
!43
2024-08-02 10:49:42 +09:30
Jon
366579c12b ci(github): add unit tests
!43
2024-08-02 04:59:54 +09:30
Jon
fed0c5c3e5 chore: artifacthub preperation
!43
2024-08-01 17:31:00 +09:30
c496d10c1a bump: version 1.0.0-b4 → 1.0.0-b5 2024-07-31 17:02:31 +00:00
Jon
3993cc96a5 Merge branch '160-api-config' into 'development'
feat: add Config groups to API

Closes #161 and #160

See merge request nofusscomputing/projects/centurion_erp!45
2024-07-31 16:44:55 +00:00
Jon
a4b37b34a9 docs: Add dets of collection
!45 closes #161
2024-08-01 02:12:42 +09:30
Jon
2f55024f0b fix(api): Ensure device groups is read only
checks for required fields

!45 #160 #162
2024-08-01 01:54:24 +09:30
Jon
213644a51a test(api): Field existence and type checks for device
checks for required fields

!45 #160 #162
2024-08-01 01:12:33 +09:30
Jon
281d839801 feat(api): Add device config groups to devices
!45 #160 nofusscomputing/projects/ansible/collections/centurion_erp_collection!7 nofusscomputing/projects/ansible/collections/centurion_erp_collection#4
2024-08-01 00:32:16 +09:30
Jon
4fd157a785 test(api): test configgroups API fields
tests for type and existence

!45 closes #160
2024-07-31 22:27:45 +09:30
Jon
968b3a0f92 feat(api): Ability to fetch configgroups from api along with config
!45 #160 nofusscomputing/projects/ansible/collections/centurion_erp_collection#4
2024-07-31 22:27:32 +09:30
Jon
f5ba608ed1 ci: var to export for use in script
!45
2024-07-31 21:19:55 +09:30
289668bb7f bump: version 1.0.0-b3 → 1.0.0-b4 2024-07-29 07:54:33 +00:00
Jon
9e28722dba Merge branch 'b3-fixes' into 'development'
fix: release-b3 fixes

Closes #155

See merge request nofusscomputing/projects/centurion_erp!44
2024-07-29 07:38:00 +00:00
Jon
9b673f4a07 fix(api): cleanup team post/get
!44 #159
2024-07-29 17:03:25 +09:30
Jon
3a9e4b29b3 fix(api): confirm HTTP method is allowed before permission check
return HTTP/405 for logged in user ONLY!!

!44 #159
2024-07-29 17:02:52 +09:30
Jon
8d59462561 fix(api): Ensure that organizations can't be created via the API
!44 fixes #155
2024-07-29 17:02:37 +09:30
Jon
098e41e6a1 feat(swagger): remove {format} suffixed doc entries
!44
2024-07-29 16:49:51 +09:30
Jon
fc3f0b39e2 ci: add debug out to extra command
!44
2024-07-29 16:49:24 +09:30
Jon
de53948cea test: confirm that the tenancymanager is called
!43
2024-07-21 13:27:45 +09:30
Jon
823ebc0eb5 fix(access): Team model class inheritance order corrected
!42
2024-07-21 13:27:36 +09:30
41414438d1 bump: version 1.0.0-b2 → 1.0.0-b3 2024-07-21 01:47:05 +00:00
Jon
5704560beb fix(itam): Limit os version count to devices user has access to
!42
2024-07-21 10:07:36 +09:30
Jon
8a48902b64 ci: return command to release
!42
2024-07-20 13:02:21 +09:30
61fe059513 bump: version 1.0.0-b1 → 1.0.0-b2 2024-07-19 10:41:41 +00:00
Jon
94576cc733 ci: fix additional command as part of release
!42
2024-07-19 19:45:50 +09:30
Jon
3a32c62119 fix(itam): only show os version once
!42 fixes #139
2024-07-19 18:01:10 +09:30
Jon
9ea4fe1adc ci: Create Version labels within repo on release
!42
2024-07-19 18:01:10 +09:30
Jon
0798a672c2 docs(administration): spread the love out
!42
2024-07-19 18:01:10 +09:30
f4e68529ba bump: version 1.0.0-a4 → 1.0.0-b1 2024-07-19 07:15:09 +00:00
Jon
92a411baec docs(administration): explain the magic
!42 #74
2024-07-19 16:27:33 +09:30
Jon
034857d088 ci: dev branch releases now beta
preperation for RC. all dev releases are now beta.

!42 #74
2024-07-19 15:35:32 +09:30
Jon
e5ce86a9bb fix(itam): ensure installed operating system count is limited to users organizations
!42
2024-07-19 11:02:56 +09:30
Jon
5188b3d52e fix(itam): ensure installed software count is limited to users organizations
!42
2024-07-19 00:39:05 +09:30
61b9435d1f bump: version 1.0.0-a3 → 1.0.0-a4 2024-07-18 12:59:03 +00:00
Jon
8244676530 test: ensure inventory upload matches by both serial number and uuid if device name different
!42 #15
2024-07-18 22:05:12 +09:30
Jon
ec1e7cca85 test: placeholder for moving organization
!42 #15
2024-07-18 22:04:27 +09:30
Jon
72ab9253d7 feat(api): When processing uploaded inventory and name does not match, update name to one within inventory file
!43
2024-07-18 17:08:52 +09:30
Jon
4f89255c4f feat(config_management): Group name to be entire breadcrumb
!43
2024-07-18 16:51:22 +09:30
8d6d1d0d56 bump: version 1.0.0-a2 → 1.0.0-a3 2024-07-18 06:25:34 +00:00
Jon
2d0c3a660a fix(config_management): dont attempt to do action during save if group being created
!42
2024-07-18 15:34:25 +09:30
Jon
974a208869 chore(config_management): remove org filter as its not required
not required as org filtering is done as part of the initial queryset within the model.

!42
2024-07-18 15:34:25 +09:30
Jon
7f225784c2 chore(settings): remove org filter as its not required
not required as org filtering is done as part of the initial queryset within the model.

!42
2024-07-18 15:34:25 +09:30
Jon
a3be95013c fix(itam): remove org filter for device so that user can see installations
not required as org filtering is done as part of the initial queryset within the model.

!42
2024-07-18 15:34:25 +09:30
Jon
adefbf3960 fix(itam): remove org filter for operating systems so that user can see installations
not required as org filtering is done as part of the initial queryset within the model.

!42
2024-07-18 15:34:25 +09:30
Jon
9a1ca7a104 fix(itam): remove org filter for software so that user can see installations
not required as org filtering is done as part of the initial queryset within the model.

!42
2024-07-18 15:34:25 +09:30
Jon
e84e80cd8f feat(config_management): Prevent a config group from being able to change organization
!42
2024-07-18 15:34:25 +09:30
Jon
ebc266010a feat(itam): On device organization change remove config groups
!42
2024-07-18 15:34:25 +09:30
Jon
519277e18b fix(itam): Device related items should not be global.
!42
2024-07-18 15:34:25 +09:30
Jon
a5a5874211 fix(itam): When changing device organization move related items too.
!42 fixes #137
2024-07-18 15:34:25 +09:30
fa2b90ee7b bump: version 1.0.0-a1 → 1.0.0-a2 2024-07-17 16:53:14 +00:00
Jon
5c74360842 fix(base): dont show user warning bar for non-authenticated user
!42
2024-07-18 01:48:57 +09:30
Jon
8457f15eca fix(api): correct inventory operating system selection by name
!42 #134
2024-07-18 01:48:21 +09:30
Jon
5bc5a4b065 docs(worker): add worker and task logs
!42 fixes #135
2024-07-18 01:28:22 +09:30
Jon
40350d166e feat(api): Inventory matching of device second by uuid
!42 #134
2024-07-18 01:17:51 +09:30
Jon
9a94ba31e4 feat(api): Inventory matching of device first by serial number
!42 #134
2024-07-18 01:17:29 +09:30
Jon
55197e7dcc fix(api): correct inventory operating system and it's linking to device
wasn't updating existing device os

!42 #134
2024-07-18 01:16:34 +09:30
Jon
a67bc70503 fix(api): correct inventory device search to be case insensitive
!42 fixes #134
2024-07-18 01:14:33 +09:30
Jon
60538e1cec feat(base): show warning bar if the user has not set a default organization
!42 fixes #133
2024-07-17 23:51:25 +09:30
Jon
416e029c23 revert: return ci build settings to not include branch alpha
partial revert of a1759ecaaf

!42
2024-07-17 11:00:41 +09:30
fe64c11927 bump: version 0.7.0 → 1.0.0-a1 2024-07-16 05:56:44 +00:00
Jon
1f8244ae40 ci: use updated commitizen
!42 !40 #74
2024-07-16 15:11:39 +09:30
Jon
9871cf248b Merge branch 'v-1-0-0-alpha' into 'development'
refactor: repo preperation for v1.0.0-Alpha-1

See merge request nofusscomputing/projects/centurion_erp!40
2024-07-16 04:24:44 +00:00
Jon
a1759ecaaf ci: add alpha branch to docker builds and publish
!40 !42 #74
2024-07-16 13:39:52 +09:30
Jon
30e0342f52 ci: temp change to release, on dev to be alpha release
!40 !35 #74
2024-07-16 13:31:18 +09:30
Jon
5a201ef548 refactor!: Squash database migrations
BREAKING CHANGE: squashed DB migrations in preparation for v1.0 release.

!40 !35 #74
2024-07-16 13:31:18 +09:30
Jon
7b26fac73d feat: Administratively set global items org/is_global field now read-only
!42 fixes #126
2024-07-16 13:19:30 +09:30
Jon
7c62309a31 fix(core): migrate manufacturer to use new form/view logic
!42 fixes #127
2024-07-16 11:55:25 +09:30
Jon
621cbd2d71 revert: return organization filtering back to forms
!42 #124
2024-07-16 00:02:45 +09:30
Jon
d8e89bee10 test: tenancy objects
!42 #15 closes #124
2024-07-15 23:22:15 +09:30
Jon
4ee62aa399 test: refactor to single abstract model for inclusion.
!42 #15
2024-07-15 23:01:49 +09:30
Jon
f1201e8933 feat(access): Add multi-tennant manager
manager filters results to that of data from the organizations the users is part of.

!42 #124
2024-07-15 16:17:08 +09:30
Jon
9acc4fdfcb docs(gitlab): update MR template
!42 #74
2024-07-14 17:16:16 +09:30
Jon
6776612b66 chore: move docker-compose to deploy directory
intent of dir is that is the location for all avail deploy methods

!42 #74
2024-07-14 17:11:49 +09:30
Jon
af3e770760 fix(settings): correct the permission to view manufacturers
!42 #74
2024-07-14 17:09:18 +09:30
Jon
fbe7e63cc9 fix(access): Correct team form fields
fixes missing name for team

!42 #74
2024-07-14 16:57:25 +09:30
Jon
aec460306b fix(config_management): don't exclude parent from field, only self
!42 #74
2024-07-14 16:48:54 +09:30
267 changed files with 12259 additions and 2639 deletions

View File

@ -1,7 +1,21 @@
---
commitizen:
name: cz_conventional_commits
customize:
change_type_map:
feature: Features
fix: Fixes
refactor: Refactoring
test: Tests
change_type_order:
- BREAKING CHANGE
- feat
- fix
- test
- refactor
commit_parser: ^(?P<change_type>feat|fix|test|refactor|perf|BREAKING CHANGE)(?:\((?P<scope>[^()\r\n]*)\)|\()?(?P<breaking>!)?:\s(?P<message>.*)?
name: cz_customize
prerelease_offset: 1
tag_format: $version
update_changelog_on_bump: false
version: 0.7.0
version: 1.0.0-b14
version_scheme: semver

39
.github/pull_request_template.md vendored Normal file
View File

@ -0,0 +1,39 @@
### :books: Summary
<!-- your summary here emojis ref: https://github.com/yodamad/gitlab-emoji -->
### :link: Links / References
<!--
using a list as any links to other references or links as required. if relevant, describe the link/reference
Include any issues or related merge requests. Note: dependent MR's also to be added to "Merge request dependencies"
-->
### :construction_worker: Tasks
- [ ] Add your tasks here if required (delete)
<!-- dont remove tasks below strike through including the checkbox by enclosing in double tidle '~~' -->
- [ ] :firecracker: Contains breaking-change Any Breaking change(s)?
_Breaking Change must also be notated in the commit that introduces it and in [Conventional Commit Format](https://www.conventionalcommits.org/en/v1.0.0/)._
- [ ] :notebook: Release notes updated
- [ ] :blue_book: Documentation written
_All features to be documented within the correct section(s). Administration, Development and/or User_
- [ ] :checkered_flag: Milestone assigned
- [ ] :test_tube: [Unit Test(s) Written](https://nofusscomputing.com/projects/centurion_erp/development/testing/)
_ensure test coverage delta is not less than zero_
- [ ] :page_facing_up: Roadmap updated

31
.github/workflows/bump.yaml vendored Normal file
View File

@ -0,0 +1,31 @@
---
name: 'Bump'
on:
workflow_dispatch:
inputs:
CZ_PRE_RELEASE:
default: none
required: false
description: Create Pre-Release {alpha,beta,rc,none}
CZ_INCREMENT:
default: none
required: false
description: Type of bump to conduct {MAJOR,MINOR,PATCH,none}
push:
branches:
- 'master'
jobs:
bump:
name: 'Bump'
uses: nofusscomputing/action_bump/.github/workflows/bump.yaml@development
with:
CZ_PRE_RELEASE: ${{ inputs.CZ_PRE_RELEASE }}
CZ_INCREMENT: ${{ inputs.CZ_INCREMENT }}
secrets:
WORKFLOW_TOKEN: ${{ secrets.WORKFLOW_TOKEN }}

109
.github/workflows/ci.yaml vendored Normal file
View File

@ -0,0 +1,109 @@
---
name: 'CI'
on:
push:
branches:
- '**'
tags:
- '*'
env:
GIT_SYNC_URL: "https://${{ secrets.GITLAB_USERNAME_ROBOT }}:${{ secrets.GITLAB_TOKEN_ROBOT }}@gitlab.com/nofusscomputing/projects/centurion_erp.git"
jobs:
docker:
name: 'Docker'
uses: nofusscomputing/action_docker/.github/workflows/docker.yaml@development
with:
DOCKER_BUILD_IMAGE_NAME: "nofusscomputing/centurion-erp"
DOCKER_PUBLISH_REGISTRY: "docker.io"
DOCKER_PUBLISH_IMAGE_NAME: "nofusscomputing/centurion-erp"
secrets:
DOCKER_PUBLISH_USERNAME: ${{ secrets.NFC_DOCKERHUB_USERNAME }}
DOCKER_PUBLISH_PASSWORD: ${{ secrets.NFC_DOCKERHUB_TOKEN }}
python:
name: 'Python'
uses: nofusscomputing/action_python/.github/workflows/python.yaml@development
secrets:
WORKFLOW_TOKEN: ${{ secrets.WORKFLOW_TOKEN }}
gitlab-mirror:
if: ${{ github.repository == 'nofusscomputing/centurion_erp' }}
runs-on: ubuntu-latest
steps:
- name: Checks
shell: bash
run: |
if [ "0${{ env.GIT_SYNC_URL }}" == "0" ]; then
echo "[ERROR] you must define variable GIT_SYNC_URL for mirroring this repository.";
exit 1;
fi
- name: clone
shell: bash
run: |
git clone --mirror https://github.com/${{ github.repository }} repo;
ls -la repo/
- name: add remote
shell: bash
run: |
cd repo;
echo "**************************************** - git remote -v";
git remote -v;
echo "****************************************";
git remote add destination $GIT_SYNC_URL;
- name: push branches
shell: bash
run: |
cd repo;
echo "**************************************** - git branch";
git branch;
echo "****************************************";
# git push destination --all --force;
git push destination --mirror || true;
# - name: push tags
# shell: bash
# run: |
# cd repo;
# echo "**************************************** - git tag";
# git tag;
# echo "****************************************";
# git push destination --tags --force;

14
.github/workflows/pull-requests.yaml vendored Normal file
View File

@ -0,0 +1,14 @@
---
name: Pull Requests
on:
pull_request: {}
jobs:
pull-request:
name: pull-request
uses: nofusscomputing/action_pull_requests/.github/workflows/pull-requests.yaml@development

37
.github/workflows/triage.yaml vendored Normal file
View File

@ -0,0 +1,37 @@
---
name: Triage
on:
issues:
types:
- opened
- reopened
- transferred
- milestoned
- demilestoned
- closed
- assigned
pull_request:
types:
- opened
- edited
- assigned
- reopened
- closed
jobs:
project:
name: Project
uses: nofusscomputing/action_project/.github/workflows/project.yaml@development
with:
PROJECT_URL: https://github.com/orgs/nofusscomputing/projects/3
secrets:
WORKFLOW_TOKEN: ${{ secrets.WORKFLOW_TOKEN }}

View File

@ -2,18 +2,21 @@
variables:
MY_PROJECT_ID: "57560288"
GIT_SYNC_URL: "https://$GITHUB_USERNAME_ROBOT:$GITHUB_TOKEN_ROBOT@github.com/NoFussComputing/centurion_erp.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: centurion-erp
DOCKER_IMAGE_BUILD_REGISTRY: $CI_REGISTRY_IMAGE
DOCKER_IMAGE_BUILD_TAG: $CI_COMMIT_SHA
# # Docker Build / Publish
# DOCKER_IMAGE_BUILD_TARGET_PLATFORMS: "linux/amd64,linux/arm64"
# 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: centurion-erp
DOCKER_IMAGE_PUBLISH_REGISTRY: docker.io/nofusscomputing
DOCKER_IMAGE_PUBLISH_URL: https://hub.docker.com/r/nofusscomputing/$DOCKER_IMAGE_PUBLISH_NAME
# # Docker Publish
# 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
# # Extra release commands
# MY_COMMAND: ./.gitlab/additional_actions_bump.sh
# Docs NFC
PAGES_ENVIRONMENT_PATH: projects/centurion_erp/
@ -22,157 +25,270 @@ variables:
include:
- local: .gitlab/pytest.gitlab-ci.yml
# - local: .gitlab/pytest.gitlab-ci.yml
# - local: .gitlab/unit-test.gitlab-ci.yml
- project: nofusscomputing/projects/gitlab-ci
ref: development
file:
- .gitlab-ci_common.yaml
- template/automagic.gitlab-ci.yaml
Update Git Submodules:
extends: .ansible_playbook_git_submodule
Docker Container:
extends: .build_docker_container
resource_group: build
needs: []
script:
- update-binfmts --display
- |
echo "[DEBUG] building multiarch/specified arch image";
docker buildx build --platform=$DOCKER_IMAGE_BUILD_TARGET_PLATFORMS . \
--label org.opencontainers.image.created="$(date '+%Y-%m-%d %H:%M:%S%:z')" \
--label org.opencontainers.image.documentation="$CI_PROJECT_URL" \
--label org.opencontainers.image.source="$CI_PROJECT_URL" \
--label org.opencontainers.image.revision="$CI_COMMIT_SHA" \
--push \
--build-arg CI_PROJECT_URL=$CI_PROJECT_URL \
--build-arg CI_COMMIT_SHA=$CI_COMMIT_SHA \
--build-arg CI_COMMIT_TAG=$CI_COMMIT_TAG \
--file $DOCKER_DOCKERFILE \
--tag $DOCKER_IMAGE_BUILD_REGISTRY/$DOCKER_IMAGE_BUILD_NAME:$DOCKER_IMAGE_BUILD_TAG;
docker buildx imagetools inspect $DOCKER_IMAGE_BUILD_REGISTRY/$DOCKER_IMAGE_BUILD_NAME:$DOCKER_IMAGE_BUILD_TAG;
# during docker multi platform build there are >=3 additional unknown images added to gitlab container registry. cleanup
# - template/automagic.gitlab-ci.yaml
- automation/.gitlab-ci-ansible.yaml
- template/mkdocs-documentation.gitlab-ci.yaml
- lint/ansible.gitlab-ci.yaml
DOCKER_MULTI_ARCH_IMAGES=$(docker buildx imagetools inspect "$DOCKER_IMAGE_BUILD_REGISTRY/$DOCKER_IMAGE_BUILD_NAME:$DOCKER_IMAGE_BUILD_TAG" --format "{{ range .Manifest.Manifests }}{{ if ne (print .Platform) \"&{unknown unknown [] }\" }}$DOCKER_IMAGE_BUILD_REGISTRY/$DOCKER_IMAGE_BUILD_NAME:$DOCKER_IMAGE_BUILD_TAG@{{ println .Digest }}{{end}} {{end}}");
docker buildx imagetools create $DOCKER_MULTI_ARCH_IMAGES --tag $DOCKER_IMAGE_BUILD_REGISTRY/$DOCKER_IMAGE_BUILD_NAME:$DOCKER_IMAGE_BUILD_TAG;
docker buildx imagetools inspect $DOCKER_IMAGE_BUILD_REGISTRY/$DOCKER_IMAGE_BUILD_NAME:$DOCKER_IMAGE_BUILD_TAG;
rules: # rules manually synced from docker/publish.gitlab-ci.yaml removing git tag
# - if: # condition_master_branch_push
# $CI_COMMIT_BRANCH == "master" &&
# $CI_PIPELINE_SOURCE == "push"
# exists:
# - '{dockerfile,dockerfile.j2}'
# when: always
- if:
$CI_COMMIT_AUTHOR =='nfc_bot <helpdesk@nofusscomputing.com>'
&&
$CI_COMMIT_BRANCH == "development"
when: never
- if: # condition_not_master_or_dev_push
$CI_COMMIT_BRANCH != "master" &&
$CI_COMMIT_BRANCH != "development" &&
$CI_PIPELINE_SOURCE == "push"
exists:
- '{dockerfile,dockerfile.j2}'
changes:
paths:
- '{dockerfile,dockerfile.j2,includes/**/*}'
compare_to: 'development'
when: always
- if: $CI_COMMIT_TAG
exists:
- '{dockerfile,dockerfile.j2}'
when: always
- if: # condition_dev_branch_push
(
$CI_COMMIT_BRANCH == "development"
||
$CI_COMMIT_BRANCH == "master"
)
&&
$CI_PIPELINE_SOURCE == "push"
exists:
- '{dockerfile,dockerfile.j2}'
allow_failure: true
when: on_success
- when: never
Docker.Hub.Branch.Publish:
extends: .publish-docker-hub
needs: [ "Docker Container" ]
resource_group: build
rules: # rules manually synced from docker/publish.gitlab-ci.yaml removing git tag
# - if: # condition_master_branch_push
# $CI_COMMIT_BRANCH == "master" &&
# $CI_PIPELINE_SOURCE == "push"
# exists:
# - '{dockerfile,dockerfile.j2}'
# when: always
- if:
$CI_COMMIT_AUTHOR =='nfc_bot <helpdesk@nofusscomputing.com>'
&&
$CI_COMMIT_BRANCH == "development"
when: never
- if: $CI_COMMIT_TAG
exists:
- '{dockerfile,dockerfile.j2}'
when: always
- if: # condition_dev_branch_push
$CI_COMMIT_BRANCH == "development" &&
$CI_PIPELINE_SOURCE == "push"
exists:
- '{dockerfile,dockerfile.j2}'
allow_failure: true
when: on_success
- when: never
# Update Git Submodules:
# extends: .ansible_playbook_git_submodule
Github (Push --mirror):
extends:
- .git_push_mirror
needs: []
rules:
- if: '$JOB_STOP_GIT_PUSH_MIRROR'
when: never
# Docker Container:
# extends: .build_docker_container
# resource_group: build
# needs: []
# script:
# - update-binfmts --display
# - |
- if: $GIT_SYNC_URL == null
when: never
# echo "[DEBUG] building multiarch/specified arch image";
- 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
# docker buildx build --platform=$DOCKER_IMAGE_BUILD_TARGET_PLATFORMS . \
# --label org.opencontainers.image.created="$(date '+%Y-%m-%d %H:%M:%S%:z')" \
# --label org.opencontainers.image.documentation="$CI_PROJECT_URL" \
# --label org.opencontainers.image.source="$CI_PROJECT_URL" \
# --label org.opencontainers.image.revision="$CI_COMMIT_SHA" \
# --push \
# --build-arg CI_PROJECT_URL=$CI_PROJECT_URL \
# --build-arg CI_COMMIT_SHA=$CI_COMMIT_SHA \
# --build-arg CI_COMMIT_TAG=$CI_COMMIT_TAG \
# --file $DOCKER_DOCKERFILE \
# --tag $DOCKER_IMAGE_BUILD_REGISTRY/$DOCKER_IMAGE_BUILD_NAME:$DOCKER_IMAGE_BUILD_TAG;
- when: never
# docker buildx imagetools inspect $DOCKER_IMAGE_BUILD_REGISTRY/$DOCKER_IMAGE_BUILD_NAME:$DOCKER_IMAGE_BUILD_TAG;
# # during docker multi platform build there are >=3 additional unknown images added to gitlab container registry. cleanup
# DOCKER_MULTI_ARCH_IMAGES=$(docker buildx imagetools inspect "$DOCKER_IMAGE_BUILD_REGISTRY/$DOCKER_IMAGE_BUILD_NAME:$DOCKER_IMAGE_BUILD_TAG" --format "{{ range .Manifest.Manifests }}{{ if ne (print .Platform) \"&{unknown unknown [] }\" }}$DOCKER_IMAGE_BUILD_REGISTRY/$DOCKER_IMAGE_BUILD_NAME:$DOCKER_IMAGE_BUILD_TAG@{{ println .Digest }}{{end}} {{end}}");
# docker buildx imagetools create $DOCKER_MULTI_ARCH_IMAGES --tag $DOCKER_IMAGE_BUILD_REGISTRY/$DOCKER_IMAGE_BUILD_NAME:$DOCKER_IMAGE_BUILD_TAG;
# docker buildx imagetools inspect $DOCKER_IMAGE_BUILD_REGISTRY/$DOCKER_IMAGE_BUILD_NAME:$DOCKER_IMAGE_BUILD_TAG;
# rules: # rules manually synced from docker/publish.gitlab-ci.yaml removing git tag
# # - if: # condition_master_branch_push
# # $CI_COMMIT_BRANCH == "master" &&
# # $CI_PIPELINE_SOURCE == "push"
# # exists:
# # - '{dockerfile,dockerfile.j2}'
# # when: always
# - if:
# $CI_COMMIT_AUTHOR =='nfc_bot <helpdesk@nofusscomputing.com>'
# &&
# $CI_COMMIT_BRANCH == "development"
# when: never
# - if: # condition_not_master_or_dev_push
# $CI_COMMIT_BRANCH != "master" &&
# $CI_COMMIT_BRANCH != "development" &&
# $CI_PIPELINE_SOURCE == "push"
# exists:
# - '{dockerfile,dockerfile.j2}'
# changes:
# paths:
# - '{dockerfile,dockerfile.j2,includes/**/*}'
# compare_to: 'development'
# when: always
# - if: $CI_COMMIT_TAG
# exists:
# - '{dockerfile,dockerfile.j2}'
# when: always
# - if: # condition_dev_branch_push
# (
# $CI_COMMIT_BRANCH == "development"
# ||
# $CI_COMMIT_BRANCH == "master"
# )
# &&
# $CI_PIPELINE_SOURCE == "push"
# exists:
# - '{dockerfile,dockerfile.j2}'
# allow_failure: true
# when: on_success
# - when: never
# .gitlab_release:
# stage: release
# image: registry.gitlab.com/gitlab-org/release-cli:latest
# before_script:
# - if [ "0$JOB_ROOT_DIR" == "0" ]; then ROOT_DIR=gitlab-ci; else ROOT_DIR=$JOB_ROOT_DIR ; fi
# - echo "[DEBUG] ROOT_DIR[$ROOT_DIR]"
# - mkdir -p "$CI_PROJECT_DIR/artifacts/$CI_JOB_STAGE/$CI_JOB_NAME"
# - mkdir -p "$CI_PROJECT_DIR/artifacts/$CI_JOB_STAGE/tests"
# - apk update
# - apk add git curl
# - apk add --update --no-cache python3 && ln -sf python3 /usr/bin/python
# - python -m ensurepip && ln -sf pip3 /usr/bin/pip
# - pip install --upgrade pip
# - pip install -r $ROOT_DIR/gitlab_release/requirements.txt
# # - pip install $ROOT_DIR/gitlab_release/python-module/cz_nfc/.
# - pip install commitizen --force
# - 'CLONE_URL="https://gitlab-ci-token:$GIT_COMMIT_TOKEN@gitlab.com/$CI_PROJECT_PATH.git"'
# - echo "[DEBUG] CLONE_URL[$CLONE_URL]"
# - git clone -b development $CLONE_URL repo
# - cd repo
# - git branch
# - git config --global user.email "helpdesk@nofusscomputing.com"
# - git config --global user.name "nfc_bot"
# - git push --set-upstream origin development
# - RELEASE_VERSION_CURRENT=$(cz version --project)
# script:
# - if [ "$CI_COMMIT_BRANCH" == "development" ] ; then RELEASE_CHANGELOG=$(cz bump --changelog --changelog-to-stdout --prerelease beta); else RELEASE_CHANGELOG=$(cz bump --changelog --changelog-to-stdout); fi
# - RELEASE_VERSION_NEW=$(cz version --project)
# - RELEASE_TAG=$RELEASE_VERSION_NEW
# - echo "[DEBUG] RELEASE_VERSION_CURRENT[$RELEASE_VERSION_CURRENT]"
# - echo "[DEBUG] RELEASE_CHANGELOG[$RELEASE_CHANGELOG]"
# - echo "[DEBUG] RELEASE_VERSION_NEW[$RELEASE_VERSION_NEW]"
# - echo "[DEBUG] RELEASE_TAG[$RELEASE_TAG]"
# - RELEASE_TAG_SHA1=$(git log -n1 --format=format:"%H")
# - echo "[DEBUG] RELEASE_TAG_SHA1[$RELEASE_TAG_SHA1]"
# - |
# if [ "0$RELEASE_VERSION_CURRENT" == "0$RELEASE_VERSION_NEW" ]; then
# echo "[DEBUG] not running extra actions, no new version";
# else
# echo "[DEBUG] Creating new Version Label";
# echo "----------------------------";
# echo ${MY_COMMAND};
# echo "----------------------------";
# cat ${MY_COMMAND};
# echo "----------------------------";
# ${MY_COMMAND};
# echo "----------------------------";
# fi
# - if [ "0$RELEASE_VERSION_CURRENT" == "0$RELEASE_VERSION_NEW" ]; then echo "[DEBUG] No tag to delete, version was not bumped"; else git tag -d $RELEASE_TAG; fi
# - if [ "0$RELEASE_VERSION_CURRENT" == "0$RELEASE_VERSION_NEW" ]; then echo "[DEBUG] No push will be conducted, version was not bumped"; else git push; fi
# - if [ "0$RELEASE_VERSION_CURRENT" == "0$RELEASE_VERSION_NEW" ]; then echo "[DEBUG] No release will be created, version was not bumped"; else release-cli create --name "Release $RELEASE_TAG" --tag-name "$RELEASE_TAG" --tag-message "$RELEASE_CHANGELOG" --ref "$RELEASE_TAG_SHA1" --description "$RELEASE_CHANGELOG"; fi
# - if [ "$CI_COMMIT_BRANCH" == "master" ] ; then git checkout master; fi
# - if [ "$CI_COMMIT_BRANCH" == "master" ] ; then git push --set-upstream origin master; fi
# - if [ "$CI_COMMIT_BRANCH" == "master" ] ; then git merge --no-ff development; fi
# - if [ "$CI_COMMIT_BRANCH" == "master" ] ; then git push origin master; fi
# after_script:
# - rm -Rf repo
# rules:
# - if: '$JOB_STOP_GITLAB_RELEASE'
# when: never
# - if: "$CI_COMMIT_AUTHOR =='nfc_bot <helpdesk@nofusscomputing.com>'"
# when: never
# - if: # condition_master_branch_push
# $CI_COMMIT_BRANCH == "master" &&
# $CI_PIPELINE_SOURCE == "push"
# allow_failure: false
# when: on_success
# - if: # condition_dev_branch_push
# $CI_COMMIT_BRANCH == "development" &&
# $CI_PIPELINE_SOURCE == "push"
# when: manual
# allow_failure: true
# # for testing
# # - if: '$CI_COMMIT_BRANCH != "master"'
# # when: always
# # allow_failure: true
# - when: never
# #
# # Release
# #
# Gitlab Release:
# extends:
# - .gitlab_release
# Docker.Hub.Branch.Publish:
# extends: .publish-docker-hub
# needs: [ "Docker Container" ]
# resource_group: build
# rules: # rules manually synced from docker/publish.gitlab-ci.yaml removing git tag
# # - if: # condition_master_branch_push
# # $CI_COMMIT_BRANCH == "master" &&
# # $CI_PIPELINE_SOURCE == "push"
# # exists:
# # - '{dockerfile,dockerfile.j2}'
# # when: always
# - if:
# $CI_COMMIT_AUTHOR =='nfc_bot <helpdesk@nofusscomputing.com>'
# &&
# $CI_COMMIT_BRANCH == "development"
# when: never
# - if: $CI_COMMIT_TAG
# exists:
# - '{dockerfile,dockerfile.j2}'
# when: always
# - if: # condition_dev_branch_push
# $CI_COMMIT_BRANCH == "development" &&
# $CI_PIPELINE_SOURCE == "push"
# exists:
# - '{dockerfile,dockerfile.j2}'
# allow_failure: true
# when: on_success
# - when: never
# 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
# &&
# $CI_PIPELINE_SOURCE == "push"
# when: always
# - when: never
Website.Submodule.Deploy:

View File

@ -0,0 +1,7 @@
#!/bin/sh
# Create Version label wtihn repo
curl \
--data "name=v${RELEASE_TAG}&color=#eee600&description=Version%20that%20is%20affected" \
--header "PRIVATE-TOKEN: $GIT_COMMIT_TOKEN" \
"https://gitlab.com/api/v4/projects/${CI_PROJECT_ID}/labels"

View File

@ -20,16 +20,20 @@
<!-- dont remove tasks below strike through including the checkbox by enclosing in double tidle '~~' -->
- [ ] ~"breaking-change" Any Breaking change(s)
- [ ] Contains ~"breaking-change" Any Breaking change(s)?
_Breaking Change must also be notated in the commit that introduces it and in [Conventional Commit Format](https://www.conventionalcommits.org/en/v1.0.0/)._
- [ ] Release notes updated
- [ ] ~Documentation Documentation written
_All features to be documented within the correct section(s). Administration, Development and/or User_
- [ ] Milestone assigned
- [ ] [Unit Test(s) Written](https://nofusscomputing.com/projects/django-template/development/testing/)
- [ ] [Unit Test(s) Written](https://nofusscomputing.com/projects/centurion_erp/development/testing/)
_ensure test coverage delta is not less than zero_
- [ ] :page_facing_up: Roadmap updated

File diff suppressed because it is too large Load Diff

View File

@ -30,6 +30,9 @@ python3 manage.py createsuperuser
# If model changes
python3 manage.py makemigrations --noinput
# To update code highlight run
pygmentize -S default -f html -a .codehilite > project-static/code.css
```
Updates to python modules will need to be captured with SCM. This can be done by running `pip freeze > requirements.txt` from the running virtual environment.

View File

@ -4,10 +4,10 @@
<br>
![Project Status - Active](https://img.shields.io/badge/Project%20Status-Active-green?logo=gitlab&style=plastic)
![Project Status - Active](https://img.shields.io/badge/Project%20Status-Active-green?logo=github&style=plastic)
[![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)
[![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) [![Artifact Hub](https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/centurion-erp)](https://artifacthub.io/packages/container/centurion-erp/centurion-erp)
@ -15,27 +15,36 @@
<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)
![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)
![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)
<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).
![GitHub Issues or Pull Requests](https://img.shields.io/github/issues/nofusscomputing/centurion_erp?style=plastic&logo=github&label=Open%20Issues&color=000) ![GitHub Issues or Pull Requests by label](https://img.shields.io/github/issues/nofusscomputing/centurion_erp/type%3A%3Abug?style=plastic&logo=github&label=Bug%20Fixes%20Required&color=000)
This project is hosted on [Github](https://github.com/NofussComputing/centurion_erp) and has a read-only copy hosted on [gitlab](https://gitlab.com/nofusscomputing/projects/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)
![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/nofusscomputing/centurion_erp/ci.yaml?branch=master&style=plastic&logo=github&label=Build&color=%23000) ![GitHub Release](https://img.shields.io/github/v/release/nofusscomputing/centurion_erp?sort=date&style=plastic&logo=github&label=Release&color=000) ![Endpoint Badge](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fnofusscomputing%2F.github%2Fmaster%2Frepositories%2Fnofusscomputing%2Fcenturion_erp%2Fmaster%2Fbadge_endpoint_coverage.json&style=plastic)
![Endpoint Badge](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fnofusscomputing%2F.github%2Fmaster%2Frepositories%2Fnofusscomputing%2Fcenturion_erp%2Fmaster%2Fbadge_endpoint_unit_test.json)
----
**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)
![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/nofusscomputing/centurion_erp/ci.yaml?branch=development&style=plastic&logo=github&label=Build&color=%23000) ![GitHub Release](https://img.shields.io/github/v/release/nofusscomputing/centurion_erp?include_prereleases&sort=date&style=plastic&logo=github&label=Release&color=000) ![Endpoint Badge](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fnofusscomputing%2F.github%2Fmaster%2Frepositories%2Fnofusscomputing%2Fcenturion_erp%2Fdevelopment%2Fbadge_endpoint_coverage.json&style=plastic)
![Endpoint Badge](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fnofusscomputing%2F.github%2Fmaster%2Frepositories%2Fnofusscomputing%2Fcenturion_erp%2Fdevelopment%2Fbadge_endpoint_unit_test.json)
----
@ -45,9 +54,9 @@ This project is hosted on [gitlab](https://gitlab.com/nofusscomputing/projects/c
links:
- [Issues](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/issues)
- [Issues](https://github.com/nofusscomputing/centurion_erp/issues)
- [Merge Requests (Pull Requests)](https://gitlab.com/nofusscomputing/projects/centurion_erp/-/merge_requests)
- [Merge Requests (Pull Requests)](https://github.com/nofusscomputing/centurion_erp/pulls)
An ERP with a large emphasis on the IT Service Management (ITSM) and Automation.
@ -55,7 +64,7 @@ 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).
All contributions for this project must conducted from [GitHub](https://github.com/nofusscomputing/centurion_erp).
For further details on contributing please refer to the [contribution guide](CONTRIBUTING.md).

7
Release-Notes.md Normal file
View File

@ -0,0 +1,7 @@
# Version 1.0.0
Initial Release of Centurion ERP.
## Breaking changes
- Nil

View File

@ -28,7 +28,8 @@ class TeamFormAdd(CommonModelForm):
class Meta:
model = Team
fields = [
'name',
'team_name',
'model_notes',
]
@ -38,7 +39,7 @@ class TeamForm(CommonModelForm):
class Meta:
model = Team
fields = [
'name',
'team_name',
'permissions',
'model_notes',
]
@ -67,6 +68,7 @@ class TeamForm(CommonModelForm):
apps = [
'access',
'assistance',
'config_management',
'core',
'django_celery_results',

View File

@ -1,6 +1,7 @@
# Generated by Django 5.0.4 on 2024-05-13 16:08
# Generated by Django 5.0.7 on 2024-07-12 03:54
import access.fields
import access.models
import django.contrib.auth.models
import django.db.models.deletion
import django.utils.timezone
@ -23,9 +24,11 @@ class Migration(migrations.Migration):
fields=[
('id', models.AutoField(primary_key=True, serialize=False, unique=True)),
('name', models.CharField(max_length=50, unique=True)),
('model_notes', models.TextField(blank=True, default=None, null=True, verbose_name='Notes')),
('slug', access.fields.AutoSlugField()),
('created', access.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False)),
('modified', access.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False)),
('manager', models.ForeignKey(help_text='Organization Manager', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name_plural': 'Organizations',
@ -37,10 +40,11 @@ class Migration(migrations.Migration):
fields=[
('group_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='auth.group')),
('is_global', models.BooleanField(default=False)),
('model_notes', models.TextField(blank=True, default=None, null=True, verbose_name='Notes')),
('team_name', models.CharField(default='', max_length=50, verbose_name='Name')),
('created', access.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False)),
('modified', access.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False)),
('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='access.organization')),
('organization', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization', validators=[access.models.TenancyObject.validatate_organization_exists])),
],
options={
'verbose_name_plural': 'Teams',

View File

@ -1,19 +0,0 @@
# Generated by Django 5.0.6 on 2024-05-23 10:37
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('access', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='team',
name='organization',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization'),
),
]

View File

@ -1,20 +0,0 @@
# Generated by Django 5.0.6 on 2024-06-05 09:16
import access.models
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('access', '0002_alter_team_organization'),
]
operations = [
migrations.AlterField(
model_name='team',
name='organization',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization', validators=[access.models.TenancyObject.validatate_organization_exists]),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.0.6 on 2024-06-11 20:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('access', '0003_alter_team_organization'),
]
operations = [
migrations.AddField(
model_name='team',
name='model_notes',
field=models.TextField(blank=True, default=None, null=True),
),
]

View File

@ -1,26 +0,0 @@
# Generated by Django 5.0.6 on 2024-06-17 10:03
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('access', '0004_team_model_notes'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='organization',
name='manager',
field=models.ForeignKey(help_text='Organization Manager', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='organization',
name='model_notes',
field=models.TextField(blank=True, default=None, null=True),
),
]

View File

@ -1,23 +0,0 @@
# 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

@ -80,7 +80,9 @@ class OrganizationMixin():
id = int(self.request.POST.get(field))
except:
pass
return id

View File

@ -15,9 +15,6 @@ class Organization(SaveHistory):
verbose_name_plural = "Organizations"
ordering = ['name']
def __str__(self):
return self.name
def save(self, *args, **kwargs):
if self.slug == '_':
@ -62,14 +59,107 @@ class Organization(SaveHistory):
def get_organization(self):
return self
def __str__(self):
return self.name
class TenancyObject(models.Model):
class TenancyManager(models.Manager):
"""Multi-Tennant Object Manager
This manager specifically caters for the multi-tenancy features of Centurion ERP.
"""
def get_queryset(self):
""" Fetch the data
This function filters the data fetched from the database to that which is from the organizations
the user is a part of.
!!! danger "Requirement"
This method may be overridden however must still be called from the overriding function. i.e. `super().get_queryset()`
## Workflow
This functions workflow is as follows:
- Fetch the user from the request
- Check if the user is authenticated
- Iterate over the users teams
- Store unique organizations from users teams
- return results
Returns:
(queryset): **super user**: return unfiltered data.
(queryset): **not super user**: return data from the stored unique organizations.
"""
request = get_request()
user_organizations: list(str()) = []
if request:
user = request.user._wrapped if hasattr(request.user,'_wrapped') else request.user
if user.is_authenticated:
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.id ]
if len(user_organizations) > 0 and not user.is_superuser:
return super().get_queryset().filter(
models.Q(organization__in=user_organizations)
|
models.Q(is_global = True)
)
return super().get_queryset()
class TenancyObject(SaveHistory):
""" Tenancy Model Abstrct class.
This class is for inclusion wihtin **every** model within Centurion ERP.
Provides the required fields, functions and methods for multi tennant objects.
Unless otherwise stated, **no** object within this class may be overridden.
Raises:
ValidationError: User failed to supply organization
"""
objects = TenancyManager()
""" Multi-Tenanant Objects """
class Meta:
abstract = True
def validatate_organization_exists(self):
"""Ensure that the user did provide an organization
Raises:
ValidationError: User failed to supply organization.
"""
if not self:
raise ValidationError('You must provide an organization')
@ -99,15 +189,13 @@ class TenancyObject(models.Model):
return self.organization
class Team(Group, TenancyObject, SaveHistory):
class Team(Group, TenancyObject):
class Meta:
# proxy = True
verbose_name_plural = "Teams"
ordering = ['team_name']
def __str__(self):
return self.name
def save(self, force_insert=False, force_update=False, using=None, update_fields=None):
@ -150,6 +238,10 @@ class Team(Group, TenancyObject, SaveHistory):
return [permission_list, self.permissions.all()]
def __str__(self):
return self.team_name
class TeamUsers(SaveHistory):
@ -227,3 +319,6 @@ class TeamUsers(SaveHistory):
return self.team
def __str__(self):
return self.user.username

View File

@ -1,6 +1,8 @@
import pytest
import unittest
from access.models import TenancyManager
class TenancyObject:
@ -66,3 +68,21 @@ class TenancyObject:
Must not be able to edit an item without an organization
"""
pass
def test_has_attr_organization(self):
""" TenancyObject attribute check
TenancyObject has function objects
"""
assert hasattr(self.model, 'objects')
def test_attribute_is_type_objects(self):
""" Attribute Check
attribute `objects` must be set to `access.models.TenancyManager()`
"""
assert type(self.model.objects) is TenancyManager

View File

@ -0,0 +1,371 @@
import pytest
import unittest
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser, User
from django.contrib.contenttypes.models import ContentType
from django.shortcuts import reverse
from django.test import Client, TestCase
from rest_framework.relations import Hyperlink
from access.models import Organization, Team, TeamUsers, Permission
class OrganizationAPI(TestCase):
model = Organization
app_namespace = 'API'
url_name = '_api_organization'
@classmethod
def setUpTestData(self):
"""Setup Test
1. Create an organization for user and item
. create an organization that is different to item
2. Create a device
3. create teams with each permission: view, add, change, delete
4. create a user per team
"""
organization = Organization.objects.create(name='test_org')
self.organization = organization
different_organization = Organization.objects.create(name='test_different_organization')
self.item = organization
self.url_view_kwargs = {'pk': self.item.id}
self.url_kwargs = {'pk': self.item.id}
view_permissions = Permission.objects.get(
codename = 'view_' + self.model._meta.model_name,
content_type = ContentType.objects.get(
app_label = self.model._meta.app_label,
model = self.model._meta.model_name,
)
)
view_team = Team.objects.create(
team_name = 'view_team',
organization = organization,
)
view_team.permissions.set([view_permissions])
self.view_user = User.objects.create_user(username="test_user_view", password="password")
teamuser = TeamUsers.objects.create(
team = view_team,
user = self.view_user
)
client = Client()
url = reverse(self.app_namespace + ':' + self.url_name, kwargs=self.url_view_kwargs)
client.force_login(self.view_user)
response = client.get(url)
self.api_data = response.data
def test_api_field_exists_id(self):
""" Test for existance of API Field
id field must exist
"""
assert 'id' in self.api_data
def test_api_field_type_id(self):
""" Test for type for API Field
id field must be int
"""
assert type(self.api_data['id']) is int
def test_api_field_exists_name(self):
""" Test for existance of API Field
name field must exist
"""
assert 'name' in self.api_data
def test_api_field_type_name(self):
""" Test for type for API Field
name field must be str
"""
assert type(self.api_data['name']) is str
def test_api_field_exists_teams(self):
""" Test for existance of API Field
teams field must exist
"""
assert 'teams' in self.api_data
def test_api_field_type_teams(self):
""" Test for type for API Field
teams field must be list
"""
assert type(self.api_data['teams']) is list
def test_api_field_exists_url(self):
""" Test for existance of API Field
url field must exist
"""
assert 'url' in self.api_data
def test_api_field_type_url(self):
""" Test for type for API Field
url field must be str
"""
assert type(self.api_data['url']) is Hyperlink
def test_api_field_exists_teams_id(self):
""" Test for existance of API Field
teams.id field must exist
"""
assert 'id' in self.api_data['teams'][0]
def test_api_field_type_teams_id(self):
""" Test for type for API Field
teams.id field must be int
"""
assert type(self.api_data['teams'][0]['id']) is int
def test_api_field_exists_teams_team_name(self):
""" Test for existance of API Field
teams.team_name field must exist
"""
assert 'team_name' in self.api_data['teams'][0]
def test_api_field_type_teams_team_name(self):
""" Test for type for API Field
teams.team_name field must be string
"""
assert type(self.api_data['teams'][0]['team_name']) is str
def test_api_field_exists_teams_permissions(self):
""" Test for existance of API Field
teams.permissions field must exist
"""
assert 'permissions' in self.api_data['teams'][0]
def test_api_field_type_teams_permissions(self):
""" Test for type for API Field
teams.permissions field must be list
"""
assert type(self.api_data['teams'][0]['permissions']) is list
def test_api_field_exists_teams_permissions_url(self):
""" Test for existance of API Field
teams.permissions_url field must exist
"""
assert 'permissions_url' in self.api_data['teams'][0]
def test_api_field_type_teams_permissions_url(self):
""" Test for type for API Field
teams.permissions_url field must be url
"""
assert type(self.api_data['teams'][0]['permissions_url']) is str
def test_api_field_exists_teams_url(self):
""" Test for existance of API Field
teams.url field must exist
"""
assert 'url' in self.api_data['teams'][0]
def test_api_field_type_teams_url(self):
""" Test for type for API Field
teams.url field must be url
"""
assert type(self.api_data['teams'][0]['url']) is str
def test_api_field_exists_teams_permissions_id(self):
""" Test for existance of API Field
teams.permissions.id field must exist
"""
assert 'id' in self.api_data['teams'][0]['permissions'][0]
def test_api_field_type_teams_permissions_id(self):
""" Test for type for API Field
teams.permissions.id field must be int
"""
assert type(self.api_data['teams'][0]['permissions'][0]['id']) is int
def test_api_field_exists_teams_permissions_name(self):
""" Test for existance of API Field
teams.permissions.name field must exist
"""
assert 'name' in self.api_data['teams'][0]['permissions'][0]
def test_api_field_type_teams_permissions_name(self):
""" Test for type for API Field
teams.permissions.name field must be str
"""
assert type(self.api_data['teams'][0]['permissions'][0]['name']) is str
def test_api_field_exists_teams_permissions_codename(self):
""" Test for existance of API Field
teams.permissions.codename field must exist
"""
assert 'codename' in self.api_data['teams'][0]['permissions'][0]
def test_api_field_type_teams_permissions_codename(self):
""" Test for type for API Field
teams.permissions.codename field must be str
"""
assert type(self.api_data['teams'][0]['permissions'][0]['codename']) is str
def test_api_field_exists_teams_permissions_content_type(self):
""" Test for existance of API Field
teams.permissions.content_type field must exist
"""
assert 'content_type' in self.api_data['teams'][0]['permissions'][0]
def test_api_field_type_teams_permissions_content_type(self):
""" Test for type for API Field
teams.permissions.content_type field must be dict
"""
assert type(self.api_data['teams'][0]['permissions'][0]['content_type']) is dict
def test_api_field_exists_teams_permissions_content_type_id(self):
""" Test for existance of API Field
teams.permissions.content_type.id field must exist
"""
assert 'id' in self.api_data['teams'][0]['permissions'][0]['content_type']
def test_api_field_type_teams_permissions_content_type_id(self):
""" Test for type for API Field
teams.permissions.content_type.id field must be int
"""
assert type(self.api_data['teams'][0]['permissions'][0]['content_type']['id']) is int
def test_api_field_exists_teams_permissions_content_type_app_label(self):
""" Test for existance of API Field
teams.permissions.content_type.app_label field must exist
"""
assert 'app_label' in self.api_data['teams'][0]['permissions'][0]['content_type']
def test_api_field_type_teams_permissions_content_type_app_label(self):
""" Test for type for API Field
teams.permissions.content_type.app_label field must be str
"""
assert type(self.api_data['teams'][0]['permissions'][0]['content_type']['app_label']) is str
def test_api_field_exists_teams_permissions_content_type_model(self):
""" Test for existance of API Field
teams.permissions.content_type.model field must exist
"""
assert 'model' in self.api_data['teams'][0]['permissions'][0]['content_type']
def test_api_field_type_teams_permissions_content_type_model(self):
""" Test for type for API Field
teams.permissions.content_type.model field must be str
"""
assert type(self.api_data['teams'][0]['permissions'][0]['content_type']['model']) is str

View File

@ -5,7 +5,7 @@ from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser, User
from django.contrib.contenttypes.models import ContentType
from django.shortcuts import reverse
from django.test import TestCase
from django.test import Client, TestCase
from access.models import Organization, Team, TeamUsers, Permission
@ -24,7 +24,7 @@ class OrganizationPermissionsAPI(TestCase, APIPermissionChange, APIPermissionVie
url_name = '_api_organization'
url_list = 'device-list'
url_list = '_api_orgs'
change_data = {'name': 'device'}
@ -124,6 +124,8 @@ class OrganizationPermissionsAPI(TestCase, APIPermissionChange, APIPermissionVie
delete_team.permissions.set([delete_permissions])
self.super_user = User.objects.create_user(username="super_user", password="password", is_superuser=True)
self.no_permissions_user = User.objects.create_user(username="test_no_permissions", password="password")
@ -171,3 +173,67 @@ class OrganizationPermissionsAPI(TestCase, APIPermissionChange, APIPermissionVie
team = different_organization_team,
user = self.different_organization_user
)
def test_add_is_prohibited_anon_user(self):
""" Ensure Organization cant be created
Attempt to create organization as anon user
"""
client = Client()
url = reverse(self.app_namespace + ':' + self.url_list)
# client.force_login(self.add_user)
response = client.post(url, data={'name': 'should not create'}, content_type='application/json')
assert response.status_code == 401
def test_add_is_prohibited_diff_org_user(self):
""" Ensure Organization cant be created
Attempt to create organization as user with different org permissions.
"""
client = Client()
url = reverse(self.app_namespace + ':' + self.url_list)
client.force_login(self.different_organization_user)
response = client.post(url, data={'name': 'should not create'}, content_type='application/json')
assert response.status_code == 405
def test_add_is_prohibited_super_user(self):
""" Ensure Organization cant be created
Attempt to create organization as user who is super user
"""
client = Client()
url = reverse(self.app_namespace + ':' + self.url_list)
client.force_login(self.super_user)
response = client.post(url, data={'name': 'should not create'}, content_type='application/json')
assert response.status_code == 405
def test_add_is_prohibited_user_same_org(self):
""" Ensure Organization cant be created
Attempt to create organization as user with permission
"""
client = Client()
url = reverse(self.app_namespace + ':' + self.url_list)
client.force_login(self.add_user)
response = client.post(url, data={'name': 'should not create'}, content_type='application/json')
assert response.status_code == 405

View File

@ -5,9 +5,14 @@ from django.test import TestCase, Client
from access.models import Organization, Team, TeamUsers, Permission
from app.tests.abstract.models import TenancyModel
class TeamModel(TestCase):
class TeamModel(
TestCase,
TenancyModel
):
model = Team
@ -53,4 +58,13 @@ class TeamModel(TestCase):
the save method is overridden. the function attributes must match default django method
"""
pass
@pytest.mark.skip(reason="uses Django group manager")
def test_attribute_is_type_objects(self):
pass
@pytest.mark.skip(reason="uses Django group manager")
def test_model_class_tenancy_manager_function_get_queryset_called(self):
pass

View File

@ -1,17 +0,0 @@
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,313 @@
import pytest
import unittest
import requests
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser, User
from django.contrib.contenttypes.models import ContentType
from django.shortcuts import reverse
from django.test import Client, TestCase
from rest_framework.relations import Hyperlink
from access.models import Organization, Team, TeamUsers, Permission
# from api.tests.abstract.api_permissions import APIPermissions
class TeamAPI(TestCase):
model = Team
app_namespace = 'API'
url_name = '_api_team'
# url_list = '_api_organization_teams'
# change_data = {'name': 'device'}
# delete_data = {'device': 'device'}
@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 team
3. create teams with each permission: view, add, change, delete
4. create a user per team
"""
organization = Organization.objects.create(name='test_org')
self.organization = organization
different_organization = Organization.objects.create(name='test_different_organization')
self.item = self.model.objects.create(
organization=organization,
team_name = 'teamone',
model_notes = 'random note'
)
self.url_kwargs = {'organization_id': self.organization.id}
self.url_view_kwargs = {'organization_id': self.organization.id, 'group_ptr_id': self.item.id}
self.add_data = {'team_name': 'team_post'}
view_permissions = Permission.objects.get(
codename = 'view_' + self.model._meta.model_name,
content_type = ContentType.objects.get(
app_label = self.model._meta.app_label,
model = self.model._meta.model_name,
)
)
# view_team = Team.objects.create(
# team_name = 'view_team',
# organization = organization,
# )
self.item.permissions.set([view_permissions])
self.view_user = User.objects.create_user(username="test_user_view", password="password")
teamuser = TeamUsers.objects.create(
team = self.item,
user = self.view_user
)
client = Client()
url = reverse(self.app_namespace + ':' + self.url_name, kwargs=self.url_view_kwargs)
client.force_login(self.view_user)
response = client.get(url)
self.api_data = response.data
def test_api_field_exists_id(self):
""" Test for existance of API Field
id field must exist
"""
assert 'id' in self.api_data
def test_api_field_type_id(self):
""" Test for type for API Field
id field must be int
"""
assert type(self.api_data['id']) is int
def test_api_field_exists_team_name(self):
""" Test for existance of API Field
team_name field must exist
"""
assert 'team_name' in self.api_data
def test_api_field_type_name(self):
""" Test for type for API Field
team_name field must be str
"""
assert type(self.api_data['team_name']) is str
def test_api_field_exists_model_notes(self):
""" Test for existance of API Field
model_notes field must exist
"""
assert 'model_notes' in self.api_data
def test_api_field_type_model_notes(self):
""" Test for type for API Field
model_notes field must be str
"""
assert type(self.api_data['model_notes']) is str
def test_api_field_exists_url(self):
""" Test for existance of API Field
url field must exist
"""
assert 'url' in self.api_data
def test_api_field_type_url(self):
""" Test for type for API Field
url field must be str
"""
assert type(self.api_data['url']) is str
def test_api_field_exists_permissions(self):
""" Test for existance of API Field
permissions field must exist
"""
assert 'permissions' in self.api_data
def test_api_field_type_permissions(self):
""" Test for type for API Field
url field must be list
"""
assert type(self.api_data['permissions']) is list
def test_api_field_exists_permissions_id(self):
""" Test for existance of API Field
permissions.id field must exist
"""
assert 'id' in self.api_data['permissions'][0]
def test_api_field_type_permissions_id(self):
""" Test for type for API Field
permissions.id field must be int
"""
assert type(self.api_data['permissions'][0]['id']) is int
def test_api_field_exists_permissions_name(self):
""" Test for existance of API Field
permissions.name field must exist
"""
assert 'name' in self.api_data['permissions'][0]
def test_api_field_type_permissions_name(self):
""" Test for type for API Field
permissions.name field must be str
"""
assert type(self.api_data['permissions'][0]['name']) is str
def test_api_field_exists_permissions_codename(self):
""" Test for existance of API Field
permissions.codename field must exist
"""
assert 'codename' in self.api_data['permissions'][0]
def test_api_field_type_permissions_codename(self):
""" Test for type for API Field
permissions.codename field must be str
"""
assert type(self.api_data['permissions'][0]['codename']) is str
def test_api_field_exists_permissions_content_type(self):
""" Test for existance of API Field
permissions.content_type field must exist
"""
assert 'content_type' in self.api_data['permissions'][0]
def test_api_field_type_permissions_content_type(self):
""" Test for type for API Field
permissions.content_type field must be dict
"""
assert type(self.api_data['permissions'][0]['content_type']) is dict
def test_api_field_exists_permissions_content_type_id(self):
""" Test for existance of API Field
permissions.content_type.id field must exist
"""
assert 'id' in self.api_data['permissions'][0]['content_type']
def test_api_field_type_permissions_content_type_id(self):
""" Test for type for API Field
permissions.content_type.id field must be int
"""
assert type(self.api_data['permissions'][0]['content_type']['id']) is int
def test_api_field_exists_permissions_content_type_app_label(self):
""" Test for existance of API Field
permissions.content_type.app_label field must exist
"""
assert 'app_label' in self.api_data['permissions'][0]['content_type']
def test_api_field_type_permissions_content_type_app_label(self):
""" Test for type for API Field
permissions.content_type.app_label field must be str
"""
assert type(self.api_data['permissions'][0]['content_type']['app_label']) is str
def test_api_field_exists_permissions_content_type_model(self):
""" Test for existance of API Field
permissions.content_type.model field must exist
"""
assert 'model' in self.api_data['permissions'][0]['content_type']
def test_api_field_type_permissions_content_type_model(self):
""" Test for type for API Field
permissions.content_type.model field must be str
"""
assert type(self.api_data['permissions'][0]['content_type']['model']) is str

View File

@ -3,17 +3,45 @@ import unittest
from django.test import TestCase
from access.models import TenancyObject
from access.models import TenancyObject, TenancyManager
from core.mixin.history_save import SaveHistory
from unittest.mock import patch
class TenancyObject(TestCase):
class TenancyManagerTests(TestCase):
item = TenancyManager
def test_has_attribute_get_queryset(self):
""" Field organization exists """
assert hasattr(self.item, 'get_queryset')
def test_is_function_get_queryset(self):
""" Attribute 'get_organization' is a function """
assert callable(self.item.get_queryset)
class TenancyObjectTests(TestCase):
item = TenancyObject
def test_class_inherits_save_history(self):
""" Confirm class inheritence
TenancyObject must inherit SaveHistory
"""
assert issubclass(TenancyObject, SaveHistory)
def test_has_attribute_organization(self):
""" Field organization exists """
@ -43,3 +71,23 @@ class TenancyObject(TestCase):
""" Attribute 'get_organization' is a function """
assert callable(self.item.get_organization)
@pytest.mark.skip(reason="figure out how to test abstract class")
def test_has_attribute_objects(self):
""" Attribute Check
attribute `objects` must be set to `access.models.TenancyManager()`
"""
assert 'objects' in self.item
@pytest.mark.skip(reason="figure out how to test abstract class")
def test_attribute_not_none_objects(self):
""" Attribute Check
attribute `objects` must be set to `access.models.TenancyManager()`
"""
assert self.item.objects is not None

View File

@ -1,4 +1,4 @@
# Generated by Django 5.0.6 on 2024-06-27 18:25
# Generated by Django 5.0.7 on 2024-07-12 03:54
import access.fields
import django.db.models.deletion

View File

@ -5,6 +5,8 @@ import string
from django.conf import settings
from django.contrib.auth.models import User
from django.db import models
from django.db.models import Field
from django.forms import ValidationError
from access.fields import *
from access.models import TenancyObject
@ -14,6 +16,37 @@ from access.models import TenancyObject
class AuthToken(models.Model):
def validate_note_no_token(self, note, token):
""" Ensure plaintext token cant be saved to notes field.
called from app.settings.views.user_settings.TokenAdd.form_valid()
Args:
note (Field): _Note field_
token (Field): _Token field_
Raises:
ValidationError: _Validation failed_
"""
validation: bool = True
if str(note) == str(token):
validation = False
if str(token)[:9] in str(note): # Allow user to use up to 8 chars so they can reference it.
validation = False
if not validation:
raise ValidationError('Token can not be placed in the notes field.')
id = models.AutoField(
primary_key=True,
unique=True,

View File

@ -14,9 +14,9 @@ class TeamSerializerBase(serializers.ModelSerializer):
class Meta:
model = Team
fields = (
"id",
"team_name",
'organization',
'team_name',
'model_notes',
'permissions',
'url',
)
@ -29,9 +29,18 @@ class TeamSerializerBase(serializers.ModelSerializer):
class TeamPermissionSerializer(serializers.ModelSerializer):
class Meta:
model = Permission
depth = 1
fields = '__all__'
class TeamSerializer(TeamSerializerBase):
permissions = serializers.SerializerMethodField('get_url')
permissions_url = serializers.SerializerMethodField('get_url')
def get_url(self, obj):
@ -63,16 +72,19 @@ class TeamSerializer(TeamSerializerBase):
class Meta:
model = Team
depth = 1
depth = 2
fields = (
"id",
"team_name",
'organization',
'model_notes',
'permissions',
'permissions_url',
'url',
)
read_only_fields = [
'permissions',
'id',
'organization',
'permissions_url',
'url'
]
@ -111,7 +123,7 @@ class OrganizationSerializer(serializers.ModelSerializer):
return request.build_absolute_uri(reverse('API:_api_organization_teams', args=[obj.id]))
teams = TeamSerializerBase(source='team_set', many=True, read_only=False)
teams = TeamSerializer(source='team_set', many=True, read_only=False)
view_name="API:_api_organization"

View File

@ -0,0 +1,86 @@
from rest_framework import serializers
from rest_framework.reverse import reverse
from config_management.models.groups import ConfigGroups
class ParentGroupSerializer(serializers.ModelSerializer):
url = serializers.SerializerMethodField('get_url')
class Meta:
model = ConfigGroups
fields = [
'id',
'name',
'url',
]
read_only_fields = [
'id',
'name',
'url',
]
def get_url(self, obj):
request = self.context.get('request')
return request.build_absolute_uri(reverse("API:_api_config_group", args=[obj.pk]))
class ConfigGroupsSerializerBase(serializers.ModelSerializer):
parent = ParentGroupSerializer(read_only=True)
url = serializers.SerializerMethodField('get_url')
class Meta:
model = ConfigGroups
fields = [
'id',
'parent',
'name',
'config',
'url',
]
read_only_fields = [
'id',
'name',
'config',
'url',
]
def get_url(self, obj):
request = self.context.get('request')
return request.build_absolute_uri(reverse("API:_api_config_group", args=[obj.pk]))
class ConfigGroupsSerializer(ConfigGroupsSerializerBase):
class Meta:
model = ConfigGroups
depth = 1
fields = [
'id',
'parent',
'name',
'config',
'url',
]
read_only_fields = [
'id',
'parent',
'name',
'config',
'url',
]

View File

@ -1,9 +1,38 @@
from django.urls import reverse
from itam.models.device import Device
from rest_framework import serializers
from api.serializers.config import ParentGroupSerializer
from config_management.models.groups import ConfigGroupHosts
from itam.models.device import Device
class DeviceConfigGroupsSerializer(serializers.ModelSerializer):
name = serializers.CharField(source='group.name', read_only=True)
url = serializers.HyperlinkedIdentityField(
view_name="API:_api_config_group", format="html"
)
class Meta:
model = ConfigGroupHosts
fields = [
'id',
'name',
'url',
]
read_only_fields = [
'id',
'name',
'url',
]
class DeviceSerializer(serializers.ModelSerializer):
@ -13,7 +42,9 @@ class DeviceSerializer(serializers.ModelSerializer):
)
config = serializers.SerializerMethodField('get_device_config')
groups = DeviceConfigGroupsSerializer(source='configgrouphosts_set', many=True, read_only=True)
def get_device_config(self, device):
request = self.context.get('request')
@ -22,11 +53,29 @@ class DeviceSerializer(serializers.ModelSerializer):
class Meta:
model = Device
fields = '__all__'
read_only_fields = [
'inventorydate',
depth = 1
fields = [
'id',
'is_global',
'slug',
'name',
'config',
'serial_number',
'uuid',
'inventorydate',
'created',
'modified',
'groups',
'organization',
'url',
]
read_only_fields = [
'id',
'config',
'inventorydate',
'created',
'modified',
'groups',
'url',
]

View File

@ -37,13 +37,6 @@ def process_inventory(self, data, organization: int):
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
@ -57,10 +50,62 @@ def process_inventory(self, data, organization: int):
device_uuid = str(data.details.uuid)
if device_serial_number: # Search for device by serial number.
device = Device.objects.filter(
serial_number__iexact=device_serial_number
)
if device.exists():
device = Device.objects.get(
serial_number__iexact=device_serial_number
)
else:
device = None
if device_uuid and not device: # Search for device by UUID.
device = Device.objects.filter(
uuid__iexact=device_uuid
)
if device.exists():
device = Device.objects.get(
uuid__iexact=device_uuid
)
else:
device = None
if not device: # Search for device by Name.
device = Device.objects.filter(
name__iexact=str(data.details.name).lower()
)
if device.exists():
device = Device.objects.get(
name__iexact=str(data.details.name).lower()
)
else:
device = None
if not device: # Create the device
device = Device.objects.create(
name = data.details.name,
device_type = None,
@ -74,25 +119,75 @@ def process_inventory(self, data, organization: int):
logger.info(f"Device: {device.name}, Serial: {device.serial_number}, UUID: {device.uuid}")
device_edited = False
if not device.uuid and device_uuid:
device.uuid = device_uuid
device.save()
device_edited = True
if not device.serial_number and device_serial_number:
device.serial_number = data.details.serial_number
device_edited = True
if str(device.name).lower() != str(data.details.name).lower(): # Update device Name
device.name = data.details.name
device_edited = True
if device_edited:
device.save()
if OperatingSystem.objects.filter( slug=data.operating_system.name ).exists():
operating_system = OperatingSystem.objects.filter(
name=data.operating_system.name,
is_global = True
)
operating_system = OperatingSystem.objects.get( slug=data.operating_system.name )
if operating_system.exists():
else: # Create Operating System
operating_system = OperatingSystem.objects.get(
name=data.operating_system.name,
is_global = True
)
else:
operating_system = None
if not operating_system:
operating_system = OperatingSystem.objects.filter(
name=data.operating_system.name,
organization = organization
)
if operating_system.exists():
operating_system = OperatingSystem.objects.get(
name=data.operating_system.name,
organization = organization
)
else:
operating_system = None
if not operating_system:
operating_system = OperatingSystem.objects.create(
name = data.operating_system.name,
@ -101,16 +196,24 @@ def process_inventory(self, data, organization: int):
)
if OperatingSystemVersion.objects.filter( name=data.operating_system.version_major, operating_system=operating_system ).exists():
operating_system_version = OperatingSystemVersion.objects.filter(
name=data.operating_system.version_major,
operating_system=operating_system
)
if operating_system_version.exists():
operating_system_version = OperatingSystemVersion.objects.get(
organization = organization,
is_global = True,
name = data.operating_system.version_major,
operating_system = operating_system
name=data.operating_system.version_major,
operating_system=operating_system
)
else: # Create Operating System Version
else:
operating_system_version = None
if not operating_system_version:
operating_system_version = OperatingSystemVersion.objects.create(
organization = organization,
@ -119,22 +222,22 @@ def process_inventory(self, data, organization: int):
operating_system = operating_system,
)
device_operating_system = DeviceOperatingSystem.objects.filter(
device=device,
)
if DeviceOperatingSystem.objects.filter( version=data.operating_system.version, device=device, operating_system_version=operating_system_version ).exists():
if device_operating_system.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
else:
device_operating_system.installdate = timezone.now()
device_operating_system = None
device_operating_system.save()
else: # Create Operating System Version
if not device_operating_system:
device_operating_system = DeviceOperatingSystem.objects.create(
organization = organization,
@ -144,6 +247,26 @@ def process_inventory(self, data, organization: int):
installdate = timezone.now()
)
if not device_operating_system.installdate: # Only update install date if empty
device_operating_system.installdate = timezone.now()
device_operating_system.save()
if device_operating_system.operating_system_version != operating_system_version:
device_operating_system.operating_system_version = operating_system_version
device_operating_system.save()
if device_operating_system.version != data.operating_system.version:
device_operating_system.version = data.operating_system.version
device_operating_system.save()
if app_settings.software_is_global:

View File

@ -255,6 +255,20 @@ class InventoryAPI(TestCase):
def test_api_inventory_device_uuid_match(self):
""" Device uuid match """
assert self.device.uuid == self.inventory['details']['uuid']
def test_api_inventory_device_serial_number_match(self):
""" Device SN match """
assert self.device.serial_number == self.inventory['details']['serial_number']
def test_api_inventory_operating_system_added(self):
""" Operating System is created """
@ -424,3 +438,552 @@ class InventoryAPI(TestCase):
"""
pass
class InventoryAPIDifferentNameSerialNumberMatch(TestCase):
""" Test inventory upload with different name
should match by serial number
"""
model = Device
model_name = 'device'
app_label = 'itam'
inventory = {
"details": {
"name": "device_name",
"serial_number": "serial_number_123",
"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
Device.objects.create(
name='random device name',
serial_number='serial_number_123'
)
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)
def test_api_inventory_device_added(self):
""" Device is created """
assert self.device.name == self.inventory['details']['name']
def test_api_inventory_device_uuid_match(self):
""" Device uuid match """
assert self.device.uuid == self.inventory['details']['uuid']
def test_api_inventory_device_serial_number_match(self):
""" Device SN match """
assert self.device.serial_number == self.inventory['details']['serial_number']
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
class InventoryAPIDifferentNameUUIDMatch(TestCase):
""" Test inventory upload with different name
should match by uuid
"""
model = Device
model_name = 'device'
app_label = 'itam'
inventory = {
"details": {
"name": "device_name",
"serial_number": "serial_number_123",
"uuid": "123-456-789"
},
"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
Device.objects.create(
name='random device name',
uuid='123-456-789'
)
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)
def test_api_inventory_device_added(self):
""" Device is created """
assert self.device.name == self.inventory['details']['name']
def test_api_inventory_device_uuid_match(self):
""" Device uuid match """
assert self.device.uuid == self.inventory['details']['uuid']
def test_api_inventory_device_serial_number_match(self):
""" Device SN match """
assert self.device.serial_number == self.inventory['details']['serial_number']
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

View File

@ -3,7 +3,7 @@ from django.urls import path
from rest_framework.routers import DefaultRouter
from rest_framework.urlpatterns import format_suffix_patterns
from .views import access, index
from .views import access, config, index
from .views.itam import software, config as itam_config
from .views.itam.device import DeviceViewSet
@ -24,6 +24,9 @@ router.register('software', software.SoftwareViewSet, basename='software')
urlpatterns = [
path("config/<slug:slug>/", itam_config.View.as_view(), name="_api_device_config"),
path("configuration/", config.ConfigGroupsList.as_view(), name='_api_config_groups'),
path("configuration/<int:pk>", config.ConfigGroupsDetail.as_view(), name='_api_config_group'),
path("device/inventory", inventory.Collect.as_view(), name="_api_device_inventory"),
path("organization/", access.OrganizationList.as_view(), name='_api_orgs'),

View File

@ -1,5 +1,7 @@
from django.contrib.auth.models import Permission
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiResponse
from rest_framework import generics, routers, serializers, views
from rest_framework.permissions import DjangoObjectPermissions
from rest_framework.response import Response
@ -7,12 +9,17 @@ from rest_framework.response import Response
from access.mixin import OrganizationMixin
from access.models import Organization, Team
from api.serializers.access import OrganizationSerializer, OrganizationListSerializer, TeamSerializer
from api.serializers.access import OrganizationSerializer, OrganizationListSerializer, TeamSerializer, TeamPermissionSerializer
from api.views.mixin import OrganizationPermissionAPI
class OrganizationList(generics.ListCreateAPIView):
@extend_schema_view(
get=extend_schema(
summary = "Fetch Organizations",
description="Returns a list of organizations."
),
)
class OrganizationList(generics.ListAPIView):
permission_classes = [
OrganizationPermissionAPI
@ -28,7 +35,18 @@ class OrganizationList(generics.ListCreateAPIView):
class OrganizationDetail(generics.RetrieveUpdateDestroyAPIView):
@extend_schema_view(
get=extend_schema(
summary = "Get An Organization",
),
patch=extend_schema(
summary = "Update an organization",
),
put=extend_schema(
summary = "Update an organization",
),
)
class OrganizationDetail(generics.RetrieveUpdateAPIView):
permission_classes = [
OrganizationPermissionAPI
@ -44,6 +62,20 @@ class OrganizationDetail(generics.RetrieveUpdateDestroyAPIView):
@extend_schema_view(
post=extend_schema(
summary = "Create a Team",
description = """Create a team within the defined organization.""",
tags = ['team',],
request = TeamSerializer,
responses = {
200: OpenApiResponse(description='Team has been updated with the supplied permissions'),
401: OpenApiResponse(description='User Not logged in'),
403: OpenApiResponse(description='User is missing permission or in different organization'),
}
),
create=extend_schema(exclude=True),
)
class TeamList(generics.ListCreateAPIView):
permission_classes = [
@ -66,6 +98,45 @@ class TeamList(generics.ListCreateAPIView):
@extend_schema_view(
get=extend_schema(
summary = "Fetch a Team",
description = """Fetch a team within the defined organization.
""",
methods=["GET"],
tags = ['team',],
request = TeamSerializer,
responses = {
200: OpenApiResponse(description='Team has been updated with the supplied permissions'),
401: OpenApiResponse(description='User Not logged in'),
403: OpenApiResponse(description='User is missing permission or in different organization'),
}
),
patch=extend_schema(
summary = "Update a Team",
description = """Update a team within the defined organization.
""",
methods=["Patch"],
tags = ['team',],
request = TeamSerializer,
responses = {
200: OpenApiResponse(description='Team has been updated with the supplied permissions'),
401: OpenApiResponse(description='User Not logged in'),
403: OpenApiResponse(description='User is missing permission or in different organization'),
}
),
put = extend_schema(
summary = "Amend a team",
tags = ['team',],
),
delete=extend_schema(
summary = "Delete a Team",
tags = ['team',],
),
post = extend_schema(
exclude = True,
)
)
class TeamDetail(generics.RetrieveUpdateDestroyAPIView):
permission_classes = [
@ -79,12 +150,66 @@ class TeamDetail(generics.RetrieveUpdateDestroyAPIView):
class TeamPermissionDetail(routers.APIRootView):
@extend_schema_view(
get=extend_schema(
summary = "Fetch a teams permissions",
tags = ['team',],
),
post=extend_schema(
summary = "Replace team Permissions",
description = """Replace the teams permissions with the permissions supplied.
# temp disabled until permission checker updated
# permission_classes = [
# OrganizationPermissionAPI
# ]
Teams Permissions will be replaced with the permissions supplied. **ALL** existing permissions will be
removed.
permissions are required to be in format `<module name>_<permission>_<table name>`
""",
methods=["POST"],
tags = ['team',],
request = TeamPermissionSerializer,
responses = {
200: OpenApiResponse(description='Team has been updated with the supplied permissions'),
401: OpenApiResponse(description='User Not logged in'),
403: OpenApiResponse(description='User is missing permission or in different organization'),
}
),
delete=extend_schema(
summary = "Delete permissions",
tags = ['team',],
),
patch = extend_schema(
summary = "Amend team Permissions",
description = """Amend the teams permissions with the permissions supplied.
Teams permissions will include the existing permissions along with the ones supplied.
permissions are required to be in format `<module name>_<permission>_<table name>`
""",
methods=["PATCH"],
parameters = None,
tags = ['team',],
request = TeamPermissionSerializer,
responses = {
200: OpenApiResponse(description='Team has been updated with the supplied permissions'),
401: OpenApiResponse(description='User Not logged in'),
403: OpenApiResponse(description='User is missing permission or in different organization'),
}
),
put = extend_schema(
summary = "Amend team Permissions",
tags = ['team',],
)
)
class TeamPermissionDetail(views.APIView):
permission_classes = [
OrganizationPermissionAPI
]
queryset = Team.objects.all()
serializer_class = TeamPermissionSerializer
def get(self, request, *args, **kwargs):

54
app/api/views/config.py Normal file
View File

@ -0,0 +1,54 @@
from drf_spectacular.utils import extend_schema, extend_schema_view
from rest_framework import generics
from api.serializers.config import ConfigGroupsSerializer
from api.views.mixin import OrganizationPermissionAPI
from config_management.models.groups import ConfigGroups
@extend_schema_view(
get=extend_schema(
summary = "Fetch Config groups",
description="Returns a list of Config Groups."
),
)
class ConfigGroupsList(generics.ListAPIView):
permission_classes = [
OrganizationPermissionAPI
]
queryset = ConfigGroups.objects.all()
lookup_field = 'pk'
serializer_class = ConfigGroupsSerializer
def get_view_name(self):
return "Config Groups"
@extend_schema_view(
get=extend_schema(
summary = "Get A Config Group",
# responses = {}
),
)
class ConfigGroupsDetail(generics.RetrieveAPIView):
permission_classes = [
OrganizationPermissionAPI
]
queryset = ConfigGroups.objects.all()
lookup_field = 'pk'
serializer_class = ConfigGroupsSerializer
def get_view_name(self):
return "Config Group"

View File

@ -27,6 +27,7 @@ class Index(viewsets.ViewSet):
{
# "teams": reverse("_api_teams", request=request),
"devices": reverse("API:device-list", request=request),
"config_groups": reverse("API:_api_config_groups", request=request),
"organizations": reverse("API:_api_orgs", request=request),
"software": reverse("API:software-list", request=request),
}

View File

@ -3,6 +3,8 @@ from django.shortcuts import get_object_or_404
from rest_framework import generics, viewsets
from access.mixin import OrganizationMixin
from api.serializers.itam.software import SoftwareSerializer
from api.views.mixin import OrganizationPermissionAPI
@ -10,7 +12,7 @@ from itam.models.software import Software
class SoftwareViewSet(viewsets.ModelViewSet):
class SoftwareViewSet(OrganizationMixin, viewsets.ModelViewSet):
permission_classes = [
OrganizationPermissionAPI

View File

@ -1,6 +1,7 @@
from django.core.exceptions import PermissionDenied
from django.forms import ValidationError
from rest_framework import exceptions
from rest_framework.permissions import DjangoObjectPermissions
from access.mixin import OrganizationMixin
@ -28,12 +29,16 @@ class OrganizationPermissionAPI(DjangoObjectPermissions, OrganizationMixin):
self.request = request
method = self.request._request.method.lower()
if method.upper() not in view.allowed_methods:
view.http_method_not_allowed(request._request)
if hasattr(view, 'queryset'):
if view.queryset.model._meta:
self.obj = view.queryset.model
method = self.request._request.method.lower()
object_organization = None
if method == 'get':

View File

@ -74,6 +74,23 @@ def user_settings(context) -> int:
return None
def user_default_organization(context) -> int:
""" Provides the users default organization.
Returns:
int: Users Default Organization
"""
if context.user.is_authenticated:
settings = UserSettings.objects.filter(user=context.user)
if settings[0].default_organization:
return settings[0].default_organization.id
return None
def nav_items(context) -> list(dict()):
""" Fetch All Project URLs
@ -203,4 +220,5 @@ def common(context):
'nav_items': nav_items(context),
'social_backends': social_backends(context),
'user_settings': user_settings(context),
'user_default_organization': user_default_organization(context)
}

View File

@ -113,6 +113,8 @@ INSTALLED_APPS = [
'core.apps.CoreConfig',
'access.apps.AccessConfig',
'itam.apps.ItamConfig',
'itim.apps.ItimConfig',
'assistance.apps.AssistanceConfig',
'settings.apps.SettingsConfig',
'drf_spectacular',
'drf_spectacular_sidecar',
@ -307,6 +309,9 @@ curl:
'SWAGGER_UI_DIST': 'SIDECAR',
'SWAGGER_UI_FAVICON_HREF': 'SIDECAR',
'REDOC_DIST': 'SIDECAR',
'PREPROCESSING_HOOKS': [
'drf_spectacular.hooks.preprocess_exclude_path_format'
],
}
DATETIME_FORMAT = 'j N Y H:i:s'
@ -354,7 +359,6 @@ if DEBUG:
# Apps Under Development
INSTALLED_APPS += [
'information.apps.InformationConfig',
'project_management.apps.ProjectManagementConfig',
]

View File

@ -1,8 +1,46 @@
import pytest
import unittest
from access.models import TenancyObject
from access.tests.abstract.tenancy_object import TenancyObject as TenancyObjectTestCases
from app.tests.abstract.views import AddView, ChangeView, DeleteView, DisplayView, IndexView
from core.mixin.history_save import SaveHistory
from core.tests.abstract.models import Models
class BaseModel:
""" Test cases for all models """
model = None
""" Model to test """
@pytest.mark.skip(reason="figure out how to test sub-sub-class")
def test_class_inherits_save_history(self):
""" Confirm class inheritence
TenancyObject must inherit SaveHistory
"""
assert issubclass(self.model, TenancyObject)
class TenancyModel(
BaseModel,
TenancyObjectTestCases,
Models
):
""" Test cases for tenancy models"""
model = None
""" Model to test """
class ModelAdd(
AddView
):

View File

@ -563,3 +563,33 @@ class AllViews(
index_view: str = None
""" Index Class name to test """
@pytest.mark.skip(reason='write test')
def test_view_index_attribute_missing_permission_required(self):
""" Attribute missing Test
Ensure that `permission_required` attribute is not defined within the view.
this can be done by mocking the inherited class with the `permission_required` attribute
set to a value that if it changed would be considered defined in the created view.
## Why?
This attribute can be dynamically added based of of the view name along with attributes
`model._meta.model_name` and `str(__class__.__name__).lower()`.
Additional test:
- ensure that the attribute does get automagically created.
- ensure that the classes name is one of add, change, delete, display or index.
"""
@pytest.mark.skip(reason='write test')
def test_view_index_attribute_missing_template_name(self):
""" Attribute missing Test
Ensure that `template_name` attribute is not defined within the view if the value
is `form.html.j2`
this valuse is already defined in the base form
"""

View File

@ -42,7 +42,9 @@ urlpatterns = [
path("account/", include("django.contrib.auth.urls")),
path("organization/", include("access.urls")),
path("assistance/", include("assistance.urls")),
path("itam/", include("itam.urls")),
path("itim/", include("itim.urls")),
path("config_management/", include("config_management.urls")),
path("history/<str:model_name>/<int:model_pk>", history.View.as_view(), name='_history'),
@ -72,9 +74,6 @@ if settings.DEBUG:
urlpatterns += [
path("__debug__/", include("debug_toolbar.urls"), name='_debug'),
# Apps Under Development
path("itim/", include("itim.urls")),
path("information/", include("information.urls")),
path("project_management/", include("project_management.urls")),
]

View File

@ -1,6 +1,6 @@
from django.apps import AppConfig
class InformationConfig(AppConfig):
class AssistanceConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'information'
name = 'assistance'

View File

@ -0,0 +1,65 @@
from django import forms
from django.forms import ValidationError
from app import settings
from assistance.models.knowledge_base import KnowledgeBase
from core.forms.common import CommonModelForm
class KnowledgeBaseForm(CommonModelForm):
__name__ = 'asdsa'
class Meta:
fields = '__all__'
model = KnowledgeBase
prefix = 'knowledgebase'
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['expiry_date'].widget = forms.widgets.DateTimeInput(attrs={'type': 'datetime-local', 'format': "%Y-%m-%dT%H:%M"})
self.fields['expiry_date'].input_formats = settings.DATETIME_FORMAT
self.fields['expiry_date'].format="%Y-%m-%dT%H:%M"
self.fields['release_date'].widget = forms.widgets.DateTimeInput(attrs={'type': 'datetime-local', 'format': "%Y-%m-%dT%H:%M"})
self.fields['release_date'].input_formats = settings.DATETIME_FORMAT
self.fields['release_date'].format="%Y-%m-%dT%H:%M"
def clean(self):
cleaned_data = super().clean()
responsible_user = cleaned_data.get("responsible_user")
responsible_teams = cleaned_data.get("responsible_teams")
if not responsible_user and not responsible_teams:
raise ValidationError('A Responsible User or Team must be assigned.')
target_team = cleaned_data.get("target_team")
target_user = cleaned_data.get("target_user")
if not target_team and not target_user:
raise ValidationError('A Target Team or Target User must be assigned.')
if target_team and target_user:
raise ValidationError('Both a Target Team or Target User Cant be assigned at the same time. Use one or the other')
return cleaned_data

View File

@ -0,0 +1,36 @@
from django.forms import ValidationError
from assistance.models.knowledge_base import KnowledgeBaseCategory
from core.forms.common import CommonModelForm
class KnowledgeBaseCategoryForm(CommonModelForm):
__name__ = 'asdsa'
class Meta:
fields = '__all__'
model = KnowledgeBaseCategory
prefix = 'knowledgebase_category'
def clean(self):
cleaned_data = super().clean()
target_team = cleaned_data.get("target_team")
target_user = cleaned_data.get("target_user")
if target_team and target_user:
raise ValidationError('Both a Target Team or Target User Cant be assigned at the same time. Use one or the other or None')
return cleaned_data

View File

@ -0,0 +1,68 @@
# Generated by Django 5.0.7 on 2024-07-20 14:37
import access.fields
import access.models
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('access', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='KnowledgeBaseCategory',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('is_global', models.BooleanField(default=False)),
('model_notes', models.TextField(blank=True, default=None, null=True, verbose_name='Notes')),
('name', models.CharField(help_text='Name/Title of the Category', max_length=50, verbose_name='Title')),
('slug', access.fields.AutoSlugField()),
('created', access.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False)),
('modified', access.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False)),
('organization', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization', validators=[access.models.TenancyObject.validatate_organization_exists])),
('parent_category', models.ForeignKey(blank=True, default=None, help_text='Category this category belongs to', null=True, on_delete=django.db.models.deletion.SET_NULL, to='assistance.knowledgebasecategory', verbose_name='Parent Category')),
('target_team', models.ManyToManyField(blank=True, default=None, help_text='Team(s) to grant access to the article', to='access.team', verbose_name='Target Team(s)')),
('target_user', models.ForeignKey(blank=True, default=None, help_text='User(s) to grant access to the article', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Target Users(s)')),
],
options={
'verbose_name': 'Category',
'verbose_name_plural': 'Categorys',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='KnowledgeBase',
fields=[
('is_global', models.BooleanField(default=False)),
('id', models.AutoField(primary_key=True, serialize=False, unique=True)),
('title', models.CharField(help_text='Title of the article', max_length=50, verbose_name='Title')),
('summary', models.TextField(blank=True, default=None, help_text='Short Summary of the article', null=True, verbose_name='Summary')),
('content', models.TextField(blank=True, default=None, help_text='Content of the article. Markdown is supported', null=True, verbose_name='Article Content')),
('release_date', models.DateTimeField(blank=True, default=None, help_text='Date the article will be published', null=True, verbose_name='Publish Date')),
('expiry_date', models.DateTimeField(blank=True, default=None, help_text='Date the article will be removed from published articles', null=True, verbose_name='End Date')),
('public', models.BooleanField(default=False, help_text='Is this article to be made available publically', verbose_name='Public Article')),
('created', access.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False)),
('modified', access.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False)),
('organization', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization', validators=[access.models.TenancyObject.validatate_organization_exists])),
('responsible_teams', models.ManyToManyField(blank=True, default=None, help_text='Team(s) whom is considered the articles owner.', related_name='responsible_teams', to='access.team', verbose_name='Responsible Team(s)')),
('responsible_user', models.ForeignKey(default=None, help_text='User(s) whom is considered the articles owner.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='responsible_user', to=settings.AUTH_USER_MODEL, verbose_name='Responsible User')),
('target_team', models.ManyToManyField(blank=True, default=None, help_text='Team(s) to grant access to the article', to='access.team', verbose_name='Target Team(s)')),
('target_user', models.ForeignKey(blank=True, default=None, help_text='User(s) to grant access to the article', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Target Users(s)')),
('category', models.ForeignKey(default=None, help_text='Article Category', max_length=50, null=True, on_delete=django.db.models.deletion.SET_NULL, to='assistance.knowledgebasecategory', verbose_name='Category')),
],
options={
'verbose_name': 'Article',
'verbose_name_plural': 'Articles',
'ordering': ['title'],
},
),
]

View File

View File

@ -0,0 +1,219 @@
from django.contrib.auth.models import User
from django.db import models
from django.forms import ValidationError
from access.fields import *
from access.models import Team, TenancyObject
class KnowledgeBaseCategory(TenancyObject):
class Meta:
ordering = [
'name',
]
verbose_name = "Category"
verbose_name_plural = "Categorys"
parent_category = models.ForeignKey(
'self',
blank = True,
default = None,
help_text = 'Category this category belongs to',
null = True,
on_delete = models.SET_NULL,
verbose_name = 'Parent Category',
)
name = models.CharField(
blank = False,
help_text = 'Name/Title of the Category',
max_length = 50,
unique = False,
verbose_name = 'Title',
)
slug = AutoSlugField()
target_team = models.ManyToManyField(
Team,
blank = True,
default = None,
help_text = 'Team(s) to grant access to the article',
verbose_name = 'Target Team(s)',
)
target_user = models.ForeignKey(
User,
blank = True,
default = None,
help_text = 'User(s) to grant access to the article',
null = True,
on_delete = models.SET_NULL,
verbose_name = 'Target Users(s)',
)
created = AutoCreatedField()
modified = AutoLastModifiedField()
def __str__(self):
return self.name
class KnowledgeBase(TenancyObject):
class Meta:
ordering = [
'title',
]
verbose_name = "Article"
verbose_name_plural = "Articles"
model_notes = None
id = models.AutoField(
primary_key=True,
unique=True,
blank=False
)
title = models.CharField(
blank = False,
help_text = 'Title of the article',
max_length = 50,
unique = False,
verbose_name = 'Title',
)
summary = models.TextField(
blank = True,
default = None,
help_text = 'Short Summary of the article',
null = True,
verbose_name = 'Summary',
)
content = models.TextField(
blank = True,
default = None,
help_text = 'Content of the article. Markdown is supported',
null = True,
verbose_name = 'Article Content',
)
category = models.ForeignKey(
KnowledgeBaseCategory,
blank = False,
default = None,
help_text = 'Article Category',
max_length = 50,
null = True,
on_delete = models.SET_NULL,
unique = False,
verbose_name = 'Category',
)
release_date = models.DateTimeField(
blank = True,
default = None,
help_text = 'Date the article will be published',
null = True,
verbose_name = 'Publish Date',
)
expiry_date = models.DateTimeField(
blank = True,
default = None,
help_text = 'Date the article will be removed from published articles',
null = True,
verbose_name = 'End Date',
)
target_team = models.ManyToManyField(
Team,
blank = True,
default = None,
help_text = 'Team(s) to grant access to the article',
verbose_name = 'Target Team(s)',
)
target_user = models.ForeignKey(
User,
blank = True,
default = None,
help_text = 'User(s) to grant access to the article',
null = True,
on_delete = models.SET_NULL,
verbose_name = 'Target Users(s)',
)
responsible_user = models.ForeignKey(
User,
blank = False,
default = None,
help_text = 'User(s) whom is considered the articles owner.',
null = True,
on_delete = models.SET_NULL,
related_name = 'responsible_user',
verbose_name = 'Responsible User',
)
responsible_teams = models.ManyToManyField(
Team,
blank = True,
default = None,
help_text = 'Team(s) whom is considered the articles owner.',
related_name = 'responsible_teams',
verbose_name = 'Responsible Team(s)',
)
public = models.BooleanField(
blank = False,
default = False,
help_text = 'Is this article to be made available publically',
verbose_name = 'Public Article',
)
created = AutoCreatedField()
modified = AutoLastModifiedField()
def __str__(self):
return self.title

View File

@ -0,0 +1,232 @@
{% extends 'base.html.j2' %}
{% load markdown %}
{% block content %}
<script>
function openCity(evt, cityName) {
var i, tabcontent, tablinks;
tabcontent = document.getElementsByClassName("tabcontent");
for (i = 0; i < tabcontent.length; i++) {
tabcontent[i].style.display = "none";
}
tablinks = document.getElementsByClassName("tablinks");
for (i = 0; i < tablinks.length; i++) {
tablinks[i].className = tablinks[i].className.replace(" active", "");
}
document.getElementById(cityName).style.display = "block";
evt.currentTarget.className += " active";
}
</script>
<style>
.detail-view-field {
display:unset;
height: 30px;
line-height: 30px;
padding: 0px 20px 40px 20px;
}
.detail-view-field label {
display: inline-block;
font-weight: bold;
width: 200px;
margin: 10px;
height: 30px;
line-height: 30px;
}
.detail-view-field span {
display: inline-block;
width: 340px;
margin: 10px;
border-bottom: 1px solid #ccc;
height: 30px;
line-height: 30px;
}
pre {
word-wrap: break-word;
white-space: pre-wrap;
}
</style>
<div class="tab">
<button
onclick="window.location='{% url 'Assistance:Knowledge Base' %}';"
style="vertical-align: middle; padding: auto; margin: 0px">
<svg xmlns="http://www.w3.org/2000/svg" height="25px" viewBox="0 -960 960 960" width="25px"
style="vertical-align: middle; margin: 0px; padding: 0px border: none; " fill="#6a6e73">
<path
d="m313-480 155 156q11 11 11.5 27.5T468-268q-11 11-28 11t-28-11L228-452q-6-6-8.5-13t-2.5-15q0-8 2.5-15t8.5-13l184-184q11-11 27.5-11.5T468-692q11 11 11 28t-11 28L313-480Zm264 0 155 156q11 11 11.5 27.5T732-268q-11 11-28 11t-28-11L492-452q-6-6-8.5-13t-2.5-15q0-8 2.5-15t8.5-13l184-184q11-11 27.5-11.5T732-692q11 11 11 28t-11 28L577-480Z" />
</svg>Back to Articles</button>
<button id="defaultOpen" class="tablinks" onclick="openCity(event, 'Details')">Details</button>
{% if perms.assistance.change_knowledgebase %}
<button class="tablinks" onclick="openCity(event, 'Notes')">Notes</button>
{% endif %}
</div>
<form method="post">
<div id="Details" class="tabcontent">
{% if perms.assistance.change_knowledgebase %}
<h3>Details</h3>
{% csrf_token %}
<div style="align-items:flex-start; align-content: center; display: flexbox; width: 100%">
<div style="display: inline; width: 40%; margin: 30px;">
<div class="detail-view-field">
<label>{{ form.title.label }}</label>
<span>{{ form.title.value }}</span>
</div>
<div class="detail-view-field">
<label>{{ form.category.label }}</label>
<span>
{% if kb.category %}
<a href="{% url 'Settings:_knowledge_base_category_view' kb.category.id %}">{{ kb.category }}</a>
{% else %}
&nbsp;
{% endif %}
</span>
</div>
<div class="detail-view-field">
<label>{{ form.responsible_user.label }}</label>
<span>
{% if form.responsible_user.value %}
{{ kb.responsible_user }}
{% else %}
&nbsp;
{% endif %}
</span>
</div>
<div class="detail-view-field">
<label>{{ form.organization.label }}</label>
<span>
{% if form.organization.value %}
{{ kb.organization }}
{% else %}
&nbsp;
{% endif %}
</span>
</div>
</div>
<div style="display: inline; width: 40%; margin: 30px; text-align: left;">
<div class="detail-view-field">
<label>{{ form.release_date.label }}</label>
<span>
{% if form.release_date.value %}
{{ form.release_date.value }}
{% else %}
&nbsp;
{% endif %}
</span>
</div>
<div class="detail-view-field">
<label>{{ form.expiry_date.label }}</label>
<span>
{% if form.expiry_date.value %}
{{ form.expiry_date.value }}
{% else %}
&nbsp;
{% endif %}
</span>
</div>
<div class="detail-view-field">
<label>{{ form.target_user.label }}</label>
<span>
{% if form.target_user.value %}
{{ kb.target_user }}
{% else %}
&nbsp;
{% endif %}
</span>
</div>
<div class="detail-view-field">
<label>{{ form.target_team.label }}</label>
<span>
{% if form.target_team.value %}
{{ form.target_team.value }} {{ kb.target_team }}
{% else %}
&nbsp;
{% endif %}
</span>
</div>
</div>
</div>
<input type="button" value="Edit" onclick="window.location='{% url 'Assistance:_knowledge_base_change' kb.id %}';">
{% endif %}
{% if form.summary.value %}
<div style="display: block; width: 100%;">
<h3>Summary</h3>
{{ form.summary.value | safe }}
<br>
<hr />
</div>
{% endif %}
<div style="display: block; width: 100%;">
<h3>Content</h3>
<hr />
<br>
{{ form.content.value | markdown | safe }}
<br>
</div>
<br>
<script>
document.getElementById("defaultOpen").click();
</script>
</div>
{% if perms.assistance.change_knowledgebase %}
<div id="Notes" class="tabcontent">
<h3>
Notes
</h3>
{{ notes_form }}
<input type="submit" name="{{notes_form.prefix}}" value="Submit" />
<div class="comments">
{% if notes %}
{% for note in notes %}
{% include 'note.html.j2' %}
{% endfor %}
{% endif %}
</div>
</div>
{% endif %}
</form>
{% endblock %}

View File

@ -0,0 +1,213 @@
{% extends 'base.html.j2' %}
{% load markdown %}
{% block content %}
<script>
function openCity(evt, cityName) {
var i, tabcontent, tablinks;
tabcontent = document.getElementsByClassName("tabcontent");
for (i = 0; i < tabcontent.length; i++) {
tabcontent[i].style.display = "none";
}
tablinks = document.getElementsByClassName("tablinks");
for (i = 0; i < tablinks.length; i++) {
tablinks[i].className = tablinks[i].className.replace(" active", "");
}
document.getElementById(cityName).style.display = "block";
evt.currentTarget.className += " active";
}
</script>
<style>
.detail-view-field {
display:unset;
height: 30px;
line-height: 30px;
padding: 0px 20px 40px 20px;
}
.detail-view-field label {
display: inline-block;
font-weight: bold;
width: 200px;
margin: 10px;
/*padding: 10px;*/
height: 30px;
line-height: 30px;
}
.detail-view-field span {
display: inline-block;
width: 340px;
margin: 10px;
/*padding: 10px;*/
border-bottom: 1px solid #ccc;
height: 30px;
line-height: 30px;
}
pre {
word-wrap: break-word;
white-space: pre-wrap;
}
</style>
<div class="tab">
<button
onclick="window.location='{% url 'Settings:KB Categories' %}';"
style="vertical-align: middle; padding: auto; margin: 0px">
<svg xmlns="http://www.w3.org/2000/svg" height="25px" viewBox="0 -960 960 960" width="25px"
style="vertical-align: middle; margin: 0px; padding: 0px border: none; " fill="#6a6e73">
<path
d="m313-480 155 156q11 11 11.5 27.5T468-268q-11 11-28 11t-28-11L228-452q-6-6-8.5-13t-2.5-15q0-8 2.5-15t8.5-13l184-184q11-11 27.5-11.5T468-692q11 11 11 28t-11 28L313-480Zm264 0 155 156q11 11 11.5 27.5T732-268q-11 11-28 11t-28-11L492-452q-6-6-8.5-13t-2.5-15q0-8 2.5-15t8.5-13l184-184q11-11 27.5-11.5T732-692q11 11 11 28t-11 28L577-480Z" />
</svg>Back to Articles</button>
<button id="defaultOpen" class="tablinks" onclick="openCity(event, 'Details')">Details</button>
<button class="tablinks" onclick="openCity(event, 'Articles')">Articles</button>
{% if perms.assistance.change_knowledgebase %}
<button class="tablinks" onclick="openCity(event, 'Notes')">Notes</button>
{% endif %}
</div>
<form method="post">
<div id="Details" class="tabcontent">
<h3>Details</h3>
{% csrf_token %}
<div style="align-items:flex-start; align-content: center; display: flexbox; width: 100%">
<div style="display: inline; width: 40%; margin: 30px;">
<div class="detail-view-field">
<label>{{ form.name.label }}</label>
<span>{{ form.name.value }}</span>
</div>
<div class="detail-view-field">
<label>{{ form.parent_category.label }}</label>
<span>
{% if item.parent_category %}
{{ item.parent_category }}
{% else %}
&nbsp;
{% endif %}
</span>
</div>
<div class="detail-view-field">
<label>Created</label>
<span>{{ item.created }}</span>
</div>
<div class="detail-view-field">
<label>Modified</label>
<span>{{ item.modified }}</span>
</div>
</div>
<div style="display: inline; width: 40%; margin: 30px; text-align: left;">
<div class="detail-view-field">
<label>{{ form.organization.label }}</label>
<span>
{% if form.organization.value %}
{{ item.organization }}
{% else %}
&nbsp;
{% endif %}
</span>
</div>
<div class="detail-view-field">
<label>{{ form.target_user.label }}</label>
<span>
{% if form.target_user.value %}
{{ form.target_user.value }}
{% else %}
&nbsp;
{% endif %}
</span>
</div>
<div class="detail-view-field">
<label>{{ form.target_team.label }}</label>
<span>
{% if form.target_team.value %}
{{ form.target_team.value }}
{% else %}
&nbsp;
{% endif %}
</span>
</div>
</div>
</div>
<input type="button" value="Edit" onclick="window.location='{% url 'Settings:_knowledge_base_category_change' item.id %}';">
<br>
<script>
document.getElementById("defaultOpen").click();
</script>
</div>
<div id="Articles" class="tabcontent">
<h3>
Articles
</h3>
<table>
<tr>
<th>Title</th>
<th>Organization</th>
</tr>
{% for article in articles %}
<tr>
<td><a href="{% url 'Assistance:_knowledge_base_view' article.id %}">{{ article.title }}</a></td>
<td>{{ article.organization }}</td>
</tr>
{% endfor %}
</table>
</div>
{% if perms.assistance.change_knowledgebase %}
<div id="Notes" class="tabcontent">
<h3>
Notes
</h3>
{{ notes_form }}
<input type="submit" name="{{notes_form.prefix}}" value="Submit" />
<div class="comments">
{% if notes %}
{% for note in notes %}
{% include 'note.html.j2' %}
{% endfor %}
{% endif %}
</div>
</div>
{% endif %}
</form>
{% endblock %}

View File

@ -0,0 +1,47 @@
{% extends 'base.html.j2' %}
{% block content %}
<input type="button" value="New Article" onclick="window.location='{% url 'Settings:_knowledge_base_category_add' %}';">
<table class="data">
<tr>
<th>Title</th>
<th>Parent</th>
<th>Organization</th>
<th>&nbsp;</th>
</tr>
{% if items %}
{% for item in items %}
<tr>
<td><a href="{% url 'Settings:_knowledge_base_category_view' pk=item.id %}">{{ item.name }}</a></td>
<td>{{ item.parent_category }}</td>
<td>{{ item.organization }}</td>
<td>&nbsp;</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="4">Nothing Found</td>
</tr>
{% endif %}
</table>
<br>
<div class="pagination">
<span class="step-links">
{% if page_obj.has_previous %}
<a href="?page=1">&laquo; first</a>
<a href="?page={{ page_obj.previous_page_number }}">previous</a>
{% endif %}
<span class="current">
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}.
</span>
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}">next</a>
<a href="?page={{ page_obj.paginator.num_pages }}">last &raquo;</a>
{% endif %}
</span>
</div>
{% endblock %}

View File

@ -0,0 +1,47 @@
{% extends 'base.html.j2' %}
{% block content %}
<input type="button" value="New Article" onclick="window.location='{% url 'Assistance:_knowledge_base_add' %}';">
<table class="data">
<tr>
<th>Title</th>
<th>Category</th>
<th>Organization</th>
<th>&nbsp;</th>
</tr>
{% if items %}
{% for item in items %}
<tr>
<td><a href="{% url 'Assistance:_knowledge_base_view' pk=item.id %}">{{ item.title }}</a></td>
<td><a href="{% url 'Settings:_knowledge_base_category_view' pk=item.category.id %}">{{ item.category }}</a></td>
<td>{{ item.organization }}</td>
<td>&nbsp;</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="4">Nothing Found</td>
</tr>
{% endif %}
</table>
<br>
<div class="pagination">
<span class="step-links">
{% if page_obj.has_previous %}
<a href="?page=1">&laquo; first</a>
<a href="?page={{ page_obj.previous_page_number }}">previous</a>
{% endif %}
<span class="current">
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}.
</span>
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}">next</a>
<a href="?page={{ page_obj.paginator.num_pages }}">last &raquo;</a>
{% endif %}
</span>
</div>
{% endblock %}

View File

@ -0,0 +1,44 @@
import pytest
import unittest
from django.test import TestCase
from access.models import Organization
from app.tests.abstract.models import TenancyModel
from assistance.models.knowledge_base import KnowledgeBase
@pytest.mark.django_db
class KnowledgeBaseModel(
TestCase,
TenancyModel
):
model = KnowledgeBase
@classmethod
def setUpTestData(self):
"""Setup Test
1. Create an organization for user and item
2. Create an item
"""
self.organization = Organization.objects.create(name='test_org')
self.item = self.model.objects.create(
organization = self.organization,
title = 'one',
content = 'dict({"key": "one", "existing": "dont_over_write"})'
)
self.second_item = self.model.objects.create(
organization = self.organization,
title = 'one_two',
content = 'dict({"key": "two"})',
)

View File

@ -0,0 +1,78 @@
import pytest
import unittest
import requests
from django.test import TestCase, Client
from access.models import Organization
from core.models.history import History
from core.tests.abstract.history_entry import HistoryEntry
from core.tests.abstract.history_entry_parent_model import HistoryEntryParentItem
from assistance.models.knowledge_base import KnowledgeBase
class KnowledgeBaseHistory(TestCase, HistoryEntry, HistoryEntryParentItem):
model = KnowledgeBase
@classmethod
def setUpTestData(self):
""" Setup Test """
organization = Organization.objects.create(name='test_org')
self.organization = organization
self.item_parent = self.model.objects.create(
title = 'test_item_parent_' + self.model._meta.model_name,
organization = self.organization
)
self.item_create = self.model.objects.create(
title = 'test_item_' + self.model._meta.model_name,
organization = self.organization,
)
self.history_create = History.objects.get(
action = History.Actions.ADD[0],
item_pk = self.item_create.pk,
item_class = self.model._meta.model_name,
)
self.item_change = self.item_create
self.item_change.title = 'test_item_' + self.model._meta.model_name + '_changed'
self.item_change.save()
self.field_after_expected_value = '{"title": "' + self.item_change.title + '"}'
self.history_change = History.objects.get(
action = History.Actions.UPDATE[0],
item_pk = self.item_change.pk,
item_class = self.model._meta.model_name,
)
self.item_delete = self.model.objects.create(
title = 'test_item_delete_' + self.model._meta.model_name,
organization = self.organization,
)
self.deleted_pk = self.item_delete.pk
self.item_delete.delete()
self.history_delete = History.objects.filter(
item_pk = self.deleted_pk,
item_class = self.model._meta.model_name,
)
self.history_delete_children = History.objects.filter(
item_parent_pk = self.deleted_pk,
item_parent_class = self.item_parent._meta.model_name,
)

View File

@ -0,0 +1,95 @@
# from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser, User
from django.contrib.contenttypes.models import ContentType
from django.shortcuts import reverse
from django.test import TestCase, Client
import pytest
import unittest
import requests
from access.models import Organization, Team, TeamUsers, Permission
from assistance.models.knowledge_base import KnowledgeBase
from core.tests.abstract.history_permissions import HistoryPermissions
class KnowledgeBaseHistoryPermissions(TestCase, HistoryPermissions):
item_model = KnowledgeBase
@classmethod
def setUpTestData(self):
"""Setup Test
1. Create an organization for user and item
2. create an organization that is different to item
3. Create a device
4. Add history device history entry as item
5. create a user
6. create user in different organization (with the required permission)
"""
organization = Organization.objects.create(name='test_org')
self.organization = organization
different_organization = Organization.objects.create(name='test_different_organization')
self.item = self.item_model.objects.create(
organization=organization,
title = 'deviceone'
)
self.history = self.model.objects.get(
item_pk = self.item.id,
item_class = self.item._meta.model_name,
action = self.model.Actions.ADD,
)
view_permissions = Permission.objects.get(
codename = 'view_' + self.model._meta.model_name,
content_type = ContentType.objects.get(
app_label = self.model._meta.app_label,
model = self.model._meta.model_name,
)
)
view_team = Team.objects.create(
team_name = 'view_team',
organization = organization,
)
view_team.permissions.set([view_permissions])
self.no_permissions_user = User.objects.create_user(username="test_no_permissions", password="password")
self.view_user = User.objects.create_user(username="test_user_view", password="password")
teamuser = TeamUsers.objects.create(
team = view_team,
user = self.view_user
)
self.different_organization_user = User.objects.create_user(username="test_different_organization_user", password="password")
different_organization_team = Team.objects.create(
team_name = 'different_organization_team',
organization = different_organization,
)
different_organization_team.permissions.set([
view_permissions,
])
TeamUsers.objects.create(
team = different_organization_team,
user = self.different_organization_user
)

View File

@ -0,0 +1,189 @@
# from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser, User
from django.contrib.contenttypes.models import ContentType
from django.shortcuts import reverse
from django.test import TestCase, Client
import pytest
import unittest
import requests
from access.models import Organization, Team, TeamUsers, Permission
from app.tests.abstract.model_permissions import ModelPermissions
from assistance.models.knowledge_base import KnowledgeBase
class KnowledgeBasePermissions(TestCase, ModelPermissions):
model = KnowledgeBase
app_namespace = 'Assistance'
url_name_view = '_knowledge_base_view'
url_name_add = '_knowledge_base_add'
url_name_change = '_knowledge_base_change'
url_name_delete = '_knowledge_base_delete'
url_delete_response = reverse('Assistance:Knowledge Base')
@classmethod
def setUpTestData(self):
"""Setup Test
1. Create an organization for user and item
. create an organization that is different to item
2. Create a device
3. create teams with each permission: view, add, change, delete
4. create a user per team
"""
organization = Organization.objects.create(name='test_org')
self.organization = organization
different_organization = Organization.objects.create(name='test_different_organization')
self.item = self.model.objects.create(
organization=organization,
title = 'deviceone'
)
self.url_view_kwargs = {'pk': self.item.id}
# self.url_add_kwargs = {'pk': self.item.id}
self.add_data = {'device': 'device', 'organization': self.organization.id}
self.url_change_kwargs = {'pk': self.item.id}
self.change_data = {'device': 'device', 'organization': self.organization.id}
self.url_delete_kwargs = {'pk': self.item.id}
self.delete_data = {'device': 'device', 'organization': self.organization.id}
view_permissions = Permission.objects.get(
codename = 'view_' + self.model._meta.model_name,
content_type = ContentType.objects.get(
app_label = self.model._meta.app_label,
model = self.model._meta.model_name,
)
)
view_team = Team.objects.create(
team_name = 'view_team',
organization = organization,
)
view_team.permissions.set([view_permissions])
add_permissions = Permission.objects.get(
codename = 'add_' + self.model._meta.model_name,
content_type = ContentType.objects.get(
app_label = self.model._meta.app_label,
model = self.model._meta.model_name,
)
)
add_team = Team.objects.create(
team_name = 'add_team',
organization = organization,
)
add_team.permissions.set([add_permissions])
change_permissions = Permission.objects.get(
codename = 'change_' + self.model._meta.model_name,
content_type = ContentType.objects.get(
app_label = self.model._meta.app_label,
model = self.model._meta.model_name,
)
)
change_team = Team.objects.create(
team_name = 'change_team',
organization = organization,
)
change_team.permissions.set([change_permissions])
delete_permissions = Permission.objects.get(
codename = 'delete_' + self.model._meta.model_name,
content_type = ContentType.objects.get(
app_label = self.model._meta.app_label,
model = self.model._meta.model_name,
)
)
delete_team = Team.objects.create(
team_name = 'delete_team',
organization = organization,
)
delete_team.permissions.set([delete_permissions])
self.no_permissions_user = User.objects.create_user(username="test_no_permissions", password="password")
self.view_user = User.objects.create_user(username="test_user_view", password="password")
teamuser = TeamUsers.objects.create(
team = view_team,
user = self.view_user
)
self.add_user = User.objects.create_user(username="test_user_add", password="password")
teamuser = TeamUsers.objects.create(
team = add_team,
user = self.add_user
)
self.change_user = User.objects.create_user(username="test_user_change", password="password")
teamuser = TeamUsers.objects.create(
team = change_team,
user = self.change_user
)
self.delete_user = User.objects.create_user(username="test_user_delete", password="password")
teamuser = TeamUsers.objects.create(
team = delete_team,
user = self.delete_user
)
self.different_organization_user = User.objects.create_user(username="test_different_organization_user", password="password")
different_organization_team = Team.objects.create(
team_name = 'different_organization_team',
organization = different_organization,
)
different_organization_team.permissions.set([
view_permissions,
add_permissions,
change_permissions,
delete_permissions,
])
TeamUsers.objects.create(
team = different_organization_team,
user = self.different_organization_user
)

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 KnowledgeBaseViews(
TestCase,
PrimaryModel
):
add_module = 'assistance.views.knowledge_base'
add_view = 'Add'
change_module = add_module
change_view = 'Change'
delete_module = add_module
delete_view = 'Delete'
display_module = add_module
display_view = 'View'
index_module = add_module
index_view = 'Index'

View File

@ -0,0 +1,42 @@
import pytest
import unittest
from django.test import TestCase
from access.models import Organization
from app.tests.abstract.models import TenancyModel
from assistance.models.knowledge_base import KnowledgeBaseCategory
@pytest.mark.django_db
class KnowledgeBaseModel(
TestCase,
TenancyModel
):
model = KnowledgeBaseCategory
@classmethod
def setUpTestData(self):
"""Setup Test
1. Create an organization for user and item
2. Create an item
"""
self.organization = Organization.objects.create(name='test_org')
self.item = self.model.objects.create(
organization = self.organization,
name = 'one',
)
self.second_item = self.model.objects.create(
organization = self.organization,
name = 'one_two',
)

View File

@ -0,0 +1,75 @@
import pytest
import unittest
import requests
from django.test import TestCase, Client
from access.models import Organization
from core.models.history import History
from core.tests.abstract.history_entry import HistoryEntry
from core.tests.abstract.history_entry_parent_model import HistoryEntryParentItem
from assistance.models.knowledge_base import KnowledgeBaseCategory
class KnowledgeBaseHistory(TestCase, HistoryEntry, HistoryEntryParentItem):
model = KnowledgeBaseCategory
@classmethod
def setUpTestData(self):
""" Setup Test """
organization = Organization.objects.create(name='test_org')
self.organization = organization
self.item_create = self.model.objects.create(
name = 'test_item_' + self.model._meta.model_name,
organization = self.organization,
)
self.history_create = History.objects.get(
action = History.Actions.ADD[0],
item_pk = self.item_create.pk,
item_class = self.model._meta.model_name,
)
self.item_change = self.item_create
self.item_change.name = 'test_item_' + self.model._meta.model_name + '_changed'
self.item_change.save()
self.field_after_expected_value = '{"name": "' + self.item_change.name + '"}'
self.history_change = History.objects.get(
action = History.Actions.UPDATE[0],
item_pk = self.item_change.pk,
item_class = self.model._meta.model_name,
)
self.item_delete = self.model.objects.create(
name = 'test_item_delete_' + self.model._meta.model_name,
organization = self.organization,
)
self.deleted_pk = self.item_delete.pk
self.item_delete.delete()
self.history_delete = History.objects.filter(
item_pk = self.deleted_pk,
item_class = self.model._meta.model_name,
)
def test_history_entry_children_delete(self):
""" Model has no child items """
pass

View File

@ -0,0 +1,95 @@
# from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser, User
from django.contrib.contenttypes.models import ContentType
from django.shortcuts import reverse
from django.test import TestCase, Client
import pytest
import unittest
import requests
from access.models import Organization, Team, TeamUsers, Permission
from assistance.models.knowledge_base import KnowledgeBaseCategory
from core.tests.abstract.history_permissions import HistoryPermissions
class KnowledgeBaseHistoryPermissions(TestCase, HistoryPermissions):
item_model = KnowledgeBaseCategory
@classmethod
def setUpTestData(self):
"""Setup Test
1. Create an organization for user and item
2. create an organization that is different to item
3. Create a device
4. Add history device history entry as item
5. create a user
6. create user in different organization (with the required permission)
"""
organization = Organization.objects.create(name='test_org')
self.organization = organization
different_organization = Organization.objects.create(name='test_different_organization')
self.item = self.item_model.objects.create(
organization=organization,
name = 'deviceone'
)
self.history = self.model.objects.get(
item_pk = self.item.id,
item_class = self.item._meta.model_name,
action = self.model.Actions.ADD,
)
view_permissions = Permission.objects.get(
codename = 'view_' + self.model._meta.model_name,
content_type = ContentType.objects.get(
app_label = self.model._meta.app_label,
model = self.model._meta.model_name,
)
)
view_team = Team.objects.create(
team_name = 'view_team',
organization = organization,
)
view_team.permissions.set([view_permissions])
self.no_permissions_user = User.objects.create_user(username="test_no_permissions", password="password")
self.view_user = User.objects.create_user(username="test_user_view", password="password")
teamuser = TeamUsers.objects.create(
team = view_team,
user = self.view_user
)
self.different_organization_user = User.objects.create_user(username="test_different_organization_user", password="password")
different_organization_team = Team.objects.create(
team_name = 'different_organization_team',
organization = different_organization,
)
different_organization_team.permissions.set([
view_permissions,
])
TeamUsers.objects.create(
team = different_organization_team,
user = self.different_organization_user
)

View File

@ -0,0 +1,189 @@
# from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser, User
from django.contrib.contenttypes.models import ContentType
from django.shortcuts import reverse
from django.test import TestCase, Client
import pytest
import unittest
import requests
from access.models import Organization, Team, TeamUsers, Permission
from app.tests.abstract.model_permissions import ModelPermissions
from assistance.models.knowledge_base import KnowledgeBaseCategory
class KnowledgeBasePermissions(TestCase, ModelPermissions):
model = KnowledgeBaseCategory
app_namespace = 'Settings'
url_name_view = '_knowledge_base_category_view'
url_name_add = '_knowledge_base_category_add'
url_name_change = '_knowledge_base_category_change'
url_name_delete = '_knowledge_base_category_delete'
url_delete_response = reverse('Settings:KB Categories')
@classmethod
def setUpTestData(self):
"""Setup Test
1. Create an organization for user and item
. create an organization that is different to item
2. Create a device
3. create teams with each permission: view, add, change, delete
4. create a user per team
"""
organization = Organization.objects.create(name='test_org')
self.organization = organization
different_organization = Organization.objects.create(name='test_different_organization')
self.item = self.model.objects.create(
organization=organization,
name = 'deviceone'
)
self.url_view_kwargs = {'pk': self.item.id}
# self.url_add_kwargs = {'pk': self.item.id}
self.add_data = {'device': 'device', 'organization': self.organization.id}
self.url_change_kwargs = {'pk': self.item.id}
self.change_data = {'device': 'device', 'organization': self.organization.id}
self.url_delete_kwargs = {'pk': self.item.id}
self.delete_data = {'device': 'device', 'organization': self.organization.id}
view_permissions = Permission.objects.get(
codename = 'view_' + self.model._meta.model_name,
content_type = ContentType.objects.get(
app_label = self.model._meta.app_label,
model = self.model._meta.model_name,
)
)
view_team = Team.objects.create(
team_name = 'view_team',
organization = organization,
)
view_team.permissions.set([view_permissions])
add_permissions = Permission.objects.get(
codename = 'add_' + self.model._meta.model_name,
content_type = ContentType.objects.get(
app_label = self.model._meta.app_label,
model = self.model._meta.model_name,
)
)
add_team = Team.objects.create(
team_name = 'add_team',
organization = organization,
)
add_team.permissions.set([add_permissions])
change_permissions = Permission.objects.get(
codename = 'change_' + self.model._meta.model_name,
content_type = ContentType.objects.get(
app_label = self.model._meta.app_label,
model = self.model._meta.model_name,
)
)
change_team = Team.objects.create(
team_name = 'change_team',
organization = organization,
)
change_team.permissions.set([change_permissions])
delete_permissions = Permission.objects.get(
codename = 'delete_' + self.model._meta.model_name,
content_type = ContentType.objects.get(
app_label = self.model._meta.app_label,
model = self.model._meta.model_name,
)
)
delete_team = Team.objects.create(
team_name = 'delete_team',
organization = organization,
)
delete_team.permissions.set([delete_permissions])
self.no_permissions_user = User.objects.create_user(username="test_no_permissions", password="password")
self.view_user = User.objects.create_user(username="test_user_view", password="password")
teamuser = TeamUsers.objects.create(
team = view_team,
user = self.view_user
)
self.add_user = User.objects.create_user(username="test_user_add", password="password")
teamuser = TeamUsers.objects.create(
team = add_team,
user = self.add_user
)
self.change_user = User.objects.create_user(username="test_user_change", password="password")
teamuser = TeamUsers.objects.create(
team = change_team,
user = self.change_user
)
self.delete_user = User.objects.create_user(username="test_user_delete", password="password")
teamuser = TeamUsers.objects.create(
team = delete_team,
user = self.delete_user
)
self.different_organization_user = User.objects.create_user(username="test_different_organization_user", password="password")
different_organization_team = Team.objects.create(
team_name = 'different_organization_team',
organization = different_organization,
)
different_organization_team.permissions.set([
view_permissions,
add_permissions,
change_permissions,
delete_permissions,
])
TeamUsers.objects.create(
team = different_organization_team,
user = self.different_organization_user
)

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 = 'assistance.views.knowledge_base_category'
add_view = 'Add'
change_module = add_module
change_view = 'Change'
delete_module = add_module
delete_view = 'Delete'
display_module = add_module
display_view = 'View'
index_module = add_module
index_view = 'Index'

15
app/assistance/urls.py Normal file
View File

@ -0,0 +1,15 @@
from django.urls import path
from assistance.views import knowledge_base
app_name = "Assistance"
urlpatterns = [
path("information", knowledge_base.Index.as_view(), name="Knowledge Base"),
path("information/add", knowledge_base.Add.as_view(), name="_knowledge_base_add"),
path("information/<int:pk>/edit", knowledge_base.Change.as_view(), name="_knowledge_base_change"),
path("information/<int:pk>/delete", knowledge_base.Delete.as_view(), name="_knowledge_base_delete"),
path("information/<int:pk>", knowledge_base.View.as_view(), name="_knowledge_base_view"),
]

View File

View File

@ -0,0 +1,215 @@
from datetime import datetime
from django.contrib.auth import decorators as auth_decorator
from django.db.models import Q
from django.urls import reverse
from django.utils.decorators import method_decorator
from access.models import TeamUsers
from assistance.forms.knowledge_base import KnowledgeBaseForm
from assistance.models.knowledge_base import KnowledgeBase
from core.forms.comment import AddNoteForm
from core.models.notes import Notes
from core.views.common import AddView, ChangeView, DeleteView, DisplayView, IndexView
from settings.models.user_settings import UserSettings
class Index(IndexView):
context_object_name = "items"
model = KnowledgeBase
paginate_by = 10
permission_required = [
'assistance.view_knowledgebase'
]
template_name = 'assistance/kb_index.html.j2'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
if not self.request.user.has_perm('assistance.change_knowledgebase') and not self.request.user.is_superuser:
user_teams = []
for team_user in TeamUsers.objects.filter(user=self.request.user):
if team_user.team.id not in user_teams:
user_teams += [ team_user.team.id ]
context['items'] = self.get_queryset().filter(
Q(expiry_date__lte=datetime.now())
|
Q(expiry_date=None)
).filter(
Q(target_team__in=user_teams)
|
Q(target_user=self.request.user.id)
).distinct()
context['model_docs_path'] = self.model._meta.app_label + '/knowledge_base/'
context['content_title'] = 'Knowledge Base Articles'
return context
class Add(AddView):
form_class = KnowledgeBaseForm
model = KnowledgeBase
permission_required = [
'assistance.add_knowledgebase',
]
def get_initial(self):
initial: dict = {
'organization': UserSettings.objects.get(user = self.request.user).default_organization
}
if 'pk' in self.kwargs:
if self.kwargs['pk']:
initial.update({'parent': self.kwargs['pk']})
self.model.parent.field.hidden = True
return initial
def get_success_url(self, **kwargs):
return reverse('Assistance:Knowledge Base')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['content_title'] = 'New Group'
return context
class Change(ChangeView):
context_object_name = "group"
form_class = KnowledgeBaseForm
model = KnowledgeBase
permission_required = [
'assistance.change_knowledgebase',
]
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['content_title'] = self.object.title
return context
def get_success_url(self, **kwargs):
return reverse('Assistance:_knowledge_base_view', args=(self.kwargs['pk'],))
class View(ChangeView):
context_object_name = "kb"
form_class = KnowledgeBaseForm
model = KnowledgeBase
permission_required = [
'assistance.view_knowledgebase',
]
template_name = 'assistance/kb_article.html.j2'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['notes_form'] = AddNoteForm(prefix='note')
context['notes'] = Notes.objects.filter(config_group=self.kwargs['pk'])
context['model_pk'] = self.kwargs['pk']
context['model_name'] = self.model._meta.model_name
context['model_delete_url'] = reverse('Assistance:_knowledge_base_delete', args=(self.kwargs['pk'],))
context['content_title'] = self.object.title
return context
@method_decorator(auth_decorator.permission_required("assistance.change_knowledgebase", raise_exception=True))
def post(self, request, *args, **kwargs):
item = KnowledgeBase.objects.get(pk=self.kwargs['pk'])
notes = AddNoteForm(request.POST, prefix='note')
if notes.is_bound and notes.is_valid() and notes.instance.note != '':
notes.instance.organization = item.organization
notes.save()
# dont allow saving any post data outside notes.
# todo: figure out what needs to be returned
# return super().post(request, *args, **kwargs)
def get_success_url(self, **kwargs):
return reverse('Assistance:_knowledge_base_view', args=(self.kwargs['pk'],))
class Delete(DeleteView):
model = KnowledgeBase
permission_required = [
'assistance.delete_knowledgebase',
]
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['content_title'] = 'Delete ' + self.object.title
return context
def get_success_url(self, **kwargs):
return reverse('Assistance:Knowledge Base')

View File

@ -0,0 +1,191 @@
from django.contrib.auth import decorators as auth_decorator
from django.urls import reverse
from django.utils.decorators import method_decorator
from assistance.forms.knowledge_base_category import KnowledgeBaseCategoryForm
from assistance.models.knowledge_base import KnowledgeBase, KnowledgeBaseCategory
from core.forms.comment import AddNoteForm
from core.models.notes import Notes
from core.views.common import AddView, ChangeView, DeleteView, DisplayView, IndexView
from settings.models.user_settings import UserSettings
class Index(IndexView):
context_object_name = "items"
model = KnowledgeBaseCategory
paginate_by = 10
permission_required = [
'assistance.view_knowledgebasecategory'
]
template_name = 'assistance/kb_category_index.html.j2'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['model_docs_path'] = self.model._meta.app_label + '/knowledge_base/'
context['content_title'] = 'Knowledge Base Categories'
return context
class Add(AddView):
form_class = KnowledgeBaseCategoryForm
model = KnowledgeBaseCategory
permission_required = [
'assistance.add_knowledgebasecategory',
]
def get_initial(self):
initial: dict = {
'organization': UserSettings.objects.get(user = self.request.user).default_organization
}
if 'pk' in self.kwargs:
if self.kwargs['pk']:
initial.update({'parent': self.kwargs['pk']})
self.model.parent.field.hidden = True
return initial
def get_success_url(self, **kwargs):
return reverse('Settings:KB Categories')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['content_title'] = 'New Group'
return context
class Change(ChangeView):
context_object_name = "group"
form_class = KnowledgeBaseCategoryForm
model = KnowledgeBaseCategory
permission_required = [
'assistance.change_knowledgebasecategory',
]
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['content_title'] = self.object.name
return context
def get_success_url(self, **kwargs):
return reverse('Settings:_knowledge_base_category_view', args=(self.kwargs['pk'],))
class View(ChangeView):
context_object_name = "item"
form_class = KnowledgeBaseCategoryForm
model = KnowledgeBaseCategory
permission_required = [
'assistance.view_knowledgebasecategory',
]
template_name = 'assistance/kb_category.html.j2'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['articles'] = KnowledgeBase.objects.filter(category=self.kwargs['pk'])
context['notes_form'] = AddNoteForm(prefix='note')
context['notes'] = Notes.objects.filter(config_group=self.kwargs['pk'])
context['model_pk'] = self.kwargs['pk']
context['model_name'] = self.model._meta.model_name
context['model_delete_url'] = reverse('Settings:_knowledge_base_category_delete', args=(self.kwargs['pk'],))
context['content_title'] = self.object.name
return context
@method_decorator(auth_decorator.permission_required("assistance.change_knowledgebasecategory", raise_exception=True))
def post(self, request, *args, **kwargs):
item = KnowledgeBase.objects.get(pk=self.kwargs['pk'])
notes = AddNoteForm(request.POST, prefix='note')
if notes.is_bound and notes.is_valid() and notes.instance.note != '':
notes.instance.organization = item.organization
notes.save()
# dont allow saving any post data outside notes.
# todo: figure out what needs to be returned
# return super().post(request, *args, **kwargs)
def get_success_url(self, **kwargs):
return reverse('Settings:_knowledge_base_category_view', args=(self.kwargs['pk'],))
class Delete(DeleteView):
model = KnowledgeBaseCategory
permission_required = [
'assistance.delete_knowledgebasecategory',
]
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['content_title'] = 'Delete ' + self.object.name
return context
def get_success_url(self, **kwargs):
return reverse('Settings:KB Categories')

View File

@ -26,9 +26,9 @@ class ConfigGroupForm(CommonModelForm):
super().__init__(*args, **kwargs)
if 'parent' in kwargs['initial']:
if hasattr(kwargs['instance'], 'id'):
self.fields['parent'].queryset = self.fields['parent'].queryset.filter(
).exclude(
id=int(kwargs['initial']['parent'])
id=int(kwargs['instance'].id)
)

View File

@ -1,6 +1,8 @@
# Generated by Django 5.0.6 on 2024-06-02 14:48
# Generated by Django 5.0.7 on 2024-07-12 03:54
import access.fields
import access.models
import config_management.models.groups
import django.db.models.deletion
import django.utils.timezone
from django.db import migrations, models
@ -11,7 +13,7 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
('access', '0002_alter_team_organization'),
('access', '0001_initial'),
]
operations = [
@ -19,16 +21,17 @@ class Migration(migrations.Migration):
name='ConfigGroups',
fields=[
('is_global', models.BooleanField(default=False)),
('model_notes', models.TextField(blank=True, default=None, null=True, verbose_name='Notes')),
('id', models.AutoField(primary_key=True, serialize=False, unique=True)),
('created', access.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False)),
('modified', access.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False)),
('name', models.CharField(max_length=50)),
('config', models.JSONField(blank=True, default=None, null=True)),
('organization', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization')),
('config', models.JSONField(blank=True, default=None, null=True, validators=[config_management.models.groups.ConfigGroups.validate_config_keys_not_reserved])),
('organization', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization', validators=[access.models.TenancyObject.validatate_organization_exists])),
('parent', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='config_management.configgroups')),
],
options={
'verbose_name': 'Config Groups',
'abstract': False,
},
),
]

View File

@ -1,43 +0,0 @@
# Generated by Django 5.0.6 on 2024-06-02 20:51
import access.fields
import config_management.models.groups
import django.db.models.deletion
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('access', '0002_alter_team_organization'),
('config_management', '0001_initial'),
('itam', '0012_alter_device_serial_number_alter_device_uuid'),
]
operations = [
migrations.AlterModelOptions(
name='configgroups',
options={},
),
migrations.AlterField(
model_name='configgroups',
name='config',
field=models.JSONField(blank=True, default=None, null=True, validators=[config_management.models.groups.ConfigGroups.validate_config_keys_not_reserved]),
),
migrations.CreateModel(
name='ConfigGroupHosts',
fields=[
('is_global', models.BooleanField(default=False)),
('id', models.AutoField(primary_key=True, serialize=False, unique=True)),
('created', access.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False)),
('modified', access.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False)),
('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='config_management.configgroups')),
('host', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='itam.device', validators=[config_management.models.groups.ConfigGroupHosts.validate_host_no_parent_group])),
('organization', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization')),
],
options={
'abstract': False,
},
),
]

View File

@ -1,7 +1,8 @@
# Generated by Django 5.0.6 on 2024-06-07 21:43
# Generated by Django 5.0.7 on 2024-07-12 03:58
import access.fields
import access.models
import config_management.models.groups
import django.db.models.deletion
import django.utils.timezone
from django.db import migrations, models
@ -10,16 +11,33 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('access', '0003_alter_team_organization'),
('config_management', '0003_alter_configgrouphosts_organization_and_more'),
('itam', '0013_alter_device_organization_and_more'),
('access', '0001_initial'),
('config_management', '0001_initial'),
('itam', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='ConfigGroupHosts',
fields=[
('is_global', models.BooleanField(default=False)),
('model_notes', models.TextField(blank=True, default=None, null=True, verbose_name='Notes')),
('id', models.AutoField(primary_key=True, serialize=False, unique=True)),
('created', access.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False)),
('modified', access.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False)),
('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='config_management.configgroups')),
('host', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='itam.device', validators=[config_management.models.groups.ConfigGroupHosts.validate_host_no_parent_group])),
('organization', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization', validators=[access.models.TenancyObject.validatate_organization_exists])),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='ConfigGroupSoftware',
fields=[
('is_global', models.BooleanField(default=False)),
('model_notes', models.TextField(blank=True, default=None, null=True, verbose_name='Notes')),
('id', models.AutoField(primary_key=True, serialize=False, unique=True)),
('created', access.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False)),
('modified', access.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False)),

View File

@ -1,26 +0,0 @@
# Generated by Django 5.0.6 on 2024-06-05 09:16
import access.models
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('access', '0003_alter_team_organization'),
('config_management', '0002_alter_configgroups_options_alter_configgroups_config_and_more'),
]
operations = [
migrations.AlterField(
model_name='configgrouphosts',
name='organization',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization', validators=[access.models.TenancyObject.validatate_organization_exists]),
),
migrations.AlterField(
model_name='configgroups',
name='organization',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization', validators=[access.models.TenancyObject.validatate_organization_exists]),
),
]

View File

@ -1,28 +0,0 @@
# Generated by Django 5.0.6 on 2024-06-11 20:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('config_management', '0004_configgroupsoftware'),
]
operations = [
migrations.AddField(
model_name='configgrouphosts',
name='model_notes',
field=models.TextField(blank=True, default=None, null=True),
),
migrations.AddField(
model_name='configgroups',
name='model_notes',
field=models.TextField(blank=True, default=None, null=True),
),
migrations.AddField(
model_name='configgroupsoftware',
name='model_notes',
field=models.TextField(blank=True, default=None, null=True),
),
]

View File

@ -1,28 +0,0 @@
# 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

@ -186,6 +186,21 @@ class ConfigGroups(GroupsCommonFields, SaveHistory):
if self.parent:
self.organization = ConfigGroups.objects.get(id=self.parent.id).organization
if self.pk:
obj = ConfigGroups.objects.get(
id = self.id,
)
# Prevent organization change. ToDo: add feature so that config can change organizations
self.organization = obj.organization
if self.parent is not None:
if self.pk == self.parent.pk:
raise ValidationError('Can not set self as parent')
super().save(*args, **kwargs)
@ -193,7 +208,7 @@ class ConfigGroups(GroupsCommonFields, SaveHistory):
if self.parent:
return f'{self.parent.name} > {self.name}'
return f'{self.parent} > {self.name}'
return self.name

View File

@ -5,12 +5,17 @@ from django.test import TestCase
from access.models import Organization
from app.tests.abstract.models import TenancyModel
from config_management.models.groups import ConfigGroups
@pytest.mark.django_db
class ConfigGroupsModel(TestCase):
class ConfigGroupsModel(
TestCase,
TenancyModel
):
model = ConfigGroups

View File

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

View File

@ -0,0 +1,224 @@
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 Client, TestCase
from access.models import Organization, Team, TeamUsers, Permission
from app.tests.abstract.models import TenancyModel
from config_management.models.groups import ConfigGroups
@pytest.mark.django_db
class ConfigGroupsAPI(
TestCase,
):
model = ConfigGroups
@classmethod
def setUpTestData(self):
"""Setup Test
1. Create an organization for user and item
2. Create an item
"""
self.organization = Organization.objects.create(name='test_org')
self.item = self.model.objects.create(
organization = self.organization,
name = 'one',
config = dict({"key": "one", "existing": "dont_over_write"})
)
self.second_item = self.model.objects.create(
organization = self.organization,
name = 'one_two',
config = dict({"key": "two"}),
parent = self.item
)
self.url_view_kwargs = {'pk': self.second_item.id}
view_permissions = Permission.objects.get(
codename = 'view_' + self.model._meta.model_name,
content_type = ContentType.objects.get(
app_label = self.model._meta.app_label,
model = self.model._meta.model_name,
)
)
view_team = Team.objects.create(
team_name = 'view_team',
organization = self.organization,
)
view_team.permissions.set([view_permissions])
self.view_user = User.objects.create_user(username="test_user_view", password="password")
teamuser = TeamUsers.objects.create(
team = view_team,
user = self.view_user
)
client = Client()
url = reverse('API:_api_config_group', kwargs=self.url_view_kwargs)
client.force_login(self.view_user)
response = client.get(url)
self.api_data = response.data
def test_api_field_exists_id(self):
""" Test for existance of API Field
id field must exist
"""
assert 'id' in self.api_data
def test_api_field_type_id(self):
""" Test for type for API Field
id field must be int
"""
assert type(self.api_data['id']) is int
def test_api_field_exists_parent(self):
""" Test for existance of API Field
parent field must exist
"""
assert 'parent' in self.api_data
def test_api_field_type_parent(self):
""" Test for type for API Field
parent field must be dict
"""
assert type(self.api_data['parent']) is dict
def test_api_field_exists_name(self):
""" Test for existance of API Field
name field must exist
"""
assert 'name' in self.api_data
def test_api_field_type_name(self):
""" Test for type for API Field
name field must be str
"""
assert type(self.api_data['name']) is str
def test_api_field_exists_config(self):
""" Test for existance of API Field
config field must exist
"""
assert 'config' in self.api_data
def test_api_field_type_config(self):
""" Test for type for API Field
config field must be dict
"""
assert type(self.api_data['config']) is dict
def test_api_field_exists_url(self):
""" Test for existance of API Field
url field must exist
"""
assert 'url' in self.api_data
def test_api_field_type_url(self):
""" Test for type for API Field
url field must be str
"""
assert type(self.api_data['url']) is str
def test_api_field_exists_parent_id(self):
""" Test for existance of API Field
parent.id field must exist
"""
assert 'id' in self.api_data['parent']
def test_api_field_type_parent_id(self):
""" Test for type for API Field
parent.id field must be int
"""
assert type(self.api_data['parent']['id']) is int
def test_api_field_exists_parent_name(self):
""" Test for existance of API Field
parent.name field must exist
"""
assert 'name' in self.api_data['parent']
def test_api_field_type_parent_name(self):
""" Test for type for API Field
parent.name field must be str
"""
assert type(self.api_data['parent']['name']) is str
def test_api_field_exists_parent_url(self):
""" Test for existance of API Field
parent.url field must exist
"""
assert 'url' in self.api_data['parent']
def test_api_field_type_parent_url(self):
""" Test for type for API Field
parent.url field must be str
"""
assert type(self.api_data['parent']['url']) is str

View File

@ -5,6 +5,8 @@ from django.test import TestCase
from access.models import Organization
from app.tests.abstract.models import TenancyModel
from config_management.models.groups import ConfigGroups, ConfigGroupSoftware
from itam.models.device import DeviceSoftware
@ -12,7 +14,10 @@ from itam.models.software import Software
class ConfigGroupSoftwareModel(TestCase):
class ConfigGroupSoftwareModel(
TestCase,
TenancyModel
):
model = ConfigGroupSoftware

View File

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

View File

@ -1,7 +1,6 @@
import json
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
@ -47,13 +46,7 @@ class GroupIndexView(IndexView):
def get_queryset(self):
if self.request.user.is_superuser:
return self.model.objects.filter(parent=None).order_by('name')
else:
return self.model.objects.filter(Q(parent=None, organization__in=self.user_organizations()) | Q(parent=None, is_global = True)).order_by('name')
return self.model.objects.filter(parent=None).order_by('name')

View File

@ -1,5 +1,4 @@
from django import forms
from django.db.models import Q
from access.models import Organization, TeamUsers
@ -27,12 +26,15 @@ class CommonModelForm(forms.ModelForm):
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.
!!! danger "Requirement"
This method may be overridden however must still be called from the overriding function. i.e. `super().__init__(*args, **kwargs)`
"""
user = kwargs.pop('user', None)
@ -92,9 +94,7 @@ class CommonModelForm(forms.ModelForm):
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(id__in=user_organizations_id)
|
Q(manager=user)
)

View File

@ -1,10 +1,16 @@
from django import forms
from core.forms.common import CommonModelForm
from core.models.manufacturer import Manufacturer
from settings.forms.admin_settings_global import AdminGlobalModels
class ManufacturerForm(forms.ModelForm):
class ManufacturerForm(
AdminGlobalModels,
CommonModelForm
):
class Meta:

View File

@ -1,6 +1,7 @@
# Generated by Django 5.0.6 on 2024-05-20 15:44
# Generated by Django 5.0.7 on 2024-07-12 03:54
import access.fields
import access.models
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
@ -13,29 +14,43 @@ class Migration(migrations.Migration):
dependencies = [
('access', '0001_initial'),
('itam', '0007_device_inventorydate'),
('config_management', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Notes',
name='History',
fields=[
('is_global', models.BooleanField(default=False)),
('id', models.AutoField(primary_key=True, serialize=False, unique=True)),
('created', access.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False)),
('modified', access.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False)),
('serial_number', models.CharField(blank=True, default=None, max_length=50, null=True, verbose_name='Serial Number')),
('note', models.TextField(default=None, null=True, verbose_name='Note')),
('device', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, to='itam.device')),
('operatingsystem', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, to='itam.operatingsystem')),
('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='access.organization')),
('software', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, to='itam.software')),
('usercreated', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='usercreated', to=settings.AUTH_USER_MODEL, verbose_name='Added By')),
('usermodified', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='usermodified', to=settings.AUTH_USER_MODEL, verbose_name='Edited By')),
('before', models.TextField(blank=True, default=None, help_text='JSON Object before Change', null=True)),
('after', models.TextField(blank=True, default=None, help_text='JSON Object After Change', null=True)),
('action', models.IntegerField(choices=[('1', 'Create'), ('2', 'Update'), ('3', 'Delete')], default=None, null=True)),
('item_pk', models.IntegerField(default=None, null=True)),
('item_class', models.CharField(default=None, max_length=50, null=True)),
('item_parent_pk', models.IntegerField(default=None, null=True)),
('item_parent_class', models.CharField(default=None, max_length=50, null=True)),
('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.DO_NOTHING, to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created'],
},
),
migrations.CreateModel(
name='Manufacturer',
fields=[
('is_global', models.BooleanField(default=False)),
('model_notes', models.TextField(blank=True, default=None, null=True, verbose_name='Notes')),
('id', models.AutoField(primary_key=True, serialize=False, unique=True)),
('created', access.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False)),
('modified', access.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False)),
('name', models.CharField(max_length=50, unique=True)),
('slug', access.fields.AutoSlugField()),
('organization', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization', validators=[access.models.TenancyObject.validatate_organization_exists])),
],
options={
'ordering': ['name'],
},
),
]

View File

@ -0,0 +1,43 @@
# Generated by Django 5.0.7 on 2024-07-12 03:58
import access.fields
import access.models
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('access', '0001_initial'),
('config_management', '0002_configgrouphosts_configgroupsoftware'),
('core', '0001_initial'),
('itam', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Notes',
fields=[
('is_global', models.BooleanField(default=False)),
('model_notes', models.TextField(blank=True, default=None, null=True, verbose_name='Notes')),
('id', models.AutoField(primary_key=True, serialize=False, unique=True)),
('created', access.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False)),
('modified', access.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False)),
('note', models.TextField(blank=True, default=None, null=True, verbose_name='Note')),
('config_group', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='config_management.configgroups')),
('device', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='itam.device')),
('operatingsystem', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='itam.operatingsystem')),
('organization', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization', validators=[access.models.TenancyObject.validatate_organization_exists])),
('software', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='itam.software')),
('usercreated', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='usercreated', to=settings.AUTH_USER_MODEL, verbose_name='Added By')),
('usermodified', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='usermodified', to=settings.AUTH_USER_MODEL, verbose_name='Edited By')),
],
options={
'ordering': ['-created'],
},
),
]

View File

@ -1,34 +0,0 @@
# Generated by Django 5.0.6 on 2024-05-20 16:06
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0001_initial'),
('itam', '0007_device_inventorydate'),
]
operations = [
migrations.RemoveField(
model_name='notes',
name='serial_number',
),
migrations.AlterField(
model_name='notes',
name='device',
field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='itam.device'),
),
migrations.AlterField(
model_name='notes',
name='operatingsystem',
field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='itam.operatingsystem'),
),
migrations.AlterField(
model_name='notes',
name='software',
field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='itam.software'),
),
]

View File

@ -1,41 +0,0 @@
# Generated by Django 5.0.6 on 2024-05-23 03:59
import access.fields
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0002_remove_notes_serial_number_alter_notes_device_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='notes',
name='note',
field=models.TextField(blank=True, default=None, null=True, verbose_name='Note'),
),
migrations.CreateModel(
name='History',
fields=[
('id', models.AutoField(primary_key=True, serialize=False, unique=True)),
('created', access.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False)),
('before', models.TextField(blank=True, default=None, help_text='JSON Object before Change', null=True)),
('after', models.TextField(blank=True, default=None, help_text='JSON Object After Change', null=True)),
('action', models.IntegerField(choices=[('1', 'Create'), ('2', 'Update'), ('3', 'Delete')], default=None, null=True)),
('item_pk', models.IntegerField(default=None, null=True)),
('item_class', models.CharField(default=None, max_length=50, null=True)),
('item_parent_pk', models.IntegerField(default=None, null=True)),
('item_parent_class', models.CharField(default=None, max_length=50, null=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created'],
},
),
]

View File

@ -1,4 +1,4 @@
# Generated by Django 5.0.6 on 2024-06-02 14:48
# Generated by Django 5.0.7 on 2024-07-21 02:35
import django.db.models.deletion
from django.db import migrations, models
@ -7,14 +7,14 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('config_management', '0001_initial'),
('core', '0006_alter_history_user'),
('core', '0002_notes'),
('itim', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='notes',
name='config_group',
field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='config_management.configgroups'),
name='service',
field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='itim.service'),
),
]

View File

@ -1,20 +0,0 @@
# Generated by Django 5.0.6 on 2024-05-23 10:37
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('access', '0002_alter_team_organization'),
('core', '0003_alter_notes_note_history'),
]
operations = [
migrations.AlterField(
model_name='notes',
name='organization',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization'),
),
]

View File

@ -1,32 +0,0 @@
# Generated by Django 5.0.6 on 2024-05-23 10:58
import access.fields
import django.db.models.deletion
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('access', '0002_alter_team_organization'),
('core', '0004_notes_is_null'),
]
operations = [
migrations.CreateModel(
name='Manufacturer',
fields=[
('is_global', models.BooleanField(default=False)),
('id', models.AutoField(primary_key=True, serialize=False, unique=True)),
('created', access.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False)),
('modified', access.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False)),
('name', models.CharField(max_length=50, unique=True)),
('slug', access.fields.AutoSlugField()),
('organization', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization')),
],
options={
'ordering': ['name'],
},
),
]

View File

@ -1,21 +0,0 @@
# Generated by Django 5.0.6 on 2024-05-25 05:21
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0005_manufacturer'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='history',
name='user',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.DO_NOTHING, to=settings.AUTH_USER_MODEL),
),
]

View File

@ -1,26 +0,0 @@
# Generated by Django 5.0.6 on 2024-06-05 09:16
import access.models
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('access', '0003_alter_team_organization'),
('core', '0007_notes_config_group'),
]
operations = [
migrations.AlterField(
model_name='manufacturer',
name='organization',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization', validators=[access.models.TenancyObject.validatate_organization_exists]),
),
migrations.AlterField(
model_name='notes',
name='organization',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='access.organization', validators=[access.models.TenancyObject.validatate_organization_exists]),
),
]

View File

@ -1,23 +0,0 @@
# Generated by Django 5.0.6 on 2024-06-11 20:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0008_alter_manufacturer_organization_and_more'),
]
operations = [
migrations.AddField(
model_name='manufacturer',
name='model_notes',
field=models.TextField(blank=True, default=None, null=True),
),
migrations.AddField(
model_name='notes',
name='model_notes',
field=models.TextField(blank=True, default=None, null=True),
),
]

View File

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

View File

@ -10,6 +10,9 @@ from itam.models.device import Device
from itam.models.software import Software
from itam.models.operating_system import OperatingSystem
from itim.models.services import Service
class NotesCommonFields(TenancyObject, models.Model):
@ -88,6 +91,14 @@ class Notes(NotesCommonFields):
blank= True
)
service = models.ForeignKey(
Service,
on_delete=models.CASCADE,
default = None,
null = True,
blank= True
)
software = models.ForeignKey(
Software,
on_delete=models.CASCADE,

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