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.
Files changed (37) hide show
  1. dogesec_commons/__init__.py +0 -0
  2. dogesec_commons/asgi.py +16 -0
  3. dogesec_commons/objects/__init__.py +1 -0
  4. dogesec_commons/objects/apps.py +10 -0
  5. dogesec_commons/objects/conf.py +8 -0
  6. dogesec_commons/objects/db_view_creator.py +164 -0
  7. dogesec_commons/objects/helpers.py +660 -0
  8. dogesec_commons/objects/views.py +427 -0
  9. dogesec_commons/settings.py +161 -0
  10. dogesec_commons/stixifier/__init__.py +0 -0
  11. dogesec_commons/stixifier/apps.py +5 -0
  12. dogesec_commons/stixifier/conf.py +1 -0
  13. dogesec_commons/stixifier/migrations/0001_initial.py +36 -0
  14. dogesec_commons/stixifier/migrations/0002_profile_ai_content_check_variable.py +18 -0
  15. dogesec_commons/stixifier/migrations/0003_rename_ai_content_check_variable_profile_ai_content_check_provider_and_more.py +23 -0
  16. dogesec_commons/stixifier/migrations/0004_profile_identity_id.py +18 -0
  17. dogesec_commons/stixifier/migrations/0005_profile_generate_pdf.py +18 -0
  18. dogesec_commons/stixifier/migrations/__init__.py +0 -0
  19. dogesec_commons/stixifier/models.py +57 -0
  20. dogesec_commons/stixifier/serializers.py +192 -0
  21. dogesec_commons/stixifier/stixifier.py +252 -0
  22. dogesec_commons/stixifier/summarizer.py +62 -0
  23. dogesec_commons/stixifier/views.py +193 -0
  24. dogesec_commons/urls.py +45 -0
  25. dogesec_commons/utils/__init__.py +3 -0
  26. dogesec_commons/utils/autoschema.py +88 -0
  27. dogesec_commons/utils/exceptions.py +28 -0
  28. dogesec_commons/utils/filters.py +66 -0
  29. dogesec_commons/utils/ordering.py +47 -0
  30. dogesec_commons/utils/pagination.py +81 -0
  31. dogesec_commons/utils/schemas.py +27 -0
  32. dogesec_commons/utils/serializers.py +47 -0
  33. dogesec_commons/wsgi.py +16 -0
  34. dogesec_commons-1.0.2.dist-info/METADATA +57 -0
  35. dogesec_commons-1.0.2.dist-info/RECORD +37 -0
  36. dogesec_commons-1.0.2.dist-info/WHEEL +4 -0
  37. 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
@@ -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,3 @@
1
+ from .pagination import Pagination
2
+ from .ordering import Ordering
3
+ from .exceptions import custom_exception_handler
@@ -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)
@@ -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()