dogesec-commons 1.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dogesec_commons/__init__.py +0 -0
- dogesec_commons/asgi.py +16 -0
- dogesec_commons/objects/__init__.py +1 -0
- dogesec_commons/objects/apps.py +10 -0
- dogesec_commons/objects/conf.py +8 -0
- dogesec_commons/objects/db_view_creator.py +164 -0
- dogesec_commons/objects/helpers.py +660 -0
- dogesec_commons/objects/views.py +427 -0
- dogesec_commons/settings.py +161 -0
- dogesec_commons/stixifier/__init__.py +0 -0
- dogesec_commons/stixifier/apps.py +5 -0
- dogesec_commons/stixifier/conf.py +1 -0
- dogesec_commons/stixifier/migrations/0001_initial.py +36 -0
- dogesec_commons/stixifier/migrations/0002_profile_ai_content_check_variable.py +18 -0
- dogesec_commons/stixifier/migrations/0003_rename_ai_content_check_variable_profile_ai_content_check_provider_and_more.py +23 -0
- dogesec_commons/stixifier/migrations/0004_profile_identity_id.py +18 -0
- dogesec_commons/stixifier/migrations/0005_profile_generate_pdf.py +18 -0
- dogesec_commons/stixifier/migrations/__init__.py +0 -0
- dogesec_commons/stixifier/models.py +57 -0
- dogesec_commons/stixifier/serializers.py +192 -0
- dogesec_commons/stixifier/stixifier.py +252 -0
- dogesec_commons/stixifier/summarizer.py +62 -0
- dogesec_commons/stixifier/views.py +193 -0
- dogesec_commons/urls.py +45 -0
- dogesec_commons/utils/__init__.py +3 -0
- dogesec_commons/utils/autoschema.py +88 -0
- dogesec_commons/utils/exceptions.py +28 -0
- dogesec_commons/utils/filters.py +66 -0
- dogesec_commons/utils/ordering.py +47 -0
- dogesec_commons/utils/pagination.py +81 -0
- dogesec_commons/utils/schemas.py +27 -0
- dogesec_commons/utils/serializers.py +47 -0
- dogesec_commons/wsgi.py +16 -0
- dogesec_commons-1.0.2.dist-info/METADATA +57 -0
- dogesec_commons-1.0.2.dist-info/RECORD +37 -0
- dogesec_commons-1.0.2.dist-info/WHEEL +4 -0
- dogesec_commons-1.0.2.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,193 @@
|
|
1
|
+
from .models import Profile
|
2
|
+
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter
|
3
|
+
from drf_spectacular.types import OpenApiTypes
|
4
|
+
from ..utils import Pagination, Ordering
|
5
|
+
|
6
|
+
from rest_framework import viewsets, response, mixins, exceptions
|
7
|
+
from django_filters.rest_framework import DjangoFilterBackend, FilterSet, Filter, BooleanFilter
|
8
|
+
from .serializers import DEFAULT_400_ERROR, DEFAULT_404_ERROR, Txt2stixExtractorSerializer
|
9
|
+
from django.forms import NullBooleanField
|
10
|
+
|
11
|
+
from .serializers import ProfileSerializer
|
12
|
+
|
13
|
+
from drf_spectacular.utils import extend_schema, extend_schema_view
|
14
|
+
import django.db.models.deletion
|
15
|
+
import textwrap
|
16
|
+
|
17
|
+
EXTRACTOR_TYPES = ["lookup", "pattern", "ai"]
|
18
|
+
|
19
|
+
@extend_schema_view(
|
20
|
+
list=extend_schema(
|
21
|
+
summary="Search profiles",
|
22
|
+
description=textwrap.dedent(
|
23
|
+
"""
|
24
|
+
Profiles determine how txt2stix processes the text in each File. A profile consists of extractors. You can search for existing profiles here.
|
25
|
+
"""
|
26
|
+
),
|
27
|
+
responses={400: DEFAULT_400_ERROR, 200: ProfileSerializer},
|
28
|
+
),
|
29
|
+
retrieve=extend_schema(
|
30
|
+
summary="Get a profile",
|
31
|
+
description=textwrap.dedent(
|
32
|
+
"""
|
33
|
+
View the configuration of an existing profile. Note, existing profiles cannot be modified.
|
34
|
+
"""
|
35
|
+
),
|
36
|
+
responses={400: DEFAULT_400_ERROR, 404: DEFAULT_404_ERROR, 200: ProfileSerializer}
|
37
|
+
),
|
38
|
+
create=extend_schema(
|
39
|
+
summary="Create a new profile",
|
40
|
+
description=textwrap.dedent(
|
41
|
+
"""
|
42
|
+
Add a new Profile that can be applied to new Files. A profile consists of extractors. You can find available extractors via their respective endpoints.
|
43
|
+
|
44
|
+
The following key/values are accepted in the body of the request:
|
45
|
+
|
46
|
+
* `name` (required - must be unique)
|
47
|
+
* `identity_id` (optional): a STIX Identity ID that you want to use to create the profile. Pass the full identity ID, e.g. `identity--de9fb9bd-7895-4b23-aa03-49250d9263c9`
|
48
|
+
* `ai_create_attack_flow` (optional, boolean): default is `true`. passing as `true` will prompt the AI model (the same entered for `ai_settings_relationships`) to generate an [Attack Flow](https://center-for-threat-informed-defense.github.io/attack-flow/) for the MITRE ATT&CK extractions to define the logical order in which they are being described. You must pass `--ai_settings_relationships` for this to work.
|
49
|
+
* `ai_summary_provider` (optional, AI provider:model): you can also generate a summary of the files this profile is linked to using an AI model. If not passed, no summary will be generated. Pass in format `"provider:model"` e.g. `"openai:gpt-4o"`.
|
50
|
+
* `ai_content_check_provider` (optional, AI provider:model): Setting a value will get the AI to try and classify the text in the input to 1) determine if it is talking about threat intelligence, and 2) what type of threat intelligence it is talking about. For context, we use this to filter out non-threat intel posts in Obstracts Web (note, we don't filter in this way Stixify Web) using `always_extract` in dogesec_commons to reduce the cost of AI models and make them easier to search through classifications. You pass `provider:model` (e.g. `"openai:gpt-4o"`) with this flag to determine the AI model you wish to use to perform the check.
|
51
|
+
* `extract_text_from_image` (required - boolean): whether to convert the images found in a blog to text. Requires a Google Vision key to be set. This is a [file2txt](https://github.com/muchdogesec/file2txt) setting.
|
52
|
+
* `defang` (required - boolean): whether to defang the observables in the blog. e.g. turns `1.1.1[.]1` to `1.1.1.1` for extraction. This is a [file2txt](https://github.com/muchdogesec/file2txt) setting.
|
53
|
+
* `ignore_image_refs` (optional, default `true`): whether to ignore embedded image references. This is a [txt2stix](https://github.com/muchdogesec/txt2stix/) setting.
|
54
|
+
* `ignore_link_refs` (optional, default `true`): whether to ignore embedded link references. This is a [txt2stix](https://github.com/muchdogesec/txt2stix/) setting.
|
55
|
+
* `extractions` (required - at least one extraction ID): can be obtained from the GET Extractors endpoint. This is a [txt2stix](https://github.com/muchdogesec/txt2stix/) setting.
|
56
|
+
* `ai_settings_extractions` (required if AI extraction used, AI provider:model): A list of AI providers and models to be used for extraction in format `["provider:model","provider:model"]` e.g. `["openai:gpt-4o"]`. This is a [txt2stix](https://github.com/muchdogesec/txt2stix/) setting.
|
57
|
+
* `ignore_extraction_boundary` (optional, default `false`): defines if a string boundary can generate multiple extractions (e.g. `url`, `domain`, etc). Setting to `true` will allow multiple extractions from the same string. This is a [txt2stix](https://github.com/muchdogesec/file2txt) setting.
|
58
|
+
* `relationship_mode` (required): either `ai` or `standard`. Required AI provider to be configured if using `ai` mode. This is a [txt2stix](https://github.com/muchdogesec/txt2stix/) setting.
|
59
|
+
* `ai_settings_relationships` (required if AI relationship used, AI provider:model): An AI provider and models to be used for relationship generation in format `"provider:model"` e.g. `"openai:gpt-4o"`. This is a [txt2stix](https://github.com/muchdogesec/txt2stix/) setting.
|
60
|
+
* `generate_pdf` (optional, boolean): default is `false` will generate a PDF of the input if set to true
|
61
|
+
* `ignore_embedded_relationships` (optional, default: false): boolean, if `true` passed, this will stop ANY embedded relationships from being generated. This applies for all object types (SDO, SCO, SRO, SMO). If you want to target certain object types see `ignore_embedded_relationships_sro` and `ignore_embedded_relationships_sro` flags. This is a [stix2arango](https://github.com/muchdogesec/stix2arango) setting.
|
62
|
+
* `ignore_embedded_relationships_sro` (optional, default: false): boolean, if `true` passed, will stop any embedded relationships from being generated from SRO objects (`type` = `relationship`). This is a [stix2arango](https://github.com/muchdogesec/stix2arango) setting.
|
63
|
+
* `ignore_embedded_relationships_smo` (optional, default: false): boolean, if `true` passed, will stop any embedded relationships from being generated from SMO objects (`type` = `marking-definition`, `extension-definition`, `language-content`). This is a [stix2arango](https://github.com/muchdogesec/stix2arango) setting.
|
64
|
+
|
65
|
+
A profile `id` is generated using a UUIDv5. The namespace used is is set using the `STIXIFIER_NAMESPACE` in dogesec tools, and the `name+identity_id` is used as the value (e.g a namespace of `9779a2db-f98c-5f4b-8d08-8ee04e02dbb5` and value `my profile+identity--de9fb9bd-7895-4b23-aa03-49250d9263c9` would have the `id`: `05004944-0eff-507e-8ef8-9ebdd043a51b`). Note, the name
|
66
|
+
|
67
|
+
You cannot modify a profile once it is created. If you need to make changes, you should create another profile with the changes made. If it is essential that the same `name` + `identity_id` value be used, then you must first delete the profile in order to recreate it.
|
68
|
+
"""
|
69
|
+
),
|
70
|
+
responses={400: DEFAULT_400_ERROR, 200: ProfileSerializer}
|
71
|
+
),
|
72
|
+
destroy=extend_schema(
|
73
|
+
summary="Delete a profile",
|
74
|
+
description=textwrap.dedent(
|
75
|
+
"""
|
76
|
+
Delete an existing profile.
|
77
|
+
|
78
|
+
Note: it is not currently possible to delete a profile that is referenced in an existing object. You must delete the objects linked to the profile first.
|
79
|
+
"""
|
80
|
+
),
|
81
|
+
responses={404: DEFAULT_404_ERROR, 204: None}
|
82
|
+
),
|
83
|
+
)
|
84
|
+
class ProfileView(viewsets.ModelViewSet):
|
85
|
+
openapi_tags = ["Profiles"]
|
86
|
+
serializer_class = ProfileSerializer
|
87
|
+
http_method_names = ["get", "post", "delete"]
|
88
|
+
pagination_class = Pagination("profiles")
|
89
|
+
lookup_url_kwarg = 'profile_id'
|
90
|
+
openapi_path_params = [
|
91
|
+
OpenApiParameter(
|
92
|
+
lookup_url_kwarg, location=OpenApiParameter.PATH, type=OpenApiTypes.UUID, description="The `id` of the Profile."
|
93
|
+
)
|
94
|
+
]
|
95
|
+
|
96
|
+
ordering_fields = ["name", "created"]
|
97
|
+
ordering = "created_descending"
|
98
|
+
filter_backends = [DjangoFilterBackend, Ordering]
|
99
|
+
|
100
|
+
class filterset_class(FilterSet):
|
101
|
+
name = Filter(
|
102
|
+
help_text="Searches Profiles by their `name`. Search is wildcard. For example, `ip` will return Profiles with names `ip-extractions`, `ips`, etc.",
|
103
|
+
lookup_expr="icontains"
|
104
|
+
)
|
105
|
+
identity_id = Filter(
|
106
|
+
help_text="filter the results by the identity that created the Profile. Use a full STIX identity ID, e.g. `identity--de9fb9bd-7895-4b23-aa03-49250d9263c9`"
|
107
|
+
)
|
108
|
+
|
109
|
+
def get_queryset(self):
|
110
|
+
return Profile.objects
|
111
|
+
|
112
|
+
class txt2stixView(mixins.RetrieveModelMixin,
|
113
|
+
mixins.ListModelMixin, viewsets.GenericViewSet):
|
114
|
+
serializer_class = Txt2stixExtractorSerializer
|
115
|
+
lookup_url_kwarg = "id"
|
116
|
+
|
117
|
+
def get_queryset(self):
|
118
|
+
return None
|
119
|
+
|
120
|
+
@classmethod
|
121
|
+
def all_extractors(cls, types):
|
122
|
+
return Txt2stixExtractorSerializer.all_extractors(types)
|
123
|
+
|
124
|
+
def get_all(self):
|
125
|
+
raise NotImplementedError("not implemented")
|
126
|
+
|
127
|
+
|
128
|
+
def list(self, request, *args, **kwargs):
|
129
|
+
page = self.paginate_queryset(list(self.get_all().values()))
|
130
|
+
return self.get_paginated_response(page)
|
131
|
+
|
132
|
+
def retrieve(self, request, *args, **kwargs):
|
133
|
+
items = self.get_all()
|
134
|
+
id_ = self.kwargs.get(self.lookup_url_kwarg)
|
135
|
+
print(id_, self.lookup_url_kwarg, self.kwargs)
|
136
|
+
item = items.get(id_)
|
137
|
+
if not item:
|
138
|
+
return response.Response(dict(message="item not found", code=404), status=404)
|
139
|
+
return response.Response(item)
|
140
|
+
|
141
|
+
@extend_schema_view(
|
142
|
+
list=extend_schema(
|
143
|
+
summary="Search Extractors",
|
144
|
+
description=textwrap.dedent(
|
145
|
+
"""
|
146
|
+
Extractors are what extract the data from the text which is then converted into STIX objects.
|
147
|
+
|
148
|
+
For more information see [txt2stix](https://github.com/muchdogesec/txt2stix/).
|
149
|
+
"""
|
150
|
+
),
|
151
|
+
responses={400: DEFAULT_400_ERROR, 200: Txt2stixExtractorSerializer},
|
152
|
+
),
|
153
|
+
retrieve=extend_schema(
|
154
|
+
summary="Get an extractor",
|
155
|
+
description=textwrap.dedent(
|
156
|
+
"""
|
157
|
+
Get a specific Extractor.
|
158
|
+
"""
|
159
|
+
),
|
160
|
+
responses={400: DEFAULT_400_ERROR, 404: DEFAULT_404_ERROR, 200: Txt2stixExtractorSerializer},
|
161
|
+
),
|
162
|
+
)
|
163
|
+
class ExtractorsView(txt2stixView):
|
164
|
+
openapi_tags = ["Extractors"]
|
165
|
+
lookup_url_kwarg = "extractor_id"
|
166
|
+
openapi_path_params = [
|
167
|
+
OpenApiParameter(
|
168
|
+
lookup_url_kwarg, location=OpenApiParameter.PATH, type=OpenApiTypes.STR, description="The `id` of the Extractor."
|
169
|
+
)
|
170
|
+
]
|
171
|
+
pagination_class = Pagination("extractors")
|
172
|
+
filter_backends = [DjangoFilterBackend]
|
173
|
+
|
174
|
+
|
175
|
+
class filterset_class(FilterSet):
|
176
|
+
type = Filter(choices=[(extractor, extractor) for extractor in EXTRACTOR_TYPES], help_text="Filter Extractors by their `type`")
|
177
|
+
name = Filter(help_text="Filter extractors by `name`. Is wildcard search so `ip` will return `ipv4`, `ipv6`, etc.)")
|
178
|
+
web_app = BooleanFilter(help_text="filters on `dogesec_web` property in txt2stix filter.\nuse case is, web app can set this to true to only show extractors allowed in web app")
|
179
|
+
|
180
|
+
def get_all(self):
|
181
|
+
types = EXTRACTOR_TYPES
|
182
|
+
if type := self.request.GET.get('type'):
|
183
|
+
types = type.split(',')
|
184
|
+
|
185
|
+
extractors = self.all_extractors(types)
|
186
|
+
|
187
|
+
if name := self.request.GET.get('name', '').lower():
|
188
|
+
extractors = {slug: extractor for slug, extractor in extractors.items() if name in extractor['name'].lower()}
|
189
|
+
|
190
|
+
webapp_filter = NullBooleanField.to_python(..., self.request.GET.get('web_app', ''))
|
191
|
+
if webapp_filter != None:
|
192
|
+
extractors = {slug: extractor for slug, extractor in extractors.items() if extractor.get('dogesec_web') == webapp_filter}
|
193
|
+
return extractors
|
dogesec_commons/urls.py
ADDED
@@ -0,0 +1,45 @@
|
|
1
|
+
"""
|
2
|
+
URL configuration for dogesec_commons project.
|
3
|
+
|
4
|
+
The `urlpatterns` list routes URLs to views. For more information please see:
|
5
|
+
https://docs.djangoproject.com/en/5.1/topics/http/urls/
|
6
|
+
Examples:
|
7
|
+
Function views
|
8
|
+
1. Add an import: from my_app import views
|
9
|
+
2. Add a URL to urlpatterns: path('', views.home, name='home')
|
10
|
+
Class-based views
|
11
|
+
1. Add an import: from other_app.views import Home
|
12
|
+
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
|
13
|
+
Including another URLconf
|
14
|
+
1. Import the include() function: from django.urls import include, path
|
15
|
+
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
16
|
+
"""
|
17
|
+
from django.contrib import admin
|
18
|
+
from django.urls import include, path
|
19
|
+
from rest_framework import routers
|
20
|
+
from dogesec_commons.stixifier.views import ExtractorsView, ProfileView
|
21
|
+
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
|
22
|
+
from dogesec_commons.objects import views as arango_views
|
23
|
+
router = routers.SimpleRouter(use_regex_path=False)
|
24
|
+
|
25
|
+
router.register('profiles', ProfileView, "profile-view")
|
26
|
+
# txt2stix views
|
27
|
+
router.register('extractors', ExtractorsView, "extractors-view")
|
28
|
+
|
29
|
+
## objects
|
30
|
+
regex_router = routers.SimpleRouter(use_regex_path=True)
|
31
|
+
regex_router.register("", arango_views.ObjectsWithReportsView, "object-view-orig")
|
32
|
+
regex_router.register('smos', arango_views.SMOView, "object-view-smo")
|
33
|
+
regex_router.register('scos', arango_views.SCOView, "object-view-sco")
|
34
|
+
regex_router.register('sros', arango_views.SROView, "object-view-sro")
|
35
|
+
regex_router.register('sdos', arango_views.SDOView, "object-view-sdo")
|
36
|
+
urlpatterns = [
|
37
|
+
path(f'api/', include(router.urls)),
|
38
|
+
path(f'objects/', include(regex_router.urls)),
|
39
|
+
path('admin/', admin.site.urls),
|
40
|
+
|
41
|
+
# YOUR PATTERNS
|
42
|
+
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
|
43
|
+
# Optional UI:
|
44
|
+
path('api/schema/swagger-ui/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
|
45
|
+
]
|
@@ -0,0 +1,88 @@
|
|
1
|
+
from typing import List
|
2
|
+
from drf_spectacular.openapi import AutoSchema
|
3
|
+
from drf_spectacular.plumbing import ComponentRegistry
|
4
|
+
from drf_spectacular.utils import _SchemaType, OpenApiResponse, OpenApiExample
|
5
|
+
from drf_spectacular.types import OpenApiTypes
|
6
|
+
import uritemplate
|
7
|
+
|
8
|
+
from dogesec_commons.utils import schemas
|
9
|
+
from .serializers import CommonErrorSerializer
|
10
|
+
|
11
|
+
from drf_spectacular.contrib.django_filters import DjangoFilterExtension, get_view_model
|
12
|
+
class OverrideDjangoFilterExtension(DjangoFilterExtension):
|
13
|
+
priority = 10
|
14
|
+
def get_schema_operation_parameters(self, auto_schema: AutoSchema, *args, **kwargs):
|
15
|
+
model = get_view_model(auto_schema.view)
|
16
|
+
if not model:
|
17
|
+
return self.override(auto_schema, *args, **kwargs)
|
18
|
+
return super().get_schema_operation_parameters(auto_schema, *args, **kwargs)
|
19
|
+
|
20
|
+
def override(self, autoschema, *args, **kwargs):
|
21
|
+
result = []
|
22
|
+
filterset_class = self.target.get_filterset_class(autoschema.view)
|
23
|
+
if not filterset_class:
|
24
|
+
return self.target.get_schema_operation_parameters(autoschema.view, *args, **kwargs)
|
25
|
+
for field_name, filter_field in filterset_class.base_filters.items():
|
26
|
+
result += self.resolve_filter_field(
|
27
|
+
autoschema, None, filterset_class, field_name, filter_field
|
28
|
+
)
|
29
|
+
return result
|
30
|
+
|
31
|
+
|
32
|
+
class CustomAutoSchema(AutoSchema):
|
33
|
+
default_responses = {
|
34
|
+
'404': (schemas.WEBSERVER_404_RESPONSE, ["application/json"]),
|
35
|
+
}
|
36
|
+
def get_tags(self) -> List[str]:
|
37
|
+
if hasattr(self.view, "openapi_tags"):
|
38
|
+
return self.view.openapi_tags
|
39
|
+
return super().get_tags()
|
40
|
+
|
41
|
+
|
42
|
+
def get_override_parameters(self):
|
43
|
+
params = super().get_override_parameters()
|
44
|
+
path_variables = uritemplate.variables(self.path)
|
45
|
+
for param in getattr(self.view, 'openapi_path_params', []):
|
46
|
+
if param.name in path_variables:
|
47
|
+
params.append(param)
|
48
|
+
return params
|
49
|
+
|
50
|
+
def _map_serializer_field(self, field, direction, bypass_extensions=False):
|
51
|
+
if getattr(field, 'internal_serializer', None):
|
52
|
+
return super()._map_serializer_field(field.internal_serializer, direction, bypass_extensions)
|
53
|
+
return super()._map_serializer_field(field, direction, bypass_extensions)
|
54
|
+
|
55
|
+
|
56
|
+
def _map_serializer(self, serializer, direction, bypass_extensions=False):
|
57
|
+
if getattr(serializer, "get_schema", None):
|
58
|
+
return serializer.get_schema()
|
59
|
+
return super()._map_serializer(serializer, direction, bypass_extensions)
|
60
|
+
|
61
|
+
|
62
|
+
def get_operation(self, *args, **kwargs):
|
63
|
+
operation = super().get_operation(*args, **kwargs)
|
64
|
+
if operation:
|
65
|
+
self.add_default_pages(operation)
|
66
|
+
return operation
|
67
|
+
|
68
|
+
def add_default_pages(self, operation):
|
69
|
+
"""
|
70
|
+
modify responses to include 404 error for when path parameters don't match specified path. e.g if integer passed instead of a uuid param
|
71
|
+
"""
|
72
|
+
responses = operation['responses']
|
73
|
+
|
74
|
+
default_responses = {
|
75
|
+
code: self._get_response_for_code(schema, code, content_type) for code, (schema, content_type) in self.default_responses.items()
|
76
|
+
}
|
77
|
+
for code, content_response in default_responses.items():
|
78
|
+
if code not in responses:
|
79
|
+
responses[code] = content_response
|
80
|
+
return operation
|
81
|
+
|
82
|
+
def _is_list_view(self, serializer=None) -> bool:
|
83
|
+
if getattr(self.view, 'action', None) == 'list' and getattr(self.view, 'skip_list_view', False):
|
84
|
+
"""
|
85
|
+
view.skip_list_view is used for checking if many should be used or not on list() action
|
86
|
+
"""
|
87
|
+
return False
|
88
|
+
return super()._is_list_view(serializer)
|
@@ -0,0 +1,28 @@
|
|
1
|
+
from rest_framework.views import exception_handler
|
2
|
+
from rest_framework.exceptions import ValidationError, PermissionDenied
|
3
|
+
from django.core import exceptions as django_exceptions
|
4
|
+
from django.http import JsonResponse, Http404
|
5
|
+
import rest_framework.exceptions
|
6
|
+
import django.db.models.deletion
|
7
|
+
|
8
|
+
|
9
|
+
def custom_exception_handler(exc, context):
|
10
|
+
if isinstance(exc, django_exceptions.ValidationError):
|
11
|
+
exc = ValidationError(detail=exc.messages, code=exc.code)
|
12
|
+
|
13
|
+
if isinstance(exc, django.db.models.deletion.ProtectedError):
|
14
|
+
return JsonResponse({'code': 403, 'message': "cannot delete object(s) because they are referenced through protected foreign keys.", 'details': {'protected_objects': [str(f) for f in exc.protected_objects]}}, status=403)
|
15
|
+
|
16
|
+
resp = exception_handler(exc, context)
|
17
|
+
if resp is not None:
|
18
|
+
if isinstance(resp.data, dict) and 'detail' in resp.data:
|
19
|
+
resp.data = resp.data['detail']
|
20
|
+
if isinstance(resp.data, str):
|
21
|
+
resp.data = dict(code=resp.status_code, message=resp.data)
|
22
|
+
if isinstance(resp.data, list):
|
23
|
+
resp.data = dict(code=resp.status_code, details={'detail':resp.data})
|
24
|
+
else:
|
25
|
+
resp.data = dict(code=resp.status_code, details=resp.data)
|
26
|
+
resp.data.setdefault('message', resp.status_text)
|
27
|
+
resp = JsonResponse(data=resp.data, status=resp.status_code)
|
28
|
+
return resp
|
@@ -0,0 +1,66 @@
|
|
1
|
+
from rest_framework.filters import BaseFilterBackend
|
2
|
+
from datetime import datetime, UTC
|
3
|
+
from django.forms import DateTimeField
|
4
|
+
from django_filters.rest_framework import filters
|
5
|
+
|
6
|
+
|
7
|
+
class DatetimeFieldUTC(DateTimeField):
|
8
|
+
def to_python(self, value):
|
9
|
+
value = super().to_python(value)
|
10
|
+
return value and value.astimezone(UTC)
|
11
|
+
|
12
|
+
|
13
|
+
class DatetimeFilter(filters.Filter):
|
14
|
+
field_class = DatetimeFieldUTC
|
15
|
+
|
16
|
+
|
17
|
+
class MinMaxDateFilter(BaseFilterBackend):
|
18
|
+
min_val = datetime.min
|
19
|
+
max_value = datetime.max
|
20
|
+
|
21
|
+
def get_fields(self, view):
|
22
|
+
out = {}
|
23
|
+
fields = getattr(view, "minmax_date_fields", [])
|
24
|
+
if not isinstance(fields, list):
|
25
|
+
return out
|
26
|
+
for field in fields:
|
27
|
+
out[f"{field}_max"] = field
|
28
|
+
out[f"{field}_min"] = field
|
29
|
+
return out
|
30
|
+
|
31
|
+
def parse_date(self, value):
|
32
|
+
return DatetimeFieldUTC().to_python(value)
|
33
|
+
|
34
|
+
def filter_queryset(self, request, queryset, view):
|
35
|
+
valid_fields = self.get_fields(view)
|
36
|
+
valid_params = [
|
37
|
+
(k, v) for k, v in request.query_params.items() if k in valid_fields
|
38
|
+
]
|
39
|
+
queries = {}
|
40
|
+
for param, value in valid_params:
|
41
|
+
field_name = valid_fields[param]
|
42
|
+
if param.endswith("_max"):
|
43
|
+
queries[f"{field_name}__lte"] = self.parse_date(value)
|
44
|
+
else:
|
45
|
+
queries[f"{field_name}__gte"] = self.parse_date(value)
|
46
|
+
return queryset.filter(**queries)
|
47
|
+
|
48
|
+
def get_schema_operation_parameters(self, view):
|
49
|
+
parameters = []
|
50
|
+
valid_fields = self.get_fields(view)
|
51
|
+
for query_name, field_name in valid_fields.items():
|
52
|
+
_type = "Maximum"
|
53
|
+
if query_name.endswith("min"):
|
54
|
+
_type = "Minimum"
|
55
|
+
parameter = {
|
56
|
+
"name": query_name,
|
57
|
+
"required": False,
|
58
|
+
"in": "query",
|
59
|
+
"description": f"{_type} value of `{field_name}` to filter by in format `YYYY-MM-DD`.",
|
60
|
+
"schema": {
|
61
|
+
"type": "string",
|
62
|
+
"format": "date",
|
63
|
+
},
|
64
|
+
}
|
65
|
+
parameters.append(parameter)
|
66
|
+
return parameters
|
@@ -0,0 +1,47 @@
|
|
1
|
+
from django.conf import settings
|
2
|
+
from rest_framework import pagination, response
|
3
|
+
from rest_framework.filters import OrderingFilter
|
4
|
+
from django.utils.encoding import force_str
|
5
|
+
from rest_framework import response
|
6
|
+
|
7
|
+
class Ordering(OrderingFilter):
|
8
|
+
ordering_param = "sort"
|
9
|
+
|
10
|
+
def get_ordering(self, request, queryset, view):
|
11
|
+
params = request.query_params.get(self.ordering_param)
|
12
|
+
ordering_mapping = self.get_ordering_mapping(queryset, view)
|
13
|
+
if params:
|
14
|
+
fields = [ordering_mapping.get(param.strip()) for param in params.split(',') if param.strip() in ordering_mapping]
|
15
|
+
ordering = self.remove_invalid_fields(queryset, fields, view, request)
|
16
|
+
if ordering:
|
17
|
+
return ordering
|
18
|
+
return self.get_default_ordering(view)
|
19
|
+
|
20
|
+
def get_ordering_mapping(self, queryset, view):
|
21
|
+
valid_fields = self.get_valid_fields(queryset, view)
|
22
|
+
mapping = {}
|
23
|
+
for k, v in valid_fields:
|
24
|
+
mapping[f"{k}_descending"] = f"-{v}"
|
25
|
+
mapping[f"{k}_ascending"] = v
|
26
|
+
return mapping
|
27
|
+
|
28
|
+
|
29
|
+
def get_schema_operation_parameters(self, view):
|
30
|
+
return [
|
31
|
+
{
|
32
|
+
'name': self.ordering_param,
|
33
|
+
'required': False,
|
34
|
+
'in': 'query',
|
35
|
+
'description': force_str(self.ordering_description),
|
36
|
+
'schema': {
|
37
|
+
'type': 'string',
|
38
|
+
'enum': list(self.get_ordering_mapping(None, view).keys())
|
39
|
+
},
|
40
|
+
},
|
41
|
+
]
|
42
|
+
|
43
|
+
def get_default_ordering(self, view):
|
44
|
+
ordering = getattr(view, 'ordering', None)
|
45
|
+
if isinstance(ordering, str):
|
46
|
+
return (self.get_ordering_mapping(None, view).get(ordering),)
|
47
|
+
return None
|
@@ -0,0 +1,81 @@
|
|
1
|
+
import contextlib
|
2
|
+
from django.conf import settings
|
3
|
+
from rest_framework import pagination, response
|
4
|
+
from rest_framework import response
|
5
|
+
from rest_framework.exceptions import NotFound
|
6
|
+
from django.core.paginator import Page as DjangoPage, InvalidPage
|
7
|
+
|
8
|
+
|
9
|
+
class Pagination(pagination.PageNumberPagination):
|
10
|
+
max_page_size = settings.MAXIMUM_PAGE_SIZE
|
11
|
+
page_size = settings.DEFAULT_PAGE_SIZE
|
12
|
+
page_size_query_param = 'page_size'
|
13
|
+
def __init__(self, results_key) -> None:
|
14
|
+
self.results_key = results_key
|
15
|
+
super().__init__()
|
16
|
+
|
17
|
+
def paginate_queryset(self, queryset, request, view=None):
|
18
|
+
with contextlib.suppress(NotFound):
|
19
|
+
return super().paginate_queryset(queryset, request, view)
|
20
|
+
self.page = DjangoPage([], -1, self)
|
21
|
+
return []
|
22
|
+
|
23
|
+
def paginate_queryset(self, queryset, request, view=None):
|
24
|
+
"""
|
25
|
+
Paginate a queryset if required, either returning a
|
26
|
+
page object, or `None` if pagination is not configured for this view.
|
27
|
+
"""
|
28
|
+
self.request = request
|
29
|
+
page_size = self.get_page_size(request)
|
30
|
+
if not page_size:
|
31
|
+
return None
|
32
|
+
|
33
|
+
paginator = self.django_paginator_class(queryset, page_size)
|
34
|
+
page_number = self.get_page_number(request, paginator)
|
35
|
+
|
36
|
+
try:
|
37
|
+
self.page = paginator.page(page_number)
|
38
|
+
except InvalidPage as exc:
|
39
|
+
if isinstance(page_number, str):
|
40
|
+
page_number = int(page_number) if page_number.isdigit() else -1
|
41
|
+
self.page = DjangoPage([], page_number, paginator)
|
42
|
+
|
43
|
+
return list(self.page)
|
44
|
+
|
45
|
+
def get_paginated_response(self, data):
|
46
|
+
|
47
|
+
return response.Response({
|
48
|
+
'page_size': self.get_page_size(self.request),
|
49
|
+
'page_number': self.page.number,
|
50
|
+
'page_results_count': len(self.page),
|
51
|
+
'total_results_count': self.page.paginator.count,
|
52
|
+
self.results_key: data,
|
53
|
+
})
|
54
|
+
|
55
|
+
def get_paginated_response_schema(self, schema):
|
56
|
+
return {
|
57
|
+
'type': 'object',
|
58
|
+
'required': ['total_results_count', self.results_key],
|
59
|
+
'properties': {
|
60
|
+
'page_size': {
|
61
|
+
'type': 'integer',
|
62
|
+
'example': self.max_page_size,
|
63
|
+
},
|
64
|
+
'page_number': {
|
65
|
+
'type': 'integer',
|
66
|
+
'example': 3,
|
67
|
+
},
|
68
|
+
'page_results_count': {
|
69
|
+
'type': 'integer',
|
70
|
+
'example': self.max_page_size,
|
71
|
+
},
|
72
|
+
'total_results_count': {
|
73
|
+
'type': 'integer',
|
74
|
+
'example': 3,
|
75
|
+
},
|
76
|
+
self.results_key: schema,
|
77
|
+
},
|
78
|
+
}
|
79
|
+
|
80
|
+
def __call__(self, *args, **kwargs):
|
81
|
+
return self.__class__(results_key=self.results_key)
|
@@ -0,0 +1,27 @@
|
|
1
|
+
from drf_spectacular.utils import OpenApiResponse, OpenApiExample
|
2
|
+
from .serializers import CommonErrorSerializer
|
3
|
+
|
4
|
+
|
5
|
+
HTTP404_EXAMPLE = OpenApiExample("http-404", {"message": "resource not found", "code": 404})
|
6
|
+
HTTP400_EXAMPLE = OpenApiExample("http-400", {"message": "request not understood", "code": 400})
|
7
|
+
|
8
|
+
WEBSERVER_404_RESPONSE = OpenApiResponse(CommonErrorSerializer, description="webserver's HTML 404 page", examples=[OpenApiExample('404-page', {"code": 404, "message": "non-existent page"})])
|
9
|
+
WEBSERVER_500_RESPONSE = OpenApiResponse(CommonErrorSerializer, description="webserver's HTML 500 page", examples=[OpenApiExample('500-page', {"code": 500, "message": "internal server error"})])
|
10
|
+
|
11
|
+
|
12
|
+
DEFAULT_400_RESPONSE = OpenApiResponse(
|
13
|
+
CommonErrorSerializer,
|
14
|
+
"The server did not understand the request",
|
15
|
+
[
|
16
|
+
HTTP400_EXAMPLE
|
17
|
+
],
|
18
|
+
)
|
19
|
+
|
20
|
+
|
21
|
+
DEFAULT_404_RESPONSE = OpenApiResponse(
|
22
|
+
CommonErrorSerializer,
|
23
|
+
"Resource not found",
|
24
|
+
[
|
25
|
+
HTTP404_EXAMPLE
|
26
|
+
],
|
27
|
+
)
|
@@ -0,0 +1,47 @@
|
|
1
|
+
import logging
|
2
|
+
from rest_framework import serializers
|
3
|
+
|
4
|
+
from django.core.exceptions import ObjectDoesNotExist
|
5
|
+
from django.utils.translation import gettext_lazy as _
|
6
|
+
|
7
|
+
from drf_spectacular.types import OpenApiTypes
|
8
|
+
from drf_spectacular.utils import extend_schema_field
|
9
|
+
|
10
|
+
|
11
|
+
|
12
|
+
class RelatedObjectField(serializers.RelatedField):
|
13
|
+
lookup_key = 'pk'
|
14
|
+
default_error_messages = {
|
15
|
+
'required': _('This field is required.'),
|
16
|
+
'does_not_exist': _('Invalid {lookup_key} "{lookup_value}" - object does not exist.'),
|
17
|
+
'incorrect_type': _('Incorrect type. Expected valid {lookup_key} value, received "{lookup_value}", type: {data_type}.'),
|
18
|
+
}
|
19
|
+
def __init__(self, /, serializer, use_raw_value=False, **kwargs):
|
20
|
+
self.internal_serializer: serializers.Serializer = serializer
|
21
|
+
self.use_raw_value = use_raw_value
|
22
|
+
super().__init__(**kwargs)
|
23
|
+
|
24
|
+
def to_internal_value(self, data):
|
25
|
+
try:
|
26
|
+
instance = self.get_queryset().get(**{self.lookup_key: data})
|
27
|
+
if self.use_raw_value:
|
28
|
+
return data
|
29
|
+
return instance
|
30
|
+
except ObjectDoesNotExist as e:
|
31
|
+
self.fail('does_not_exist', lookup_value=data, lookup_key=self.lookup_key)
|
32
|
+
except BaseException as e:
|
33
|
+
logging.exception(e)
|
34
|
+
self.fail('incorrect_type', data_type=type(data), lookup_value=data, lookup_key=self.lookup_key)
|
35
|
+
|
36
|
+
def to_representation(self, value):
|
37
|
+
return self.internal_serializer.to_representation(value)
|
38
|
+
|
39
|
+
|
40
|
+
@extend_schema_field(OpenApiTypes.ANY)
|
41
|
+
class AnyField(serializers.Field):
|
42
|
+
pass
|
43
|
+
|
44
|
+
class CommonErrorSerializer(serializers.Serializer):
|
45
|
+
message = serializers.CharField(required=False)
|
46
|
+
code = serializers.IntegerField(required=True)
|
47
|
+
details = serializers.JSONField(required=False)
|
dogesec_commons/wsgi.py
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
"""
|
2
|
+
WSGI config for dogesec_commons project.
|
3
|
+
|
4
|
+
It exposes the WSGI callable as a module-level variable named ``application``.
|
5
|
+
|
6
|
+
For more information on this file, see
|
7
|
+
https://docs.djangoproject.com/en/5.1/howto/deployment/wsgi/
|
8
|
+
"""
|
9
|
+
|
10
|
+
import os
|
11
|
+
|
12
|
+
from django.core.wsgi import get_wsgi_application
|
13
|
+
|
14
|
+
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'dogesec_commons.settings')
|
15
|
+
|
16
|
+
application = get_wsgi_application()
|