Compare commits
144 Commits
Author | SHA1 | Date | |
---|---|---|---|
7a9680d988 | |||
d7d85bd01d | |||
becb1eef26 | |||
bf973d3765 | |||
7912a67ab7 | |||
6272eef45f | |||
176537d583 | |||
2491ab611b | |||
3925b96cb0 | |||
8488642651 | |||
cb96d33f74 | |||
70978b9fdc | |||
0452293546 | |||
9c65d7f355 | |||
32137334ad | |||
a9fb70fcc7 | |||
c34903abf2 | |||
70c7100b1d | |||
8eb1650273 | |||
68364906f9 | |||
f99c1e2132 | |||
179340e1a2 | |||
b4d41970aa | |||
5f62828f28 | |||
46996b7f14 | |||
2929e347c7 | |||
b8281a3dd7 | |||
3888ab737b | |||
acc31ef079 | |||
19ce303045 | |||
0dbde5e1d6 | |||
f3a45d6ec2 | |||
f24f02df5d | |||
3ab5babd31 | |||
14c193d72a | |||
b22f4e2ea9 | |||
29553474f2 | |||
19d6b4f7e8 | |||
f1fb3cd7ff | |||
f1570a1997 | |||
2f95482150 | |||
b2e82fd8c9 | |||
192efadb08 | |||
00d16f841f | |||
c5107861a1 | |||
1d9580f14b | |||
aa77c69ed7 | |||
acf9f20ab9 | |||
6cf6a23964 | |||
a69e102432 | |||
89e2de437f | |||
dcebd4b8d6 | |||
2e35b651ef | |||
2f06814d99 | |||
faff43510d | |||
42105d4621 | |||
f61799dbfe | |||
427584c8d9 | |||
a07ee8472e | |||
3eb02602d6 | |||
7dac29971f | |||
0c6fb786dd | |||
d09e649765 | |||
8741e0b636 | |||
529512be3e | |||
d038165146 | |||
b63f6b7092 | |||
7b6fe804a9 | |||
b874fc7298 | |||
4f6debac88 | |||
5fdc0b32a6 | |||
e607999a62 | |||
5dd4bddea9 | |||
b60aa3be7a | |||
53956e0772 | |||
f4ccd3d164 | |||
629f5aec8e | |||
69eb7eb294 | |||
e317a96e45 | |||
9720ae527a | |||
33644a25d1 | |||
eb7ff47873 | |||
b542e92bbd | |||
b515b26203 | |||
b562e09622 | |||
748a1c80ef | |||
492bbfb521 | |||
86d5ce2062 | |||
541b7734e5 | |||
9fcea0528a | |||
0a5778258b | |||
75412df8b6 | |||
28e80bff50 | |||
f0ec8e4e56 | |||
8124d58014 | |||
9f1a73d7a5 | |||
b411d1fb24 | |||
41727d0a16 | |||
2eafb88367 | |||
6858c04bfd | |||
124c96512a | |||
23793e2133 | |||
4b06d6a2a1 | |||
8a56fdfcdb | |||
c4b640fb53 | |||
1afa102679 | |||
ddf3449b3f | |||
95c5f271ba | |||
db0cf389c3 | |||
8b17ea54c7 | |||
330d00a73d | |||
5f691748bc | |||
4876919015 | |||
55e30ab4f5 | |||
4d6438833d | |||
5b97f5400f | |||
8a787a516f | |||
51013d12d3 | |||
44a750f32b | |||
5938a51193 | |||
f833121c08 | |||
90a1e4baad | |||
20a1f69077 | |||
d79b13d98e | |||
05cf5b2835 | |||
1edc398f41 | |||
adfeec5fef | |||
7fbe6fda95 | |||
40f7f7739f | |||
51807b4747 | |||
ef1742c537 | |||
1216092413 | |||
ee23cb1f6e | |||
158e8436d8 | |||
235e4db5b6 | |||
aa1cd3eda2 | |||
1ed96ff9fc | |||
6fabdb6d17 | |||
c6a38684db | |||
4ecc841462 | |||
eda1fb673b | |||
d23e05ac7b | |||
1818ee94e7 | |||
9d88bf8827 |
2
.cz.yaml
2
.cz.yaml
@ -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
|
||||
|
15
.github/ISSUE_TEMPLATE/new_model.md
vendored
15
.github/ISSUE_TEMPLATE/new_model.md
vendored
@ -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
1
.gitignore
vendored
@ -15,3 +15,4 @@ node_modules/
|
||||
package-lock.json
|
||||
package.json
|
||||
**.junit.xml
|
||||
feature_flags.json
|
||||
|
14
.vscode/launch.json
vendored
14
.vscode/launch.json
vendored
@ -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",
|
||||
|
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
@ -20,5 +20,4 @@
|
||||
"cSpell.language": "en-AU",
|
||||
"jest.enable": false,
|
||||
"pylint.enabled": true,
|
||||
"pylint.importStrategy": "fromEnvironment",
|
||||
}
|
143
CHANGELOG.md
143
CHANGELOG.md
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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',
|
||||
]
|
||||
|
||||
|
@ -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 """
|
||||
|
@ -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 )
|
||||
|
@ -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
|
||||
|
||||
|
@ -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()
|
||||
|
@ -59,8 +59,6 @@ class TeamUserModelSerializer(
|
||||
|
||||
del get_url['knowledge_base']
|
||||
|
||||
del get_url['notes']
|
||||
|
||||
return get_url
|
||||
|
||||
|
||||
|
@ -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):
|
||||
|
@ -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):
|
||||
|
@ -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):
|
||||
|
@ -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'
|
||||
|
@ -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'
|
||||
|
@ -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'
|
||||
|
@ -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'
|
||||
|
@ -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'
|
||||
|
@ -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
|
||||
|
@ -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",
|
||||
|
@ -54,7 +54,6 @@ class AuthTokenModelSerializer(
|
||||
|
||||
del get_url['history']
|
||||
del get_url['knowledge_base']
|
||||
del get_url['notes']
|
||||
|
||||
|
||||
return get_url
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
):
|
||||
|
@ -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
21
app/api/urls_public.py
Normal 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')),
|
||||
]
|
@ -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')),
|
||||
]
|
||||
|
@ -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'
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
}
|
||||
)
|
||||
|
68
app/api/viewsets/public.py
Normal file
68
app/api/viewsets/public.py
Normal 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,
|
||||
)
|
||||
}
|
||||
)
|
@ -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
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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'
|
||||
|
@ -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'
|
||||
|
@ -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'
|
||||
|
@ -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'
|
||||
|
@ -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'
|
||||
|
145
app/centurion_feature_flag/README.md
Normal file
145
app/centurion_feature_flag/README.md
Normal 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.
|
0
app/centurion_feature_flag/__init__.py
Normal file
0
app/centurion_feature_flag/__init__.py
Normal file
6
app/centurion_feature_flag/apps.py
Normal file
6
app/centurion_feature_flag/apps.py
Normal file
@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CenturionFeatureFlagConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'centurion_feature_flag'
|
0
app/centurion_feature_flag/lib/__init__.py
Normal file
0
app/centurion_feature_flag/lib/__init__.py
Normal file
348
app/centurion_feature_flag/lib/feature_flag.py
Normal file
348
app/centurion_feature_flag/lib/feature_flag.py
Normal 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)
|
117
app/centurion_feature_flag/lib/serializer.py
Normal file
117
app/centurion_feature_flag/lib/serializer.py
Normal 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
|
||||
}
|
||||
}
|
@ -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')
|
||||
|
||||
|
||||
|
||||
|
||||
|
40
app/centurion_feature_flag/middleware/feature_flag.py
Normal file
40
app/centurion_feature_flag/middleware/feature_flag.py
Normal 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)
|
||||
|
||||
|
0
app/centurion_feature_flag/migrations/__init__.py
Normal file
0
app/centurion_feature_flag/migrations/__init__.py
Normal file
0
app/centurion_feature_flag/urls/__init__.py
Normal file
0
app/centurion_feature_flag/urls/__init__.py
Normal file
47
app/centurion_feature_flag/urls/django.py
Normal file
47
app/centurion_feature_flag/urls/django.py
Normal 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)
|
108
app/centurion_feature_flag/urls/routers.py
Normal file
108
app/centurion_feature_flag/urls/routers.py
Normal 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()
|
38
app/centurion_feature_flag/views/disabled.py
Normal file
38
app/centurion_feature_flag/views/disabled.py
Normal 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()
|
@ -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 """
|
||||
|
@ -64,7 +64,6 @@ class ConfigGroupSoftwareModelSerializer(
|
||||
|
||||
del get_url['history']
|
||||
del get_url['knowledge_base']
|
||||
del get_url['notes']
|
||||
|
||||
get_url.update({
|
||||
'organization': reverse(
|
||||
|
@ -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'
|
||||
|
@ -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'
|
||||
|
@ -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'
|
||||
|
@ -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'
|
13
app/core/lib/feature_not_used.py
Normal file
13
app/core/lib/feature_not_used.py
Normal 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
|
@ -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
|
||||
|
||||
|
@ -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',),
|
||||
),
|
||||
]
|
@ -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:
|
||||
|
@ -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 """
|
||||
|
@ -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 )
|
||||
|
44
app/core/models/ticket/ticket_category_notes.py
Normal file
44
app/core/models/ticket/ticket_category_notes.py
Normal 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
|
||||
}
|
@ -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):
|
||||
|
44
app/core/models/ticket/ticket_comment_category_notes.py
Normal file
44
app/core/models/ticket/ticket_comment_category_notes.py
Normal 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
|
||||
}
|
@ -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)}'
|
||||
|
42
app/core/serializers/ticket_category_notes.py
Normal file
42
app/core/serializers/ticket_category_notes.py
Normal 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
|
42
app/core/serializers/ticket_comment_category_notes.py
Normal file
42
app/core/serializers/ticket_comment_category_notes.py
Normal 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
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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()
|
@ -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
|
@ -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()
|
@ -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
|
@ -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
|
||||
|
@ -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,
|
||||
)
|
@ -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,
|
||||
}
|
@ -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)
|
@ -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,
|
||||
)
|
@ -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,
|
||||
}
|
@ -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)
|
@ -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'
|
||||
|
@ -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
|
||||
|
@ -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'
|
||||
|
@ -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'
|
||||
|
@ -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'
|
||||
|
@ -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'
|
||||
|
@ -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'
|
||||
|
60
app/core/viewsets/ticket_category_notes.py
Normal file
60
app/core/viewsets/ticket_category_notes.py
Normal 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
|
@ -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'
|
||||
|
@ -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'
|
||||
|
60
app/core/viewsets/ticket_comment_category_notes.py
Normal file
60
app/core/viewsets/ticket_comment_category_notes.py
Normal 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
|
@ -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
0
app/devops/__init__.py
Normal file
6
app/devops/apps.py
Normal file
6
app/devops/apps.py
Normal 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
Reference in New Issue
Block a user