Compare commits

..

144 Commits

Author SHA1 Message Date
7a9680d988 build: bump version 1.13.0 -> 1.13.1 2025-03-17 04:16:28 +00:00
Jon
d7d85bd01d Merge pull request #688 from nofusscomputing/fix-feature-flag-crash 2025-03-17 13:34:04 +09:30
Jon
becb1eef26 fix(devops): After fetching feature flags dont attempt to access results unless status=200
ref: #688 #687
2025-03-17 13:12:29 +09:30
Jon
bf973d3765 fix(docker): only download feature flags when not a worker
ref: #688 fixes #687
2025-03-17 12:23:35 +09:30
Jon
7912a67ab7 refactor(docker): Use crontabs not cron.d
ref: #688 fixes #687
2025-03-17 12:17:59 +09:30
Jon
6272eef45f fix(devops): Use correct stderr function when using feature_flag management command
ref: #688 #687
2025-03-17 11:44:05 +09:30
Jon
176537d583 fix(devops): Cater for connection timeout when fetching feature flags
ref: #688 #687
2025-03-17 11:43:37 +09:30
Jon
2491ab611b fix: when building feature flag version, use first 8 chars of build hash
ref: #688 #687
2025-03-17 11:42:01 +09:30
3925b96cb0 build: bump version 1.12.0 -> 1.13.0 2025-03-16 02:34:25 +00:00
Jon
8488642651 Merge pull request #660 from nofusscomputing/feature-next-release 2025-03-16 11:51:16 +09:30
Jon
cb96d33f74 Merge pull request #679 from nofusscomputing/devops-feature-flagging 2025-03-16 11:35:01 +09:30
Jon
70978b9fdc chore: squash migrations prior to release
ref: #679 #660
2025-03-16 11:18:02 +09:30
Jon
0452293546 docs(development): Notate usage of dict for table field
ref: #679 nofusscomputing/centurion_erp_ui#95 closes #669
2025-03-16 11:09:15 +09:30
Jon
9c65d7f355 test(devops): Feature Flag History API render checks
ref: #679 #659
2025-03-16 09:14:50 +09:30
Jon
32137334ad test(devops): Feature Flag Serializer checks
ref: #679 #659
2025-03-16 09:14:37 +09:30
Jon
a9fb70fcc7 feat(devops): Add ability for user to turn off feature flagging check-in
ref: #679 #496
2025-03-16 08:39:37 +09:30
Jon
c34903abf2 test(devops): CheckIn Entry created of fetching feature flags
ref: #679 #676 #496
2025-03-16 08:25:51 +09:30
Jon
70c7100b1d test(devops): CheckIn model test cases
ref: #679 closes #677
2025-03-16 06:41:43 +09:30
Jon
8eb1650273 chore: update ticket template new model unit test (tenancy)
ref: #679
2025-03-16 06:39:01 +09:30
Jon
68364906f9 chore(devops): Squash All migrations
as this has not been released to production, squash to single migration

ref: #660 #68 #496
2025-03-15 05:03:36 +09:30
Jon
f99c1e2132 Merge pull request #678 from nofusscomputing/devops-check-in 2025-03-15 04:55:13 +09:30
Jon
179340e1a2 feat(devops): When displaying the feature_flag deployments, limit to last 24-hours
ref: #678 #676 #496
2025-03-15 04:35:45 +09:30
Jon
b4d41970aa feat(devops): During feature flag Checkin derive the version from the last field of the user-agent
ref: #678 #676
2025-03-15 04:10:40 +09:30
Jon
5f62828f28 feat(devops): Add missing column to model Checkin
ref: #678 #677
2025-03-15 04:08:58 +09:30
Jon
46996b7f14 feat(devops): Remove model Checkin permissions from permissions selector
ref: #678 #677
2025-03-15 03:42:53 +09:30
Jon
2929e347c7 docs(user): Add how to enable feature flagging
ref: #678 #676
2025-03-15 03:35:01 +09:30
Jon
b8281a3dd7 fix(devops): Only track checkin if no other error occured
ref: #678 #676
2025-03-15 03:33:47 +09:30
Jon
3888ab737b fix(devops): during feature flag checkin, if no client-id provided, use value not-provided
ref: #678 #676
2025-03-15 02:29:37 +09:30
Jon
acc31ef079 feat(devops): Display the days total unique check-ins for feature flags within software feature flagging tab
ref: #678 #676
2025-03-15 02:24:47 +09:30
Jon
19ce303045 feat(devops): Record to check-in table every time feature flags are obtained
ref: #678 #676
2025-03-15 02:21:56 +09:30
Jon
0dbde5e1d6 feat(devops): Migrations for model CheckIns
ref: #678 #677
2025-03-15 02:18:24 +09:30
Jon
f3a45d6ec2 feat(devops): New model CheckIns
ref: #678 #677
2025-03-15 02:16:54 +09:30
Jon
f24f02df5d Merge pull request #675 from nofusscomputing/temp-add-feature-flag 2025-03-14 23:57:16 +09:30
Jon
3ab5babd31 feat: Generate a deployment unique ID
ref: #675 #575
2025-03-14 23:34:35 +09:30
Jon
14c193d72a feat(devops): Provide user with option to disable downloading feature flags
ref: #675 #575
2025-03-14 23:04:57 +09:30
Jon
b22f4e2ea9 feat(devops): Feature Flagging url.path wrapper
ref: #675 #575
2025-03-14 22:38:43 +09:30
Jon
29553474f2 fix(devops): When init the feature flag clients, look for all args within settings
ref: #675 #575
2025-03-13 20:49:18 +09:30
Jon
19d6b4f7e8 fix(devops): Only add Last-Modified header to response if exists
ref: #675 #575
2025-03-13 19:53:59 +09:30
Jon
f1fb3cd7ff feat(docker): Configure cron to download feature flags every four hours
ref: #675 #575
2025-03-13 19:53:00 +09:30
Jon
f1570a1997 feat(docker): Start and run crond within container
ref: #675 #575
2025-03-13 19:52:24 +09:30
Jon
2f95482150 feat(docker): Download feature flags on container start
ref: #675 #575
2025-03-13 19:52:04 +09:30
Jon
b2e82fd8c9 feat(devops): Feature Flagging DRF Router wrapper
ref: #675 #575
2025-03-13 19:49:31 +09:30
Jon
192efadb08 feat(devops): Feature Flagging middleware
ref: #675 #575
2025-03-13 19:49:11 +09:30
Jon
00d16f841f feat(devops): Feature Flagging management command
ref: #675 #575
2025-03-13 19:49:02 +09:30
Jon
c5107861a1 feat(devops): Add Feature Flagging lib
ref: #675 #575
2025-03-13 19:47:44 +09:30
Jon
1d9580f14b feat(devops): add temp application for feature flag client
ref: #675 #575
2025-03-13 19:40:42 +09:30
Jon
aa77c69ed7 Merge pull request #673 from nofusscomputing/public-endpoint-feature-flagging 2025-03-10 20:25:45 +09:30
Jon
acf9f20ab9 test(devops): public feature flag fields corrections
ref: #673 #663
2025-03-10 20:07:12 +09:30
Jon
6cf6a23964 feat(devops): public feature flag endpoint pagination limited to 20 results
ref: #673 closes #663
2025-03-10 19:37:37 +09:30
Jon
a69e102432 test(devops): public feature flag functional ViewSet checks
ref: #673 #663
2025-03-10 19:36:52 +09:30
Jon
89e2de437f fix(devops): Correct logic for data changed check for public endpoint for feature flagging
ref: #673 #663
2025-03-10 19:19:58 +09:30
Jon
dcebd4b8d6 fix(devops): feature flag public ViewSet serializer name correction and qs cache correction
ref: #673 #663
2025-03-10 18:28:18 +09:30
Jon
2e35b651ef test(devops): feature flag ViewSet checks
ref: #673 #663
2025-03-10 16:04:43 +09:30
Jon
2f06814d99 test(api): Update vieset test cases to cater for mockrequest to contain headers attribute
ref: #673
2025-03-10 16:04:09 +09:30
Jon
faff43510d test(devops): feature flag public endpoint API field, header checks
ref: #673 #663
2025-03-10 15:21:07 +09:30
Jon
42105d4621 fix(devops): feature flag public endpoint field modified name typo
ref: #673 #663
2025-03-10 15:19:57 +09:30
Jon
f61799dbfe docs(devops): feature flag if-modified-since header
ref: #673 #663
2025-03-08 21:19:49 +09:30
Jon
427584c8d9 feat(devops): Add support for if-modified-since header for Feature Flags public endpoint
ref: #673 #663
2025-03-08 21:05:44 +09:30
Jon
a07ee8472e fix(devops): Filter public feature flag endpoint to org and software where software is enabled
ref: #673 #663
2025-03-08 19:36:12 +09:30
Jon
3eb02602d6 fix(devops): Move software field filter for feature flag to the serializer
ref: #673 #663
2025-03-08 19:30:45 +09:30
Jon
7dac29971f feat(api): Add public API feature flag index endpoint
ref: #673 #663
2025-03-08 17:49:07 +09:30
Jon
0c6fb786dd feat(api): Add public API endpoint
ref: #673 #663
2025-03-08 17:45:20 +09:30
Jon
d09e649765 feat(devops): Add feature flag public ViewSet
ref: #673 #663
2025-03-08 16:43:34 +09:30
Jon
8741e0b636 feat(devops): Add feature flag public serializer
ref: #673 #663
2025-03-08 16:42:09 +09:30
Jon
529512be3e feat(api): Add common viewset for public RO list
ref: #663
2025-03-08 16:35:00 +09:30
Jon
d038165146 Merge pull request #671 from nofusscomputing/remove-viewset-serializer-caching 2025-03-07 18:05:38 +09:30
Jon
b63f6b7092 feat: Remove serializer caching from ALL viewsets
ref: #671 closes #668
2025-03-07 17:53:14 +09:30
Jon
7b6fe804a9 feat(devops): Add delete col to software enabled feature flags
ref: #659
2025-03-07 17:38:05 +09:30
Jon
b874fc7298 Merge pull request #670 from nofusscomputing/software-feature-flagging 2025-03-07 17:22:32 +09:30
Jon
4f6debac88 fix(devops): Dont attempt to validate feature flag software or organization if it is absent
ref: #670 #659
2025-03-07 17:07:28 +09:30
Jon
5fdc0b32a6 feat(devops): Prevent deletion of software when it has feature flagging enabled and/or feature flags
ref: #670 closes #659
2025-03-07 16:35:04 +09:30
Jon
e607999a62 test(devops): Ensure that only enabled org and enabled software is possible
ref: #670 #659
2025-03-07 16:24:26 +09:30
Jon
5dd4bddea9 fix(devops): Correct feature flagging validation for enabled software and enabled orgs
ref: #670 #659
2025-03-07 16:23:48 +09:30
Jon
b60aa3be7a feat(devops): limit feature_flag to organizations that's had feature flags enabled
ref: #670 #659
2025-03-07 15:45:49 +09:30
Jon
53956e0772 feat(devops): limit feature_flag to software that's had feature flags enabled
ref: #670 #659
2025-03-07 15:45:25 +09:30
Jon
f4ccd3d164 fix(devops): dont cache serializer for featureflag
ref: #670
2025-03-07 15:44:06 +09:30
Jon
629f5aec8e feat(python): Update Django 5.1.5 -> 5.1.7
ref: #670 https://github.com/nofusscomputing/centurion_erp/security/dependabot/10
2025-03-07 13:28:50 +09:30
Jon
69eb7eb294 test(devops): software_feature_flag_enable ViewSet checks
ref: #670 closes #664
2025-03-07 13:25:15 +09:30
Jon
e317a96e45 test(devops): software_feature_flag_enable Serializer checks
ref: #670 #664
2025-03-07 13:24:36 +09:30
Jon
9720ae527a test(devops): Update feature flag test case setup to enable feature flag for testing software
ref: #662 #659
2025-03-05 04:11:20 +09:30
Jon
33644a25d1 test(devops): Update feature flag test case setup to enable feature flag for testing software
ref: #662 #659
2025-03-05 03:32:43 +09:30
Jon
eb7ff47873 fix(devops): Correct Feature Flag serializer validation to cater for edit
ref: #662 #659
2025-03-05 03:32:11 +09:30
Jon
b542e92bbd fix(devops): Feature Flag field is mandatory
ref: #670 #659
2025-03-05 03:31:10 +09:30
Jon
b515b26203 test(api): Remove serializer cache test cases
caching the serializer prevents drf form editor

ref: #670 #664
2025-03-05 02:43:56 +09:30
Jon
b562e09622 test(devops): software_feature_flag_enable api field checks
ref: #670 #664
2025-03-05 02:43:12 +09:30
Jon
748a1c80ef fix(api): make history url dynamic. only display if history should save
ref: #670
2025-03-05 02:42:51 +09:30
Jon
492bbfb521 test(devops): software_feature_flag_enable viewset checks
ref: #670 #664
2025-03-05 02:29:40 +09:30
Jon
86d5ce2062 test(devops): software_feature_flag_enable model checks
ref: #670 #664
2025-03-05 02:29:28 +09:30
Jon
541b7734e5 test(devops): software_feature_flag_enable tenancy object checks
ref: #670 #664
2025-03-05 02:29:12 +09:30
Jon
9fcea0528a feat(devops): Serializer limiting of software and os disabled for time being
ref: #670 #659
2025-03-05 02:28:05 +09:30
Jon
0a5778258b feat(devops): Serializer validate software and org
ref: #670 #659
2025-03-05 01:34:29 +09:30
Jon
75412df8b6 feat(devops): Serializer software filter to enabled feature_flag software
ref: #670 #659
2025-03-05 01:34:11 +09:30
Jon
28e80bff50 feat(devops): Serializer org filter to enabled feature_flag organizations
ref: #670 #659
2025-03-05 01:33:47 +09:30
Jon
f0ec8e4e56 feat(devops): Add endpoint for enabling software for feature flagging
ref: #670 #664
2025-03-05 01:32:49 +09:30
Jon
8124d58014 feat(devops): Add serializer for enabling software for feature flagging
ref: #670 #664
2025-03-05 01:32:23 +09:30
Jon
9f1a73d7a5 feat(devops): Add model for enabling software for feature flagging
ref: #670 #664
2025-03-05 01:32:05 +09:30
Jon
b411d1fb24 fix(devops): if software is deleted delete feature flags
ref: #659 #670
2025-03-05 01:29:43 +09:30
Jon
41727d0a16 Merge pull request #662 from nofusscomputing/feature-flagging 2025-03-04 06:11:12 +09:30
Jon
2eafb88367 fix(core): disable of notes for models not requiring it
ref: #662 #665
2025-03-04 05:58:26 +09:30
Jon
6858c04bfd fix(api): when generating notes url, use correct object
ref: #662 #659
2025-03-04 04:19:27 +09:30
Jon
124c96512a feat(devops): Add model tag feature_flag to ticket linked item
ref: #662 #659
2025-03-04 03:56:57 +09:30
Jon
23793e2133 fix(api): Add missing import for featurenotused
ref: #662
2025-03-04 03:39:53 +09:30
Jon
4b06d6a2a1 feat(devops): Add KB tab to feature flag model
ref: #662 #659
2025-03-04 03:34:51 +09:30
Jon
8a56fdfcdb feat(devops): Add Notes to feature flag model
ref: #662 #659
2025-03-04 03:34:30 +09:30
Jon
c4b640fb53 test(devops): correct dir name for tests
ref: #662 #659
2025-03-04 03:33:27 +09:30
Jon
1afa102679 test(devops): Notes feature flag model checks
ref: #662 #659
2025-03-04 03:33:09 +09:30
Jon
ddf3449b3f feat(core): Migration for feature_flag model reference
ref: #662 #659
2025-03-04 03:30:59 +09:30
Jon
95c5f271ba feat(core): url endpoints added for ticket comment category and ticket category notes
ref: #662 #665
2025-03-04 03:30:12 +09:30
Jon
db0cf389c3 feat(itam): disable model notes for model device os
ref: #662 #665
2025-03-04 03:29:34 +09:30
Jon
8b17ea54c7 feat(api): disable model notes for model auth token
ref: #662 #665
2025-03-04 03:29:16 +09:30
Jon
330d00a73d feat(core): disable model notes for model teamuser
ref: #662 #665
2025-03-04 03:28:56 +09:30
Jon
5f691748bc feat(core): disable model notes for model notes
ref: #662 #666 #667
2025-03-04 03:24:57 +09:30
Jon
4876919015 feat(core): Migrations for adding notes to ticket category and ticket comment category
ref: #662 #666 #667
2025-03-04 03:24:20 +09:30
Jon
55e30ab4f5 test(core): Ticket Comment Category Notes checks
ref: #662 closes #666
2025-03-04 03:24:08 +09:30
Jon
4d6438833d test(core): Ticket Category Notes checks
ref: #662 closes #667
2025-03-04 03:23:27 +09:30
Jon
5b97f5400f fix(core): Add ability to add notes for ticket comment category
ref: #662 #666
2025-03-04 03:23:04 +09:30
Jon
8a787a516f fix(core): Add ability to add notes for ticket category
ref: #662 #667
2025-03-04 03:22:04 +09:30
Jon
51013d12d3 test(app): Model test cases for api field rendering _urls.notes
ref: #662 closes #665
2025-03-04 03:21:30 +09:30
Jon
44a750f32b test(app): Model test cases for get_url_kwargs_notes function
ref: #662 #665
2025-03-04 03:20:39 +09:30
Jon
5938a51193 test(access): Correct Team notes url route name
ref: #662 #665
2025-03-04 03:18:50 +09:30
Jon
f833121c08 fix(core): Serializer _urls.notes URL generation now dynamic
ref: #662 #665
2025-03-04 03:17:31 +09:30
Jon
90a1e4baad feat(core): Add Feature Flag model reference
ref: #662 #659
2025-03-03 20:22:18 +09:30
Jon
20a1f69077 docs(admin): initial feature flags
ref: #662 #661 #496 closes #659
2025-03-02 02:51:19 +09:30
Jon
d79b13d98e docs(user): initial feature flags
ref: #662 #659 #661 #496
2025-03-02 02:39:37 +09:30
Jon
05cf5b2835 docs(user): Add devops module
ref: #662 closes #656
2025-03-02 02:14:27 +09:30
Jon
1edc398f41 fix(api): Dont attempt to access model.get_app_namespace if it doesnt exist
ref: #656 #662
2025-03-02 01:10:00 +09:30
Jon
adfeec5fef feat(devops): Add devops module to installed applications
ref: #662 #656
2025-03-02 00:48:38 +09:30
Jon
7fbe6fda95 test(devops): Feature Flag viewset unit Checks
ref: #662 #659
2025-03-02 00:37:13 +09:30
Jon
40f7f7739f test(devops): Feature Flag model Checks
ref: #662 #659
2025-03-02 00:37:03 +09:30
Jon
51807b4747 test(devops): Feature Flag api Checks
ref: #662 #659
2025-03-02 00:36:55 +09:30
Jon
ef1742c537 test(devops): Feature Flag tenancy object Checks
ref: #662 #659
2025-03-02 00:36:45 +09:30
Jon
1216092413 test(devops): Feature Flag viewset functional Checks
ref: #662 #659
2025-03-02 00:36:25 +09:30
Jon
ee23cb1f6e test(devops): Feature Flag serializer Checks
ref: #662 #659
2025-03-02 00:36:12 +09:30
Jon
158e8436d8 test(devops): Feature Flag History Checks
ref: #662 #661
2025-03-02 00:35:41 +09:30
Jon
235e4db5b6 feat(devops): Add Feature Flag viewset
ref: #662 #659
2025-03-02 00:34:57 +09:30
Jon
aa1cd3eda2 feat(devops): Add Feature Flag serializer
ref: #662 #659
2025-03-02 00:33:59 +09:30
Jon
1ed96ff9fc feat(devops): Add devops Navigation menu
ref: #662 #656
2025-03-02 00:32:47 +09:30
Jon
6fabdb6d17 feat(devops): Add devops module URL includes
ref: #662 #656
2025-03-02 00:32:03 +09:30
Jon
c6a38684db feat(devops): Add devops to permissions
ref: #662 #656
2025-03-02 00:29:23 +09:30
Jon
4ecc841462 feat(devops): DB Migrations for Feature Flag and History model
ref: #662 #661 #659
2025-03-02 00:28:38 +09:30
Jon
eda1fb673b feat(devops): Add Feature Flag History model
ref: #662 #661
2025-03-02 00:28:04 +09:30
Jon
d23e05ac7b feat(devops): Add Feature Flag model
ref: #662 #659
2025-03-02 00:27:43 +09:30
Jon
1818ee94e7 feat(access): add support for nested application namespaces
ref: #656 #662
2025-03-02 00:22:58 +09:30
Jon
9d88bf8827 feat(devops): Add devops module
ref: 656
2025-03-01 20:50:58 +09:30
211 changed files with 7930 additions and 455 deletions

View File

@ -17,5 +17,5 @@ commitizen:
prerelease_offset: 1
tag_format: $version
update_changelog_on_bump: false
version: 1.12.0
version: 1.13.1
version_scheme: semver

View File

@ -35,7 +35,10 @@ Describe in detail the following:
- [ ] 🏷️ Model tag added to `app/core/lib/slash_commands/linked_model.CommandLinkedModel.get_model()` function
- [ ] Tag updated in the [docs](https://nofusscomputing.com/projects/centurion_erp/user/core/markdown/#model-reference)
- [ ] 📘 Tag updated in the [docs](https://nofusscomputing.com/projects/centurion_erp/user/core/markdown/#model-reference)
- [ ] tag added to `app/core/models/ticket/ticket_linked_items.TicketLinkedItem.__str__()`
- [ ] tag added to `app/core/lib/slash_commands/linked_model.CommandLinkedModel.get_model()`
- [ ] ⚒️ Migration _Ticket Linked Item item_type choices update_
>[!note]
> Ensure that when creating the tag the following is adhered to:
@ -45,7 +48,12 @@ Describe in detail the following:
- [ ] 📝 New [History model](https://nofusscomputing.com/projects/centurion_erp/development/core/model_history/) created
- [ ] 📓 New [Notes model](https://nofusscomputing.com/projects/centurion_erp/development/core/model_notes/) created
- [ ] 🆕 Model Created
- [ ] 🛠️ Migrations added
- [ ] Add `app_label` to KB Models `app/assistance/models/model_knowledge_base_article.all_models().model_apps`
- [ ] _(Notes not used/required) - _ Add `model_name` to KB Models `app/assistance/models/model_knowledge_base_article.all_models().excluded_models`
- [ ] 🧪 [Unit tested](https://nofusscomputing.com/projects/centurion_erp/development/core/model_notes/#testing)
- [ ] 🧪 [Functional tested](https://nofusscomputing.com/projects/centurion_erp/development/core/model_notes/#testing)
- [ ] Admin Documentation added/updated _if applicable_
- [ ] Developer Documentation added/updated _if applicable_
@ -60,14 +68,15 @@ Describe in detail the following:
#### 🧪 Tests
- [ ] Unit Test Model
- [ ] Unit Test Tenancy Object
- [ ] Unit Test Serializer
- [ ] Unit Test Tenancy Object
- [ ] Unit Test ViewSet
- [ ] Function Test ViewSet
- [ ] Function Test API Metadata
- [ ] Function Test API Permissions
- [ ] Function Test API Render (fields)
- [ ] Function Test History Entries
- [ ] Function Test History API Render (fields)
### ✅ Requirements

1
.gitignore vendored
View File

@ -15,3 +15,4 @@ node_modules/
package-lock.json
package.json
**.junit.xml
feature_flags.json

14
.vscode/launch.json vendored
View File

@ -4,6 +4,7 @@
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Centurion",
"type": "debugpy",
@ -40,6 +41,19 @@
"PROMETHEUS_MULTIPROC_DIR": ""
}
},
{
"name": "Centurion Feature Flag (Management Command)",
"type": "debugpy",
"request": "launch",
"args": [
"feature_flag",
// "0.0.0.0:8002"
],
"django": true,
"autoStartBrowser": false,
"program": "${workspaceFolder}/app/manage.py"
},
{
"name": "Migrate",
"type": "debugpy",

View File

@ -20,5 +20,4 @@
"cSpell.language": "en-AU",
"jest.enable": false,
"pylint.enabled": true,
"pylint.importStrategy": "fromEnvironment",
}

View File

@ -1,3 +1,146 @@
## 1.13.1 (2025-03-17)
### Fixes
- **devops**: After fetching feature flags dont attempt to access results unless status=200
- **docker**: only download feature flags when not a worker
- **devops**: Use correct stderr function when using feature_flag management command
- **devops**: Cater for connection timeout when fetching feature flags
- when building feature flag version, use first 8 chars of build hash
### Refactoring
- **docker**: Use crontabs not cron.d
## 1.13.0 (2025-03-16)
### feat
- **devops**: Add ability for user to turn off feature flagging check-in
- **devops**: When displaying the feature_flag deployments, limit to last 24-hours
- **devops**: During feature flag `Checkin` derive the version from the last field of the user-agent
- **devops**: Add missing column to model `Checkin`
- **devops**: Remove model `Checkin` permissions from permissions selector
- **devops**: Display the days total unique check-ins for feature flags within software feature flagging tab
- **devops**: Record to check-in table every time feature flags are obtained
- **devops**: Migrations for model `CheckIns`
- **devops**: New model `CheckIns`
- Generate a deployment unique ID
- **devops**: Provide user with option to disable downloading feature flags
- **devops**: Feature Flagging url.path wrapper
- **docker**: Configure cron to download feature flags every four hours
- **docker**: Start and run crond within container
- **docker**: Download feature flags on container start
- **devops**: Feature Flagging DRF Router wrapper
- **devops**: Feature Flagging middleware
- **devops**: Feature Flagging management command
- **devops**: Add Feature Flagging lib
- **devops**: add temp application for feature flag client
- **devops**: public feature flag endpoint pagination limited to 20 results
- **devops**: Add support for `if-modified-since` header for Feature Flags public endpoint
- **api**: Add public API feature flag index endpoint
- **api**: Add public API endpoint
- **devops**: Add feature flag public ViewSet
- **devops**: Add feature flag public serializer
- **api**: Add common viewset for public RO list
- Remove serializer caching from ALL viewsets
- **devops**: Add delete col to software enabled feature flags
- **devops**: Prevent deletion of software when it has feature flagging enabled and/or feature flags
- **devops**: limit feature_flag to organizations that's had feature flags enabled
- **devops**: limit feature_flag to software that's had feature flags enabled
- **python**: Update Django 5.1.5 -> 5.1.7
- **devops**: Serializer limiting of software and os disabled for time being
- **devops**: Serializer validate software and org
- **devops**: Serializer software filter to enabled feature_flag software
- **devops**: Serializer org filter to enabled feature_flag organizations
- **devops**: Add endpoint for enabling software for feature flagging
- **devops**: Add serializer for enabling software for feature flagging
- **devops**: Add model for enabling software for feature flagging
- **devops**: Add model tag feature_flag to ticket linked item
- **devops**: Add KB tab to feature flag model
- **devops**: Add Notes to feature flag model
- **core**: Migration for feature_flag model reference
- **core**: url endpoints added for ticket comment category and ticket category notes
- **itam**: disable model notes for model device os
- **api**: disable model notes for model auth token
- **core**: disable model notes for model teamuser
- **core**: disable model notes for model notes
- **core**: Migrations for adding notes to ticket category and ticket comment category
- **core**: Add Feature Flag model reference
- **devops**: Add devops module to installed applications
- **devops**: Add Feature Flag viewset
- **devops**: Add Feature Flag serializer
- **devops**: Add devops Navigation menu
- **devops**: Add devops module URL includes
- **devops**: Add devops to permissions
- **devops**: DB Migrations for Feature Flag and History model
- **devops**: Add Feature Flag History model
- **devops**: Add Feature Flag model
- **access**: add support for nested application namespaces
- **devops**: Add devops module
### Fixes
- **devops**: Only track checkin if no other error occured
- **devops**: during feature flag checkin, if no `client-id` provided, use value `not-provided`
- **devops**: When init the feature flag clients, look for all args within settings
- **devops**: Only add `Last-Modified` header to response if exists
- **devops**: Correct logic for data changed check for public endpoint for feature flagging
- **devops**: feature flag public ViewSet serializer name correction and qs cache correction
- **devops**: feature flag public endpoint field modified name typo
- **devops**: Filter public feature flag endpoint to org and software where software is enabled
- **devops**: Move software field filter for feature flag to the serializer
- **devops**: Dont attempt to validate feature flag software or organization if it is absent
- **devops**: Correct feature flagging validation for enabled software and enabled orgs
- **devops**: dont cache serializer for featureflag
- **devops**: Correct Feature Flag serializer validation to cater for edit
- **devops**: Feature Flag field is mandatory
- **api**: make history url dynamic. only display if history should save
- **devops**: if software is deleted delete feature flags
- **core**: disable of notes for models not requiring it
- **api**: when generating notes url, use correct object
- **api**: Add missing import for featurenotused
- **core**: Add ability to add notes for ticket comment category
- **core**: Add ability to add notes for ticket category
- **core**: Serializer `_urls.notes` URL generation now dynamic
- **api**: Dont attempt to access model.get_app_namespace if it doesnt exist
### Tests
- **devops**: Feature Flag History API render checks
- **devops**: Feature Flag Serializer checks
- **devops**: CheckIn Entry created of fetching feature flags
- **devops**: CheckIn model test cases
- **devops**: public feature flag fields corrections
- **devops**: public feature flag functional ViewSet checks
- **devops**: feature flag ViewSet checks
- **api**: Update vieset test cases to cater for mockrequest to contain headers attribute
- **devops**: feature flag public endpoint API field, header checks
- **devops**: Ensure that only enabled org and enabled software is possible
- **devops**: software_feature_flag_enable ViewSet checks
- **devops**: software_feature_flag_enable Serializer checks
- **devops**: Update feature flag test case setup to enable feature flag for testing software
- **devops**: Update feature flag test case setup to enable feature flag for testing software
- **api**: Remove serializer cache test cases
- **devops**: software_feature_flag_enable api field checks
- **devops**: software_feature_flag_enable viewset checks
- **devops**: software_feature_flag_enable model checks
- **devops**: software_feature_flag_enable tenancy object checks
- **devops**: correct dir name for tests
- **devops**: Notes feature flag model checks
- **core**: Ticket Comment Category Notes checks
- **core**: Ticket Category Notes checks
- **app**: Model test cases for api field rendering `_urls.notes`
- **app**: Model test cases for get_url_kwargs_notes function
- **access**: Correct Team notes url route name
- **devops**: Feature Flag viewset unit Checks
- **devops**: Feature Flag model Checks
- **devops**: Feature Flag api Checks
- **devops**: Feature Flag tenancy object Checks
- **devops**: Feature Flag viewset functional Checks
- **devops**: Feature Flag serializer Checks
- **devops**: Feature Flag History Checks
## 1.12.0 (2025-03-01)
### feat

View File

@ -1,3 +1,9 @@
## Version 1.13.0
- DevOps Module added.
- Feature Flagging Component added as par of the DevOps module.
## Version 1.11.0

View File

@ -12,6 +12,7 @@ def permission_queryset():
'assistance',
'config_management',
'core',
'devops',
'django_celery_results',
'itam',
'itim',
@ -30,15 +31,19 @@ def permission_queryset():
]
exclude_permissions = [
'add_checkin',
'add_history',
'add_organization',
'add_taskresult',
'change_checkin',
'change_history',
'change_organization',
'change_taskresult',
'delete_checkin',
'delete_history',
'delete_organization',
'delete_taskresult',
'view_checkin',
'view_history',
]

View File

@ -136,6 +136,20 @@ class Team(Group, TenancyObject):
}
def get_url_kwargs_notes(self) -> dict:
"""Fetch the URL kwargs for model notes
Returns:
dict: notes kwargs required for generating the URL with `reverse`
"""
return {
'organization_id': self.organization.id,
'model_id': self.id
}
# @property
# def parent_object(self):
# """ Fetch the parent object """

View File

@ -49,6 +49,6 @@ class TeamNotes(
if request:
return reverse("v2:_api_v2_organization_team_note-detail", request=request, kwargs = kwargs )
return reverse("v2:_api_v2_team_note-detail", request=request, kwargs = kwargs )
return reverse("v2:_api_v2_organization_team_note-detail", kwargs = kwargs )
return reverse("v2:_api_v2_team_note-detail", kwargs = kwargs )

View File

@ -12,6 +12,7 @@ from access.fields import (
from access.models.organization import Organization
from access.models.team import Team
from core.lib.feature_not_used import FeatureNotUsed
from core.mixin.history_save import SaveHistory
@ -111,6 +112,11 @@ class TeamUsers(SaveHistory):
return reverse(f"v2:_api_v2_organization_team_user-detail", kwargs = url_kwargs )
def get_url_kwargs_notes(self):
return FeatureNotUsed
def save(self, *args, **kwargs):
""" Save Team

View File

@ -165,6 +165,29 @@ class TenancyObject(SaveHistory):
def get_organization(self) -> Organization:
return self.organization
app_namespace: str = None
"""Application namespace.
Specify the applications namespace i.e. `devops`, without including
the API version, i.e. `v2:devops`.
"""
def get_app_namespace(self) -> str:
"""Fetch the Application namespace if specified.
Returns:
str: Application namespace suffixed with colin `:`
None: No application namespace found.
"""
app_namespace = ''
if self.app_namespace:
app_namespace = self.app_namespace + ':'
return str(app_namespace)
def get_url( self, request = None ) -> str:
"""Fetch the models URL
@ -183,9 +206,9 @@ class TenancyObject(SaveHistory):
if request:
return reverse(f"v2:_api_v2_{model_name}-detail", request=request, kwargs = self.get_url_kwargs() )
return reverse(f"v2:" + self.get_app_namespace() + f"_api_v2_{model_name}-detail", request=request, kwargs = self.get_url_kwargs() )
return reverse(f"v2:_api_v2_{model_name}-detail", kwargs = self.get_url_kwargs() )
return reverse(f"v2:" + self.get_app_namespace() + f"_api_v2_{model_name}-detail", kwargs = self.get_url_kwargs() )
def get_url_kwargs(self) -> dict:
@ -200,6 +223,18 @@ class TenancyObject(SaveHistory):
}
def get_url_kwargs_notes(self) -> dict:
"""Fetch the URL kwargs for model notes
Returns:
dict: notes kwargs required for generating the URL with `reverse`
"""
return {
'model_id': self.id
}
def save(self, force_insert=False, force_update=False, using=None, update_fields=None):
self.clean()

View File

@ -59,8 +59,6 @@ class TeamUserModelSerializer(
del get_url['knowledge_base']
del get_url['notes']
return get_url

View File

@ -18,7 +18,7 @@ class ViewSetBase(
viewset = ViewSet
url_name = '_api_v2_organization_team_note'
url_name = '_api_v2_team_note'
@classmethod
def setUpTestData(self):

View File

@ -15,7 +15,7 @@ class TeamNotesAPI(
model = TeamNotes
view_name: str = '_api_v2_organization_team_note'
view_name: str = '_api_v2_team_note'
@classmethod
def setUpTestData(self):

View File

@ -18,7 +18,7 @@ class ViewsetCommon(
viewset = ViewSet
route_name = 'v2:_api_v2_organization_team_note'
route_name = 'v2:_api_v2_team_note'
@classmethod
def setUpTestData(self):

View File

@ -77,11 +77,6 @@ class ViewSet( ModelViewSet ):
def get_serializer_class(self):
if self.serializer_class is not None:
return self.serializer_class
if (
self.action == 'list'
or self.action == 'retrieve'

View File

@ -45,11 +45,6 @@ class ViewSet(ModelNoteViewSet):
def get_serializer_class(self):
if self.serializer_class is not None:
return self.serializer_class
if (
self.action == 'list'
or self.action == 'retrieve'

View File

@ -159,10 +159,6 @@ class ViewSet( ModelViewSet ):
def get_serializer_class(self):
if self.serializer_class is not None:
return self.serializer_class
if (
self.action == 'list'
or self.action == 'retrieve'

View File

@ -45,11 +45,6 @@ class ViewSet(ModelNoteViewSet):
def get_serializer_class(self):
if self.serializer_class is not None:
return self.serializer_class
if (
self.action == 'list'
or self.action == 'retrieve'

View File

@ -186,11 +186,6 @@ class ViewSet( ModelViewSet ):
def get_serializer_class(self):
if self.serializer_class is not None:
return self.serializer_class
if (
self.action == 'list'
or self.action == 'retrieve'

View File

@ -13,6 +13,8 @@ from access.fields import (
AutoLastModifiedField
)
from core.lib.feature_not_used import FeatureNotUsed
class AuthToken(models.Model):
@ -162,3 +164,8 @@ class AuthToken(models.Model):
'model_id': self.user.id,
'pk': self.id
}
def get_url_kwargs_notes(self):
return FeatureNotUsed

View File

@ -83,6 +83,14 @@ class ReactUIMetadata(OverRideJSONAPIMetadata):
url_self = None
app_namespace = ''
if getattr(view, 'model', None):
if getattr(view.model, 'get_app_namespace', None):
app_namespace = view.model().get_app_namespace()
if view.kwargs.get('pk', None) is not None:
@ -95,11 +103,11 @@ class ReactUIMetadata(OverRideJSONAPIMetadata):
elif view.kwargs:
url_self = reverse('v2:' + view.basename + '-list', request = view.request, kwargs = view.kwargs )
url_self = reverse('v2:' + app_namespace + view.basename + '-list', request = view.request, kwargs = view.kwargs )
else:
url_self = reverse('v2:' + view.basename + '-list', request = view.request )
url_self = reverse('v2:' + app_namespace + view.basename + '-list', request = view.request )
if url_self:
@ -448,6 +456,19 @@ class ReactUIMetadata(OverRideJSONAPIMetadata):
},
}
},
'devops': {
"display_name": "DevOPs",
"name": "devops",
"icon": "devops",
"pages": {
'view_featureflag': {
"display_name": "Feature Flags",
"name": "feature_flag",
"icon": 'feature_flag',
"link": "/devops/feature_flag"
}
}
},
'config_management': {
"display_name": "Config Management",
"name": "config_management",

View File

@ -54,7 +54,6 @@ class AuthTokenModelSerializer(
del get_url['history']
del get_url['knowledge_base']
del get_url['notes']
return get_url

View File

@ -4,8 +4,10 @@ from rest_framework.reverse import reverse
from access.serializers.organization import Organization
from core import fields as centurion_field
from assistance.models.model_knowledge_base_article import all_models
from core import fields as centurion_field
from core.lib.feature_not_used import FeatureNotUsed
@ -56,18 +58,8 @@ class CommonModelSerializer(CommonBaseSerializer):
def get_url(self, item) -> dict:
return {
get_url = {
'_self': item.get_url( request = self._context['view'].request ),
'history': reverse(
"v2:_api_v2_model_history-list",
request = self._context['view'].request,
kwargs = {
'app_label': self.Meta.model._meta.app_label,
'model_name': self.Meta.model._meta.model_name,
'model_id': item.pk
}
),
'knowledge_base': reverse(
"v2:_api_v2_model_kb-list",
request=self._context['view'].request,
@ -76,11 +68,42 @@ class CommonModelSerializer(CommonBaseSerializer):
'model_pk': item.pk
}
),
'notes': reverse(
"v2:_api_v2_operating_system_note-list",
}
if getattr(self.Meta.model, 'save_model_history', True):
get_url['history'] = reverse(
"v2:_api_v2_model_history-list",
request = self._context['view'].request,
kwargs = {
'app_label': self.Meta.model._meta.app_label,
'model_name': self.Meta.model._meta.model_name,
'model_id': item.pk
}
),
}
)
obj = getattr(item, 'get_url_kwargs_notes', None)
if callable(obj):
obj = obj()
if(
not str(item._meta.model_name).lower().endswith('notes')
and obj is not FeatureNotUsed
):
note_basename = '_api_v2_' + str(item._meta.verbose_name).lower().replace(' ', '_') + '_note'
if getattr(self.Meta, 'note_basename', None):
note_basename = self.Meta.note_basename
get_url['notes'] = reverse(
"v2:" + note_basename + "-list",
request = self._context['view'].request,
kwargs = item.get_url_kwargs_notes()
)
return get_url

View File

@ -1,5 +1,9 @@
from rest_framework.relations import Hyperlink
from assistance.models.model_knowledge_base_article import all_models
from core.lib.feature_not_used import FeatureNotUsed
class APICommonFields:
@ -82,7 +86,7 @@ class APICommonFields:
assert '_self' in self.api_data['_urls']
def test_api_field_type_urls(self):
def test_api_field_type_urls_self(self):
""" Test for type for API Field
_urls._self field must be str
@ -92,6 +96,59 @@ class APICommonFields:
def test_api_field_exists_urls_notes(self):
""" Test for existance of API Field
_urls.notes field must exist
"""
obj = getattr(self.item, 'get_url_kwargs_notes', None)
if callable(obj):
obj = obj()
if(
not str(self.model._meta.model_name).lower().endswith('notes')
and obj is not FeatureNotUsed
):
assert 'notes' in self.api_data['_urls']
else:
print('Test is n/a')
assert True
def test_api_field_type_urls_notes(self):
""" Test for type for API Field
_urls._self field must be str
"""
obj = getattr(self.item, 'get_url_kwargs_notes', None)
if callable(obj):
obj = obj()
if(
not str(self.model._meta.model_name).lower().endswith('notes')
and obj is not FeatureNotUsed
):
assert type(self.api_data['_urls']['notes']) is str
else:
print('Test is n/a')
assert True
class APIModelFields(
APICommonFields
):

View File

@ -671,6 +671,8 @@ class ViewSetModel(
viewset = self.viewset
)
view_set.request.headers = {}
view_set.kwargs = self.kwargs
view_set.action = 'list'
@ -701,6 +703,7 @@ class ViewSetModel(
viewset = self.viewset
)
view_set.request.headers = {}
view_set.kwargs = self.kwargs
view_set.action = 'list'
view_set.detail = False
@ -735,85 +738,3 @@ class ViewSetModel(
assert setter_not_called
assert qs.call_count == 2
def test_view_func_get_serializer_class_cache_result(self):
"""Viewset Test
Ensure that the `get_serializer_class` function caches the result under
attribute `<viewset>.serializer_class`
"""
view_set = self.viewset()
view_set.request = MockRequest(
user = self.view_user,
organization = self.organization,
viewset = self.viewset
)
view_set.kwargs = self.kwargs
view_set.action = 'list'
view_set.detail = False
assert view_set.serializer_class is None # Must be empty before init
q = view_set.get_serializer_class()
assert view_set.serializer_class is not None # Must not be empty after init
assert q == view_set.serializer_class
def test_view_func_get_serializer_class_cache_result_used(self):
"""Viewset Test
Ensure that the `get_serializer_class` function caches the result under
attribute `<viewset>.serializer_class`
"""
view_set = self.viewset()
view_set.request = MockRequest(
user = self.view_user,
organization = self.organization,
viewset = self.viewset
)
view_set.kwargs = self.kwargs
view_set.action = 'list'
view_set.detail = False
mock_return = view_set.get_serializer_class() # Real item to be used as mock return Some
# functions use `Queryset` for additional filtering
setter_not_called = True
with patch.object(self.viewset, 'serializer_class', new_callable=PropertyMock) as qs:
qs.return_value = mock_return
mocked_view_set = self.viewset()
mocked_view_set.kwargs = self.kwargs
mocked_view_set.action = 'list'
mocked_view_set.detail = False
qs.reset_mock() # Just in case
mocked_setup = mocked_view_set.get_serializer_class() # should only add two calls, if exists and the return
for mock_call in list(qs.mock_calls): # mock_calls with args means setter was called
if len(mock_call.args) > 0:
setter_not_called = False
assert setter_not_called
assert qs.call_count == 2

21
app/api/urls_public.py Normal file
View File

@ -0,0 +1,21 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from api.viewsets import (
public
)
app_name = "public"
router = DefaultRouter(trailing_slash=False)
router.register('', public.Index, basename='_public_api_v2')
urlpatterns = router.urls
urlpatterns += [
path('', include('devops.urls_public')),
]

View File

@ -1,4 +1,4 @@
from django.urls import path
from django.urls import include, path
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
@ -48,13 +48,19 @@ from core.viewsets import (
manufacturer as manufacturer_v2,
manufacturer_notes,
ticket_category,
ticket_category_notes,
ticket_comment,
ticket_comment_category,
ticket_comment_category_notes,
ticket_linked_item,
related_ticket,
)
from devops.viewsets import (
software_enable_feature_flag,
)
from itam.viewsets import (
index as itam_index_v2,
device as device_v2,
@ -128,7 +134,7 @@ router.register('access', access_v2.Index, basename='_api_v2_access_home')
router.register('access/organization', organization_v2.ViewSet, basename='_api_v2_organization')
router.register('access/organization/(?P<model_id>[0-9]+)/notes', organization_notes.ViewSet, basename='_api_v2_organization_note')
router.register('access/organization/(?P<organization_id>[0-9]+)/team', team_v2.ViewSet, basename='_api_v2_organization_team')
router.register('access/organization/(?P<organization_id>[0-9]+)/team/(?P<model_id>[0-9]+)/notes', team_notes.ViewSet, basename='_api_v2_organization_team_note')
router.register('access/organization/(?P<organization_id>[0-9]+)/team/(?P<model_id>[0-9]+)/notes', team_notes.ViewSet, basename='_api_v2_team_note')
router.register('access/organization/(?P<organization_id>[0-9]+)/team/(?P<team_id>[0-9]+)/user', team_user_v2.ViewSet, basename='_api_v2_organization_team_user')
@ -178,6 +184,7 @@ router.register('itam/software/(?P<software_id>[0-9]+)/installs', device_softwar
router.register('itam/software/(?P<model_id>[0-9]+)/notes', software_notes.ViewSet, basename='_api_v2_software_note')
router.register('itam/software/(?P<software_id>[0-9]+)/version', software_version_v2.ViewSet, basename='_api_v2_software_version')
router.register('itam/software/(?P<software_id>[0-9]+)/version/(?P<model_id>[0-9]+)/notes', software_version_notes.ViewSet, basename='_api_v2_software_version_note')
router.register('itam/software/(?P<software_id>[0-9]+)/feature_flag', software_enable_feature_flag.ViewSet, basename='_api_v2_feature_flag_software')
router.register('itim', itim_v2.Index, basename='_api_v2_itim_home')
@ -223,7 +230,9 @@ router.register('settings/project_type/(?P<model_id>[0-9]+)/notes', project_type
router.register('settings/software_category', software_category_v2.ViewSet, basename='_api_v2_software_category')
router.register('settings/software_category/(?P<model_id>[0-9]+)/notes', software_category_notes.ViewSet, basename='_api_v2_software_category_note')
router.register('settings/ticket_category', ticket_category.ViewSet, basename='_api_v2_ticket_category')
router.register('settings/ticket_category/(?P<model_id>[0-9]+)/notes', ticket_category_notes.ViewSet, basename='_api_v2_ticket_category_note')
router.register('settings/ticket_comment_category', ticket_comment_category.ViewSet, basename='_api_v2_ticket_comment_category')
router.register('settings/ticket_comment_category/(?P<model_id>[0-9]+)/notes', ticket_comment_category_notes.ViewSet, basename='_api_v2_ticket_comment_category_note')
router.register('settings/user_settings', user_settings_v2.ViewSet, basename='_api_v2_user_settings')
router.register('settings/user_settings/(?P<model_id>[0-9]+)/token', auth_token.ViewSet, basename='_api_v2_user_settings_token')
@ -236,3 +245,8 @@ urlpatterns = [
]
urlpatterns += router.urls
urlpatterns += [
path("devops/", include("devops.urls")),
path('public/', include('api.urls_public')),
]

View File

@ -71,11 +71,6 @@ class ViewSet(
def get_serializer_class(self):
if self.serializer_class is not None:
return self.serializer_class
if (
self.action == 'list'
or self.action == 'retrieve'

View File

@ -1,9 +1,10 @@
import importlib
from django.utils.safestring import mark_safe
from rest_framework import viewsets
from rest_framework import viewsets, pagination
from rest_framework_json_api.metadata import JSONAPIMetadata
from rest_framework.exceptions import APIException
from rest_framework.permissions import IsAuthenticated
from rest_framework.permissions import IsAuthenticated, IsAuthenticatedOrReadOnly
from rest_framework.response import Response
from access.mixins.organization import OrganizationMixin
@ -670,11 +671,6 @@ class ModelViewSetBase(
def get_serializer_class(self):
if self.serializer_class is not None:
return self.serializer_class
if (
self.action == 'list'
or self.action == 'retrieve'
@ -769,6 +765,17 @@ class ReadOnlyModelViewSet(
class ReadOnlyListModelViewSet(
ModelViewSetBase,
List,
viewsets.GenericViewSet,
):
pass
class AuthUserReadOnlyModelViewSet(
ReadOnlyModelViewSet
):
@ -794,3 +801,40 @@ class IndexViewset(
IsAuthenticated,
]
class StaticPageNumbering(
pagination.PageNumberPagination
):
"""Enforce Page Numbering
Enfore results per page min/max to static value that cant be changed.
"""
page_size = 20
max_page_size = 20
class PublicReadOnlyViewSet(
ReadOnlyListModelViewSet
):
"""Public Viewable ViewSet
User does not need to be authenticated. This viewset is intended to be
inherited by viewsets that are intended to be consumed by unauthenticated
public users.
URL **must** be prefixed with `public`
Args:
ReadOnlyModelViewSet (ViewSet): Common Read-Only Viewset
"""
pagination_class = StaticPageNumbering
permission_classes = [
IsAuthenticatedOrReadOnly,
]
metadata_class = JSONAPIMetadata

View File

@ -33,6 +33,7 @@ class Index(IndexViewset):
"itim": reverse('v2:_api_v2_itim_home-list', request=request),
"config_management": reverse('v2:_api_v2_config_management_home-list', request=request),
"project_management": reverse('v2:_api_v2_project_management_home-list', request=request),
"public": reverse('v2:public:_public_api_v2-list', request=request),
"settings": reverse('v2:_api_v2_settings_home-list', request=request)
}
)

View File

@ -0,0 +1,68 @@
from drf_spectacular.utils import extend_schema
from rest_framework.response import Response
from rest_framework.reverse import reverse
from api.viewsets.common import IndexViewset
from devops.models.software_enable_feature_flag import SoftwareEnableFeatureFlag
@extend_schema(exclude = True)
class Index(
IndexViewset
):
"""Publicly available API endpoints.
**Note:** This page must not be made publicly available as it's an index
of publicly accessable links.
Args:
IndexViewset (ViewSet): Common Index ViewSet
"""
allowed_methods: list = [
'GET',
'HEAD',
'OPTIONS'
]
view_description = 'Centurion ERP public endpoints.'
view_name = "Public"
def list(self, request, *args, **kwargs):
items = SoftwareEnableFeatureFlag.objects.select_related(
'organization',
'software'
).filter(
enabled = True
).order_by('organization__name')
endpoints = {}
for item in items:
ref = str(item.organization.name) + '_' + str(item.software.name)
endpoints[ref] = reverse(
'v2:public:devops:_public_api_v2_feature_flag-list',
request=request,
kwargs = {
'organization_id': int(item.software.id),
'software_id': int(item.organization.id)
}
)
return Response(
{
"flags": reverse(
'v2:public:devops:_api_v2_flags-list',
request=request,
)
}
)

View File

@ -10,6 +10,7 @@ For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.0/ref/settings/
"""
import hashlib
import os
import sys
@ -67,6 +68,7 @@ CELERY_WORKER_MAX_TASKS_PER_CHILD = 1 # worker_max_tasks_per_child
CELERY_TASK_SEND_SENT_EVENT = True
CELERY_WORKER_SEND_TASK_EVENTS = True # worker_send_task_events
FEATURE_FLAGGING_ENABLED = True # Turn Feature Flagging on/off
# PROMETHEUS_METRICS_EXPORT_PORT_RANGE = range(8010, 8010)
# PROMETHEUS_METRICS_EXPORT_PORT = 8010
@ -134,6 +136,8 @@ INSTALLED_APPS = [
'drf_spectacular_sidecar',
'config_management.apps.ConfigManagementConfig',
'project_management.apps.ProjectManagementConfig',
'devops.apps.DevOpsConfig',
'centurion_feature_flag',
]
MIDDLEWARE = [
@ -148,6 +152,7 @@ MIDDLEWARE = [
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'core.middleware.get_request.RequestMiddleware',
'app.middleware.timezone.TimezoneMiddleware',
# 'centurion_feature_flag.middleware.feature_flag.FeatureFlagMiddleware',
]
@ -428,3 +433,60 @@ if SSO_ENABLED:
'social_core.pipeline.social_auth.load_extra_data',
'social_core.pipeline.user.user_details',
)
if BUILD_VERSION:
feature_flag_version = str(BUILD_VERSION) + '+' + str(BUILD_SHA)[:8]
else:
if BUILD_SHA is not None:
feature_flag_version = str(BUILD_SHA)
else:
feature_flag_version = 'development'
""" Unique ID Rational
Unique ID generation required to determine how many installations are deployed. Also provides the opportunity
should it be required in the future to enable feature flags on a per `unique_id`.
Objects:
- CELERY_BROKER_URL
- SITE_URL
- SECRET_KEY
Will provide enough information alone once hashed, to identify a majority of deployments as unique.
Adding object `feature_flag_version`, Ensures that as each release occurs that a deployments `unique_id` will
change, thus preventing long term monitoring of a deployments usage of Centurion.
value `DOCS_ROOT` is added so there is more data to hash.
You are advised not to change the `unique_id` as you may inadvertantly reduce your privacy. However the choice
is yours. If you do change the value ensure that it's still hashed as a sha256 hash.
"""
unique_id = str(f'{CELERY_BROKER_URL}{DOCS_ROOT}{SITE_URL}{SECRET_KEY}{feature_flag_version}')
unique_id = hashlib.sha256(unique_id.encode()).hexdigest()
if FEATURE_FLAGGING_ENABLED:
FEATURE_FLAGGING_URL = 'https://alfred.nofusscomputing.com/api/v2/public/4/flags/1'
if DEBUG:
FEATURE_FLAGGING_URL = 'http://127.0.0.1:8002/api/v2/public/1/flags/2844'
feature_flag = {
'url': str(FEATURE_FLAGGING_URL),
'user_agent': 'Centurion ERP',
'cache_dir': str(BASE_DIR) + '/',
'disable_downloading': False,
'unique_id': unique_id,
'version': feature_flag_version
}

View File

@ -9,6 +9,7 @@ from access.tests.abstract.tenancy_object import TenancyObject as TenancyObjectT
from app.tests.abstract.views import AddView, ChangeView, DeleteView, DisplayView, IndexView
from core.lib.feature_not_used import FeatureNotUsed
from core.mixin.history_save import SaveHistory
from core.tests.abstract.models import Models
@ -293,6 +294,111 @@ class BaseModel:
def test_attribute_exists_get_url_kwargs_notes(self):
"""Test for existance of field in `<model>`
Attribute `get_url_kwargs_notes` must be defined in class.
"""
obj = getattr(self.item, 'get_url_kwargs_notes', None)
if callable(obj):
obj = obj()
if(
not str(self.model._meta.model_name).lower().endswith('notes')
and obj is not FeatureNotUsed
):
assert hasattr(self.item, 'get_url_kwargs_notes')
else:
print('Test is n/a')
assert True
def test_attribute_not_empty_get_url_kwargs_notes(self):
"""Test field `<model>` is not empty
Attribute `get_url` must contain values
"""
obj = getattr(self.item, 'get_url_kwargs_notes', None)
if callable(obj):
obj = obj()
if(
not str(self.model._meta.model_name).lower().endswith('notes')
and obj is not FeatureNotUsed
):
assert self.item.get_url_kwargs_notes() is not None
else:
print('Test is n/a')
assert True
def test_attribute_type_get_url_kwargs_notes(self):
"""Test field `<model>`type
Attribute `get_url_kwargs_notes` must be dict
"""
obj = getattr(self.item, 'get_url_kwargs_notes', None)
if callable(obj):
obj = obj()
if(
not str(self.model._meta.model_name).lower().endswith('notes')
and obj is not FeatureNotUsed
):
assert type(self.item.get_url_kwargs_notes()) is dict
else:
print('Test is n/a')
assert True
def test_attribute_callable_get_url_kwargs_notes(self):
"""Test field `<model>` callable
Attribute `get_url_kwargs_notes` must be a function
"""
obj = getattr(self.item, 'get_url_kwargs_notes', None)
if callable(obj):
obj = obj()
if(
not str(self.model._meta.model_name).lower().endswith('notes')
and obj is not FeatureNotUsed
):
assert callable(self.item.get_url_kwargs_notes)
else:
print('Test is n/a')
assert True
class TenancyModel(
BaseModel,
TenancyObjectTestCases,

View File

@ -11,6 +11,9 @@ from access.models.tenancy import TenancyObject
from assistance.models.knowledge_base import KnowledgeBase
from core.lib.feature_not_used import FeatureNotUsed
def all_models() -> list(tuple()):
models: list(tuple()) = []
@ -22,6 +25,7 @@ def all_models() -> list(tuple()):
'assistance',
'config_management',
'core',
'devops',
'itam',
'itim',
'project_management',
@ -31,14 +35,20 @@ def all_models() -> list(tuple()):
excluded_models: list = [
'appsettings',
'authtoken',
'configgrouphosts',
'configgroupsoftware',
'deviceoperatingsystem',
'devicesoftware',
'history',
'knowledgebase',
'modelknowledgebasearticle',
'notes',
'relatedtickets',
'teamusers',
'ticket',
'ticketcomment',
'ticketlinkeditem',
'token',
'usersettings',
]
@ -47,6 +57,7 @@ def all_models() -> list(tuple()):
if(
str(app_model._meta.app_label) in model_apps
and str(app_model._meta.model_name) not in excluded_models
and not str(app_model._meta.model_name).lower().endswith('notes')
):
models.append(
@ -162,3 +173,7 @@ class ModelKnowledgeBaseArticle(TenancyObject):
""" Function not required nor-used"""
return None
def get_url_kwargs_notes(self):
return FeatureNotUsed

View File

@ -197,7 +197,7 @@ class ModelKnowledgeBaseArticleAPI(
@pytest.mark.skip( reason = 'not required for this model' )
def test_api_field_type_urls(self):
def test_api_field_type_urls_self(self):
""" Test for type for API Field
_urls._self field must be str

View File

@ -82,11 +82,6 @@ class ViewSet( ModelViewSet ):
def get_serializer_class(self):
if self.serializer_class is not None:
return self.serializer_class
if (
self.action == 'list'
or self.action == 'retrieve'

View File

@ -79,11 +79,6 @@ class ViewSet( ModelViewSet ):
def get_serializer_class(self):
if self.serializer_class is not None:
return self.serializer_class
if (
self.action == 'list'
or self.action == 'retrieve'

View File

@ -45,11 +45,6 @@ class ViewSet(ModelNoteViewSet):
def get_serializer_class(self):
if self.serializer_class is not None:
return self.serializer_class
if (
self.action == 'list'
or self.action == 'retrieve'

View File

@ -45,11 +45,6 @@ class ViewSet(ModelNoteViewSet):
def get_serializer_class(self):
if self.serializer_class is not None:
return self.serializer_class
if (
self.action == 'list'
or self.action == 'retrieve'

View File

@ -142,13 +142,6 @@ class ViewSet( ModelViewSet ):
def get_serializer_class(self):
# all_models = apps.get_models()
if self.serializer_class is not None:
return self.serializer_class
if (
self.action == 'list'
or self.action == 'retrieve'

View File

@ -0,0 +1,145 @@
# No Fuss Computing - Centurion ERP Feature Flag Client
This Django application serves the purpose of using feature flags as part of a Django applications development. You will require your own deployment of [Centurion ERP](https://nofusscomputing.com/projects/centurion_erp/) which is where the [feature flags](https://nofusscomputing.com/projects/centurion_erp/user/devops/feature_flags/) will be defined.
To setup the feature flagging the following will need to be added to your Django applications settings:
``` py
# settings.py
feature_flag = {
'url': 'https://127.0.0.1:8002/api/v2/public/1/flags/2844', # URL to your Centurion ERP instance
'user_agent': 'My Django Application Name', # The name of your Django Application
'cache_dir': str(BASE_DIR) + '/', # Directory name (with trailing slash `/`) where the cached flags will be stored
'disable_downloading': False # Prevent downloading feature flags
'unique_id': 'unique ID for application', # Unique ID for this instance of your Django application
'version': '1.0.0', # The Version of Your Django Application
} # Note: All key values are strings
```
!!! danger
Failing to add the `feature_flag` dictionary to your Django Applications setting.py file will leave feature flagging **disabled**.
## Features
Within Django the following locations have the feature flagging available
- anywhere you can access the `request` object
- Django DRF Router(s)
- Django URLs
- Management command to fetch feature flag file
- Caching of flags
## Request Object
Any location within your django project where you can access the `request` object, you can use feature flagging. To enable this add the following to your middleware:
``` py
MIDDLEWARE = [
...
'centurion_feature_flag.middleware.feature_flag.FeatureFlagMiddleware',
]
```
After the middleware has been added, property `feature_flag` is added to the request object.
Example usage within a view and/or Django DRF ViewSet:
``` py
class MyView:
def get_queryset(self):
if self.request.feature_flag['2025-00001']:
# code to run if feature flag is enabled
```
## Django URLs
To enable feature flagging for urls, substitute `from django.urls import path, re_path` with `from centurion_feature_flag.urls.django import path, re_path`. Then optionally, whilst calling the `path` function include attribute `feature_flag` with a string value of the feature flag id. Once enabled if an attempt to navigate to the url is made and for a disabled feature flag, a `HTTP/404` will be returned.
``` py
# urls.py
from django.contrib import admin
from centurion_feature_flag.urls.django import (
include,
path,
re_path
)
from my_app.views import home
urlpatterns = [
path('', home.HomeView.as_view(), name='home', feature_flag = '2025-00001'),
path('admin/', admin.site.urls, name='_administration'),
re_path(r'^static/(?P<path>.*)$', serve,{'document_root': settings.STATIC_ROOT}, feature_flag = '2025-00003'),
path("some-path/", include("my_app.urls"), feature_flag = '2025-00002'),
]
```
!!! tip
module `centurion_feature_flag.urls.django` also contains function `include` from `django.urls` so there is no need to import `django.urls` for this function.
!!! note
If you use feature flagging on the `path` function and its called with the `include` function as the view attribute, not all paths from the include function are available. As a consequence, sub-paths are unavailable. For example. The `some-path/` url can be navigated to whilst `some-path/sub-path/` can not be. This generally wont be an issue, although if you attempt to use the `reverse` function on the sub-path and the feature flag is disabled; the reverse function will raise an exception.
## DRF Router
Enabling feature flagging for Django DRF Routers is as simple as substituting `from rest_framework.routers import <router name>` with `from centurion_feature_flag.urls.routers import <router name>` then optionally updating the route register method with the feature flag to use. for example, using feature flag `2025-00001`
``` py
from centurion_feature_flag.urls.routers import DefaultRouter
from some_app.viewsets import my_viewset
router = DefaultRouter(trailing_slash=False)
router.register('my_viewset_path', my_viewset.ViewSet, feature_flag = '2025-00001', basename='_my_view_name')
urlpatterns = router.urls
```
!!! warning
If a feature flag is updated any router that contains a feature flag that has been edited since Django was last restarted, will not be updated. To ensure that any router that uses feature flagging has the most up to date feature flag configuration. After downloading your feature flags, please restart your Django App.
!!! danger
If the feature flags have not been downloaded and cached before your Django app is started. Any router that relies upon a feature flag will not be enabled. this is by design so that in the event you are unable to fetch the feature flags from your Centurion ERP instance, no feature will be unintentionally enabled.
## Management Command
The management command available is `feature_flag` with optional argument `--reload`. running this will download the available feature flags from the configured Centurion ERP Instance. To fetch the feature flags run command `python manage.py feature_flag --reload`.
!!! note
Arg `--reload` only works within production. Which in this case is when Centurion ERP is deployed using one of our [docker containers](https://hub.docker.com/r/nofusscomputing/centurion-erp)
## Caching
The feature flags are saved to the local file system and updated every four hours.

View File

View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class CenturionFeatureFlagConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'centurion_feature_flag'

View File

@ -0,0 +1,348 @@
import json
import requests
from datetime import datetime
from dateutil.parser import parse
from pathlib import Path
from centurion_feature_flag.lib.serializer import FeatureFlag
class CenturionFeatureFlagging:
"""Centurion ERP Feature Flags
This class contains all required methods so as to use feature flags
provided by a Centurion ERP deployment.
Examples:
Checking if feature flagging is usable can be done with:
>>> ff = CenturionFeatureFlagging(
>>> 'http://127.0.0.1:8002/api/v2/public/1/flags/2844',
>>> 'Centurion ERP',
>>> './your-cache-dir'
>>> )
>>> if ff:
>>> print('ok')
ok
To use a feature flag, in this case `2025-00007` can be achived with:
>>> if ff["2025-00007"]:
>>> print('ok')
ok
Note: This assumes that feature flag `2025-00007` is enabled. If it is not
`false` will be returned as the boolean check returns the flags `enabled`
value.
Args:
url (str): URL of the Centurion Instance to query
user_agent (str): User Agent to report to Centurion Instance this
should be the name of your application
cache_dir (str): Directory where the feature flag cache file is saved.
disable_downloading (bool): Prevent the downloaing of feature flags
unique_id (str, optional): Unique ID of the application that is
reporting to Centurion ERP
version (str, optional): The version of your application
Attributes:
__len__ (int): Count of feature flags
__bool__ (bool): Feature Flag fetch was successful
CenturionFeatureFlagging[<feature flag>] (dict): Feature flag data
get (None): Make a http request to the Centurion ERP
instance.
"""
_cache_date: datetime = None
"""Date the feature flag file was last saved"""
_cache_dir: str = None
"""Directory name (with trailing slash `/`) where the feature flags will be saved/cached."""
_disable_downloading: bool = False
"""Prevent check-in and subsequent downloading from remote Centurion instance"""
_feature_flags: list = None
_feature_flag_filename: str = 'feature_flags.json'
""" File name for the cached feture flags"""
_headers: dict = {
"Accept": "application/json",
}
_last_modified: datetime = None
""" Last modified date/time of the feature flags"""
_response: requests.Response = None
"""Cached response from fetched feature flags"""
_ssl_verify: bool = True
"""Verify the SSL certificate of the remote Centurion ERP instance"""
_url: str = None
""" url of the centurion ERP instance"""
def __init__(
self,
url: str,
user_agent: str,
cache_dir: str,
disable_downloading: bool = False,
unique_id: str = None,
version: str = None,
):
if not str(cache_dir).endswith('/'):
raise AttributeError(f'cache directory {cache_dir} must end with trailing slash `/`')
self._url = url
self._cache_dir = cache_dir
self._disable_downloading = disable_downloading
if version is None:
self._headers.update({
'User-Agent': f'{user_agent} 0.0'
})
else:
self._headers.update({
'User-Agent': f'{user_agent} {version}'
})
if unique_id is not None:
self._headers.update({
'client-id': unique_id
})
def __bool__(self) -> bool:
if(
(
(
getattr(self._response, 'status_code', 0) == 200
or getattr(self._response, 'status_code', 0) == 304
)
and self._feature_flags is not None
)
or ( # Feature flags were loaded from file
self._feature_flags is not None
and self._last_modified is not None
)
):
return True
return False
def __getitem__(self, key: str, raise_exceptions: bool = False) -> dict:
""" Fetch a Feature Flag
Args:
key (str): Feature Flag id to fetch.
raise_exceptions (bool, optional): Raise an exception if the key is
not found. Default `False`
Raises:
KeyError: The specified Feature Flag does not exist. Only if arg `raise_exceptions=True`
Returns:
dict: A complete Feature Flag.
"""
if self._feature_flags is None:
print('Feature Flagging has not been completly initialized.')
print(' please ensure that the feature flags have been downloaded.')
return False
if(
self._feature_flags.get(key, None) is None
and raise_exceptions
):
raise KeyError(f'Feature Flag "{key}" does not exist')
elif(
not raise_exceptions
and self._feature_flags.get(key, None) is None
):
return False
return self._feature_flags[key]
def __len__(self) -> int:
"""Count the Feature Flags
Returns:
int: Total number of feature flags.
"""
return len(self._feature_flags)
def get( self ):
""" Get the available Feature Flags
Will first check the filesystem for file `feature_flags.json` and if
the file is '< 4 hours' old, will load the feature flags from the file.
If the file does not exist or the file is '> 4 hours' old, the feature
flags will be fetched from Centurion ERP.
"""
url = self._url
fetched_flags: list = []
feature_flag_path = self._cache_dir + self._feature_flag_filename
feature_flag_file = Path(feature_flag_path)
if feature_flag_file.is_file():
if(
feature_flag_file.lstat().st_mtime > datetime.now().timestamp() - (4 * 3580) # -20 second buffer
or self._disable_downloading
):
# Only open file if less than 4 hours old
with open(feature_flag_path, 'r') as saved_feature_flags:
fetched_flags = json.loads(saved_feature_flags.read())
self._cache_date = datetime.fromtimestamp(feature_flag_file.lstat().st_mtime)
url = None
response = None
if self._disable_downloading: # User has disabled downloading.
url = None
while(url is not None):
try:
resp = requests.get(
headers = self._headers,
timeout = 3,
url = url,
verify = self._ssl_verify,
)
if response is None: # Only save first request
response = resp
self._response = response
if resp.status_code == 304: # Nothing has changed, exit the loop
url = None
elif resp.ok: # Fetch next page of results
fetched_flags += resp.json()['results']
url = resp.json()['next']
else:
url = None
except requests.exceptions.ConnectionError as err:
print(f'Error Connecting to {url}')
url = None
except requests.exceptions.ReadTimeout as err:
print(f'Connection Timed Out connecting to {url}')
url = None
if(
getattr(response, 'status_code', 0) == 200
or len(fetched_flags) > 0
):
feature_flags: dict = {}
for entry in fetched_flags:
[*key], [*flag] = zip(*entry.items())
feature_flags.update({
key[0]: FeatureFlag(key[0], flag[0])
})
self._feature_flags = feature_flags
if response is not None:
if response.headers.get('last-modified', None) is not None:
self._last_modified = datetime.strptime(response.headers['last-modified'], '%a, %d %b %Y %H:%M:%S %z')
else:
last_mod_date: datetime = datetime.fromtimestamp(0)
for item in self._feature_flags:
parsed_date = parse(self._feature_flags[item].modified)
if parsed_date.timestamp() > last_mod_date.timestamp():
last_mod_date = parsed_date
self._last_modified = last_mod_date
if getattr(response, 'status_code', 0) == 200:
with open(feature_flag_path, 'w') as feature_flag_file:
feature_flag_file.write(self.toJson())
self._cache_date = datetime.now()
def toJson(self):
obj = []
for entry in self._feature_flags:
obj += [
self._feature_flags[entry].dump()
]
return json.dumps(obj)

View File

@ -0,0 +1,117 @@
from datetime import datetime
class FeatureFlag:
"""Centurion ERP Feature Flag
Contains a Centurion ERP feature flag.
Args:
key (str):
Attributes:
__bool__ (bool): Enabled value
__str__ (str): Name of the feature flag
key (str): Feature Flag key
name (str): Feature Flag name
description (str): Feature Flag Description
enabled (bool): Enabled value of the feature flag
created (datetime): Creation date of the feature flag
modified (datetime): Date when feature flag was last modified
"""
_key: str = None
_name: str = None
_description: str = None
_enabled: bool = None
_created: datetime = None
_modified: datetime = None
def __init__(self, key, flag: dict):
self._key = key
self._name = flag['name']
self._description = flag['description']
self._enabled = flag['enabled']
self._created = flag['created']
self._modified = flag['modified']
def __bool__(self) -> bool:
"""Feature Flag Enabled
Returns:
bool: Feature flag enabled value.
"""
return self._enabled
def __str__(self) -> str:
"""Fetch name of Feature Flag
Returns:
str: Name of the Feature Flag
"""
return self._name
@property
def key(self) -> str:
return self._key
@property
def name(self) -> str:
return self._name
@property
def description(self) -> str:
return self._description
@property
def enabled(self) -> bool:
return self._enabled
@property
def created(self) -> datetime:
return self._created
@property
def modified(self) -> datetime:
return self._modified
def dump(self) -> dict:
return {
self.key: {
'name': self.name,
'description': self.description,
'enabled': self.enabled,
'created': self.created,
'modified': self.modified
}
}

View File

@ -0,0 +1,73 @@
import subprocess
from django.core.management.base import BaseCommand
from app import settings
from centurion_feature_flag.lib.feature_flag import CenturionFeatureFlagging
class Command(BaseCommand):
help = 'Running this command will download the available feature flags form the Centurion Server if the cache has expired (>4hours) or the cache file does not exist.'
def add_arguments(self, parser):
parser.add_argument('-r', '--reload', action='store_true', help='Restart the Centurion Process')
def handle(self, *args, **kwargs):
if getattr(settings,'feature_flag', None):
feature_flagging = CenturionFeatureFlagging(
url = settings.feature_flag['url'],
user_agent = settings.feature_flag['user_agent'],
cache_dir =settings.feature_flag['cache_dir'],
disable_downloading = settings.feature_flag.get('disable_downloading', False),
unique_id = settings.feature_flag.get('unique_id', None),
version = settings.feature_flag.get('version', None),
)
self.stdout.write('Fetching Feature Flags.....')
feature_flagging.get()
if feature_flagging:
self.stdout.write('Success.')
else:
self.stderr.write('Error. Something went wrong.')
if kwargs['reload']:
if settings.BUILD_SHA:
self.stdout.write('restarting Centurion')
restart = subprocess.run(["supervisorctl", "restart", "gunicorn"], capture_output = True)
status = subprocess.run(["supervisorctl", "status", "gunicorn"], capture_output = True)
if status.returncode == 0:
self.stdout.write('Centurion restarted successfully')
a = 'b'
else:
self.stdout.write('using kwarg `--reload` whilst not within production does nothing.')
else:
self.stdout.write('Feature Flaggin is not enabled')

View File

@ -0,0 +1,40 @@
from app import settings
from centurion_feature_flag.lib.feature_flag import CenturionFeatureFlagging
class FeatureFlagMiddleware:
_feature_flagging: CenturionFeatureFlagging = None
def __init__(self, get_response):
self.get_response = get_response
if getattr(settings,'feature_flag', None):
self._feature_flagging = CenturionFeatureFlagging(
url = settings.feature_flag['url'],
user_agent = settings.feature_flag['user_agent'],
cache_dir =settings.feature_flag['cache_dir'],
disable_downloading = settings.feature_flag.get('disable_downloading', False),
unique_id = settings.feature_flag.get('unique_id', None),
version = settings.feature_flag.get('version', None),
)
def __call__(self, request):
if(
'/flags/' not in request.path
and self._feature_flagging is not None
):
self._feature_flagging.get()
setattr(request, 'feature_flag', self._feature_flagging)
return self.get_response(request)

View File

@ -0,0 +1,47 @@
from django.urls.conf import (
_path as _django_path,
include, # pylint: disable=W0611:unused-import
partial,
RegexPattern as DjangoRegexPattern,
RoutePattern as DjangoRoutePattern,
)
from app import settings
from centurion_feature_flag.lib.feature_flag import CenturionFeatureFlagging
from centurion_feature_flag.views.disabled import FeatureFlagView
_feature_flagging: CenturionFeatureFlagging = None
if getattr(settings,'feature_flag', None):
_feature_flagging = CenturionFeatureFlagging(
url = settings.feature_flag['url'],
user_agent = settings.feature_flag['user_agent'],
cache_dir =settings.feature_flag['cache_dir'],
disable_downloading = settings.feature_flag.get('disable_downloading', False),
unique_id = settings.feature_flag.get('unique_id', None),
version = settings.feature_flag.get('version', None),
)
_feature_flagging.get()
def _path(route, view, kwargs=None, name=None, Pattern=None, feature_flag: str =None):
if feature_flag is not None:
if not _feature_flagging[feature_flag]:
view = FeatureFlagView.as_view()
return _django_path(route, view, kwargs=kwargs, name=name, Pattern=Pattern)
path = partial(_path, Pattern=DjangoRoutePattern)
re_path = partial(_path, Pattern=DjangoRegexPattern)

View File

@ -0,0 +1,108 @@
from rest_framework.routers import (
APIRootView as DRFAPIRootView,
BaseRouter as DRFBaseRouter,
DefaultRouter as DRFDefaultRouter,
SimpleRouter as DRFSimpleRouter,
)
from app import settings
from centurion_feature_flag.lib.feature_flag import CenturionFeatureFlagging
class BaseRouter(
DRFBaseRouter,
):
_feature_flagging: CenturionFeatureFlagging = None
def register(self, prefix, viewset, feature_flag=None, basename=None):
enabled = True
if feature_flag is not None:
if not self._feature_flagging[feature_flag]:
enabled = False
if(
enabled
or feature_flag is None
):
super().register(prefix, viewset, basename=basename)
class APIRootView(
DRFAPIRootView,
):
def __init__(self, **kwargs):
super().__init__(**kwargs)
if getattr(settings,'feature_flag', None):
self._feature_flagging = CenturionFeatureFlagging(
url = settings.feature_flag['url'],
user_agent = settings.feature_flag['user_agent'],
cache_dir =settings.feature_flag['cache_dir'],
disable_downloading = settings.feature_flag.get('disable_downloading', False),
unique_id = settings.feature_flag.get('unique_id', None),
version = settings.feature_flag.get('version', None),
)
class SimpleRouter(
BaseRouter,
DRFSimpleRouter,
):
def __init__(self, trailing_slash=True, use_regex_path=True):
super().__init__(trailing_slash=trailing_slash, use_regex_path=use_regex_path)
if getattr(settings,'feature_flag', None):
self._feature_flagging = CenturionFeatureFlagging(
url = settings.feature_flag['url'],
user_agent = settings.feature_flag['user_agent'],
cache_dir =settings.feature_flag['cache_dir'],
disable_downloading = settings.feature_flag.get('disable_downloading', False),
unique_id = settings.feature_flag.get('unique_id', None),
version = settings.feature_flag.get('version', None),
)
self._feature_flagging.get()
class DefaultRouter(
BaseRouter,
DRFDefaultRouter,
):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if getattr(settings,'feature_flag', None):
self._feature_flagging = CenturionFeatureFlagging(
url = settings.feature_flag['url'],
user_agent = settings.feature_flag['user_agent'],
cache_dir =settings.feature_flag['cache_dir'],
disable_downloading = settings.feature_flag.get('disable_downloading', False),
unique_id = settings.feature_flag.get('unique_id', None),
version = settings.feature_flag.get('version', None),
)
self._feature_flagging.get()

View File

@ -0,0 +1,38 @@
from django.shortcuts import Http404, HttpResponse #, redirect, render
from django.views.generic import View
class FeatureFlagView(View):
"""Featur Flag View
This view serves the purpose of being the stand-in view for a view that
has been disabled via a feature flag.
"""
def delete(self, request):
raise Http404()
def get(self, request):
raise Http404()
def head(self, request):
raise Http404()
def options(self, request):
raise Http404()
def patch(self, request):
raise Http404()
def post(self, request):
raise Http404()
def put(self, request):
raise Http404()

View File

@ -12,6 +12,7 @@ from access.models.tenancy import TenancyObject
from app.helpers.merge_software import merge_software
from core.lib.feature_not_used import FeatureNotUsed
from core.mixin.history_save import SaveHistory
from core.signal.ticket_linked_item_delete import TicketLinkedItem, deleted_model
@ -415,6 +416,10 @@ class ConfigGroupHosts(GroupsCommonFields, SaveHistory):
)
def get_url_kwargs_notes(self):
return FeatureNotUsed
@property
def parent_object(self):
""" Fetch the parent object """
@ -513,6 +518,11 @@ class ConfigGroupSoftware(GroupsCommonFields, SaveHistory):
}
def get_url_kwargs_notes(self):
return FeatureNotUsed
@property
def parent_object(self):
""" Fetch the parent object """

View File

@ -64,7 +64,6 @@ class ConfigGroupSoftwareModelSerializer(
del get_url['history']
del get_url['knowledge_base']
del get_url['notes']
get_url.update({
'organization': reverse(

View File

@ -98,11 +98,6 @@ class ViewSet( ModelViewSet ):
def get_serializer_class(self):
if self.serializer_class is not None:
return self.serializer_class
if (
self.action == 'list'
or self.action == 'retrieve'

View File

@ -45,11 +45,6 @@ class ViewSet(ModelNoteViewSet):
def get_serializer_class(self):
if self.serializer_class is not None:
return self.serializer_class
if (
self.action == 'list'
or self.action == 'retrieve'

View File

@ -90,11 +90,6 @@ class ViewSet( ModelViewSet ):
def get_serializer_class(self):
if self.serializer_class is not None:
return self.serializer_class
if (
self.action == 'list'
or self.action == 'retrieve'

View File

@ -7,6 +7,7 @@ from django.http import Http404
from rest_framework import exceptions, status
from rest_framework.exceptions import (
MethodNotAllowed,
NotFound,
NotAuthenticated,
ParseError,
PermissionDenied,
@ -27,3 +28,14 @@ class APIError(
default_detail = 'An unknown ERROR occured'
default_code = 'unknown_error'
class NotModified(
exceptions.APIException
):
status_code = status.HTTP_304_NOT_MODIFIED
default_detail = ''
default_code = 'not_modified'

View File

@ -0,0 +1,13 @@
class FeatureNotUsed:
"""Type used to denote that a feature is not enabled"""
def __bool__(self):
return False
def __list__(self):
return False

View File

@ -157,6 +157,14 @@ For this command to process the following conditions must be met:
item_type = TicketLinkedItem.Modules.DEVICE
elif model_type == 'feature_flag':
from devops.models.feature_flag import FeatureFlag
model = FeatureFlag
item_type = TicketLinkedItem.Modules.FEATURE_FLAG
elif model_type == 'kb':
from assistance.models.knowledge_base import KnowledgeBase
@ -175,7 +183,7 @@ For this command to process the following conditions must be met:
elif model_type == 'organization':
from access.models import Organization
from access.models.organization import Organization
model = Organization
@ -199,7 +207,7 @@ For this command to process the following conditions must be met:
elif model_type == 'team':
from access.models import Team
from access.models.team import Team
model = Team

View File

@ -0,0 +1,47 @@
# Generated by Django 5.1.5 on 2025-03-16 01:47
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0016_data_move_history_to_new_table'),
]
operations = [
migrations.AlterField(
model_name='ticketlinkeditem',
name='item_type',
field=models.IntegerField(choices=[(1, 'Cluster'), (2, 'Config Group'), (3, 'Device'), (4, 'Operating System'), (5, 'Service'), (6, 'Software'), (7, 'Knowledge Base'), (8, 'Organization'), (9, 'Team'), (10, 'Feature Flag')], help_text='Python Model location for linked item', verbose_name='Item Type'),
),
migrations.CreateModel(
name='TicketCategoryNotes',
fields=[
('modelnotes_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='core.modelnotes')),
('model', models.ForeignKey(help_text='Model this note belongs to', on_delete=django.db.models.deletion.CASCADE, related_name='notes', to='core.ticketcategory', verbose_name='Model')),
],
options={
'verbose_name': 'Ticket Category Note',
'verbose_name_plural': 'Ticket Category Notes',
'db_table': 'core_ticketcategory_notes',
'ordering': ['-created'],
},
bases=('core.modelnotes',),
),
migrations.CreateModel(
name='TicketCommentCategoryNotes',
fields=[
('modelnotes_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='core.modelnotes')),
('model', models.ForeignKey(help_text='Model this note belongs to', on_delete=django.db.models.deletion.CASCADE, related_name='notes', to='core.ticketcommentcategory', verbose_name='Model')),
],
options={
'verbose_name': 'Ticket Comment Category Note',
'verbose_name_plural': 'Ticket Comment Category Notes',
'db_table': 'core_ticketcommentcategory_notes',
'ordering': ['-created'],
},
bases=('core.modelnotes',),
),
]

View File

@ -7,6 +7,8 @@ from access.fields import AutoCreatedField
from access.models.organization import Organization
from access.models.tenancy import TenancyObject
from core.lib.feature_not_used import FeatureNotUsed
class ModelHistory(
@ -181,6 +183,11 @@ class ModelHistory(
}
def get_url_kwargs_notes(self):
return FeatureNotUsed
def get_url( self, request = None ) -> str:
if request:

View File

@ -8,6 +8,8 @@ from access.models.tenancy import TenancyObject
from config_management.models.groups import ConfigGroups
from core.lib.feature_not_used import FeatureNotUsed
from itam.models.device import Device
from itam.models.software import Software
from itam.models.operating_system import OperatingSystem
@ -111,6 +113,11 @@ class ModelNotes(TenancyObject):
return 'Note ' + str(self.id)
def get_url_kwargs_notes(self):
return FeatureNotUsed
@property
def parent_object(self):
""" Fetch the parent object """

View File

@ -14,6 +14,7 @@ from access.models.team import Team
from access.models.tenancy import TenancyObject
from core import exceptions as centurion_exceptions
from core.lib.feature_not_used import FeatureNotUsed
from core.lib.slash_commands import SlashCommands
from core.middleware.get_request import get_request
from core.models.ticket.ticket_category import TicketCategory, KnowledgeBase
@ -729,6 +730,11 @@ class Ticket(
return reverse(f"v2:_api_v2_ticket_{ticket_type}-detail", kwargs = kwargs )
def get_url_kwargs_notes(self):
return FeatureNotUsed
@property
def linked_items(self) -> list(dict()):
"""Fetch items linked to ticket
@ -1519,6 +1525,11 @@ class RelatedTickets(TenancyObject):
)
def get_url_kwargs_notes(self):
return FeatureNotUsed
def __str__(self):
# return '#' + str( self.id )

View File

@ -0,0 +1,44 @@
from django.db import models
from core.models.ticket.ticket_category import TicketCategory
from core.models.model_notes import ModelNotes
class TicketCategoryNotes(
ModelNotes
):
class Meta:
db_table = 'core_ticketcategory_notes'
ordering = ModelNotes._meta.ordering
verbose_name = 'Ticket Category Note'
verbose_name_plural = 'Ticket Category Notes'
model = models.ForeignKey(
TicketCategory,
blank = False,
help_text = 'Model this note belongs to',
null = False,
on_delete = models.CASCADE,
related_name = 'notes',
verbose_name = 'Model',
)
table_fields: list = []
page_layout: dict = []
def get_url_kwargs(self) -> dict:
return {
'model_id': self.model.pk,
'pk': self.pk
}

View File

@ -8,6 +8,7 @@ from access.fields import AutoCreatedField, AutoLastModifiedField
from access.models.team import Team
from access.models.tenancy import TenancyObject
from core.lib.feature_not_used import FeatureNotUsed
from core.lib.slash_commands import SlashCommands
from .ticket import Ticket
@ -449,6 +450,10 @@ class TicketComment(
return reverse(f"v2:{url_name}-detail", kwargs = kwargs )
def get_url_kwargs_notes(self):
return FeatureNotUsed
@property
def parent_object(self):

View File

@ -0,0 +1,44 @@
from django.db import models
from core.models.ticket.ticket_comment_category import TicketCommentCategory
from core.models.model_notes import ModelNotes
class TicketCommentCategoryNotes(
ModelNotes
):
class Meta:
db_table = 'core_ticketcommentcategory_notes'
ordering = ModelNotes._meta.ordering
verbose_name = 'Ticket Comment Category Note'
verbose_name_plural = 'Ticket Comment Category Notes'
model = models.ForeignKey(
TicketCommentCategory,
blank = False,
help_text = 'Model this note belongs to',
null = False,
on_delete = models.CASCADE,
related_name = 'notes',
verbose_name = 'Model',
)
table_fields: list = []
page_layout: dict = []
def get_url_kwargs(self) -> dict:
return {
'model_id': self.model.pk,
'pk': self.pk
}

View File

@ -9,6 +9,7 @@ from .ticket_enum_values import TicketValues
from access.models.tenancy import TenancyObject
from core.lib.feature_not_used import FeatureNotUsed
from core.middleware.get_request import get_request
from core.models.ticket.ticket import Ticket, KnowledgeBase
@ -40,6 +41,7 @@ class TicketLinkedItem(TenancyObject):
KB = 7, 'Knowledge Base'
ORGANIZATION = 8, 'Organization'
TEAM = 9, 'Team'
FEATURE_FLAG = 10, 'Feature Flag'
is_global = None
@ -105,6 +107,10 @@ class TicketLinkedItem(TenancyObject):
}
)
def get_url_kwargs_notes(self):
return FeatureNotUsed
def __str__(self) -> str:
@ -146,6 +152,11 @@ class TicketLinkedItem(TenancyObject):
item_type = 'team'
else:
item_type = str(self.get_item_type_display()).lower().replace(' ', '_')
if item_type:
return f'${item_type}-{int(self.item)}'

View File

@ -0,0 +1,42 @@
from core.models.ticket.ticket_category_notes import TicketCategoryNotes
from core.serializers.model_notes import (
ModelNotes,
ModelNoteBaseSerializer,
ModelNoteModelSerializer,
ModelNoteViewSerializer
)
class TicketCategoryNoteBaseSerializer(ModelNoteBaseSerializer):
pass
class TicketCategoryNoteModelSerializer(
ModelNoteModelSerializer
):
class Meta:
model = TicketCategoryNotes
fields = ModelNoteModelSerializer.Meta.fields + [
'model',
]
read_only_fields = ModelNoteModelSerializer.Meta.read_only_fields + [
'model',
'content_type',
]
class TicketCategoryNoteViewSerializer(
ModelNoteViewSerializer,
TicketCategoryNoteModelSerializer,
):
pass

View File

@ -0,0 +1,42 @@
from core.models.ticket.ticket_comment_category_notes import TicketCommentCategoryNotes
from core.serializers.model_notes import (
ModelNotes,
ModelNoteBaseSerializer,
ModelNoteModelSerializer,
ModelNoteViewSerializer
)
class TicketCommentCategoryNoteBaseSerializer(ModelNoteBaseSerializer):
pass
class TicketCommentCategoryNoteModelSerializer(
ModelNoteModelSerializer
):
class Meta:
model = TicketCommentCategoryNotes
fields = ModelNoteModelSerializer.Meta.fields + [
'model',
]
read_only_fields = ModelNoteModelSerializer.Meta.read_only_fields + [
'model',
'content_type',
]
class TicketCommentCategoryNoteViewSerializer(
ModelNoteViewSerializer,
TicketCommentCategoryNoteModelSerializer,
):
pass

View File

@ -190,6 +190,14 @@ class TicketLinkedItemViewSerializer(TicketLinkedItemModelSerializer):
model = Device
elif item.item_type == TicketLinkedItem.Modules.FEATURE_FLAG:
from devops.serializers.feature_flag import FeatureFlag, BaseSerializer
base_serializer = BaseSerializer
model = FeatureFlag
elif item.item_type == TicketLinkedItem.Modules.KB:
from assistance.serializers.knowledge_base import KnowledgeBase, KnowledgeBaseBaseSerializer

View File

@ -336,6 +336,24 @@ class BaseModelHistoryAPI(
)
def test_api_field_exists_urls_notes(self):
""" Test for existance of API Field
_urls.notes field must exist
"""
assert True
def test_api_field_type_urls_notes(self):
""" Test for type for API Field
_urls._self field must be str
"""
assert True
class PrimaryModelHistoryAPI(
BaseModelHistoryAPI,

View File

@ -0,0 +1,54 @@
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase
from core.tests.abstract.model_notes_api_fields import ModelNotesNotesAPIFields
from core.models.ticket.ticket_category_notes import TicketCategory, TicketCategoryNotes
class NotesAPI(
ModelNotesNotesAPIFields,
TestCase,
):
model = TicketCategoryNotes
view_name: str = '_api_v2_ticket_category_note'
@classmethod
def setUpTestData(self):
"""Setup Test
1. Call parent setup
2. Create a model note
3. add url kwargs
4. make the API request
"""
super().setUpTestData()
self.item = self.model.objects.create(
organization = self.organization,
content = 'a random comment',
content_type = ContentType.objects.get(
app_label = str(self.model._meta.app_label).lower(),
model = str(self.model.model.field.related_model.__name__).replace(' ', '').lower(),
),
model = TicketCategory.objects.create(
organization = self.organization,
name = 'note model',
),
created_by = self.view_user,
modified_by = self.view_user,
)
self.url_view_kwargs = {
'model_id': self.item.model.pk,
'pk': self.item.pk
}
self.make_request()

View File

@ -0,0 +1,125 @@
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase
from core.viewsets.ticket_category_notes import ViewSet
from core.tests.abstract.test_functional_notes_viewset import (
ModelNotesViewSetBase,
ModelNotesMetadata,
ModelNotesPermissionsAPI,
ModelNotesSerializer
)
class ViewSetBase(
ModelNotesViewSetBase
):
viewset = ViewSet
url_name = '_api_v2_ticket_category_note'
@classmethod
def setUpTestData(self):
super().setUpTestData()
self.item = self.viewset.model.objects.create(
organization = self.organization,
content = 'a random comment',
content_type = ContentType.objects.get(
app_label = str(self.model._meta.app_label).lower(),
model = str(self.model.model.field.related_model.__name__).replace(' ', '').lower(),
),
model = self.viewset.model.model.field.related_model.objects.create(
organization = self.organization,
name = 'note model',
),
created_by = self.view_user,
modified_by = self.view_user,
)
self.other_org_item = self.viewset.model.objects.create(
organization = self.different_organization,
content = 'a random comment',
content_type = ContentType.objects.get(
app_label = str(self.model._meta.app_label).lower(),
model = str(self.model.model.field.related_model.__name__).replace(' ', '').lower(),
),
model = self.viewset.model.model.field.related_model.objects.create(
organization = self.different_organization,
name = 'note model other_org_item',
),
created_by = self.view_user,
modified_by = self.view_user,
)
self.global_org_item = self.viewset.model.objects.create(
organization = self.global_organization,
content = 'a random comment global_organization',
content_type = ContentType.objects.get(
app_label = str(self.model._meta.app_label).lower(),
model = str(self.model.model.field.related_model.__name__).replace(' ', '').lower(),
),
model = self.viewset.model.model.field.related_model.objects.create(
organization = self.global_organization,
name = 'note model global_organization',
),
created_by = self.view_user,
modified_by = self.view_user,
)
self.url_kwargs = {
'model_id': self.item.model.pk,
}
self.url_view_kwargs = {
'model_id': self.item.model.pk,
'pk': self.item.id
}
class ManufacturerModelNotesPermissionsAPI(
ViewSetBase,
ModelNotesPermissionsAPI,
TestCase,
):
def test_returned_data_from_user_and_global_organizations_only(self):
"""Check items returned
This test case is a over-ride of a test case with the same name.
This model is not a global model making this test not-applicable.
Items returned from the query Must be from the users organization and
global ONLY!
"""
pass
class ManufacturerBaseModelNotesSerializer(
ViewSetBase,
ModelNotesSerializer,
TestCase,
):
pass
class ManufacturerModelNotesMetadata(
ViewSetBase,
ModelNotesMetadata,
TestCase,
):
pass

View File

@ -0,0 +1,54 @@
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase
from core.tests.abstract.model_notes_api_fields import ModelNotesNotesAPIFields
from core.models.ticket.ticket_comment_category_notes import TicketCommentCategory, TicketCommentCategoryNotes
class NotesAPI(
ModelNotesNotesAPIFields,
TestCase,
):
model = TicketCommentCategoryNotes
view_name: str = '_api_v2_ticket_comment_category_note'
@classmethod
def setUpTestData(self):
"""Setup Test
1. Call parent setup
2. Create a model note
3. add url kwargs
4. make the API request
"""
super().setUpTestData()
self.item = self.model.objects.create(
organization = self.organization,
content = 'a random comment',
content_type = ContentType.objects.get(
app_label = str(self.model._meta.app_label).lower(),
model = str(self.model.model.field.related_model.__name__).replace(' ', '').lower(),
),
model = TicketCommentCategory.objects.create(
organization = self.organization,
name = 'note model',
),
created_by = self.view_user,
modified_by = self.view_user,
)
self.url_view_kwargs = {
'model_id': self.item.model.pk,
'pk': self.item.pk
}
self.make_request()

View File

@ -0,0 +1,125 @@
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase
from core.viewsets.ticket_comment_category_notes import ViewSet
from core.tests.abstract.test_functional_notes_viewset import (
ModelNotesViewSetBase,
ModelNotesMetadata,
ModelNotesPermissionsAPI,
ModelNotesSerializer
)
class ViewSetBase(
ModelNotesViewSetBase
):
viewset = ViewSet
url_name = '_api_v2_ticket_comment_category_note'
@classmethod
def setUpTestData(self):
super().setUpTestData()
self.item = self.viewset.model.objects.create(
organization = self.organization,
content = 'a random comment',
content_type = ContentType.objects.get(
app_label = str(self.model._meta.app_label).lower(),
model = str(self.model.model.field.related_model.__name__).replace(' ', '').lower(),
),
model = self.viewset.model.model.field.related_model.objects.create(
organization = self.organization,
name = 'note model',
),
created_by = self.view_user,
modified_by = self.view_user,
)
self.other_org_item = self.viewset.model.objects.create(
organization = self.different_organization,
content = 'a random comment',
content_type = ContentType.objects.get(
app_label = str(self.model._meta.app_label).lower(),
model = str(self.model.model.field.related_model.__name__).replace(' ', '').lower(),
),
model = self.viewset.model.model.field.related_model.objects.create(
organization = self.different_organization,
name = 'note model other_org_item',
),
created_by = self.view_user,
modified_by = self.view_user,
)
self.global_org_item = self.viewset.model.objects.create(
organization = self.global_organization,
content = 'a random comment global_organization',
content_type = ContentType.objects.get(
app_label = str(self.model._meta.app_label).lower(),
model = str(self.model.model.field.related_model.__name__).replace(' ', '').lower(),
),
model = self.viewset.model.model.field.related_model.objects.create(
organization = self.global_organization,
name = 'note model global_organization',
),
created_by = self.view_user,
modified_by = self.view_user,
)
self.url_kwargs = {
'model_id': self.item.model.pk,
}
self.url_view_kwargs = {
'model_id': self.item.model.pk,
'pk': self.item.id
}
class ManufacturerModelNotesPermissionsAPI(
ViewSetBase,
ModelNotesPermissionsAPI,
TestCase,
):
def test_returned_data_from_user_and_global_organizations_only(self):
"""Check items returned
This test case is a over-ride of a test case with the same name.
This model is not a global model making this test not-applicable.
Items returned from the query Must be from the users organization and
global ONLY!
"""
pass
class ManufacturerBaseModelNotesSerializer(
ViewSetBase,
ModelNotesSerializer,
TestCase,
):
pass
class ManufacturerModelNotesMetadata(
ViewSetBase,
ModelNotesMetadata,
TestCase,
):
pass

View File

@ -372,3 +372,24 @@ class CeleryTaskResultAPI(
"""
assert type(self.api_data['meta']) is str
def test_api_field_exists_urls_notes(self):
""" Test for existance of API Field
test is na for this model
_urls.notes field must exist
"""
assert True
def test_api_field_type_urls_notes(self):
""" Test for type for API Field
test is na for this model
_urls._self field must be str
"""
assert True

View File

@ -0,0 +1,37 @@
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase
from core.tests.abstract.test_unit_model_notes_model import ModelNotesModel
from core.models.ticket.ticket_category_notes import TicketCategoryNotes
class NotesModel(
ModelNotesModel,
TestCase,
):
model = TicketCategoryNotes
@classmethod
def setUpTestData(self):
"""Setup Test"""
super().setUpTestData()
self.item = self.model.objects.create(
organization = self.organization,
content = 'a random comment for an exiting item',
content_type = ContentType.objects.get(
app_label = str(self.model._meta.app_label).lower(),
model = str(self.model.model.field.related_model.__name__).replace(' ', '').lower(),
),
model = self.model.model.field.related_model.objects.create(
organization = self.organization,
name = 'note model existing item',
),
created_by = self.user,
)

View File

@ -0,0 +1,60 @@
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase
from core.tests.abstract.test_unit_model_notes_serializer import ModelNotesSerializerTestCases
from core.serializers.ticket_category_notes import TicketCategoryNotes, TicketCategoryNoteModelSerializer
class NotesSerializer(
ModelNotesSerializerTestCases,
TestCase,
):
model = TicketCategoryNotes
model_serializer = TicketCategoryNoteModelSerializer
@classmethod
def setUpTestData(self):
"""Setup Test"""
super().setUpTestData()
self.note_model = self.model.model.field.related_model.objects.create(
organization = self.organization,
name = 'note model',
)
self.note_model_two = self.model.model.field.related_model.objects.create(
organization = self.organization,
name = 'note model two',
)
self.item = self.model.objects.create(
organization = self.organization,
content = 'a random comment for an exiting item',
content_type = ContentType.objects.get(
app_label = str(self.model._meta.app_label).lower(),
model = str(self.model.model.field.related_model.__name__).replace(' ', '').lower(),
),
model = self.model.model.field.related_model.objects.create(
organization = self.organization,
name = 'note model existing item',
),
created_by = self.user_two,
)
self.valid_data = {
'organization': self.organization_two.id,
'content': 'a random comment',
'content_type': self.content_type_two.id,
'model': self.note_model_two.id,
'created_by': self.user_two.id,
'modified_by': self.user_two.id,
}

View File

@ -0,0 +1,75 @@
from django.contrib.auth.models import User
from django.test import Client, TestCase
from rest_framework.reverse import reverse
from access.models.organization import Organization
from api.tests.abstract.viewsets import ViewSetModel
from core.viewsets.ticket_category_notes import ViewSet
class ViewsetCommon(
ViewSetModel,
):
viewset = ViewSet
route_name = 'v2:_api_v2_ticket_category_note'
@classmethod
def setUpTestData(self):
"""Setup Test
1. Create an organization
3. create super user
"""
organization = Organization.objects.create(name='test_org')
self.organization = organization
self.view_user = User.objects.create_user(username="test_view_user", password="password", is_superuser=True)
class NotesViewsetList(
ViewsetCommon,
TestCase,
):
@classmethod
def setUpTestData(self):
"""Setup Test
1. Create object that is to be tested against
2. add kwargs
3. make list request
"""
super().setUpTestData()
self.note_model = self.viewset.model.model.field.related_model.objects.create(
organization = self.organization,
name = 'note model',
)
self.kwargs = {
'model_id': self.note_model.pk,
}
client = Client()
url = reverse(
self.route_name + '-list',
kwargs = self.kwargs
)
client.force_login(self.view_user)
self.http_options_response_list = client.options(url)

View File

@ -0,0 +1,37 @@
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase
from core.tests.abstract.test_unit_model_notes_model import ModelNotesModel
from core.models.ticket.ticket_comment_category_notes import TicketCommentCategoryNotes
class NotesModel(
ModelNotesModel,
TestCase,
):
model = TicketCommentCategoryNotes
@classmethod
def setUpTestData(self):
"""Setup Test"""
super().setUpTestData()
self.item = self.model.objects.create(
organization = self.organization,
content = 'a random comment for an exiting item',
content_type = ContentType.objects.get(
app_label = str(self.model._meta.app_label).lower(),
model = str(self.model.model.field.related_model.__name__).replace(' ', '').lower(),
),
model = self.model.model.field.related_model.objects.create(
organization = self.organization,
name = 'note model existing item',
),
created_by = self.user,
)

View File

@ -0,0 +1,60 @@
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase
from core.tests.abstract.test_unit_model_notes_serializer import ModelNotesSerializerTestCases
from core.serializers.ticket_comment_category_notes import TicketCommentCategoryNotes, TicketCommentCategoryNoteModelSerializer
class NotesSerializer(
ModelNotesSerializerTestCases,
TestCase,
):
model = TicketCommentCategoryNotes
model_serializer = TicketCommentCategoryNoteModelSerializer
@classmethod
def setUpTestData(self):
"""Setup Test"""
super().setUpTestData()
self.note_model = self.model.model.field.related_model.objects.create(
organization = self.organization,
name = 'note model',
)
self.note_model_two = self.model.model.field.related_model.objects.create(
organization = self.organization,
name = 'note model two',
)
self.item = self.model.objects.create(
organization = self.organization,
content = 'a random comment for an exiting item',
content_type = ContentType.objects.get(
app_label = str(self.model._meta.app_label).lower(),
model = str(self.model.model.field.related_model.__name__).replace(' ', '').lower(),
),
model = self.model.model.field.related_model.objects.create(
organization = self.organization,
name = 'note model existing item',
),
created_by = self.user_two,
)
self.valid_data = {
'organization': self.organization_two.id,
'content': 'a random comment',
'content_type': self.content_type_two.id,
'model': self.note_model_two.id,
'created_by': self.user_two.id,
'modified_by': self.user_two.id,
}

View File

@ -0,0 +1,75 @@
from django.contrib.auth.models import User
from django.test import Client, TestCase
from rest_framework.reverse import reverse
from access.models.organization import Organization
from api.tests.abstract.viewsets import ViewSetModel
from core.viewsets.ticket_comment_category_notes import ViewSet
class ViewsetCommon(
ViewSetModel,
):
viewset = ViewSet
route_name = 'v2:_api_v2_ticket_comment_category_note'
@classmethod
def setUpTestData(self):
"""Setup Test
1. Create an organization
3. create super user
"""
organization = Organization.objects.create(name='test_org')
self.organization = organization
self.view_user = User.objects.create_user(username="test_view_user", password="password", is_superuser=True)
class NotesViewsetList(
ViewsetCommon,
TestCase,
):
@classmethod
def setUpTestData(self):
"""Setup Test
1. Create object that is to be tested against
2. add kwargs
3. make list request
"""
super().setUpTestData()
self.note_model = self.viewset.model.model.field.related_model.objects.create(
organization = self.organization,
name = 'note model',
)
self.kwargs = {
'model_id': self.note_model.pk,
}
client = Client()
url = reverse(
self.route_name + '-list',
kwargs = self.kwargs
)
client.force_login(self.view_user)
self.http_options_response_list = client.options(url)

View File

@ -95,10 +95,6 @@ class ViewSet(AuthUserReadOnlyModelViewSet):
def get_serializer_class(self):
if self.serializer_class is not None:
return self.serializer_class
if (
self.action == 'list'
or self.action == 'retrieve'

View File

@ -149,10 +149,6 @@ class ViewSet(ReadOnlyModelViewSet):
def get_serializer_class(self):
if self.serializer_class is not None:
return self.serializer_class
self.serializer_class = globals()[str( self.model._meta.verbose_name).replace(' ', '') + 'ViewSerializer']
return self.serializer_class

View File

@ -77,11 +77,6 @@ class ViewSet(ModelViewSet):
def get_serializer_class(self):
if self.serializer_class is not None:
return self.serializer_class
if (
self.action == 'list'
or self.action == 'retrieve'

View File

@ -45,11 +45,6 @@ class ViewSet(ModelNoteViewSet):
def get_serializer_class(self):
if self.serializer_class is not None:
return self.serializer_class
if (
self.action == 'list'
or self.action == 'retrieve'

View File

@ -91,11 +91,6 @@ class ViewSet(ModelListRetrieveDeleteViewSet):
def get_serializer_class(self):
if self.serializer_class is not None:
return self.serializer_class
if (
self.action == 'list'
or self.action == 'retrieve'

View File

@ -281,11 +281,6 @@ class TicketViewSet(ModelViewSet):
serializer_prefix = str(self._ticket_type).replace(' ', '')
if self.serializer_class is not None:
return self.serializer_class
if (
self.action == 'create'
or self.action == 'list'

View File

@ -77,11 +77,6 @@ class ViewSet(ModelViewSet):
def get_serializer_class(self):
if self.serializer_class is not None:
return self.serializer_class
if (
self.action == 'list'
or self.action == 'retrieve'

View File

@ -0,0 +1,60 @@
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiResponse
from core.serializers.ticket_category_notes import (
TicketCategoryNotes,
TicketCategoryNoteModelSerializer,
TicketCategoryNoteViewSerializer,
)
from core.viewsets.model_notes import ModelNoteViewSet
@extend_schema_view(
create=extend_schema(
summary = 'Add a note to a Ticket Category',
description = '',
responses = {
201: OpenApiResponse(description='created', response=TicketCategoryNoteViewSerializer),
400: OpenApiResponse(description='Validation failed.'),
403: OpenApiResponse(description='User is missing create permissions'),
}
),
destroy = extend_schema(
summary = 'Delete a Ticket Category note',
description = ''
),
list = extend_schema(
summary = 'Fetch all Ticket Category notes',
description='',
),
retrieve = extend_schema(
summary = 'Fetch a single Ticket Category note',
description='',
),
update = extend_schema(exclude = True),
partial_update = extend_schema(
summary = 'Update a Ticket Category note',
description = ''
),
)
class ViewSet(ModelNoteViewSet):
model = TicketCategoryNotes
def get_serializer_class(self):
if (
self.action == 'list'
or self.action == 'retrieve'
):
self.serializer_class = TicketCategoryNoteViewSerializer
else:
self.serializer_class = TicketCategoryNoteModelSerializer
return self.serializer_class

View File

@ -217,10 +217,6 @@ class ViewSet(ModelViewSet):
def get_serializer_class(self):
if self.serializer_class is not None:
return self.serializer_class
organization:int = None
serializer_prefix:str = 'TicketComment'

View File

@ -73,11 +73,6 @@ class ViewSet(ModelViewSet):
def get_serializer_class(self):
if self.serializer_class is not None:
return self.serializer_class
if (
self.action == 'list'
or self.action == 'retrieve'

View File

@ -0,0 +1,60 @@
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiResponse
from core.serializers.ticket_comment_category_notes import (
TicketCommentCategoryNotes,
TicketCommentCategoryNoteModelSerializer,
TicketCommentCategoryNoteViewSerializer,
)
from core.viewsets.model_notes import ModelNoteViewSet
@extend_schema_view(
create=extend_schema(
summary = 'Add a note to a Ticket Comment Category',
description = '',
responses = {
201: OpenApiResponse(description='created', response=TicketCommentCategoryNoteViewSerializer),
400: OpenApiResponse(description='Validation failed.'),
403: OpenApiResponse(description='User is missing create permissions'),
}
),
destroy = extend_schema(
summary = 'Delete a Ticket Comment Category note',
description = ''
),
list = extend_schema(
summary = 'Fetch all Ticket Comment Category notes',
description='',
),
retrieve = extend_schema(
summary = 'Fetch a single Ticket Comment Category note',
description='',
),
update = extend_schema(exclude = True),
partial_update = extend_schema(
summary = 'Update a Ticket Comment Category note',
description = ''
),
)
class ViewSet(ModelNoteViewSet):
model = TicketCommentCategoryNotes
def get_serializer_class(self):
if (
self.action == 'list'
or self.action == 'retrieve'
):
self.serializer_class = TicketCommentCategoryNoteViewSerializer
else:
self.serializer_class = TicketCommentCategoryNoteModelSerializer
return self.serializer_class

View File

@ -204,11 +204,6 @@ class ViewSet(ModelViewSet):
def get_serializer_class(self):
if self.serializer_class is not None:
return self.serializer_class
if (
self.action == 'list'
or self.action == 'retrieve'

0
app/devops/__init__.py Normal file
View File

6
app/devops/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class DevOpsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'devops'

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