diff --git a/CHANGES/2388.bugfix b/CHANGES/2388.bugfix new file mode 100644 index 0000000000..03885a7886 --- /dev/null +++ b/CHANGES/2388.bugfix @@ -0,0 +1 @@ +Vendor django-automated-logging to provide Django 4.x compatibility \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in index d4f1096923..80073bf970 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -15,3 +15,5 @@ exclude netlify.toml exclude docs exclude docs_requirements.txt exclude .readthedocs.yaml +include django-automated-logging-LICENSE.txt +include galaxy_ng/automated_logging/templates/dal/admin/view.html diff --git a/django-automated-logging-LICENSE.txt b/django-automated-logging-LICENSE.txt new file mode 100644 index 0000000000..dd2fc2fa9e --- /dev/null +++ b/django-automated-logging-LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Bilal Mahmoud + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/flake8.cfg b/flake8.cfg index 13b8e83717..548abd7e8b 100644 --- a/flake8.cfg +++ b/flake8.cfg @@ -9,6 +9,7 @@ exclude = .ci/scripts/*, .github/workflows/scripts/*, .venv/*, + ./galaxy_ng/_vendor/automated_logging/*, ignore = BLK,W503,Q000,D,D100,D101,D102,D103,D104,D105,D106,D107,D200,D401,D402,E203 max-line-length = 100 diff --git a/galaxy_ng/__init__.py b/galaxy_ng/__init__.py index bac2b8df2f..3501295896 100644 --- a/galaxy_ng/__init__.py +++ b/galaxy_ng/__init__.py @@ -1,3 +1,8 @@ +import sys +from ._vendor import automated_logging + +sys.modules.setdefault("automated_logging", automated_logging) + __version__ = "4.8.0dev" default_app_config = "galaxy_ng.app.PulpGalaxyPluginAppConfig" diff --git a/galaxy_ng/_vendor/__init__.py b/galaxy_ng/_vendor/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/galaxy_ng/_vendor/automated_logging/__init__.py b/galaxy_ng/_vendor/automated_logging/__init__.py new file mode 100755 index 0000000000..bd0ba9fa96 --- /dev/null +++ b/galaxy_ng/_vendor/automated_logging/__init__.py @@ -0,0 +1,7 @@ +""" +Django Automated Logging makes logging easy. + +Django Automated Logging (DAL) is a package with the purpose of +making logging in django automated and easy. +""" +default_app_config = 'automated_logging.apps.AutomatedloggingConfig' diff --git a/galaxy_ng/_vendor/automated_logging/admin/__init__.py b/galaxy_ng/_vendor/automated_logging/admin/__init__.py new file mode 100644 index 0000000000..01564b2e1e --- /dev/null +++ b/galaxy_ng/_vendor/automated_logging/admin/__init__.py @@ -0,0 +1,9 @@ +""" This is just a file that imports all admin interfaces defined in this package """ + +from automated_logging.admin.model_entry import * +from automated_logging.admin.model_event import * +from automated_logging.admin.model_mirror import * + +from automated_logging.admin.request_event import * + +from automated_logging.admin.unspecified_event import * diff --git a/galaxy_ng/_vendor/automated_logging/admin/base.py b/galaxy_ng/_vendor/automated_logging/admin/base.py new file mode 100644 index 0000000000..869abd4bd2 --- /dev/null +++ b/galaxy_ng/_vendor/automated_logging/admin/base.py @@ -0,0 +1,70 @@ +from django.contrib.admin.options import BaseModelAdmin, ModelAdmin, TabularInline +from django.contrib.admin.templatetags.admin_urls import admin_urlname +from django.shortcuts import resolve_url +from django.utils.html import format_html +from django.utils.safestring import SafeText + +from automated_logging.models import BaseModel + + +class MixinBase(BaseModelAdmin): + """ + TabularInline and ModelAdmin readonly mixin have both the same methods and + return the same, because of that fact we have a mixin base + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.readonly_fields = [f.name for f in self.model._meta.get_fields()] + + def get_actions(self, request): + """ get_actions from ModelAdmin, but remove all write operations.""" + actions = super().get_actions(request) + actions.pop('delete_selected', None) + + return actions + + def has_add_permission(self, request, instance=None): + """ no-one should have the ability to add something => r/o""" + return False + + def has_delete_permission(self, request, instance=None): + """ no-one should have the ability to delete something => r/o """ + return False + + def has_change_permission(self, request, instance=None): + """ no-one should have the ability to edit something => r/o """ + return False + + def save_model(self, request, instance, form, change): + """ disable saving by doing nothing """ + pass + + def delete_model(self, request, instance): + """ disable deleting by doing nothing """ + pass + + def save_related(self, request, form, formsets, change): + """ we don't need to save related, because save_model does nothing """ + pass + + # helpers + def model_admin_url(self, instance: BaseModel, name: str = None) -> str: + """ Helper to return a URL to another object """ + url = resolve_url( + admin_urlname(instance._meta, SafeText("change")), instance.pk + ) + return format_html('{}', url, name or str(instance)) + + +class ReadOnlyAdminMixin(MixinBase, ModelAdmin): + """ Disables all editing capabilities for the model admin """ + + change_form_template = "dal/admin/view.html" + + +class ReadOnlyTabularInlineMixin(MixinBase, TabularInline): + """ Disables all editing capabilities for inline """ + + model = None diff --git a/galaxy_ng/_vendor/automated_logging/admin/model_entry.py b/galaxy_ng/_vendor/automated_logging/admin/model_entry.py new file mode 100644 index 0000000000..af1630f661 --- /dev/null +++ b/galaxy_ng/_vendor/automated_logging/admin/model_entry.py @@ -0,0 +1,79 @@ +""" +Everything related to the admin interface of ModelEntry is located in here +""" + +from django.contrib.admin import register +from django.shortcuts import redirect + +from automated_logging.admin.model_event import ModelEventAdmin +from automated_logging.admin.base import ReadOnlyAdminMixin, ReadOnlyTabularInlineMixin +from automated_logging.models import ModelEntry, ModelEvent + + +class ModelEventInline(ReadOnlyTabularInlineMixin): + """ inline for all attached events """ + + model = ModelEvent + + def __init__(self, *args, **kwargs): + super(ModelEventInline, self).__init__(*args, **kwargs) + + self.readonly_fields = [*self.readonly_fields, 'get_uuid', 'get_modifications'] + + def get_uuid(self, instance): + """ make the uuid small """ + return self.model_admin_url(instance, str(instance.id).split('-')[0]) + + get_uuid.short_description = 'UUID' + + def get_modifications(self, instance): + """ ModelEventAdmin already implements this functions, we just refer to it""" + return ModelEventAdmin.get_modifications(self, instance) + + get_modifications.short_description = 'Modifications' + + fields = ('get_uuid', 'updated_at', 'user', 'get_modifications') + + ordering = ('-updated_at',) + + verbose_name = 'Event' + verbose_name_plural = 'Events' + + +@register(ModelEntry) +class ModelEntryAdmin(ReadOnlyAdminMixin): + """ admin page specification for ModelEntry """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.readonly_fields = [*self.readonly_fields, 'get_model', 'get_application'] + + def changelist_view(self, request, **kwargs): + """ instead of showing the changelist view redirect to the parent app_list""" + return redirect('admin:app_list', self.model._meta.app_label) + + def has_module_permission(self, request): + """ remove model entries from the index.html list """ + return False + + def get_model(self, instance): + """ get the model mirror """ + return self.model_admin_url(instance.mirror) + + get_model.short_description = 'Model' + + def get_application(self, instance): + """ get the application """ + return instance.mirror.application + + get_application.short_description = 'Application' + + fieldsets = ( + ( + 'Information', + {'fields': ('id', 'get_model', 'get_application', 'primary_key', 'value')}, + ), + ) + + inlines = [ModelEventInline] diff --git a/galaxy_ng/_vendor/automated_logging/admin/model_event.py b/galaxy_ng/_vendor/automated_logging/admin/model_event.py new file mode 100644 index 0000000000..07069024c9 --- /dev/null +++ b/galaxy_ng/_vendor/automated_logging/admin/model_event.py @@ -0,0 +1,195 @@ +""" +Everything related to the admin interface of ModelEvent is located in here +""" + +from django.contrib.admin import register, RelatedOnlyFieldListFilter +from django.utils.html import format_html + +from automated_logging.admin.base import ReadOnlyTabularInlineMixin, ReadOnlyAdminMixin +from automated_logging.helpers import Operation +from automated_logging.models import ( + ModelValueModification, + ModelRelationshipModification, + ModelEvent, +) + + +class ModelValueModificationInline(ReadOnlyTabularInlineMixin): + """ inline for all modifications """ + + model = ModelValueModification + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.readonly_fields = [*self.readonly_fields, 'get_uuid', 'get_field'] + + def get_uuid(self, instance): + """ make the uuid small """ + return str(instance.id).split('-')[0] + + get_uuid.short_description = 'UUID' + + def get_field(self, instance): + """ show the field name """ + return instance.field.name + + get_field.short_description = 'Field' + + fields = ('get_uuid', 'operation', 'get_field', 'previous', 'current') + can_delete = False + + verbose_name = 'Modification' + verbose_name_plural = 'Modifications' + + +class ModelRelationshipModificationInline(ReadOnlyTabularInlineMixin): + """ inline for all relationship modifications """ + + model = ModelRelationshipModification + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.readonly_fields = [*self.readonly_fields, 'get_uuid', 'get_field'] + + def get_uuid(self, instance): + """ make the uuid small """ + return str(instance.id).split('-')[0] + + get_uuid.short_description = 'UUID' + + def get_field(self, instance): + """ show the field name """ + return instance.field.name + + get_field.short_description = 'Field' + + fields = ('get_uuid', 'operation', 'get_field', 'entry') + can_delete = False + + verbose_name = 'Relationship' + verbose_name_plural = 'Relationships' + + +@register(ModelEvent) +class ModelEventAdmin(ReadOnlyAdminMixin): + """ admin page specification for ModelEvent """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.readonly_fields = [ + *self.readonly_fields, + 'get_application', + 'get_user', + 'get_model_link', + ] + + def get_modifications(self, instance): + """ + Modifications in short form, are colored for better readability. + + Colors taken from + https://github.com/django/django/tree/master/django/contrib/admin/static/admin/img + """ + colors = { + Operation.CREATE: '#70bf2b', + Operation.MODIFY: '#efb80b', + Operation.DELETE: '#dd4646', + } + return format_html( + ', '.join( + [ + *[ + f'' + f'{m.short()}' + f'' + for m in instance.modifications.all() + ], + *[ + f'' + f'{r.medium()[0]}' + f'[{r.medium()[1]}]' + for r in instance.relationships.all() + ], + ], + ) + ) + + get_modifications.short_description = 'Modifications' + + def get_model(self, instance): + """ + get the model + TODO: consider splitting this up to model/pk/value + """ + return instance.entry.short() + + get_model.short_description = 'Model' + + def get_model_link(self, instance): + """ get the model with a link to the entry """ + return self.model_admin_url(instance.entry) + + get_model_link.short_description = 'Model' + + def get_application(self, instance): + """ + helper to get the application from the child ModelMirror + :param instance: + :return: + """ + return instance.entry.mirror.application + + get_application.short_description = 'Application' + + def get_id(self, instance): + """ shorten the id to the first 8 digits """ + return str(instance.id).split('-')[0] + + get_id.short_description = 'UUID' + + def get_user(self, instance): + """ return the user with a link """ + return self.model_admin_url(instance.user) if instance.user else None + + get_user.short_description = 'User' + + list_display = ( + 'get_id', + 'updated_at', + 'user', + 'get_application', + 'get_model', + 'get_modifications', + ) + + list_filter = ( + 'updated_at', + ('user', RelatedOnlyFieldListFilter), + ('entry__mirror__application', RelatedOnlyFieldListFilter), + ('entry__mirror', RelatedOnlyFieldListFilter), + ) + + date_hierarchy = 'updated_at' + ordering = ('-updated_at',) + + fieldsets = ( + ( + 'Information', + { + 'fields': ( + 'id', + 'get_user', + 'updated_at', + 'get_application', + 'get_model_link', + ) + }, + ), + ('Introspection', {'fields': ('performance', 'snapshot')}), + ) + inlines = [ModelValueModificationInline, ModelRelationshipModificationInline] + + show_change_link = True diff --git a/galaxy_ng/_vendor/automated_logging/admin/model_mirror.py b/galaxy_ng/_vendor/automated_logging/admin/model_mirror.py new file mode 100644 index 0000000000..fea1482bf9 --- /dev/null +++ b/galaxy_ng/_vendor/automated_logging/admin/model_mirror.py @@ -0,0 +1,39 @@ +""" +Everything related to the admin interface of ModelMirror is located in here +""" +from django.contrib.admin import register +from django.shortcuts import redirect + +from automated_logging.admin.base import ReadOnlyAdminMixin, ReadOnlyTabularInlineMixin +from automated_logging.models import ModelMirror, ModelField + + +class ModelFieldInline(ReadOnlyTabularInlineMixin): + """ list all recorded fields """ + + model = ModelField + + fields = ['name', 'type'] + + verbose_name = 'Recorded Field' + verbose_name_plural = 'Recorded Fields' + + +@register(ModelMirror) +class ModelMirrorAdmin(ReadOnlyAdminMixin): + """ admin page specification for ModelMirror """ + + def has_module_permission(self, request): + """ prevents this from showing up index.html """ + return False + + def changelist_view(self, request, **kwargs): + """ instead of showing the changelist view redirect to the parent app_list""" + return redirect('admin:app_list', self.model._meta.app_label) + + date_hierarchy = 'updated_at' + ordering = ('-updated_at',) + + fieldsets = (('Information', {'fields': ('id', 'application', 'name')},),) + + inlines = [ModelFieldInline] diff --git a/galaxy_ng/_vendor/automated_logging/admin/request_event.py b/galaxy_ng/_vendor/automated_logging/admin/request_event.py new file mode 100644 index 0000000000..93d597b01c --- /dev/null +++ b/galaxy_ng/_vendor/automated_logging/admin/request_event.py @@ -0,0 +1,48 @@ +""" +Everything related to the admin interface of RequestEvent is located in here +""" +from django.contrib.admin import register, RelatedOnlyFieldListFilter + +from automated_logging.admin.base import ReadOnlyAdminMixin, ReadOnlyTabularInlineMixin +from automated_logging.models import RequestEvent + + +@register(RequestEvent) +class RequestEventAdmin(ReadOnlyAdminMixin): + """ admin page specification for the RequestEvent """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.readonly_fields = [*self.readonly_fields, 'get_uri', 'get_user'] + + def get_id(self, instance): + """ shorten the id to the first 8 digits """ + return str(instance.id).split('-')[0] + + get_id.short_description = 'UUID' + + def get_uri(self, instance): + """ get the uri. just a redirect to set the short description. """ + return instance.uri + + get_uri.short_description = 'URI' + + def get_user(self, instance): + """ get the user with a URL """ + return self.model_admin_url(instance.user) + + get_user.short_description = 'User' + + list_display = ('get_id', 'updated_at', 'user', 'method', 'status', 'uri') + + date_hierarchy = 'updated_at' + ordering = ('-updated_at',) + + list_filter = ('updated_at', ('user', RelatedOnlyFieldListFilter)) + + fieldsets = ( + ('Information', {'fields': ('id', 'get_user', 'updated_at', 'application',)},), + ('HTTP', {'fields': ('method', 'status', 'get_uri')}), + ) + # TODO: Context diff --git a/galaxy_ng/_vendor/automated_logging/admin/unspecified_event.py b/galaxy_ng/_vendor/automated_logging/admin/unspecified_event.py new file mode 100644 index 0000000000..cc784ac5c3 --- /dev/null +++ b/galaxy_ng/_vendor/automated_logging/admin/unspecified_event.py @@ -0,0 +1,36 @@ +""" +Everything related to the admin interface of UnspecifiedEvent is located in here +""" +from django.contrib.admin import register, RelatedOnlyFieldListFilter + +from automated_logging.admin.base import ReadOnlyAdminMixin, ReadOnlyTabularInlineMixin +from automated_logging.models import UnspecifiedEvent + + +@register(UnspecifiedEvent) +class UnspecifiedEventAdmin(ReadOnlyAdminMixin): + """ admin page specification for the UnspecifiedEvent """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def get_id(self, instance): + """ shorten the id to the first 8 digits """ + return str(instance.id).split('-')[0] + + get_id.short_description = 'UUID' + + list_display = ('get_id', 'updated_at', 'level', 'message') + + date_hierarchy = 'updated_at' + ordering = ('-updated_at',) + + list_filter = ('updated_at',) + + fieldsets = ( + ( + 'Information', + {'fields': ('id', 'updated_at', 'application', 'level', 'message')}, + ), + ('Location', {'fields': ('file', 'line')}), + ) diff --git a/galaxy_ng/_vendor/automated_logging/apps.py b/galaxy_ng/_vendor/automated_logging/apps.py new file mode 100755 index 0000000000..4f8bde218a --- /dev/null +++ b/galaxy_ng/_vendor/automated_logging/apps.py @@ -0,0 +1,16 @@ +from django.apps import AppConfig +from .settings import settings + + +class AutomatedloggingConfig(AppConfig): + name = 'automated_logging' + verbose_name = 'Django Automated Logging (DAL)' + + def ready(self): + if 'request' in settings.modules: + from .signals import request + if 'model' in settings.modules: + from .signals import save + from .signals import m2m + + from .handlers import DatabaseHandler diff --git a/galaxy_ng/_vendor/automated_logging/decorators.py b/galaxy_ng/_vendor/automated_logging/decorators.py new file mode 100644 index 0000000000..820e510519 --- /dev/null +++ b/galaxy_ng/_vendor/automated_logging/decorators.py @@ -0,0 +1,212 @@ +from functools import wraps, partial +from typing import List, NamedTuple, Set, Optional, Type, Dict, Callable, Union + + +from automated_logging.helpers import ( + Operation, + get_or_create_thread, + function2path, +) +from automated_logging.helpers.enums import VerbOperationMap + + +def _normalize_view_args(methods: List[str]) -> Set[str]: + if methods is not None: + methods = {m.upper() for m in methods} + + return methods + + +# TODO: consider adding status_codes +def exclude_view(func=None, *, methods: Optional[List[str]] = ()): + """ + Decorator used for ignoring specific views, without adding them + to the AUTOMATED_LOGGING configuration. + + This is done via the local threading object. This is done via the function + name and module location. + + :param func: function to be decorated + :param methods: methods to be ignored (case-insensitive), + None => No method will be ignored, + [] => All methods will be ignored + + :return: function + """ + if func is None: + return partial(exclude_view, methods=methods) + + methods = _normalize_view_args(methods) + + @wraps(func) + def wrapper(*args, **kwargs): + """ simple wrapper """ + thread, _ = get_or_create_thread() + + path = function2path(func) + if ( + path in thread.dal['ignore.views'] + and thread.dal['ignore.views'][path] is not None + and methods is not None + ): + methods.update(thread.dal['ignore.views'][path]) + + thread.dal['ignore.views'][path] = methods + + return func(*args, **kwargs) + + return wrapper + + +def include_view(func=None, *, methods: List[str] = None): + """ + Decorator used for including specific views **regardless** if they + are included in one of the exclusion patterns, this can be selectively done + via methods. Non matching methods will still go through the exclusion pattern + matching. + + :param func: function to be decorated + :param methods: methods to be included (case-insensitive) + None => All methods will be explicitly included + [] => No method will be explicitly included + :return: function + """ + if func is None: + return partial(include_view, methods=methods) + + methods = _normalize_view_args(methods) + + @wraps(func) + def wrapper(*args, **kwargs): + """ simple wrapper """ + thread, _ = get_or_create_thread() + + path = function2path(func) + if ( + path in thread.dal['include.views'] + and thread.dal['include.views'][path] is not None + and methods is not None + ): + methods.update(thread.dal['include.views'][path]) + + thread.dal['include.views'][path] = methods + return func(*args, **kwargs) + + return wrapper + + +def _normalize_model_args( + operations: List[str], fields: List[str] +) -> [Set[Operation], Set[str]]: + if operations is not None and not all( + isinstance(op, Operation) for op in operations + ): + operations = { + VerbOperationMap[o.lower()] + for o in operations + if o.lower() in VerbOperationMap.keys() + } + + if fields is not None: + fields = set(fields) + + return operations, fields + + +_include_models = {} +_exclude_models = {} + +IgnoreModel = NamedTuple( + "IgnoreModel", (('operations', Set[Operation]), ('fields', Set[str])) +) + + +def _register_model( + registry: Dict[str, Union['IgnoreModel', 'IncludeModel']], + container: Type[Union['IgnoreModel', 'IncludeModel']], + decorator: Callable, + model=None, + operations: List[str] = None, + fields: List[str] = None, +): + if model is None: + return partial(decorator, operations=operations, fields=fields) + + operations, fields = _normalize_model_args(operations, fields) + path = function2path(model) + + if ( + path in registry + and registry[path].operations is not None + and operations is not None + ): + operations.update(registry[path].operations) + + if path in registry and registry[path].fields is not None and fields is not None: + fields.update(registry[path].fields) + + registry[path] = container(operations, fields) + + # this makes it so that we have a method we can call to re apply dal. + model.__dal_register__ = lambda: _register_model( + registry, container, decorator, model, operations, fields + ) + return model + + +def exclude_model( + model=None, *, operations: Optional[List[str]] = (), fields: List[str] = () +): + """ + Decorator used for ignoring specific models, without using the + class or AUTOMATED_LOGGING configuration + + This is done via the local threading object. __module__ and __name__ are used + to determine the right model. + + :param model: function to be decorated + :param operations: operations to be ignored can be a list of: + modify, create, delete (case-insensitive) + [] => All operations will be ignored + None => No operation will be ignored + :param fields: fields to be ignored in not ignored operations + [] => All fields will be ignored + None => No field will be ignored + :return: function + """ + global _exclude_models + + return _register_model( + _exclude_models, IgnoreModel, exclude_model, model, operations, fields + ) + + +IncludeModel = NamedTuple( + "IncludeModel", (('operations', Set[Operation]), ('fields', Set[str])) +) + + +def include_model( + model=None, *, operations: List[str] = None, fields: List[str] = None +): + """ + Decorator used for including specific models, despite potentially being ignored + by the exclusion preferences set in the configuration. + + :param model: function to be decorated + :param operations: operations to be ignored can be a list of: + modify, create, delete (case-insensitive) + [] => No operation will be explicitly included + None => All operations will be explicitly included + :param fields: fields to be explicitly included + [] => No fields will be explicitly included + None => All fields will be explicitly included. + + :return: function + """ + + global _include_models + + return _register_model( + _include_models, IncludeModel, include_model, model, operations, fields + ) diff --git a/galaxy_ng/_vendor/automated_logging/handlers.py b/galaxy_ng/_vendor/automated_logging/handlers.py new file mode 100755 index 0000000000..4e264d9274 --- /dev/null +++ b/galaxy_ng/_vendor/automated_logging/handlers.py @@ -0,0 +1,289 @@ +import re +from collections import OrderedDict +from datetime import timedelta +from logging import Handler, LogRecord +from pathlib import Path +from threading import Thread +from typing import Dict, Any, TYPE_CHECKING, List, Optional, Union, Type, Tuple + +from django.core.exceptions import ObjectDoesNotExist +from django.utils import timezone +from django.db.models import ForeignObject, Model + + +if TYPE_CHECKING: + # we need to do this, to avoid circular imports + from automated_logging.models import ( + RequestEvent, + ModelEvent, + ModelValueModification, + ModelRelationshipModification, + ) + + +class DatabaseHandler(Handler): + def __init__( + self, *args, batch: Optional[int] = 1, threading: bool = False, **kwargs + ): + self.limit = batch or 1 + self.threading = threading + self.instances = OrderedDict() + super(DatabaseHandler, self).__init__(*args, **kwargs) + + @staticmethod + def _clear(config): + from automated_logging.models import ModelEvent, RequestEvent, UnspecifiedEvent + from django.db import transaction + + current = timezone.now() + with transaction.atomic(): + if config.model.max_age: + ModelEvent.objects.filter( + created_at__lte=current - config.model.max_age + ).delete() + if config.unspecified.max_age: + UnspecifiedEvent.objects.filter( + created_at__lte=current - config.unspecified.max_age + ).delete() + + if config.request.max_age: + RequestEvent.objects.filter( + created_at__lte=current - config.request.max_age + ).delete() + + def save(self, instance=None, commit=True, clear=True): + """ + Internal save procedure. + Handles deletion when an event exceeds max_age + and batch saving via atomic transactions. + + :return: None + """ + from django.db import transaction + from automated_logging.settings import settings + + if instance: + self.instances[instance.pk] = instance + if len(self.instances) < self.limit: + if clear: + self._clear(settings) + return instance + + if not commit: + return instance + + def database(instances, config): + """ wrapper so that we can actually use threading """ + with transaction.atomic(): + [i.save() for k, i in instances.items()] + + if clear: + self._clear(config) + instances.clear() + + if self.threading: + thread = Thread( + group=None, target=database, args=(self.instances, settings) + ) + thread.start() + else: + database(self.instances, settings) + + return instance + + def get_or_create(self, target: Type[Model], **kwargs) -> Tuple[Model, bool]: + """ + proxy for "get_or_create" from django, + instead of creating it immediately we + dd it to the list of objects to be created in a single swoop + + :type target: Model to be get_or_create + :type kwargs: properties to be used to find and create the new object + """ + created = False + try: + instance = target.objects.get(**kwargs) + except ObjectDoesNotExist: + instance = target(**kwargs) + self.save(instance, commit=False, clear=False) + created = True + + return instance, created + + def prepare_save(self, instance: Model): + """ + Due to the nature of all modifications and such there are some models + that are in nature get_or_create and not creations + (we don't want so much additional data) + + This is a recursive function that looks for relationships and + replaces specific values with their get_or_create counterparts. + + :param instance: model + :return: instance that is suitable for saving + """ + from automated_logging.models import ( + Application, + ModelMirror, + ModelField, + ModelEntry, + ) + + if isinstance(instance, Application): + return Application.objects.get_or_create(name=instance.name)[0] + elif isinstance(instance, ModelMirror): + return self.get_or_create( + ModelMirror, + name=instance.name, + application=self.prepare_save(instance.application), + )[0] + elif isinstance(instance, ModelField): + entry, _ = self.get_or_create( + ModelField, + name=instance.name, + mirror=self.prepare_save(instance.mirror), + ) + if entry.type != instance.type: + entry.type = instance.type + self.save(entry, commit=False, clear=False) + return entry + + elif isinstance(instance, ModelEntry): + entry, _ = self.get_or_create( + ModelEntry, + mirror=self.prepare_save(instance.mirror), + primary_key=instance.primary_key, + ) + if entry.value != instance.value: + entry.value = instance.value + self.save(entry, commit=False, clear=False) + return entry + + # ForeignObjectRel is untouched rn + for field in [ + f + for f in instance._meta.get_fields() + if isinstance(f, ForeignObject) + and getattr(instance, f.name, None) is not None + # check the attribute module really being automated_logging + # to make sure that we do not follow down a rabbit hole + and getattr(instance, f.name).__class__.__module__.split('.', 1)[0] + == 'automated_logging' + ]: + setattr( + instance, field.name, self.prepare_save(getattr(instance, field.name)) + ) + + self.save(instance, commit=False, clear=False) + return instance + + def unspecified(self, record: LogRecord) -> None: + """ + This is for messages that are not sent from django-automated-logging. + The option to still save these log messages is there. We create + the event in the handler and then save them. + + :param record: + :return: + """ + from automated_logging.models import UnspecifiedEvent, Application + from automated_logging.signals import unspecified_exclusion + from django.apps import apps + + event = UnspecifiedEvent() + if hasattr(record, 'message'): + event.message = record.message + event.level = record.levelno + event.line = record.lineno + event.file = Path(record.pathname) + + # this is semi-reliable, but I am unsure of a better way to do this. + applications = apps.app_configs.keys() + path = Path(record.pathname) + candidates = [p for p in path.parts if p in applications] + if candidates: + # use the last candidate (closest to file) + event.application = Application(name=candidates[-1]) + elif record.module in applications: + # if we cannot find the application, we use the module as application + event.application = Application(name=record.module) + else: + # if we cannot determine the application from the application + # or from the module we presume that the application is unknown + event.application = Application(name=None) + + if not unspecified_exclusion(event): + self.prepare_save(event) + self.save(event) + + def model( + self, + record: LogRecord, + event: 'ModelEvent', + modifications: List['ModelValueModification'], + data: Dict[str, Any], + ) -> None: + """ + This is for model specific logging events. + Compiles the information into an event and saves that event + and all modifications done. + + :param event: + :param modifications: + :param record: + :param data: + :return: + """ + self.prepare_save(event) + self.save(event) + + for modification in modifications: + modification.event = event + self.prepare_save(modification) + self.save() + + def m2m( + self, + record: LogRecord, + event: 'ModelEvent', + relationships: List['ModelRelationshipModification'], + data: Dict[str, Any], + ) -> None: + self.prepare_save(event) + self.save(event) + + for relationship in relationships: + relationship.event = event + self.prepare_save(relationship) + self.save(relationship) + + def request(self, record: LogRecord, event: 'RequestEvent') -> None: + """ + The request event already has a model prepared that we just + need to prepare and save. + + :param record: LogRecord + :param event: Event supplied via the LogRecord + :return: nothing + """ + + self.prepare_save(event) + self.save(event) + + def emit(self, record: LogRecord) -> None: + """ + Emit function that gets triggered for every log message in scope. + + The record will be processed according to the action set. + :param record: + :return: + """ + if not hasattr(record, 'action'): + return self.unspecified(record) + + if record.action == 'model': + return self.model(record, record.event, record.modifications, record.data) + elif record.action == 'model[m2m]': + return self.m2m(record, record.event, record.relationships, record.data) + elif record.action == 'request': + return self.request(record, record.event) diff --git a/galaxy_ng/_vendor/automated_logging/helpers/__init__.py b/galaxy_ng/_vendor/automated_logging/helpers/__init__.py new file mode 100644 index 0000000000..1f949a684f --- /dev/null +++ b/galaxy_ng/_vendor/automated_logging/helpers/__init__.py @@ -0,0 +1,189 @@ +""" +Helpers that are used throughout django-automated-logging +""" + +from datetime import datetime +from typing import Any, Union, Dict, NamedTuple + +from automated_logging.helpers.enums import Operation +from automated_logging.middleware import AutomatedLoggingMiddleware + + +def namedtuple2dict(root: Union[NamedTuple, Dict]) -> dict: + """ + transforms nested namedtuple into a dict + + :param root: namedtuple to convert + :return: dictionary from namedtuple + """ + + output = {} + if ( + isinstance(root, tuple) + and hasattr(root, '_serialize') + and callable(root._serialize) + ): + return root._serialize() + + root = root if isinstance(root, dict) else root._asdict() + + def eligible(x): + """ check if value x is eligible for recursion """ + return isinstance(x, tuple) or isinstance(x, dict) + + for k, v in root.items(): + if isinstance(v, set) or isinstance(v, list): + output[k] = [namedtuple2dict(i) if eligible(i) else i for i in v] + else: + output[k] = namedtuple2dict(v) if eligible(v) else v + + return output + + +def get_or_create_meta(instance) -> [Any, bool]: + """ + Simple helper function that creates the dal object + in _meta. + + :param instance: + :return: + """ + return instance, get_or_create_local(instance._meta) + + +def get_or_create_thread() -> [Any, bool]: + """ + Get or create the local thread, will always return False as the thread + won't be created, but the local dal object will. + + get_or_create to conform with the other functions. + + :return: thread, created dal object? + """ + thread = AutomatedLoggingMiddleware.thread + + return ( + thread, + get_or_create_local( + thread, + { + 'ignore.views': dict, + 'ignore.models': dict, + 'include.views': dict, + 'include.models': dict, + }, + ), + ) + + +def get_or_create_local(target: Any, defaults={}, key='dal') -> bool: + """ + Get or create local storage DAL metadata container, + where dal specific data is. + + :return: created? + """ + + if not hasattr(target, key): + setattr(target, key, MetaDataContainer(defaults)) + return True + + return False + + +def get_or_create_model_event( + instance, operation: Operation, force=False, extra=False +) -> [Any, bool]: + """ + Get or create the ModelEvent of an instance. + This function will also populate the event with the current information. + + :param instance: instance to derive an event from + :param operation: specified operation that is done + :param force: force creation of new event? + :param extra: extra information inserted? + :return: [event, created?] + """ + from automated_logging.models import ( + ModelEvent, + ModelEntry, + ModelMirror, + Application, + ) + from automated_logging.settings import settings + + get_or_create_meta(instance) + + if hasattr(instance._meta.dal, 'event') and not force: + return instance._meta.dal.event, False + + instance._meta.dal.event = None + + event = ModelEvent() + event.user = AutomatedLoggingMiddleware.get_current_user() + + if settings.model.snapshot and extra: + event.snapshot = instance + + if ( + settings.model.performance + and hasattr(instance._meta.dal, 'performance') + and extra + ): + event.performance = datetime.now() - instance._meta.dal.performance + instance._meta.dal.performance = None + + event.operation = operation + event.entry = ModelEntry() + event.entry.mirror = ModelMirror() + event.entry.mirror.name = instance.__class__.__name__ + event.entry.mirror.application = Application(name=instance._meta.app_label) + event.entry.value = repr(instance) or str(instance) + event.entry.primary_key = instance.pk + + instance._meta.dal.event = event + + return instance._meta.dal.event, True + + +def function2path(func): + """ simple helper function to return the module path of a function """ + return f'{func.__module__}.{func.__name__}' + + +class MetaDataContainer(dict): + """ + MetaDataContainer is used to store DAL specific metadata + in various places. + + Values can be retrieved via attribute or key retrieval. + + A dictionary with key attributes can be provided when __init__. + The key should be the name of the item, the value should be a function + that gets called when an item with that key does + not exist gets accessed, to auto-initialize that key. + """ + + def __init__(self, defaults={}): + super().__init__() + + self.auto = defaults + + def __getitem__(self, item): + try: + return super().__getitem__(item) + except KeyError: + if item in self.auto: + self[item] = self.auto[item]() + return self[item] + else: + raise KeyError + + def __getattr__(self, item): + try: + return self[item] + except KeyError: + raise AttributeError + + def __setattr__(self, key, value): + self[key] = value diff --git a/galaxy_ng/_vendor/automated_logging/helpers/enums.py b/galaxy_ng/_vendor/automated_logging/helpers/enums.py new file mode 100644 index 0000000000..2096e68118 --- /dev/null +++ b/galaxy_ng/_vendor/automated_logging/helpers/enums.py @@ -0,0 +1,49 @@ +""" Various enums used in DAL """ + +from enum import Enum + + +class Operation(int, Enum): + """ + Simple Enum that will be used across the code to + indicate the current operation that happened. + + Due to the fact that enum support for django was + only added in 3.0 we have DjangoOperations to convert + it to the old django format. + """ + + CREATE = 1 + MODIFY = 0 + DELETE = -1 + + +DjangoOperations = [(e.value, o.lower()) for o, e in Operation.__members__.items()] +VerbOperationMap = { + 'create': Operation.CREATE, + 'modify': Operation.MODIFY, + 'delete': Operation.DELETE, + 'add': Operation.CREATE, + 'remove': Operation.DELETE, +} +VerbM2MOperationMap = { + 'add': Operation.CREATE, + 'modify': Operation.MODIFY, + 'remove': Operation.DELETE, +} +PastOperationMap = { + 'created': Operation.CREATE, + 'modified': Operation.MODIFY, + 'deleted': Operation.DELETE, +} +PastM2MOperationMap = { + 'added': Operation.CREATE, + 'modified': Operation.MODIFY, + 'removed': Operation.DELETE, +} +ShortOperationMap = { + '+': Operation.CREATE, + '~': Operation.MODIFY, + '-': Operation.DELETE, +} +TranslationOperationMap = {**VerbOperationMap, **PastOperationMap, **ShortOperationMap} diff --git a/galaxy_ng/_vendor/automated_logging/helpers/exceptions.py b/galaxy_ng/_vendor/automated_logging/helpers/exceptions.py new file mode 100644 index 0000000000..4de4c30fad --- /dev/null +++ b/galaxy_ng/_vendor/automated_logging/helpers/exceptions.py @@ -0,0 +1,13 @@ +""" custom exceptions """ + + +class NoMatchFound(Exception): + """ error that indicates that no match has been found for a regex """ + + pass + + +class CouldNotConvertError(Exception): + """ error that is thrown when no conversion could be done """ + + pass diff --git a/galaxy_ng/_vendor/automated_logging/helpers/schemas.py b/galaxy_ng/_vendor/automated_logging/helpers/schemas.py new file mode 100644 index 0000000000..e77ed46cdd --- /dev/null +++ b/galaxy_ng/_vendor/automated_logging/helpers/schemas.py @@ -0,0 +1,224 @@ +import re +import typing +from collections import namedtuple +from datetime import timedelta +from typing import NamedTuple + +from marshmallow import Schema, EXCLUDE, post_load +from marshmallow.fields import List, String, Nested, TimeDelta + +from automated_logging.helpers.exceptions import NoMatchFound, CouldNotConvertError + +Search = NamedTuple('Search', (('type', str), ('value', str))) +Search._serialize = lambda self: f'{self.type}:{self.value}' + + +class Set(List): + """ + This is like a list, just compiles down to a set when serializing. + """ + + def _serialize( + self, value, attr, obj, **kwargs + ) -> typing.Optional[typing.Set[typing.Any]]: + return set(super(Set, self)._serialize(value, attr, obj, **kwargs)) + + def _deserialize(self, value, attr, data, **kwargs) -> typing.Set[typing.Any]: + return set(super(Set, self)._deserialize(value, attr, data, **kwargs)) + + +class LowerCaseString(String): + """ + String that is always going to be serialized to a lowercase string, + using `str.lower()` + """ + + def _deserialize(self, value, attr, data, **kwargs) -> str: + output = super()._deserialize(value, attr, data, **kwargs) + + return output.lower() + + +class Duration(TimeDelta): + """ TimeDelta derivative, with more input methods """ + + def _convert( + self, target: typing.Union[int, str, timedelta, None] + ) -> typing.Optional[timedelta]: + if target is None: + return None + + if isinstance(target, timedelta): + return target + + if isinstance(target, int) or isinstance(target, float): + return timedelta(seconds=target) + + if isinstance(target, str): + REGEX = ( + r'^P(?!$)(\d+Y)?(\d+M)?(\d+W)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+S)?)?$' + ) + match = re.match(REGEX, target, re.IGNORECASE) + if not match: + raise self.make_error('invalid') from NoMatchFound + + components = list(match.groups()) + # remove leading T capture - isn't used, by removing the 5th capture group + components.pop(4) + + adjusted = {'days': 0, 'seconds': 0} + conversion = [ + ['days', 365], # year + ['days', 30], # month + ['days', 7], # week + ['days', 1], # day + ['seconds', 3600], # hour + ['seconds', 60], # minute + ['seconds', 1], # second + ] + + for pointer in range(len(components)): + if not components[pointer]: + continue + rate = conversion[pointer] + native = int(re.findall(r'(\d+)', components[pointer])[0]) + + adjusted[rate[0]] += native * rate[1] + + return timedelta(**adjusted) + + raise self.make_error('invalid') from CouldNotConvertError + + def _deserialize(self, value, attr, data, **kwargs) -> typing.Optional[timedelta]: + try: + output = self._convert(value) + except OverflowError as error: + raise self.make_error('invalid') from error + + return output + + +class SearchString(String): + """ + Used for: + - ModelString + - FieldString + - ApplicationString + - FileString + + SearchStrings are used for models, fields and applications. + They can be either a glob (prefixed with either glob or gl), + regex (prefixed with either regex or re) + or plain (prefixed with plain or pl). + + All SearchStrings ignore the case of the raw string. + + format: : + examples: + - gl:app* (glob matching) + - glob:app* (glob matching) + - pl:app (exact matching) + - plain:app (exact matching) + - re:^app.*$ (regex matching) + - regex:^app.*$ (regex matching) + - :app* (glob matching) + - app (glob matching) + """ + + def _deserialize(self, value, attr, data, **kwargs) -> Search: + if isinstance(value, dict) and 'type' in value and 'value' in value: + value = f'{value["type"]}:{value["value"]}' + + output = super()._deserialize(value, attr, data, **kwargs) + + match = re.match(r'^(\w*):(.*)$', output, re.IGNORECASE) + if match: + module = match.groups()[0].lower() + match = match.groups()[1] + + if module.startswith('gl'): + return Search('glob', match.lower()) + elif module.startswith('pl'): + return Search('plain', match.lower()) + elif module.startswith('re'): + # regex shouldn't be lowercase + # we just ignore the case = + return Search('regex', match) + + raise self.make_error('invalid') from NotImplementedError + + return Search('glob', output) + + +class MissingNested(Nested): + """ + Modified marshmallow Nested, that is defaulting missing to loading an empty + schema, to populate it with data. + """ + + def __init__(self, *args, **kwargs): + if 'missing' not in kwargs: + kwargs['missing'] = lambda: args[0]().load({}) + + super().__init__(*args, **kwargs) + + +class BaseSchema(Schema): + """ + Modified marshmallow Schema, that is defaulting the unknown keyword to EXCLUDE, + not RAISE (marshmallow default) and when loading converts the dict into a namedtuple. + """ + + def __init__(self, *args, **kwargs): + if 'unknown' not in kwargs: + kwargs['unknown'] = EXCLUDE + + super().__init__(*args, **kwargs) + + @staticmethod + def namedtuple_or(left: NamedTuple, right: NamedTuple): + """ + __or__ implementation for the namedtuple + """ + values = {} + + if not isinstance(left, tuple) or not isinstance(right, tuple): + raise NotImplementedError + + for name in left._fields: + field = getattr(left, name) + values[name] = field + + if not hasattr(right, name): + continue + + if isinstance(field, tuple) or isinstance(field, set): + values[name] = field | getattr(right, name) + + return left._replace(**values) + + @staticmethod + def namedtuple_factory(name, keys): + """ + create the namedtuple from the name and keys to attach functions that are needed. + + Attaches: + binary **or** operation to support globals propagation + """ + Object = namedtuple(name, keys) + Object.__or__ = BaseSchema.namedtuple_or + return Object + + @post_load + def make_namedtuple(self, data: typing.Dict, **kwargs): + """ + converts the loaded data dict into a namedtuple + + :param data: loaded data + :param kwargs: marshmallow kwargs + :return: namedtuple + """ + name = self.__class__.__name__.replace('Schema', '') + + Object = BaseSchema.namedtuple_factory(name, data.keys()) + return Object(**data) diff --git a/galaxy_ng/_vendor/automated_logging/middleware.py b/galaxy_ng/_vendor/automated_logging/middleware.py new file mode 100644 index 0000000000..78ac09634d --- /dev/null +++ b/galaxy_ng/_vendor/automated_logging/middleware.py @@ -0,0 +1,130 @@ +import logging +import threading +from typing import NamedTuple, Optional, TYPE_CHECKING + +from django.http import HttpRequest, HttpResponse + +if TYPE_CHECKING: + from django.contrib.auth.models import AbstractUser + +RequestInformation = NamedTuple( + 'RequestInformation', + [ + ('request', HttpRequest), + ('response', Optional[HttpResponse]), + ('exception', Optional[Exception]), + ], +) + + +class AutomatedLoggingMiddleware: + """ + Middleware used by django-automated-logging + to provide request specific data to the request signals via + the local thread. + """ + + thread = threading.local() + + def __init__(self, get_response): + self.get_response = get_response + + AutomatedLoggingMiddleware.thread.__dal__ = None + + @staticmethod + def save(request, response=None, exception=None): + """ + Helper middleware, that sadly needs to be present. + the request_finished and request_started signals only + expose the class, not the actual request and response. + + We save the request and response specific data in the thread. + + :param request: Django Request + :param response: Optional Django Response + :param exception: Optional Exception + :return: + """ + + AutomatedLoggingMiddleware.thread.__dal__ = RequestInformation( + request, response, exception + ) + + def __call__(self, request): + """ + TODO: fix staticfiles has no environment?! + it seems like middleware isn't getting called for serving the static files, + this seems very odd. + + There are 2 different states, request object will be stored when available + and response will only be available post get_response. + + :param request: + :return: + """ + self.save(request) + + response = self.get_response(request) + + self.save(request, response) + + return response + + def process_exception(self, request, exception): + """ + Exception proceeds the same as __call__ and therefore should + also save things in the local thread. + + :param request: Django Request + :param exception: Thrown Exception + :return: - + """ + self.save(request, exception=exception) + + @staticmethod + def cleanup(): + """ + Cleanup function, that should be called last. Overwrites the + custom __dal__ object with None, to make sure the next request + does not use the same object. + + :return: - + """ + AutomatedLoggingMiddleware.thread.__dal__ = None + + @staticmethod + def get_current_environ() -> Optional[RequestInformation]: + """ + Helper staticmethod that looks if the __dal__ custom attribute + is present and returns either the attribute or None + + :return: Optional[RequestInformation] + """ + + if getattr(AutomatedLoggingMiddleware.thread, '__dal__', None): + return RequestInformation(*AutomatedLoggingMiddleware.thread.__dal__) + + return None + + @staticmethod + def get_current_user( + environ: RequestInformation = None, + ) -> Optional['AbstractUser']: + """ + Helper staticmethod that returns the current user, taken from + the current environment. + + :return: Optional[User] + """ + from django.contrib.auth.models import AnonymousUser + + if not environ: + environ = AutomatedLoggingMiddleware.get_current_environ() + + if not environ: + return None + + if isinstance(environ.request.user, AnonymousUser): + return None + + return environ.request.user diff --git a/galaxy_ng/_vendor/automated_logging/migrations/0001_initial.py b/galaxy_ng/_vendor/automated_logging/migrations/0001_initial.py new file mode 100644 index 0000000000..ed7e3a91f7 --- /dev/null +++ b/galaxy_ng/_vendor/automated_logging/migrations/0001_initial.py @@ -0,0 +1,171 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.2 on 2018-02-15 14:22 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('contenttypes', '0002_remove_content_type_name'), + ] + + operations = [ + migrations.CreateModel( + name='LDAP', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('action', models.TextField()), + ('succeeded', models.NullBooleanField()), + ('errorMessage', models.TextField(blank=True, null=True)), + ('basedn', models.TextField(blank=True, null=True)), + ('entry', models.TextField(blank=True, null=True)), + ('objectClass', models.TextField(blank=True, null=True)), + ('cn', models.TextField(blank=True, null=True)), + ('existing_members', models.TextField(blank=True, null=True)), + ('data_members', models.TextField(blank=True, null=True)), + ('diff_members', models.TextField(blank=True, null=True)), + ], + options={ + 'verbose_name': 'LDAP event log entry', + 'verbose_name_plural': 'LDAP event log entries', + }, + ), + migrations.CreateModel( + name='Model', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('message', models.TextField(null=True)), + ('action', models.PositiveSmallIntegerField(choices=[(0, 'n/a'), (1, 'add'), (2, 'change'), (3, 'delete')], default=0)), + ('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='atl_model_application', to='contenttypes.ContentType')), + ], + options={ + 'verbose_name': 'model log entry', + 'verbose_name_plural': 'model log entries', + }, + ), + migrations.CreateModel( + name='ModelChangelog', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'Model entry change', + 'verbose_name_plural': 'Model entry changes', + }, + ), + migrations.CreateModel( + name='ModelModification', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='ModelObject', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('value', models.CharField(max_length=255)), + ('model', models.CharField(max_length=255)), + ('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='atl_modelobject_application', to='contenttypes.ContentType')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Request', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('url', models.URLField()), + ('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')), + ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'request event entry', + 'verbose_name_plural': 'request event entries', + }, + ), + migrations.CreateModel( + name='Unspecified', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('message', models.TextField(null=True)), + ('level', models.PositiveSmallIntegerField(default=20)), + ('file', models.CharField(max_length=255, null=True)), + ('line', models.PositiveIntegerField(null=True)), + ], + options={ + 'verbose_name': ' logging entry (Errors, Warnings, Info)', + 'verbose_name_plural': ' logging entries (Errors, Warnings, Info)', + }, + ), + migrations.AddField( + model_name='modelmodification', + name='currently', + field=models.ManyToManyField(related_name='changelog_current', to='automated_logging.ModelObject'), + ), + migrations.AddField( + model_name='modelmodification', + name='previously', + field=models.ManyToManyField(related_name='changelog_previous', to='automated_logging.ModelObject'), + ), + migrations.AddField( + model_name='modelchangelog', + name='information', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='automated_logging.ModelObject'), + ), + migrations.AddField( + model_name='modelchangelog', + name='inserted', + field=models.ManyToManyField(related_name='changelog_inserted', to='automated_logging.ModelObject'), + ), + migrations.AddField( + model_name='modelchangelog', + name='modification', + field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, to='automated_logging.ModelModification'), + ), + migrations.AddField( + model_name='modelchangelog', + name='removed', + field=models.ManyToManyField(related_name='changelog_removed', to='automated_logging.ModelObject'), + ), + migrations.AddField( + model_name='model', + name='information', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='atl_model_information', to='automated_logging.ModelObject'), + ), + migrations.AddField( + model_name='model', + name='modification', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='automated_logging.ModelChangelog'), + ), + migrations.AddField( + model_name='model', + name='user', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/galaxy_ng/_vendor/automated_logging/migrations/0002_auto_20180215_1540.py b/galaxy_ng/_vendor/automated_logging/migrations/0002_auto_20180215_1540.py new file mode 100644 index 0000000000..c0df10ac90 --- /dev/null +++ b/galaxy_ng/_vendor/automated_logging/migrations/0002_auto_20180215_1540.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.2 on 2018-02-15 15:40 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('automated_logging', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Application', + fields=[ + ( + 'id', + models.UUIDField( + default=uuid.uuid4, primary_key=True, serialize=False + ), + ), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=255)), + ], + options={'abstract': False,}, + ), + migrations.RenameField( + model_name='modelobject', old_name='application', new_name='type', + ), + migrations.RemoveField(model_name='modelobject', name='model',), + migrations.RemoveField(model_name='model', name='application'), + migrations.AddField( + model_name='model', + name='application', + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='atl_model_application', + to='automated_logging.Application', + ), + ), + migrations.RemoveField(model_name='request', name='application'), + migrations.AddField( + model_name='request', + name='application', + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='automated_logging.Application', + ), + ), + ] diff --git a/galaxy_ng/_vendor/automated_logging/migrations/0003_auto_20180216_0900.py b/galaxy_ng/_vendor/automated_logging/migrations/0003_auto_20180216_0900.py new file mode 100644 index 0000000000..11f35258d2 --- /dev/null +++ b/galaxy_ng/_vendor/automated_logging/migrations/0003_auto_20180216_0900.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.2 on 2018-02-16 09:00 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('automated_logging', '0002_auto_20180215_1540'), + ] + + operations = [ + migrations.AlterField( + model_name='modelobject', + name='value', + field=models.CharField(max_length=255, null=True), + ), + ] diff --git a/galaxy_ng/_vendor/automated_logging/migrations/0004_auto_20180216_0935.py b/galaxy_ng/_vendor/automated_logging/migrations/0004_auto_20180216_0935.py new file mode 100644 index 0000000000..ba93662f28 --- /dev/null +++ b/galaxy_ng/_vendor/automated_logging/migrations/0004_auto_20180216_0935.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.2 on 2018-02-16 09:35 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('automated_logging', '0003_auto_20180216_0900'), + ] + + operations = [ + migrations.AlterField( + model_name='modelobject', + name='type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='atl_modelobject_application', to='contenttypes.ContentType'), + ), + ] diff --git a/galaxy_ng/_vendor/automated_logging/migrations/0005_auto_20180216_0941.py b/galaxy_ng/_vendor/automated_logging/migrations/0005_auto_20180216_0941.py new file mode 100644 index 0000000000..b237147f7a --- /dev/null +++ b/galaxy_ng/_vendor/automated_logging/migrations/0005_auto_20180216_0941.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.2 on 2018-02-16 09:41 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('automated_logging', '0004_auto_20180216_0935'), + ] + + operations = [ + migrations.AlterField( + model_name='modelchangelog', + name='information', + field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, to='automated_logging.ModelObject'), + ), + ] diff --git a/galaxy_ng/_vendor/automated_logging/migrations/0006_auto_20180216_1004.py b/galaxy_ng/_vendor/automated_logging/migrations/0006_auto_20180216_1004.py new file mode 100644 index 0000000000..31d3cd3344 --- /dev/null +++ b/galaxy_ng/_vendor/automated_logging/migrations/0006_auto_20180216_1004.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.2 on 2018-02-16 10:04 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('automated_logging', '0005_auto_20180216_0941'), + ] + + operations = [ + migrations.AlterField( + model_name='model', + name='application', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='atl_model_application', to='automated_logging.Application'), + ), + ] diff --git a/galaxy_ng/_vendor/automated_logging/migrations/0007_auto_20180216_1005.py b/galaxy_ng/_vendor/automated_logging/migrations/0007_auto_20180216_1005.py new file mode 100644 index 0000000000..c67f272823 --- /dev/null +++ b/galaxy_ng/_vendor/automated_logging/migrations/0007_auto_20180216_1005.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.2 on 2018-02-16 10:05 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('automated_logging', '0006_auto_20180216_1004'), + ] + + operations = [ + migrations.AlterField( + model_name='request', + name='application', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='automated_logging.Application'), + ), + ] diff --git a/galaxy_ng/_vendor/automated_logging/migrations/0008_auto_20180216_1005.py b/galaxy_ng/_vendor/automated_logging/migrations/0008_auto_20180216_1005.py new file mode 100644 index 0000000000..92963baf3e --- /dev/null +++ b/galaxy_ng/_vendor/automated_logging/migrations/0008_auto_20180216_1005.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.2 on 2018-02-16 10:05 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('automated_logging', '0007_auto_20180216_1005'), + ] + + operations = [ + migrations.AlterField( + model_name='model', + name='application', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='atl_model_application', to='automated_logging.Application'), + ), + ] diff --git a/galaxy_ng/_vendor/automated_logging/migrations/0009_auto_20180216_1006.py b/galaxy_ng/_vendor/automated_logging/migrations/0009_auto_20180216_1006.py new file mode 100644 index 0000000000..a248ca4595 --- /dev/null +++ b/galaxy_ng/_vendor/automated_logging/migrations/0009_auto_20180216_1006.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.2 on 2018-02-16 10:06 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('automated_logging', '0008_auto_20180216_1005'), + ] + + operations = [ + migrations.AlterField( + model_name='model', + name='application', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='atl_model_application', to='automated_logging.Application'), + ), + migrations.AlterField( + model_name='model', + name='information', + field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='atl_model_information', to='automated_logging.ModelObject'), + ), + ] diff --git a/galaxy_ng/_vendor/automated_logging/migrations/0010_auto_20180216_1430.py b/galaxy_ng/_vendor/automated_logging/migrations/0010_auto_20180216_1430.py new file mode 100644 index 0000000000..7dbf92ac15 --- /dev/null +++ b/galaxy_ng/_vendor/automated_logging/migrations/0010_auto_20180216_1430.py @@ -0,0 +1,49 @@ +# Generated by Django 2.0.2 on 2018-02-16 14:30 + +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('automated_logging', '0009_auto_20180216_1006'), + ] + + operations = [ + migrations.CreateModel( + name='Field', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=255)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='ModelStorage', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=255)), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='field', + name='model', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='automated_logging.ModelStorage'), + ), + migrations.AddField( + model_name='modelobject', + name='field', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='automated_logging.Field'), + ), + ] diff --git a/galaxy_ng/_vendor/automated_logging/migrations/0011_auto_20180216_1545.py b/galaxy_ng/_vendor/automated_logging/migrations/0011_auto_20180216_1545.py new file mode 100644 index 0000000000..5c972ab79b --- /dev/null +++ b/galaxy_ng/_vendor/automated_logging/migrations/0011_auto_20180216_1545.py @@ -0,0 +1,29 @@ +# Generated by Django 2.0.2 on 2018-02-16 15:45 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('automated_logging', '0010_auto_20180216_1430'), + ] + + operations = [ + migrations.AlterModelOptions( + name='model', + options={'verbose_name': 'Changelog', 'verbose_name_plural': 'Changelogs'}, + ), + migrations.AlterModelOptions( + name='modelchangelog', + options={}, + ), + migrations.AlterModelOptions( + name='request', + options={'verbose_name': 'Request', 'verbose_name_plural': 'Requests'}, + ), + migrations.AlterModelOptions( + name='unspecified', + options={'verbose_name': 'Non DJL Message', 'verbose_name_plural': 'Non DJL Messages'}, + ), + ] diff --git a/galaxy_ng/_vendor/automated_logging/migrations/0012_auto_20180218_1101.py b/galaxy_ng/_vendor/automated_logging/migrations/0012_auto_20180218_1101.py new file mode 100644 index 0000000000..eb6456c2db --- /dev/null +++ b/galaxy_ng/_vendor/automated_logging/migrations/0012_auto_20180218_1101.py @@ -0,0 +1,24 @@ +# Generated by Django 2.0.2 on 2018-02-18 11:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('automated_logging', '0011_auto_20180216_1545'), + ] + + operations = [ + migrations.AddField( + model_name='request', + name='method', + field=models.CharField(default='GET', max_length=64), + preserve_default=False, + ), + migrations.AlterField( + model_name='request', + name='url', + field=models.CharField(max_length=255), + ), + ] diff --git a/galaxy_ng/_vendor/automated_logging/migrations/0013_auto_20180218_1106.py b/galaxy_ng/_vendor/automated_logging/migrations/0013_auto_20180218_1106.py new file mode 100644 index 0000000000..45760e1892 --- /dev/null +++ b/galaxy_ng/_vendor/automated_logging/migrations/0013_auto_20180218_1106.py @@ -0,0 +1,23 @@ +# Generated by Django 2.0.2 on 2018-02-18 11:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('automated_logging', '0012_auto_20180218_1101'), + ] + + operations = [ + migrations.RemoveField( + model_name='request', + name='url', + ), + migrations.AddField( + model_name='request', + name='uri', + field=models.URLField(default='/'), + preserve_default=False, + ), + ] diff --git a/galaxy_ng/_vendor/automated_logging/migrations/0014_auto_20180219_0859.py b/galaxy_ng/_vendor/automated_logging/migrations/0014_auto_20180219_0859.py new file mode 100644 index 0000000000..f8910b4a0b --- /dev/null +++ b/galaxy_ng/_vendor/automated_logging/migrations/0014_auto_20180219_0859.py @@ -0,0 +1,31 @@ +# Generated by Django 2.0.2 on 2018-02-19 08:59 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('automated_logging', '0013_auto_20180218_1106'), + ] + + operations = [ + migrations.AddField( + model_name='request', + name='status', + field=models.PositiveSmallIntegerField(null=True), + ), + migrations.RemoveField(model_name='field', name='model'), + migrations.AddField( + model_name='field', + name='model', + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='dal_field', + to='contenttypes.ContentType', + ), + ), + migrations.DeleteModel(name='ModelStorage',), + ] diff --git a/galaxy_ng/_vendor/automated_logging/migrations/0015_auto_20181229_2323.py b/galaxy_ng/_vendor/automated_logging/migrations/0015_auto_20181229_2323.py new file mode 100644 index 0000000000..c883081449 --- /dev/null +++ b/galaxy_ng/_vendor/automated_logging/migrations/0015_auto_20181229_2323.py @@ -0,0 +1,61 @@ +# Generated by Django 2.1.1 on 2018-12-29 23:23 + +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('automated_logging', '0014_auto_20180219_0859'), + ] + + operations = [ + migrations.DeleteModel( + name='LDAP', + ), + migrations.AlterModelOptions( + name='unspecified', + options={'verbose_name': 'Non DAL Message', 'verbose_name_plural': 'Non DAL Messages'}, + ), + migrations.AlterField( + model_name='application', + name='id', + field=models.UUIDField(db_index=True, default=uuid.uuid4, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='field', + name='id', + field=models.UUIDField(db_index=True, default=uuid.uuid4, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='model', + name='id', + field=models.UUIDField(db_index=True, default=uuid.uuid4, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='modelchangelog', + name='id', + field=models.UUIDField(db_index=True, default=uuid.uuid4, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='modelmodification', + name='id', + field=models.UUIDField(db_index=True, default=uuid.uuid4, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='modelobject', + name='id', + field=models.UUIDField(db_index=True, default=uuid.uuid4, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='request', + name='id', + field=models.UUIDField(db_index=True, default=uuid.uuid4, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='unspecified', + name='id', + field=models.UUIDField(db_index=True, default=uuid.uuid4, primary_key=True, serialize=False), + ), + ] diff --git a/galaxy_ng/_vendor/automated_logging/migrations/0016_auto_20200803_1917.py b/galaxy_ng/_vendor/automated_logging/migrations/0016_auto_20200803_1917.py new file mode 100644 index 0000000000..d90be04d8d --- /dev/null +++ b/galaxy_ng/_vendor/automated_logging/migrations/0016_auto_20200803_1917.py @@ -0,0 +1,788 @@ +# Generated by Django 3.0.7 on 2020-08-03 19:17 +# Edited by Bilal Mahmoud for 5.x.x to 6.x.x conversion + +from django.db import migrations +from django.conf import settings +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import picklefield.fields +import uuid + +import logging + +logger = logging.getLogger(__name__) + + +def convert(apps, schema_editor): + """ convert from 5.x.x to 6.x.x """ + alias = schema_editor.connection.alias + + # convert all new applications + logger.info('Converting Applications from 5.x.x to 6.x.x') + # Application does not change, except new unknown Application + Application = apps.get_model('automated_logging', 'Application') + applications = [Application(name=None)] + no_application = applications[0] + + # convert request events + logger.info('Converting Request Events from 5.x.x to 6.x.x') + RequestOld = apps.get_model('automated_logging', 'Request') + RequestEvent = apps.get_model('automated_logging', 'RequestEvent') + requests = [ + RequestEvent( + id=r.id, + created_at=r.created_at, + updated_at=r.updated_at, + user=r.user, + uri=str(r.uri), + method=r.method[:32], + status=r.status, + application=Application.objects.using(alias).get(id=r.application.id) + if r.application + else no_application, + ) + for r in RequestOld.objects.using(alias).all() + ] + + # convert unspecified events + logger.info('Converting Unspecified Events from 5.x.x to 6.x.x') + UnspecifiedOld = apps.get_model('automated_logging', 'Unspecified') + UnspecifiedEvent = apps.get_model('automated_logging', 'UnspecifiedEvent') + unspecified = [ + UnspecifiedEvent( + id=u.id, + created_at=u.created_at, + updated_at=u.updated_at, + message=u.message, + level=u.level, + file=u.file, + line=u.line, + application=no_application, + ) + for u in UnspecifiedOld.objects.using(alias).all() + ] + + # convert model events + logger.info( + 'Converting Models Events from 5.x.x to 6.x.x (This might take a while)' + ) + ModelOld = apps.get_model('automated_logging', 'Model') + ModelEvent = apps.get_model('automated_logging', 'ModelEvent') + + ModelMirror = apps.get_model('automated_logging', 'ModelMirror') + ModelEntry = apps.get_model('automated_logging', 'ModelEntry') + ModelField = apps.get_model('automated_logging', 'ModelField') + ModelRelationshipModification = apps.get_model( + 'automated_logging', 'ModelRelationshipModification' + ) + ModelValueModification = apps.get_model( + 'automated_logging', 'ModelValueModification' + ) + + mirrors = [ModelMirror(name='', application=no_application)] + no_mirror = mirrors[0] + + entries = [ModelEntry(value='', primary_key='', mirror=no_mirror)] + no_entry = entries[0] + + fields = [ModelField(name='', mirror=no_mirror, type='')] + no_field = fields[0] + + events = [] + + value_modifications = [] + relationship_modifications = [] + + def get_application(content_type): + """ + simple helper function to get a new application from a content type + :return: ApplicationNew + """ + app = next( + (a for a in applications if a.name == content_type.app_label), + None, + ) + + if not app: + app = Application(name=content_type.app_label) + applications.append(app) + + return app + + def get_mirror(content_type): + """ + simple helper function to get the new mirror from content type + :return: ModelMirror + """ + app = get_application(content_type) + mir = next((m for m in mirrors if m.name == content_type.model), None) + if not mir: + mir = ModelMirror(name=content_type.model, application=app) + mirrors.append(mir) + + return mir + + def get_entry(value, mir): + """ + simple helper function to find the appropriate entry + :return: ModelEntry + """ + ent = next((e for e in entries if e.value == value), None) + if not ent: + ent = ModelEntry(value=value, primary_key='', mirror=mir) + entries.append(ent) + return ent + + def get_field(target): + """ + simple inline helper function to get the correct field + :return: ModelField + """ + + if target is None: + return no_field + + app = get_application(target.model) + + mir = next( + (m for m in mirrors if m.application.name == target.model.app_label), + None, + ) + if not mir: + mir = ModelMirror(name=target.model.model, application=app) + + fie = next( + (e for e in fields if e.name == target.name and e.mirror == mir), + None, + ) + if not fie: + fie = ModelField(name=target.name, mirror=mir, type='') + fields.append(fie) + + return fie + + oldies = ModelOld.objects.using(alias).all() + + progress = oldies.count() // 10 + idx = 0 + for old in oldies: + event = ModelEvent( + id=old.id, created_at=old.created_at, updated_at=old.updated_at + ) + + # converting old.information + # old.information -> event.model + # value => value + # content_type => ModelMirror + # app_label => Application + # model => ModelMirror.name + # provide some defaults, so that if no content_type is there + # the program doesn't poo all over the floor. + entry = no_entry + if old.information: + mirror = no_mirror + + # there is a chance old.information.type is null, + # we need to check for that case + if old.information.type: + mirror = get_mirror(old.information.type) + + entry = next((e for e in entries if e.value == old.information.value), None) + if not entry: + entry = ModelEntry( + value=old.information.value, primary_key='', mirror=mirror + ) + entries.append(entry) + event.entry = entry + + ACTION_TRANSLATIONS = {0: None, 1: 1, 2: 0, 3: -1} + + # old.modification -> event.modifications, event.relationships + # if information has content_type then relationship, else modification + # problem: field is unknown + # modification => modifications + # operation.YOUCANDECIDE => operation + # previously => previous + # currently => current + # inserted => relationship + # removed => relationship + + event.operation = ACTION_TRANSLATIONS[old.action] + + if old.modification: + for inserted in old.modification.inserted.all(): + mirror = get_mirror(inserted.type) + rel = ModelRelationshipModification( + field=get_field(inserted.field), + entry=get_entry(inserted.value, mirror), + operation=1, + ) + rel.event = event + relationship_modifications.append(rel) + + for removed in old.modification.removed.all(): + mirror = get_mirror(removed.type) + rel = ModelRelationshipModification( + field=get_field(removed.field), + entry=get_entry(removed.value, mirror), + operation=-1, + ) + rel.event = event + relationship_modifications.append(rel) + + if old.modification.modification: + previously = { + f.field.id: f + for f in old.modification.modification.previously.all() + } + for currently in old.modification.modification.currently.all(): + # skip None string values + if currently.value == 'None': + currently.value = None + + operation = 0 + previous = None + if currently.field.id not in previously: + operation = 1 + else: + previous = previously[currently.field.id].value + # previous can be "None" string + if previous == 'None': + operation = 1 + previous = None + if previous is not None and currently.value is None: + operation = -1 + + val = ModelValueModification( + operation=operation, + field=get_field(currently.field), + previous=previous, + current=currently.value, + ) + val.event = event + value_modifications.append(val) + + for removed in set( + p.field.id for p in old.modification.modification.previously.all() + ).difference( + c.field.id for c in old.modification.modification.currently.all() + ): + removed = previously[removed] + # skip None string values + if removed.value == 'None': + continue + + val = ModelValueModification( + operation=-1, + field=get_field(removed.field), + previous=removed.value, + current=None, + ) + val.event = event + value_modifications.append(val) + + event.user = old.user + events.append(event) + if idx % progress == 0: + logger.info(f'{(idx // progress) * 10}%...') + idx += 1 + + logger.info('Bulk Saving Converted Objects (This can take a while)') + Application.objects.using(alias).bulk_create(applications) + RequestEvent.objects.using(alias).bulk_create(requests) + UnspecifiedEvent.objects.using(alias).bulk_create(unspecified) + logger.info('Saved Application, RequestEvent and UnspecifiedEvent') + + ModelMirror.objects.using(alias).bulk_create(mirrors) + ModelEntry.objects.using(alias).bulk_create(entries) + ModelField.objects.using(alias).bulk_create(fields) + logger.info('Saved ModelMirror, ModelEntry, ModelField') + + ModelEvent.objects.using(alias).bulk_create(events) + + ModelValueModification.objects.using(alias).bulk_create(value_modifications) + ModelRelationshipModification.objects.using(alias).bulk_create( + relationship_modifications + ) + + logger.info( + 'Saved ModelValueModification, ModelRelationshipModification and ModelEvent' + ) + + +class Migration(migrations.Migration): + dependencies = [ + ('automated_logging', '0015_auto_20181229_2323'), + ] + + operations = [ + # migrations.CreateModel( + # # create temporary model + # # will be renamed + # name='ApplicationTemp', + # fields=[ + # ( + # 'id', + # models.UUIDField( + # db_index=True, + # default=uuid.uuid4, + # primary_key=True, + # serialize=False, + # ), + # ), + # ('created_at', models.DateTimeField(auto_now_add=True)), + # ('updated_at', models.DateTimeField(auto_now=True)), + # ('name', models.CharField(max_length=255, null=True)), + # ], + # options={ + # 'verbose_name': 'Application', + # 'verbose_name_plural': 'Applications', + # }, + # ), + migrations.CreateModel( + name='ModelEntry', + fields=[ + ( + 'id', + models.UUIDField( + db_index=True, + default=uuid.uuid4, + primary_key=True, + serialize=False, + ), + ), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('value', models.TextField()), + ('primary_key', models.TextField()), + ], + options={ + 'verbose_name': 'Model Entry', + 'verbose_name_plural': 'Model Entries', + }, + ), + migrations.CreateModel( + name='ModelEvent', + fields=[ + ( + 'id', + models.UUIDField( + db_index=True, + default=uuid.uuid4, + primary_key=True, + serialize=False, + ), + ), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ( + 'operation', + models.SmallIntegerField( + choices=[(1, 'create'), (0, 'modify'), (-1, 'delete')], + null=True, + validators=[ + django.core.validators.MinValueValidator(-1), + django.core.validators.MaxValueValidator(1), + ], + ), + ), + ( + 'snapshot', + picklefield.fields.PickledObjectField(editable=False, null=True), + ), + ('performance', models.DurationField(null=True)), + ( + 'entry', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='automated_logging.ModelEntry', + ), + ), + ( + 'user', + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + 'verbose_name': 'Model Event', + 'verbose_name_plural': 'Model Events', + }, + ), + migrations.CreateModel( + name='ModelField', + fields=[ + ( + 'id', + models.UUIDField( + db_index=True, + default=uuid.uuid4, + primary_key=True, + serialize=False, + ), + ), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=255)), + ('type', models.CharField(max_length=255)), + ], + options={ + 'verbose_name': 'Model Field', + 'verbose_name_plural': 'Model Fields', + }, + ), + migrations.CreateModel( + name='RequestContext', + fields=[ + ( + 'id', + models.UUIDField( + db_index=True, + default=uuid.uuid4, + primary_key=True, + serialize=False, + ), + ), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ( + 'content', + picklefield.fields.PickledObjectField(editable=False, null=True), + ), + ('type', models.CharField(max_length=255)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='UnspecifiedEvent', + fields=[ + ( + 'id', + models.UUIDField( + db_index=True, + default=uuid.uuid4, + primary_key=True, + serialize=False, + ), + ), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('message', models.TextField(null=True)), + ('level', models.PositiveIntegerField(default=20)), + ('line', models.PositiveIntegerField(null=True)), + ('file', models.TextField(null=True)), + ( + 'application', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='automated_logging.Application', + ), + ), + ], + options={ + 'verbose_name': 'Unspecified Event', + 'verbose_name_plural': 'Unspecified Events', + }, + ), + migrations.CreateModel( + name='RequestEvent', + fields=[ + ( + 'id', + models.UUIDField( + db_index=True, + default=uuid.uuid4, + primary_key=True, + serialize=False, + ), + ), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('uri', models.TextField()), + ('status', models.PositiveSmallIntegerField()), + ('method', models.CharField(max_length=32)), + ('ip', models.GenericIPAddressField(null=True)), + ( + 'application', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='automated_logging.Application', + ), + ), + ( + 'request', + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='request_context', + to='automated_logging.RequestContext', + ), + ), + ( + 'response', + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='response_context', + to='automated_logging.RequestContext', + ), + ), + ( + 'user', + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + 'verbose_name': 'Request Event', + 'verbose_name_plural': 'Request Events', + }, + ), + migrations.CreateModel( + name='ModelValueModification', + fields=[ + ( + 'id', + models.UUIDField( + db_index=True, + default=uuid.uuid4, + primary_key=True, + serialize=False, + ), + ), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ( + 'operation', + models.SmallIntegerField( + choices=[(1, 'create'), (0, 'modify'), (-1, 'delete')], + null=True, + validators=[ + django.core.validators.MinValueValidator(-1), + django.core.validators.MaxValueValidator(1), + ], + ), + ), + ('previous', models.TextField(null=True)), + ('current', models.TextField(null=True)), + ( + 'event', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='modifications', + to='automated_logging.ModelEvent', + ), + ), + ( + 'field', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='automated_logging.ModelField', + ), + ), + ], + options={ + 'verbose_name': 'Model Entry Event Value Modification', + 'verbose_name_plural': 'Model Entry Event Value Modifications', + }, + ), + migrations.CreateModel( + name='ModelRelationshipModification', + fields=[ + ( + 'id', + models.UUIDField( + db_index=True, + default=uuid.uuid4, + primary_key=True, + serialize=False, + ), + ), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ( + 'operation', + models.SmallIntegerField( + choices=[(1, 'create'), (0, 'modify'), (-1, 'delete')], + null=True, + validators=[ + django.core.validators.MinValueValidator(-1), + django.core.validators.MaxValueValidator(1), + ], + ), + ), + ( + 'event', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='relationships', + to='automated_logging.ModelEvent', + ), + ), + ( + 'field', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='automated_logging.ModelField', + ), + ), + ( + 'entry', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='automated_logging.ModelEntry', + ), + ), + ], + options={ + 'verbose_name': 'Model Entry Event Relationship Modification', + 'verbose_name_plural': 'Model Entry Event Relationship Modifications', + }, + ), + migrations.CreateModel( + name='ModelMirror', + fields=[ + ( + 'id', + models.UUIDField( + db_index=True, + default=uuid.uuid4, + primary_key=True, + serialize=False, + ), + ), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=255)), + ( + 'application', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='automated_logging.Application', + ), + ), + ], + options={ + 'verbose_name': 'Model Mirror', + 'verbose_name_plural': 'Model Mirrors', + }, + ), + migrations.AddField( + model_name='modelfield', + name='mirror', + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='automated_logging.ModelMirror', + ), + ), + migrations.AddField( + model_name='modelentry', + name='mirror', + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='automated_logging.ModelMirror', + ), + ), + migrations.AlterField( + model_name='application', + name='name', + field=models.CharField(max_length=255, null=True), + ), + # end of adding + # conversion + migrations.RunPython(convert), + # migrations.RenameModel('ApplicationTemp', 'Application'), + # removing 5.x.x + migrations.RemoveField( + model_name='field', + name='model', + ), + migrations.RemoveField( + model_name='model', + name='application', + ), + migrations.RemoveField( + model_name='model', + name='information', + ), + migrations.RemoveField( + model_name='model', + name='modification', + ), + migrations.RemoveField( + model_name='model', + name='user', + ), + migrations.RemoveField( + model_name='modelchangelog', + name='information', + ), + migrations.RemoveField( + model_name='modelchangelog', + name='inserted', + ), + migrations.RemoveField( + model_name='modelchangelog', + name='modification', + ), + migrations.RemoveField( + model_name='modelchangelog', + name='removed', + ), + migrations.RemoveField( + model_name='modelmodification', + name='currently', + ), + migrations.RemoveField( + model_name='modelmodification', + name='previously', + ), + migrations.RemoveField( + model_name='modelobject', + name='field', + ), + migrations.RemoveField( + model_name='modelobject', + name='type', + ), + migrations.RemoveField( + model_name='request', + name='application', + ), + migrations.RemoveField( + model_name='request', + name='user', + ), + migrations.DeleteModel( + name='Unspecified', + ), + # migrations.DeleteModel(name='Application',), + migrations.DeleteModel( + name='Field', + ), + migrations.DeleteModel( + name='Model', + ), + migrations.DeleteModel( + name='ModelChangelog', + ), + migrations.DeleteModel( + name='ModelModification', + ), + migrations.DeleteModel( + name='ModelObject', + ), + migrations.DeleteModel( + name='Request', + ), + # end of removing + ] diff --git a/galaxy_ng/_vendor/automated_logging/migrations/0017_auto_20200819_1004.py b/galaxy_ng/_vendor/automated_logging/migrations/0017_auto_20200819_1004.py new file mode 100644 index 0000000000..4372363138 --- /dev/null +++ b/galaxy_ng/_vendor/automated_logging/migrations/0017_auto_20200819_1004.py @@ -0,0 +1,17 @@ +# Generated by Django 3.0.7 on 2020-08-19 10:04 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('automated_logging', '0016_auto_20200803_1917'), + ] + + operations = [ + migrations.AlterModelOptions( + name='application', + options={'verbose_name': 'Application', 'verbose_name_plural': 'Applications'}, + ), + ] diff --git a/galaxy_ng/_vendor/automated_logging/migrations/0018_decoratoroverrideexclusiontest_foreignkeytest_fullclassbasedexclusiontest_fulldecoratorbasedexclusio.py b/galaxy_ng/_vendor/automated_logging/migrations/0018_decoratoroverrideexclusiontest_foreignkeytest_fullclassbasedexclusiontest_fulldecoratorbasedexclusio.py new file mode 100644 index 0000000000..454608683b --- /dev/null +++ b/galaxy_ng/_vendor/automated_logging/migrations/0018_decoratoroverrideexclusiontest_foreignkeytest_fullclassbasedexclusiontest_fulldecoratorbasedexclusio.py @@ -0,0 +1,282 @@ +# Generated by Django 3.0.7 on 2020-09-13 18:33 + +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + from django.conf import settings + + dependencies = [ + ('automated_logging', '0017_auto_20200819_1004'), + ] + + if hasattr(settings, 'AUTOMATED_LOGGING_DEV') and settings.AUTOMATED_LOGGING_DEV: + operations = [ + migrations.CreateModel( + name='DecoratorOverrideExclusionTest', + fields=[ + ( + 'id', + models.UUIDField( + default=uuid.uuid4, primary_key=True, serialize=False + ), + ), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('random', models.CharField(max_length=255, null=True)), + ('random2', models.CharField(max_length=255, null=True)), + ], + ), + migrations.CreateModel( + name='FullClassBasedExclusionTest', + fields=[ + ( + 'id', + models.UUIDField( + default=uuid.uuid4, primary_key=True, serialize=False + ), + ), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('random', models.CharField(max_length=255, null=True)), + ('random2', models.CharField(max_length=255, null=True)), + ], + ), + migrations.CreateModel( + name='FullDecoratorBasedExclusionTest', + fields=[ + ( + 'id', + models.UUIDField( + default=uuid.uuid4, primary_key=True, serialize=False + ), + ), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('random', models.CharField(max_length=255, null=True)), + ('random2', models.CharField(max_length=255, null=True)), + ], + ), + migrations.CreateModel( + name='OrdinaryTest', + fields=[ + ( + 'id', + models.UUIDField( + default=uuid.uuid4, primary_key=True, serialize=False + ), + ), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('random', models.CharField(max_length=255, null=True)), + ('random2', models.CharField(max_length=255, null=True)), + ], + ), + migrations.CreateModel( + name='PartialClassBasedExclusionTest', + fields=[ + ( + 'id', + models.UUIDField( + default=uuid.uuid4, primary_key=True, serialize=False + ), + ), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('random', models.CharField(max_length=255, null=True)), + ('random2', models.CharField(max_length=255, null=True)), + ], + ), + migrations.CreateModel( + name='PartialDecoratorBasedExclusionTest', + fields=[ + ( + 'id', + models.UUIDField( + default=uuid.uuid4, primary_key=True, serialize=False + ), + ), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('random', models.CharField(max_length=255, null=True)), + ('random2', models.CharField(max_length=255, null=True)), + ], + ), + migrations.CreateModel( + name='SpeedTest', + fields=[ + ( + 'id', + models.UUIDField( + default=uuid.uuid4, primary_key=True, serialize=False + ), + ), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('column0', models.CharField(max_length=15, null=True)), + ('column1', models.CharField(max_length=15, null=True)), + ('column2', models.CharField(max_length=15, null=True)), + ('column3', models.CharField(max_length=15, null=True)), + ('column4', models.CharField(max_length=15, null=True)), + ('column5', models.CharField(max_length=15, null=True)), + ('column6', models.CharField(max_length=15, null=True)), + ('column7', models.CharField(max_length=15, null=True)), + ('column8', models.CharField(max_length=15, null=True)), + ('column9', models.CharField(max_length=15, null=True)), + ('column10', models.CharField(max_length=15, null=True)), + ('column11', models.CharField(max_length=15, null=True)), + ('column12', models.CharField(max_length=15, null=True)), + ('column13', models.CharField(max_length=15, null=True)), + ('column14', models.CharField(max_length=15, null=True)), + ('column15', models.CharField(max_length=15, null=True)), + ('column16', models.CharField(max_length=15, null=True)), + ('column17', models.CharField(max_length=15, null=True)), + ('column18', models.CharField(max_length=15, null=True)), + ('column19', models.CharField(max_length=15, null=True)), + ('column20', models.CharField(max_length=15, null=True)), + ('column21', models.CharField(max_length=15, null=True)), + ('column22', models.CharField(max_length=15, null=True)), + ('column23', models.CharField(max_length=15, null=True)), + ('column24', models.CharField(max_length=15, null=True)), + ('column25', models.CharField(max_length=15, null=True)), + ('column26', models.CharField(max_length=15, null=True)), + ('column27', models.CharField(max_length=15, null=True)), + ('column28', models.CharField(max_length=15, null=True)), + ('column29', models.CharField(max_length=15, null=True)), + ('column30', models.CharField(max_length=15, null=True)), + ('column31', models.CharField(max_length=15, null=True)), + ('column32', models.CharField(max_length=15, null=True)), + ('column33', models.CharField(max_length=15, null=True)), + ('column34', models.CharField(max_length=15, null=True)), + ('column35', models.CharField(max_length=15, null=True)), + ('column36', models.CharField(max_length=15, null=True)), + ('column37', models.CharField(max_length=15, null=True)), + ('column38', models.CharField(max_length=15, null=True)), + ('column39', models.CharField(max_length=15, null=True)), + ('column40', models.CharField(max_length=15, null=True)), + ('column41', models.CharField(max_length=15, null=True)), + ('column42', models.CharField(max_length=15, null=True)), + ('column43', models.CharField(max_length=15, null=True)), + ('column44', models.CharField(max_length=15, null=True)), + ('column45', models.CharField(max_length=15, null=True)), + ('column46', models.CharField(max_length=15, null=True)), + ('column47', models.CharField(max_length=15, null=True)), + ('column48', models.CharField(max_length=15, null=True)), + ('column49', models.CharField(max_length=15, null=True)), + ('column50', models.CharField(max_length=15, null=True)), + ('column51', models.CharField(max_length=15, null=True)), + ('column52', models.CharField(max_length=15, null=True)), + ('column53', models.CharField(max_length=15, null=True)), + ('column54', models.CharField(max_length=15, null=True)), + ('column55', models.CharField(max_length=15, null=True)), + ('column56', models.CharField(max_length=15, null=True)), + ('column57', models.CharField(max_length=15, null=True)), + ('column58', models.CharField(max_length=15, null=True)), + ('column59', models.CharField(max_length=15, null=True)), + ('column60', models.CharField(max_length=15, null=True)), + ('column61', models.CharField(max_length=15, null=True)), + ('column62', models.CharField(max_length=15, null=True)), + ('column63', models.CharField(max_length=15, null=True)), + ('column64', models.CharField(max_length=15, null=True)), + ('column65', models.CharField(max_length=15, null=True)), + ('column66', models.CharField(max_length=15, null=True)), + ('column67', models.CharField(max_length=15, null=True)), + ('column68', models.CharField(max_length=15, null=True)), + ('column69', models.CharField(max_length=15, null=True)), + ('column70', models.CharField(max_length=15, null=True)), + ('column71', models.CharField(max_length=15, null=True)), + ('column72', models.CharField(max_length=15, null=True)), + ('column73', models.CharField(max_length=15, null=True)), + ('column74', models.CharField(max_length=15, null=True)), + ('column75', models.CharField(max_length=15, null=True)), + ('column76', models.CharField(max_length=15, null=True)), + ('column77', models.CharField(max_length=15, null=True)), + ('column78', models.CharField(max_length=15, null=True)), + ('column79', models.CharField(max_length=15, null=True)), + ('column80', models.CharField(max_length=15, null=True)), + ('column81', models.CharField(max_length=15, null=True)), + ('column82', models.CharField(max_length=15, null=True)), + ('column83', models.CharField(max_length=15, null=True)), + ('column84', models.CharField(max_length=15, null=True)), + ('column85', models.CharField(max_length=15, null=True)), + ('column86', models.CharField(max_length=15, null=True)), + ('column87', models.CharField(max_length=15, null=True)), + ('column88', models.CharField(max_length=15, null=True)), + ('column89', models.CharField(max_length=15, null=True)), + ('column90', models.CharField(max_length=15, null=True)), + ('column91', models.CharField(max_length=15, null=True)), + ('column92', models.CharField(max_length=15, null=True)), + ('column93', models.CharField(max_length=15, null=True)), + ('column94', models.CharField(max_length=15, null=True)), + ('column95', models.CharField(max_length=15, null=True)), + ('column96', models.CharField(max_length=15, null=True)), + ('column97', models.CharField(max_length=15, null=True)), + ('column98', models.CharField(max_length=15, null=True)), + ('column99', models.CharField(max_length=15, null=True)), + ], + ), + migrations.CreateModel( + name='OneToOneTest', + fields=[ + ( + 'id', + models.UUIDField( + default=uuid.uuid4, primary_key=True, serialize=False + ), + ), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ( + 'relationship', + models.OneToOneField( + null=True, + on_delete=django.db.models.deletion.CASCADE, + to='automated_logging.OrdinaryTest', + ), + ), + ], + ), + migrations.CreateModel( + name='M2MTest', + fields=[ + ( + 'id', + models.UUIDField( + default=uuid.uuid4, primary_key=True, serialize=False + ), + ), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ( + 'relationship', + models.ManyToManyField(to='automated_logging.OrdinaryTest'), + ), + ], + ), + migrations.CreateModel( + name='ForeignKeyTest', + fields=[ + ( + 'id', + models.UUIDField( + default=uuid.uuid4, primary_key=True, serialize=False + ), + ), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ( + 'relationship', + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + to='automated_logging.OrdinaryTest', + ), + ), + ], + ), + ] + + else: + operations = [] diff --git a/galaxy_ng/_vendor/automated_logging/migrations/__init__.py b/galaxy_ng/_vendor/automated_logging/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/galaxy_ng/_vendor/automated_logging/models.py b/galaxy_ng/_vendor/automated_logging/models.py new file mode 100755 index 0000000000..6cb072eaff --- /dev/null +++ b/galaxy_ng/_vendor/automated_logging/models.py @@ -0,0 +1,355 @@ +""" +Model definitions for django-automated-logging. +""" +import uuid + +from django.conf import settings +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models +from django.db.models import ( + CharField, + ForeignKey, + CASCADE, + TextField, + SmallIntegerField, + PositiveIntegerField, + DurationField, + GenericIPAddressField, + PositiveSmallIntegerField, +) +from picklefield.fields import PickledObjectField + +from automated_logging.helpers import Operation +from automated_logging.helpers.enums import ( + DjangoOperations, + PastM2MOperationMap, + ShortOperationMap, +) +from automated_logging.settings import dev + + +class BaseModel(models.Model): + """BaseModel that is inherited from every model. Includes basic information.""" + + id = models.UUIDField(default=uuid.uuid4, primary_key=True, db_index=True) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + abstract = True + + +class Application(BaseModel): + """ + Used to save from which application an event or model originates. + This is used to group by application. + + The application name can be null, + if the name is None, then the application is unknown. + """ + + name = CharField(max_length=255, null=True) + + class Meta: + verbose_name = "Application" + verbose_name_plural = "Applications" + + class LoggingIgnore: + complete = True + + def __str__(self): + return self.name or "Unknown" + + +class ModelMirror(BaseModel): + """ + Used to mirror properties of models - this is used to preserve logs of + models removed to make the logs independent of the presence of the model + in the application. + """ + + name = CharField(max_length=255) + application = ForeignKey(Application, on_delete=CASCADE) + + class Meta: + verbose_name = "Model Mirror" + verbose_name_plural = "Model Mirrors" + + class LoggingIgnore: + complete = True + + def __str__(self): + return self.name + + +class ModelField(BaseModel): + """ + Used to mirror properties of model fields - this is used to preserve logs of + models and fields that might be removed/modified and have them independent + of the actual field. + """ + + name = CharField(max_length=255) + + mirror = ForeignKey(ModelMirror, on_delete=CASCADE) + type = CharField(max_length=255) # string of type + + class Meta: + verbose_name = "Model Field" + verbose_name_plural = "Model Fields" + + class LoggingIgnore: + complete = True + + +class ModelEntry(BaseModel): + """ + Used to mirror the evaluated model value (via repr) and primary key and + to ensure the log integrity independent of presence of the entry. + """ + + mirror = ForeignKey(ModelMirror, on_delete=CASCADE) + + value = TextField() # (repr) + primary_key = TextField() + + class Meta: + verbose_name = "Model Entry" + verbose_name_plural = "Model Entries" + + class LoggingIgnore: + complete = True + + def __str__(self) -> str: + return f'{self.mirror.name}' f'(pk="{self.primary_key}", value="{self.value}")' + + def long(self) -> str: + """ + long representation + """ + + return f'{self.mirror.application.name}.{self})' + + def short(self) -> str: + """ + short representation + """ + return f'{self.mirror.name}({self.primary_key})' + + +class ModelEvent(BaseModel): + """ + Used to record model entry events, like modification, removal or adding of + values or relationships. + """ + + operation = SmallIntegerField( + validators=[MinValueValidator(-1), MaxValueValidator(1)], + null=True, + choices=DjangoOperations, + ) + + user = ForeignKey( + settings.AUTH_USER_MODEL, on_delete=CASCADE, null=True + ) # maybe don't cascade? + entry = ForeignKey(ModelEntry, on_delete=CASCADE) + + # modifications = None # One2Many -> ModelModification + # relationships = None # One2Many -> ModelRelationship + + # v experimental, opt-in (pickled object) + snapshot = PickledObjectField(null=True) + performance = DurationField(null=True) + + class Meta: + verbose_name = "Model Event" + verbose_name_plural = "Model Events" + + class LoggingIgnore: + complete = True + + +class ModelValueModification(BaseModel): + """ + Used to record the model entry event modifications of simple values. + + The operation attribute can have 4 valid values: + -1 (delete), 0 (modify), 1 (create), None (n/a) + + previous and current record the value change that happened. + """ + + operation = SmallIntegerField( + validators=[MinValueValidator(-1), MaxValueValidator(1)], + null=True, + choices=DjangoOperations, + ) + + field = ForeignKey(ModelField, on_delete=CASCADE) + + previous = TextField(null=True) + current = TextField(null=True) + + event = ForeignKey(ModelEvent, on_delete=CASCADE, related_name='modifications') + + class Meta: + verbose_name = "Model Entry Event Value Modification" + verbose_name_plural = "Model Entry Event Value Modifications" + + class LoggingIgnore: + complete = True + + def __str__(self) -> str: + return ( + f'[{self.field.mirror.application.name}:' + f'{self.field.mirror.name}:' + f'{self.field.name}] ' + f'{self.previous} -> {self.current}' + ) + + def short(self) -> str: + """ + short representation analogue of __str__ + """ + operation = Operation(self.operation) + shorthand = {v: k for k, v in ShortOperationMap.items()}[operation] + + return f'{shorthand}{self.field.name}' + + +class ModelRelationshipModification(BaseModel): + """ + Used to record the model entry even modifications of relationships. (M2M, Foreign) + + + The operation attribute can have 4 valid values: + -1 (delete), 0 (modify), 1 (create), None (n/a) + + field is the field where the relationship changed (entry got added or removed) + and model is the entry that got removed/added from the relationship. + """ + + operation = SmallIntegerField( + validators=[MinValueValidator(-1), MaxValueValidator(1)], + null=True, + choices=DjangoOperations, + ) + + field = ForeignKey(ModelField, on_delete=CASCADE) + entry = ForeignKey(ModelEntry, on_delete=CASCADE) + + event = ForeignKey(ModelEvent, on_delete=CASCADE, related_name='relationships') + + class Meta: + verbose_name = "Model Entry Event Relationship Modification" + verbose_name_plural = "Model Entry Event Relationship Modifications" + + class LoggingIgnore: + complete = True + + def __str__(self) -> str: + operation = Operation(self.operation) + past = {v: k for k, v in PastM2MOperationMap.items()}[operation] + + return ( + f'[{self.field.mirror.application}:' + f'{self.field.mirror.name}:' + f'{self.field.name}] ' + f'{past} {self.entry}' + ) + + def short(self) -> str: + """ + short representation + """ + operation = Operation(self.operation) + shorthand = {v: k for k, v in ShortOperationMap.items()}[operation] + return f'{shorthand}{self.entry.short()}' + + def medium(self) -> [str, str]: + """ + short representation analogue of __str__ with additional field context + :return: + """ + operation = Operation(self.operation) + shorthand = {v: k for k, v in ShortOperationMap.items()}[operation] + + return f'{shorthand}{self.field.name}', f'{self.entry.short()}' + + +class RequestContext(BaseModel): + """ + Used to record contents of request and responses and their type. + """ + + content = PickledObjectField(null=True) + type = CharField(max_length=255) + + class LoggingIgnore: + complete = True + + +class RequestEvent(BaseModel): + """ + Used to record events of requests that happened. + + uri is the accessed path and data is the data that was being transmitted + and is opt-in for collection. + + status and method are their respective HTTP equivalents. + """ + + user = ForeignKey(settings.AUTH_USER_MODEL, on_delete=CASCADE, null=True) + + # to mitigate "max_length" + uri = TextField() + + request = ForeignKey( + RequestContext, on_delete=CASCADE, null=True, related_name='request_context' + ) + response = ForeignKey( + RequestContext, on_delete=CASCADE, null=True, related_name='response_context' + ) + + status = PositiveSmallIntegerField() + method = CharField(max_length=32) + + application = ForeignKey(Application, on_delete=CASCADE) + + ip = GenericIPAddressField(null=True) + + class Meta: + verbose_name = "Request Event" + verbose_name_plural = "Request Events" + + class LoggingIgnore: + complete = True + + +class UnspecifiedEvent(BaseModel): + """ + Used to record unspecified internal events that are dispatched via + the python logging library. saves the message, level, line, file and application. + """ + + message = TextField(null=True) + level = PositiveIntegerField(default=20) + + line = PositiveIntegerField(null=True) + file = TextField(null=True) + + application = ForeignKey(Application, on_delete=CASCADE) + + class Meta: + verbose_name = "Unspecified Event" + verbose_name_plural = "Unspecified Events" + + class LoggingIgnore: + complete = True + + +if dev: + # if in development mode (set when testing or development) + # import extra models + from automated_logging.tests.models import * diff --git a/galaxy_ng/_vendor/automated_logging/settings.py b/galaxy_ng/_vendor/automated_logging/settings.py new file mode 100644 index 0000000000..081236f5c1 --- /dev/null +++ b/galaxy_ng/_vendor/automated_logging/settings.py @@ -0,0 +1,259 @@ +""" +Serialization of AUTOMATED_LOGGING_SETTINGS +""" + +from collections import namedtuple +from functools import lru_cache +from logging import INFO, NOTSET, CRITICAL +from pprint import pprint + +from marshmallow.fields import Boolean, Integer +from marshmallow.validate import OneOf, Range + +from automated_logging.helpers.schemas import ( + Set, + LowerCaseString, + SearchString, + MissingNested, + BaseSchema, + Search, + Duration, +) + + +class RequestExcludeSchema(BaseSchema): + """ + Configuration schema for request exclusion, that is only used in RequestSchema, + is used to exclude unknown sources, applications, methods and status codes. + """ + + unknown = Boolean(missing=False) + applications = Set(SearchString(), missing=set()) + + methods = Set(LowerCaseString(), missing={'GET'}) + status = Set(Integer(validate=Range(min=0)), missing={200}) + + +class RequestDataSchema(BaseSchema): + """ + Configuration schema for request data that is only used in RequestSchema + and is used to enable data collection, ignore keys that are going to be omitted + mask keys (their value is going to be replaced with ) + """ + + enabled = Set( + LowerCaseString(validate=OneOf(['request', 'response'])), + missing=set(), + ) + query = Boolean(missing=False) + + ignore = Set(LowerCaseString(), missing=set()) + mask = Set(LowerCaseString(), missing={'password'}) + + # TODO: add more, change name? + content_types = Set( + LowerCaseString(validate=OneOf(['application/json'])), + missing={'application/json'}, + ) + + +class RequestSchema(BaseSchema): + """ + Configuration schema for the request module. + """ + + loglevel = Integer(missing=INFO, validate=Range(min=NOTSET, max=CRITICAL)) + exclude = MissingNested(RequestExcludeSchema) + + data = MissingNested(RequestDataSchema) + + ip = Boolean(missing=True) + # TODO: performance setting? + + log_request_was_not_recorded = Boolean(missing=True) + max_age = Duration(missing=None) + + +class ModelExcludeSchema(BaseSchema): + """ + Configuration schema, that is only used in ModelSchema and is used to + exclude unknown sources, fields, models and applications. + + fields should be either (every field that matches this name will be excluded), + or ., or .. + + models should be either (every model regardless of module or application). + (python module location) or . (python module location) + """ + + unknown = Boolean(missing=False) + fields = Set(SearchString(), missing=set()) + models = Set(SearchString(), missing=set()) + applications = Set(SearchString(), missing=set()) + + +class ModelSchema(BaseSchema): + """ + Configuration schema for the model module. mask property indicates + which fields to specifically replace with , + this should be used for fields that are + sensitive, but shouldn't be completely excluded. + """ + + loglevel = Integer(missing=INFO, validate=Range(min=NOTSET, max=CRITICAL)) + exclude = MissingNested(ModelExcludeSchema) + + # should the log message include all modifications done? + detailed_message = Boolean(missing=True) + + # if execution_time should be measured of ModelEvent + performance = Boolean(missing=False) + snapshot = Boolean(missing=False) + + max_age = Duration(missing=None) + + +class UnspecifiedExcludeSchema(BaseSchema): + """ + Configuration schema, that is only used in UnspecifiedSchema and defines + the configuration settings to allow unknown sources, exclude files and + specific Django applications + """ + + unknown = Boolean(missing=False) + files = Set(SearchString(), missing=set()) + applications = Set(SearchString(), missing=set()) + + +class UnspecifiedSchema(BaseSchema): + """ + Configuration schema for the unspecified module. + """ + + loglevel = Integer(missing=INFO, validate=Range(min=NOTSET, max=CRITICAL)) + exclude = MissingNested(UnspecifiedExcludeSchema) + + max_age = Duration(missing=None) + + +class GlobalsExcludeSchema(BaseSchema): + """ + Configuration schema, that is used for every single module. + There are some packages where it is sensible to have the same + exclusions. + + Things specified in globals will get appended to the other configurations. + """ + + applications = Set( + SearchString(), + missing={ + Search('glob', 'session*'), + Search('plain', 'admin'), + Search('plain', 'basehttp'), + Search('plain', 'migrations'), + Search('plain', 'contenttypes'), + }, + ) + + +class GlobalsSchema(BaseSchema): + """ + Configuration schema for global, module unspecific configuration details. + """ + + exclude = MissingNested(GlobalsExcludeSchema) + + +class ConfigSchema(BaseSchema): + """ + Skeleton configuration schema, that is used to enable/disable modules + and includes the nested module configurations. + """ + + modules = Set( + LowerCaseString(validate=OneOf(['request', 'model', 'unspecified'])), + missing={'request', 'model', 'unspecified'}, + ) + + request = MissingNested(RequestSchema) + model = MissingNested(ModelSchema) + unspecified = MissingNested(UnspecifiedSchema) + + globals = MissingNested(GlobalsSchema) + + +default: namedtuple = ConfigSchema().load({}) + + +class Settings: + """ + Settings wrapper, + with the wrapper we can force lru_cache to be + cleared on the specific instance + """ + + def __init__(self): + self.loaded = None + self.load() + + @lru_cache() + def load(self): + """ + loads settings from the schemes provided, + done via function to utilize LRU cache + """ + + from django.conf import settings as st + + loaded: namedtuple = default + + if hasattr(st, 'AUTOMATED_LOGGING'): + loaded = ConfigSchema().load(st.AUTOMATED_LOGGING) + + # be sure `loaded` has globals as we're working with those, + # if that is not the case early return. + if not hasattr(loaded, 'globals'): + return loaded + + # use the binary **or** operator to apply globals to Set() attributes + values = {} + for name in loaded._fields: + field = getattr(loaded, name) + values[name] = field + + if not isinstance(field, tuple) or name == 'globals': + continue + + values[name] = field | loaded.globals + + self.loaded = loaded._replace(**values) + return self + + def __getattr__(self, item): + # self.load() should only trigger when the cache is invalid + self.load() + + return getattr(self.loaded, item) + + +@lru_cache() +def load_dev(): + """ + utilize LRU cache and local imports to always + have an up to date version of the settings + + :return: + """ + from django.conf import settings as st + + return getattr(st, 'AUTOMATED_LOGGING_DEV', False) + + +if __name__ == '__main__': + from automated_logging.helpers import namedtuple2dict + + pprint(namedtuple2dict(default)) + +settings = Settings() +dev = load_dev() diff --git a/galaxy_ng/_vendor/automated_logging/signals/__init__.py b/galaxy_ng/_vendor/automated_logging/signals/__init__.py new file mode 100644 index 0000000000..35e9bee1e8 --- /dev/null +++ b/galaxy_ng/_vendor/automated_logging/signals/__init__.py @@ -0,0 +1,258 @@ +""" +Helper functions that are specifically used in the signals only. +""" + +import re +from fnmatch import fnmatch +from functools import lru_cache +from pathlib import Path +from typing import List, Optional, Callable, Any + +from automated_logging.helpers import ( + get_or_create_meta, + get_or_create_thread, + function2path, + Operation, +) +from automated_logging.models import RequestEvent, UnspecifiedEvent +import automated_logging.decorators +from automated_logging.settings import settings +from automated_logging.helpers.schemas import Search + + +# suboptimal meta is also cached -> look into how to solve +@lru_cache() +def cached_model_exclusion(sender, meta, operation) -> bool: + """ cached so that we don't need to abuse ._meta and can invalidate the cache """ + return model_exclusion(sender, meta, operation) + + +def lazy_model_exclusion(instance, operation, sender) -> bool: + """ + First look if the model has been excluded already + -> only then look if excluded. + + Replaced by LRU-Cache. + """ + + return cached_model_exclusion(sender, instance._meta, operation) + + +def candidate_in_scope(candidate: str, scope: List[Search]) -> bool: + """ + Check if the candidate string is valid with the scope supplied, + the scope should be list of search strings - that can be either + glob, plain or regex + + :param candidate: search string + :param scope: List of Search + :return: valid? + """ + + for search in scope: + match = False + if search.type == 'glob': + match = fnmatch(candidate.lower(), search.value.lower()) + if search.type == 'plain': + match = candidate.lower() == search.value.lower() + if search.type == 'regex': + match = bool(re.match(search.value, candidate, re.IGNORECASE)) + + if match: + return True + + return False + + +def request_exclusion(event: RequestEvent, view: Optional[Callable] = None) -> bool: + """ + Determine if a request should be ignored/excluded from getting + logged, these exclusions should be specified in the settings. + + :param event: RequestEvent + :param view: Optional - function used by the resolver + :return: should be excluded? + """ + + if view: + thread, _ = get_or_create_thread() + ignore = thread.dal['ignore.views'] + include = thread.dal['include.views'] + path = function2path(view) + + # if include None or method in include return False and don't + # check further, else just continue with checking + if path in include and (include[path] is None or event.method in include[path]): + return False + + if ( + path in ignore + # if ignored[compiled] is None, then no method will be ignored + and ignore[path] is not None + # ignored[compiled] == [] indicates all should be ignored + and (len(ignore[path]) == 0 or event.method in ignore[path]) + ): + return True + + exclusions = settings.request.exclude + if event.method.lower() in exclusions.methods: + return True + + if event.application.name and candidate_in_scope( + event.application.name, exclusions.applications + ): + return True + + if event.status in exclusions.status: + return True + + # if the application.name = None, then the application is unknown. + # exclusions.unknown specifies if unknown should be excluded! + if not event.application.name and exclusions.unknown: + return True + + return False + + +def _function_model_exclusion(sender, scope: str, item: Any) -> Optional[bool]: + if not sender: + return None + + thread, _ = get_or_create_thread() + + # noinspection PyProtectedMember + ignore = automated_logging.decorators._exclude_models + # noinspection PyProtectedMember + include = automated_logging.decorators._include_models + + path = function2path(sender) + + if path in include: + items = getattr(include[path], scope) + if items is None or item in items: + return False + + if path in ignore: + items = getattr(ignore[path], scope) + if items is not None and (len(items) == 0 or item in items): + return True + + return None + + +def model_exclusion(sender, meta, operation: Operation) -> bool: + """ + Determine if the instance of a model should be excluded, + these exclusions should be specified in the settings. + + :param meta: + :param sender: + :param operation: + :return: should be excluded? + """ + decorators = _function_model_exclusion(sender, 'operations', operation) + if decorators is not None: + return decorators + + if hasattr(sender, 'LoggingIgnore') and ( + getattr(sender.LoggingIgnore, 'complete', False) + or { + Operation.CREATE: 'create', + Operation.MODIFY: 'modify', + Operation.DELETE: 'delete', + }[operation] + in [o.lower() for o in getattr(sender.LoggingIgnore, 'operations', [])] + ): + return True + + exclusions = settings.model.exclude + module = sender.__module__ + name = sender.__name__ + application = meta.app_label + + if ( + candidate_in_scope(name, exclusions.models) + or candidate_in_scope(f'{module}.{name}', exclusions.models) + or candidate_in_scope(f'{application}.{name}', exclusions.models) + ): + return True + + if candidate_in_scope(module, exclusions.models): + return True + + if application and candidate_in_scope(application, exclusions.applications): + return True + + # if there is no application string then we assume the model + # location is unknown, if the flag exclude.unknown = True, then we just exclude + if not application and exclusions.unknown: + return True + + return False + + +def field_exclusion(field: str, instance, sender=None) -> bool: + """ + Determine if the field of an instance should be excluded. + """ + + decorators = _function_model_exclusion(sender, 'fields', field) + if decorators is not None: + return decorators + + if hasattr(instance.__class__, 'LoggingIgnore') and ( + getattr(instance.__class__.LoggingIgnore, 'complete', False) + or field in getattr(instance.__class__.LoggingIgnore, 'fields', []) + ): + return True + + exclusions = settings.model.exclude + application = instance._meta.app_label + model = instance.__class__.__name__ + + if ( + candidate_in_scope(field, exclusions.fields) + or candidate_in_scope(f'{model}.{field}', exclusions.fields) + or candidate_in_scope(f'{application}.{model}.{field}', exclusions.fields) + ): + return True + + return False + + +def unspecified_exclusion(event: UnspecifiedEvent) -> bool: + """ + Determine if an unspecified event needs to be excluded. + """ + exclusions = settings.unspecified.exclude + + if event.application.name and candidate_in_scope( + event.application.name, exclusions.applications + ): + return True + + if candidate_in_scope(str(event.file), exclusions.files): + return True + + path = Path(event.file) + # match greedily by first trying the complete path, if that doesn't match try + # full relative and then complete relative. + if [ + v + for v in exclusions.files + if v.type != 'regex' + and ( + path.match(v.value) + or path.match(f'/*{v.value}') + or fnmatch(path, f'{v.value}/*') + or fnmatch(path, f'/*{v.value}') + or fnmatch(path, f'/*{v.value}/*') + ) + ]: + return True + + # application.name = None and exclusion.unknown = True + if not event.application.name and exclusions.unknown: + return True + + return False diff --git a/galaxy_ng/_vendor/automated_logging/signals/m2m.py b/galaxy_ng/_vendor/automated_logging/signals/m2m.py new file mode 100644 index 0000000000..c44c20ed53 --- /dev/null +++ b/galaxy_ng/_vendor/automated_logging/signals/m2m.py @@ -0,0 +1,195 @@ +""" +This module specifically handlers "many to many" changes, those are +a bit more complicated as we need to detect the changes +on a per field basis. + +This finds the changes and redirects them to the handler, +without doing any changes to the database. +""" + + +import logging +from typing import Optional + +from django.db.models import Manager +from django.db.models.fields.related import ManyToManyField +from django.db.models.signals import m2m_changed +from django.dispatch import receiver + +from automated_logging.helpers import ( + Operation, + get_or_create_model_event, + get_or_create_meta, +) +from automated_logging.models import ( + ModelRelationshipModification, + ModelEntry, + ModelMirror, + Application, + ModelField, +) +from automated_logging.settings import settings +from automated_logging.signals import lazy_model_exclusion + +logger = logging.getLogger("automated_logging") + + +def find_m2m_rel(sender, model) -> Optional[ManyToManyField]: + """ + This finds the "many to many" relationship that is used by the sender. + """ + for field in model._meta.get_fields(): + if isinstance(field, ManyToManyField) and field.remote_field.through == sender: + return field + + return None + + +def post_processor(sender, instance, model, operation, targets): + """ + if the change is in reverse or not, the processing of the changes is still + the same, so we have this method to take care of constructing the changes + :param sender: + :param instance: + :param model: + :param operation: + :param targets: + :return: + """ + relationships = [] + + m2m_rel = find_m2m_rel(sender, model) + if not m2m_rel: + logger.warning(f'[DAL] save[m2m] could not find ManyToManyField for {instance}') + return + + field = ModelField() + field.name = m2m_rel.name + field.mirror = ModelMirror( + name=model.__name__, application=Application(name=instance._meta.app_label) + ) + field.type = m2m_rel.__class__.__name__ + + # there is the possibility that a pre_clear occurred, if that is the case + # extend the targets and pop the list of affected instances from the attached + # field + get_or_create_meta(instance) + if ( + hasattr(instance._meta.dal, 'm2m_pre_clear') + and field.name in instance._meta.dal.m2m_pre_clear + and operation == Operation.DELETE + ): + cleared = instance._meta.dal.m2m_pre_clear[field.name] + targets.extend(cleared) + instance._meta.dal.m2m_pre_clear.pop(field.name) + + for target in targets: + relationship = ModelRelationshipModification() + relationship.operation = operation + relationship.field = field + mirror = ModelMirror() + mirror.name = target.__class__.__name__ + mirror.application = Application(name=target._meta.app_label) + relationship.entry = ModelEntry( + mirror=mirror, value=repr(target), primary_key=target.pk + ) + relationships.append(relationship) + + if len(relationships) == 0: + # there was no actual change, so we're not propagating the event + return + + event, _ = get_or_create_model_event(instance, operation) + + user = None + logger.log( + settings.model.loglevel, + f'{user or "Anonymous"} modified field ' + f'{field.name} | Model: ' + f'{field.mirror.application}.{field.mirror} ' + f'| Modifications: {", ".join([r.short() for r in relationships])}', + extra={ + 'action': 'model[m2m]', + 'data': {'instance': instance, 'sender': sender}, + 'relationships': relationships, + 'event': event, + }, + ) + + +def pre_clear_processor(sender, instance, pks, model, reverse, operation) -> None: + """ + pre_clear needs a specific processor as we attach the changes that are about + to happen to the instance first, and then use them in post_delete/post_clear + + if reverse = False then every element gets removed from the relationship field, + but if reverse = True then instance should be removed from every target. + + Note: it seems that pre_clear is not getting fired for reverse. + + :return: None + """ + if reverse: + return + + get_or_create_meta(instance) + + rel = find_m2m_rel(sender, instance.__class__) + if 'm2m_pre_clear' not in instance._meta.dal: + instance._meta.dal.m2m_pre_clear = {} + + cleared = getattr(instance, rel.name, []) + if isinstance(cleared, Manager): + cleared = list(cleared.all()) + instance._meta.dal.m2m_pre_clear = {rel.name: cleared} + + +@receiver(m2m_changed, weak=False) +def m2m_changed_signal( + sender, instance, action, reverse, model, pk_set, using, **kwargs +) -> None: + """ + Django sends this signal when many-to-many relationships change. + + One of the more complex signals, due to the fact that change can be reversed + we need to either process + instance field changes of pk_set (reverse=False) or + pk_set field changes of instance. (reverse=True) + + The changes will always get applied in the model where the field in defined. + + # TODO: post_remove also gets triggered when there is nothing actually getting removed + :return: None + """ + if action not in ['post_add', 'post_remove', 'pre_clear', 'post_clear']: + return + + if action == 'pre_clear': + operation = Operation.DELETE + + return pre_clear_processor( + sender, + instance, + list(pk_set) if pk_set else None, + model, + reverse, + operation, + ) + elif action == 'post_add': + operation = Operation.CREATE + elif action == 'post_clear': + operation = Operation.DELETE + else: + operation = Operation.DELETE + + targets = model.objects.filter(pk__in=list(pk_set)) if pk_set else [] + if reverse: + for target in [ + t for t in targets if not lazy_model_exclusion(t, operation, t.__class__) + ]: + post_processor(sender, target, target.__class__, operation, [instance]) + else: + if lazy_model_exclusion(instance, operation, instance.__class__): + return + + post_processor(sender, instance, instance.__class__, operation, targets) diff --git a/galaxy_ng/_vendor/automated_logging/signals/request.py b/galaxy_ng/_vendor/automated_logging/signals/request.py new file mode 100644 index 0000000000..e82ccee360 --- /dev/null +++ b/galaxy_ng/_vendor/automated_logging/signals/request.py @@ -0,0 +1,140 @@ +""" +File handles the processing and redirection of all request related +signals +""" + +import logging +import urllib.parse + +from django.core.handlers.wsgi import WSGIRequest +from django.dispatch import receiver +from django.core.signals import got_request_exception, request_finished +from django.http import Http404, JsonResponse +from django.urls import resolve + +from automated_logging.middleware import AutomatedLoggingMiddleware +from automated_logging.models import RequestEvent, Application, RequestContext +from automated_logging.settings import settings +from automated_logging.signals import request_exclusion + +# TODO: should django-ipware be optional? +try: + from ipware import get_client_ip +except ImportError: + get_client_ip = None + + +logger = logging.getLogger("automated_logging") + + +@receiver(request_finished, weak=False) +def request_finished_signal(sender, **kwargs) -> None: + """ + This signal gets the environment from the local thread and + sends a logging message, that message will be processed by the + handler later on. + + This is a simple redirection. + + :return: - + """ + level = settings.request.loglevel + environ = AutomatedLoggingMiddleware.get_current_environ() + + if not environ: + if settings.request.log_request_was_not_recorded: + logger.info( + "Environment for request couldn't be determined. " + "Request was not recorded." + ) + return + + request = RequestEvent() + + request.user = AutomatedLoggingMiddleware.get_current_user(environ) + request.uri = environ.request.get_full_path() + + if not settings.request.data.query: + request.uri = urllib.parse.urlparse(request.uri).path + + if 'request' in settings.request.data.enabled: + request_context = RequestContext() + request_context.content = environ.request.body + request_context.type = environ.request.content_type + + request.request = request_context + + if 'response' in settings.request.data.enabled: + response_context = RequestContext() + response_context.content = environ.response.content + response_context.type = environ.response['Content-Type'] + + request.response = response_context + + # TODO: context parsing, masking and removal + if get_client_ip and settings.request.ip: + request.ip, _ = get_client_ip(environ.request) + + request.status = environ.response.status_code if environ.response else None + request.method = environ.request.method.upper() + request.context_type = environ.request.content_type + + try: + function = resolve(environ.request.path).func + except Http404: + function = None + + request.application = Application(name=None) + if function: + application = function.__module__.split('.')[0] + request.application = Application(name=application) + + if request_exclusion(request, function): + return + + logger_ip = f' from {request.ip}' if get_client_ip and settings.request.ip else '' + logger.log( + level, + f'[{request.method}] [{request.status}] ' + f'{getattr(request, "user", None) or "Anonymous"} ' + f'at {request.uri}{logger_ip}', + extra={'action': 'request', 'event': request}, + ) + + +@receiver(got_request_exception, weak=False) +def request_exception(sender, request, **kwargs): + """ + Exception logging for requests, via the django signal. + + The signal can also return a WSGIRequest exception, which does not + have all fields that are needed. + + :return: - + """ + + status = int(request.status_code) if hasattr(request, 'status_code') else None + method = request.method if hasattr(request, 'method') else None + reason = request.reason_phrase if hasattr(request, 'reason_phrase') else None + level = logging.CRITICAL if status and status <= 500 else logging.WARNING + + is_wsgi = isinstance(request, WSGIRequest) + + logger.log( + level, + f'[{method or "UNK"}] [{status or "UNK"}] ' + f'{is_wsgi and "[WSGIResponse] "}' + f'Exception: {reason or "UNKNOWN"}', + ) + + +@receiver(request_finished, weak=False) +def thread_cleanup(sender, **kwargs): + """ + This signal just calls the thread cleanup function to make sure, + that the custom thread object is always clean for the next request. + This needs to be always the last function registered by the receiver! + + :return: - + """ + AutomatedLoggingMiddleware.cleanup() diff --git a/galaxy_ng/_vendor/automated_logging/signals/save.py b/galaxy_ng/_vendor/automated_logging/signals/save.py new file mode 100644 index 0000000000..0f7fea9649 --- /dev/null +++ b/galaxy_ng/_vendor/automated_logging/signals/save.py @@ -0,0 +1,282 @@ +""" +File handles every signal related to the saving/deletion of django models. +""" + +import logging +from collections import namedtuple +from datetime import datetime +from pprint import pprint +from typing import Any + +from django.core.exceptions import ObjectDoesNotExist +from django.db import transaction +from django.db.models.signals import pre_save, post_save, post_delete +from django.dispatch import receiver + +from automated_logging.models import ( + ModelValueModification, + ModelField, + ModelMirror, + Application, +) +from automated_logging.settings import settings +from automated_logging.signals import ( + model_exclusion, + lazy_model_exclusion, + field_exclusion, +) +from automated_logging.helpers import ( + get_or_create_meta, + Operation, + get_or_create_model_event, +) +from automated_logging.helpers.enums import PastOperationMap + +ChangeSet = namedtuple('ChangeSet', ('deleted', 'added', 'changed')) +logger = logging.getLogger("automated_logging") + + +def normalize_save_value(value: Any): + """ normalize the values given to the function to make stuff more readable """ + if value is None or value == '': + return None + if isinstance(value, str): + return value + + return repr(value) + + +@receiver(pre_save, weak=False) +@transaction.atomic +def pre_save_signal(sender, instance, **kwargs) -> None: + """ + Compares the current instance and old instance (fetched via the pk) + and generates a dictionary of changes + + :param sender: + :param instance: + :param kwargs: + :return: None + """ + get_or_create_meta(instance) + # clear the event to be sure + instance._meta.dal.event = None + + operation = Operation.MODIFY + try: + pre = sender.objects.get(pk=instance.pk) + except ObjectDoesNotExist: + # __dict__ is used on pre, therefore we need to create a function + # that uses __dict__ too, but returns nothing. + + pre = lambda _: None + operation = Operation.CREATE + + excluded = lazy_model_exclusion(instance, operation, instance.__class__) + if excluded: + return + + old, new = pre.__dict__, instance.__dict__ + + previously = set( + k for k in old.keys() if not k.startswith('_') and old[k] is not None + ) + currently = set( + k for k in new.keys() if not k.startswith('_') and new[k] is not None + ) + + added = currently.difference(previously) + deleted = previously.difference(currently) + changed = { + k + for k in + # take all keys from old and new, and only use those that are in both + set(old.keys()) & set(new.keys()) + # remove values that have been added or deleted (optimization) + .difference(added).difference(deleted) + # check if the value is equal, if not they are not changed + if old[k] != new[k] + } + + summary = [ + *( + { + 'operation': Operation.CREATE, + 'previous': None, + 'current': new[k], + 'key': k, + } + for k in added + ), + *( + { + 'operation': Operation.DELETE, + 'previous': old[k], + 'current': None, + 'key': k, + } + for k in deleted + ), + *( + { + 'operation': Operation.MODIFY, + 'previous': old[k], + 'current': new[k], + 'key': k, + } + for k in changed + ), + ] + + # exclude fields not present in _meta.get_fields + fields = {f.name: f for f in instance._meta.get_fields()} + extra = {f.attname: f for f in instance._meta.get_fields() if hasattr(f, 'attname')} + fields = {**extra, **fields} + + summary = [s for s in summary if s['key'] in fields.keys()] + + # field exclusion + summary = [ + s + for s in summary + if not field_exclusion(s['key'], instance, instance.__class__) + ] + + model = ModelMirror() + model.name = sender.__name__ + model.application = Application(name=instance._meta.app_label) + + modifications = [] + for entry in summary: + field = ModelField() + field.name = entry['key'] + field.mirror = model + + field.type = fields[entry['key']].__class__.__name__ + + modification = ModelValueModification() + modification.operation = entry['operation'] + modification.field = field + + modification.previous = normalize_save_value(entry['previous']) + modification.current = normalize_save_value(entry['current']) + + modifications.append(modification) + + instance._meta.dal.modifications = modifications + + if settings.model.performance: + instance._meta.dal.performance = datetime.now() + + +def post_processor(status, sender, instance, updated=None, suffix='') -> None: + """ + Due to the fact that both post_delete and post_save have + the same logic for propagating changes, we have this helper class + to do so, just simply wraps and logs the data the handler needs. + + The event gets created here instead of the handler to keep + everything consistent and have the handler as simple as possible. + + :param status: Operation + :param sender: model class + :param instance: model instance + :param updated: updated fields + :param suffix: suffix to be added to the message + :return: None + """ + past = {v: k for k, v in PastOperationMap.items()} + + get_or_create_meta(instance) + + event, _ = get_or_create_model_event(instance, status, force=True, extra=True) + modifications = getattr(instance._meta.dal, 'modifications', []) + + # clear the modifications meta list + instance._meta.dal.modifications = [] + + if len(modifications) == 0 and status == Operation.MODIFY: + # if the event is modify, but nothing changed, don't actually propagate + return + + logger.log( + settings.model.loglevel, + f'{event.user or "Anonymous"} {past[status]} ' + f'{event.entry.mirror.application}.{sender.__name__} | ' + f'Instance: {instance!r}{suffix}', + extra={ + 'action': 'model', + 'data': {'status': status, 'instance': instance}, + 'event': event, + 'modifications': modifications, + }, + ) + + +@receiver(post_save, weak=False) +@transaction.atomic +def post_save_signal( + sender, instance, created, update_fields: frozenset, **kwargs +) -> None: + """ + Signal is getting called after a save has been concluded. When this + is the case we can be sure the save was successful and then only + propagate the changes to the handler. + + :param sender: model class + :param instance: model instance + :param created: bool, was the model created? + :param update_fields: which fields got explicitly updated? + :param kwargs: django needs kwargs to be there + :return: - + """ + status = Operation.CREATE if created else Operation.MODIFY + if lazy_model_exclusion( + instance, + status, + instance.__class__, + ): + return + get_or_create_meta(instance) + + suffix = f'' + if ( + status == Operation.MODIFY + and hasattr(instance._meta.dal, 'modifications') + and settings.model.detailed_message + ): + suffix = ( + f' | Modifications: ' + f'{", ".join([m.short() for m in instance._meta.dal.modifications])}' + ) + + if update_fields is not None and hasattr(instance._meta.dal, 'modifications'): + instance._meta.dal.modifications = [ + m for m in instance._meta.dal.modifications if m.field.name in update_fields + ] + + post_processor(status, sender, instance, update_fields, suffix) + + +@receiver(post_delete, weak=False) +@transaction.atomic +def post_delete_signal(sender, instance, **kwargs) -> None: + """ + Signal is getting called after instance deletion. We just redirect the + event to the post_processor. + + TODO: consider doing a "delete snapshot" + + :param sender: model class + :param instance: model instance + :param kwargs: required bt django + :return: - + """ + + get_or_create_meta(instance) + instance._meta.dal.event = None + + if lazy_model_exclusion(instance, Operation.DELETE, instance.__class__): + return + + post_processor(Operation.DELETE, sender, instance) diff --git a/galaxy_ng/_vendor/automated_logging/templates/dal/admin/view.html b/galaxy_ng/_vendor/automated_logging/templates/dal/admin/view.html new file mode 100644 index 0000000000..2635c73aae --- /dev/null +++ b/galaxy_ng/_vendor/automated_logging/templates/dal/admin/view.html @@ -0,0 +1,8 @@ +{% extends "admin/change_form.html" %} +{% load i18n %} + +{% block submit_buttons_bottom %} + +{% endblock %} diff --git a/galaxy_ng/_vendor/automated_logging/tests/__init__.py b/galaxy_ng/_vendor/automated_logging/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/galaxy_ng/_vendor/automated_logging/tests/base.py b/galaxy_ng/_vendor/automated_logging/tests/base.py new file mode 100644 index 0000000000..e8181a900c --- /dev/null +++ b/galaxy_ng/_vendor/automated_logging/tests/base.py @@ -0,0 +1,178 @@ +""" Test base every unit test uses """ +import importlib +import logging.config +from copy import copy, deepcopy + +from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.auth.models import AbstractUser +from django.test import TestCase, RequestFactory +from django.urls import path + +from automated_logging.helpers import namedtuple2dict +from automated_logging.middleware import AutomatedLoggingMiddleware +from automated_logging.models import ModelEvent, RequestEvent, UnspecifiedEvent +from automated_logging.signals import cached_model_exclusion + +User: AbstractUser = get_user_model() +USER_CREDENTIALS = {'username': 'example', 'password': 'example'} + + +def clear_cache(): + """ utility method to clear the cache """ + if hasattr(AutomatedLoggingMiddleware.thread, 'dal'): + delattr(AutomatedLoggingMiddleware.thread, 'dal') + + import automated_logging.decorators + + # noinspection PyProtectedMember + automated_logging.decorators._exclude_models.clear() + # noinspection PyProtectedMember + automated_logging.decorators._include_models.clear() + + cached_model_exclusion.cache_clear() + + +class BaseTestCase(TestCase): + def __init__(self, method_name): + from django.conf import settings + + settings.AUTOMATED_LOGGING_DEV = True + + super().__init__(method_name) + + def request(self, method, view, data=None): + """ + request a specific view and return the response. + + This is not ideal and super hacky. Backups the actual urlpatterns, + and then overrides the urlpatterns with a temporary one and then + inserts the new one again. + """ + + urlconf = importlib.import_module(settings.ROOT_URLCONF) + + backup = copy(urlconf.urlpatterns) + urlconf.urlpatterns.clear() + urlconf.urlpatterns.append(path('', view)) + + response = self.client.generic(method, '/', data=data) + + urlconf.urlpatterns.clear() + urlconf.urlpatterns.extend(backup) + + return response + + def setUp(self): + """ setUp the DAL specific test environment """ + from django.conf import settings + from automated_logging.settings import default, settings as conf + + self.user = User.objects.create_user(**USER_CREDENTIALS) + self.user.save() + + self.original_config = deepcopy(settings.AUTOMATED_LOGGING) + + base = namedtuple2dict(default) + + settings.AUTOMATED_LOGGING.clear() + for key, value in base.items(): + settings.AUTOMATED_LOGGING[key] = deepcopy(value) + + conf.load.cache_clear() + + self.setUpLogging() + super().setUp() + + def tearDown(self) -> None: + """ tearDown the DAL specific environment """ + from django.conf import settings + from automated_logging.settings import settings as conf + + super().tearDown() + + self.tearDownLogging() + + settings.AUTOMATED_LOGGING.clear() + for key, value in self.original_config.items(): + settings.AUTOMATED_LOGGING[key] = deepcopy(value) + + conf.load.cache_clear() + + clear_cache() + + @staticmethod + def clear(): + """ clear all events """ + ModelEvent.objects.all().delete() + RequestEvent.objects.all().delete() + UnspecifiedEvent.objects.all().delete() + + def tearDownLogging(self): + """ + replace our own logging config + with the original to not break any other tests + that might depend on it. + """ + from django.conf import settings + + settings.LOGGING = self.logging_backup + logging.config.dictConfig(settings.LOGGING) + + def setUpLogging(self): + """ sets up logging dict, so that we can actually use our own """ + from django.conf import settings + + self.logging_backup = deepcopy(settings.LOGGING) + settings.LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'root': {'level': 'INFO', 'handlers': ['console', 'db'],}, + 'formatters': { + 'verbose': { + 'format': '%(levelname)s %(asctime)s %(module)s ' + '%(process)d %(thread)d %(message)s' + }, + 'simple': {'format': '%(levelname)s %(message)s'}, + 'syslog': { + 'format': '%(asctime)s %%LOCAL0-%(levelname) %(message)s' + # 'format': '%(levelname)s %(message)s' + }, + }, + 'handlers': { + 'console': { + 'level': 'INFO', + 'class': 'logging.StreamHandler', + 'formatter': 'verbose', + }, + 'db': { + 'level': 'INFO', + 'class': 'automated_logging.handlers.DatabaseHandler', + }, + }, + 'loggers': { + 'automated_logging': { + 'level': 'INFO', + 'handlers': ['console', 'db'], + 'propagate': False, + }, + 'django': { + 'level': 'INFO', + 'handlers': ['console', 'db'], + 'propagate': False, + }, + }, + } + + logging.config.dictConfig(settings.LOGGING) + + def bypass_request_restrictions(self): + """ bypass all request default restrictions of DAL """ + from django.conf import settings + from automated_logging.settings import settings as conf + + settings.AUTOMATED_LOGGING['request']['exclude']['status'] = [] + settings.AUTOMATED_LOGGING['request']['exclude']['methods'] = [] + conf.load.cache_clear() + + self.clear() diff --git a/galaxy_ng/_vendor/automated_logging/tests/helpers.py b/galaxy_ng/_vendor/automated_logging/tests/helpers.py new file mode 100644 index 0000000000..cb2244abae --- /dev/null +++ b/galaxy_ng/_vendor/automated_logging/tests/helpers.py @@ -0,0 +1,9 @@ +""" test specific helpers """ + +import string +from random import choice + + +def random_string(length=10): + """ generate a random string with the length specified """ + return ''.join(choice(string.ascii_letters) for _ in range(length)) diff --git a/galaxy_ng/_vendor/automated_logging/tests/models.py b/galaxy_ng/_vendor/automated_logging/tests/models.py new file mode 100644 index 0000000000..8405fff54c --- /dev/null +++ b/galaxy_ng/_vendor/automated_logging/tests/models.py @@ -0,0 +1,132 @@ +import uuid +from django.db.models import ( + Model, + UUIDField, + DateTimeField, + ManyToManyField, + CASCADE, + ForeignKey, + OneToOneField, + CharField, +) + +from automated_logging.decorators import exclude_model, include_model + + +class TestBase(Model): + """ Base for all the test models """ + + id = UUIDField(default=uuid.uuid4, primary_key=True) + + created_at = DateTimeField(auto_now_add=True) + updated_at = DateTimeField(auto_now=True) + + class Meta: + abstract = True + app_label = 'automated_logging' + + +class OrdinaryBaseTest(TestBase): + """ Ordinary base test. Has a random char field.""" + + random = CharField(max_length=255, null=True) + random2 = CharField(max_length=255, null=True) + + class Meta: + abstract = True + app_label = 'automated_logging' + + +class OrdinaryTest(OrdinaryBaseTest): + """ Ordinary test. Has a random char field.""" + + class Meta: + app_label = 'automated_logging' + + +class M2MTest(TestBase): + """ Used to test the Many-To-Many Relationship functionality of DAL""" + + relationship = ManyToManyField(OrdinaryTest) + + class Meta: + app_label = 'automated_logging' + + +class ForeignKeyTest(TestBase): + """ Used to test ForeignKey functionality of DAL.""" + + relationship = ForeignKey(OrdinaryTest, on_delete=CASCADE, null=True) + + class Meta: + app_label = 'automated_logging' + + +class OneToOneTest(TestBase): + """ Used to test the One-To-One Relationship functionality of DAL.""" + + relationship = OneToOneField(OrdinaryTest, on_delete=CASCADE, null=True) + + class Meta: + app_label = 'automated_logging' + + +class SpeedTest(TestBase): + """ Used to test the speed of DAL """ + + for idx in range(100): + exec(f"column{idx} = CharField(max_length=15, null=True)") + + class Meta: + app_label = 'automated_logging' + + +class FullClassBasedExclusionTest(OrdinaryBaseTest): + """ Used to test the full model exclusion via meta class""" + + class Meta: + app_label = 'automated_logging' + + class LoggingIgnore: + complete = True + + +class PartialClassBasedExclusionTest(OrdinaryBaseTest): + """ Used to test partial ignore via fields """ + + class Meta: + app_label = 'automated_logging' + + class LoggingIgnore: + fields = ['random'] + operations = ['delete'] + + +@exclude_model +class FullDecoratorBasedExclusionTest(OrdinaryBaseTest): + """ Used to test full decorator exclusion """ + + class Meta: + app_label = 'automated_logging' + + +@exclude_model(operations=['delete'], fields=['random']) +class PartialDecoratorBasedExclusionTest(OrdinaryBaseTest): + """ Used to test partial decorator exclusion """ + + class Meta: + app_label = 'automated_logging' + + +@include_model +class DecoratorOverrideExclusionTest(OrdinaryBaseTest): + """ + Used to check if include_model + has precedence over class based configuration + """ + + class Meta: + app_label = 'automated_logging' + + class LoggingIgnore: + complete = True diff --git a/galaxy_ng/_vendor/automated_logging/tests/test_exclusion.py b/galaxy_ng/_vendor/automated_logging/tests/test_exclusion.py new file mode 100644 index 0000000000..8349e81690 --- /dev/null +++ b/galaxy_ng/_vendor/automated_logging/tests/test_exclusion.py @@ -0,0 +1,622 @@ +import logging +import threading +from pathlib import Path + +from django.contrib import admin +from django.http import JsonResponse + +from automated_logging.decorators import ( + include_model, + exclude_view, + include_view, + exclude_model, +) +from automated_logging.helpers import Operation +from automated_logging.middleware import AutomatedLoggingMiddleware +from automated_logging.models import ( + ModelEvent, + RequestEvent, + UnspecifiedEvent, + Application, +) +from automated_logging.signals import ( + cached_model_exclusion, + model_exclusion, + request_exclusion, +) +from automated_logging.tests.models import ( + OrdinaryTest, + OneToOneTest, + FullClassBasedExclusionTest, + PartialClassBasedExclusionTest, + FullDecoratorBasedExclusionTest, + PartialDecoratorBasedExclusionTest, + DecoratorOverrideExclusionTest, + M2MTest, +) +from automated_logging.tests.base import BaseTestCase, USER_CREDENTIALS, clear_cache +from automated_logging.tests.helpers import random_string + + +class ConfigurationBasedExclusionsTestCase(BaseTestCase): + def setUp(self): + super().setUp() + + self.client.login(**USER_CREDENTIALS) + + @staticmethod + def view(request): + return JsonResponse({}) + + def test_globals(self): + from django.conf import settings + from automated_logging.settings import settings as conf + + self.clear() + + settings.AUTOMATED_LOGGING['unspecified']['exclude']['applications'] = [] + settings.AUTOMATED_LOGGING['model']['exclude']['applications'] = [] + settings.AUTOMATED_LOGGING['request']['exclude']['applications'] = [] + settings.AUTOMATED_LOGGING['globals']['exclude']['applications'] = [ + 'automated*' + ] + + conf.load.cache_clear() + + OrdinaryTest(random=random_string()).save() + self.assertEqual(ModelEvent.objects.count(), 0) + + self.request('GET', self.view) + self.assertEqual(RequestEvent.objects.count(), 0) + + logger = logging.getLogger(__name__) + logger.info('[TEST]') + self.assertEqual(UnspecifiedEvent.objects.count(), 0) + + def test_applications(self): + from django.conf import settings + from automated_logging.settings import settings as conf + + self.clear() + + settings.AUTOMATED_LOGGING['globals']['exclude']['applications'] = [] + conf.load.cache_clear() + + logger = logging.getLogger(__name__) + logger.info('[TEST]') + self.assertEqual(UnspecifiedEvent.objects.count(), 1) + self.clear() + + settings.AUTOMATED_LOGGING['unspecified']['exclude']['applications'] = [ + 'automated*' + ] + conf.load.cache_clear() + + logger = logging.getLogger(__name__) + logger.info('[TEST]') + self.assertEqual(UnspecifiedEvent.objects.count(), 0) + + settings.AUTOMATED_LOGGING['model']['exclude']['applications'] = ['automated*'] + conf.load.cache_clear() + + OrdinaryTest(random=random_string()).save() + self.assertEqual(ModelEvent.objects.count(), 0) + + settings.AUTOMATED_LOGGING['request']['exclude']['applications'] = [ + 'automated*' + ] + conf.load.cache_clear() + + self.request('GET', self.view) + self.assertEqual(RequestEvent.objects.count(), 0) + + def test_fields(self): + from django.conf import settings + from automated_logging.settings import settings as conf + + subject = OrdinaryTest() + subject.save() + + self.clear() + settings.AUTOMATED_LOGGING['model']['exclude']['fields'] = [ + 'automated_logging.OrdinaryTest.random' + ] + conf.load.cache_clear() + + subject.random = random_string() + subject.save() + self.assertEqual(ModelEvent.objects.count(), 0) + + settings.AUTOMATED_LOGGING['model']['exclude']['fields'] = [ + 'OrdinaryTest.random' + ] + conf.load.cache_clear() + + subject.random = random_string() + subject.save() + self.assertEqual(ModelEvent.objects.count(), 0) + + settings.AUTOMATED_LOGGING['model']['exclude']['fields'] = ['random'] + conf.load.cache_clear() + subject.random = random_string() + subject.save() + self.assertEqual(ModelEvent.objects.count(), 0) + + subject.random = random_string() + subject.random2 = random_string() + subject.save() + self.assertEqual(ModelEvent.objects.count(), 1) + self.assertEqual(ModelEvent.objects.all()[0].modifications.count(), 1) + + def test_models(self): + from django.conf import settings + from automated_logging.settings import settings as conf + + self.clear() + + settings.AUTOMATED_LOGGING['model']['exclude']['models'] = [ + 'automated_logging.tests.models.OrdinaryTest' + ] + conf.load.cache_clear() + + OrdinaryTest(random=random_string()).save() + self.assertEqual(ModelEvent.objects.count(), 0) + + settings.AUTOMATED_LOGGING['model']['exclude']['models'] = [ + 'automated_logging.OrdinaryTest' + ] + conf.load.cache_clear() + + OrdinaryTest(random=random_string()).save() + self.assertEqual(ModelEvent.objects.count(), 0) + + settings.AUTOMATED_LOGGING['model']['exclude']['models'] = ['OrdinaryTest'] + conf.load.cache_clear() + + OrdinaryTest(random=random_string()).save() + self.assertEqual(ModelEvent.objects.count(), 0) + + OneToOneTest().save() + self.assertEqual(ModelEvent.objects.count(), 1) + self.assertEqual(ModelEvent.objects.all()[0].modifications.count(), 1) + + @staticmethod + def redirect_view(request): + return JsonResponse({}, status=301) + + def test_status(self): + from django.conf import settings + from automated_logging.settings import settings as conf + + settings.AUTOMATED_LOGGING['request']['exclude']['methods'] = [] + settings.AUTOMATED_LOGGING['request']['exclude']['status'] = [200] + conf.load.cache_clear() + + self.clear() + + self.request('GET', self.view) + self.assertEqual(RequestEvent.objects.count(), 0) + + self.request('GET', self.redirect_view) + self.assertEqual(RequestEvent.objects.count(), 1) + + def test_method(self): + from django.conf import settings + from automated_logging.settings import settings as conf + + settings.AUTOMATED_LOGGING['request']['exclude']['methods'] = ['GET'] + settings.AUTOMATED_LOGGING['request']['exclude']['status'] = [] + conf.load.cache_clear() + + self.clear() + + self.request('GET', self.view) + self.assertEqual(RequestEvent.objects.count(), 0) + + self.request('POST', self.view) + self.assertEqual(RequestEvent.objects.count(), 1) + + def test_files(self): + from django.conf import settings + from automated_logging.settings import settings as conf + + path = Path(__file__).absolute() + project = Path(__file__).parent.parent.parent + relative = path.relative_to(project) + + logger = logging.getLogger(__name__) + self.clear() + + # absolute path + settings.AUTOMATED_LOGGING['unspecified']['exclude']['files'] = [ + path.as_posix() + ] + conf.load.cache_clear() + + logger.info(random_string()) + self.assertEqual(UnspecifiedEvent.objects.count(), 0) + + # relative path + settings.AUTOMATED_LOGGING['unspecified']['exclude']['files'] = [ + relative.as_posix() + ] + conf.load.cache_clear() + + logger.info(random_string()) + self.assertEqual(UnspecifiedEvent.objects.count(), 0) + + # file name + settings.AUTOMATED_LOGGING['unspecified']['exclude']['files'] = [relative.name] + conf.load.cache_clear() + + logger.info(random_string()) + self.assertEqual(UnspecifiedEvent.objects.count(), 0) + + # single directory name + settings.AUTOMATED_LOGGING['unspecified']['exclude']['files'] = [ + 'automated_logging' + ] + conf.load.cache_clear() + + logger.info(random_string()) + self.assertEqual(UnspecifiedEvent.objects.count(), 0) + + # absolute directory + settings.AUTOMATED_LOGGING['unspecified']['exclude']['files'] = [ + path.parent.as_posix() + ] + conf.load.cache_clear() + + logger.info(random_string()) + self.assertEqual(UnspecifiedEvent.objects.count(), 0) + + # file not excluded + settings.AUTOMATED_LOGGING['unspecified']['exclude']['files'] = ['dal'] + conf.load.cache_clear() + + logger.info(random_string()) + self.assertEqual(UnspecifiedEvent.objects.count(), 1) + + def test_unknown(self): + from django.conf import settings + from automated_logging.settings import settings as conf + + logger = logging.getLogger(__name__) + + default_factory = logging.getLogRecordFactory() + + def factory(*args, **kwargs): + """ + force setting the pathname and module + wrong so that we can pretend to exclude unknowns + """ + + record = default_factory(*args, **kwargs) + + record.pathname = '/example.py' + record.module = 'default' + return record + + self.clear() + logging.setLogRecordFactory(factory=factory) + + settings.AUTOMATED_LOGGING['unspecified']['exclude']['unknown'] = True + conf.load.cache_clear() + + logger.info(random_string()) + self.assertEqual(UnspecifiedEvent.objects.count(), 0) + + settings.AUTOMATED_LOGGING['unspecified']['exclude']['unknown'] = False + conf.load.cache_clear() + + logger.info(random_string()) + self.assertEqual(UnspecifiedEvent.objects.count(), 1) + + logging.setLogRecordFactory(default_factory) + + def test_search_types(self): + from django.conf import settings + from automated_logging.settings import settings as conf + + settings.AUTOMATED_LOGGING['globals']['exclude']['applications'] = [] + settings.AUTOMATED_LOGGING['model']['exclude']['applications'] = [ + 'pl:automated_logging' + ] + conf.load.cache_clear() + cached_model_exclusion.cache_clear() + + self.clear() + OrdinaryTest().save() + self.assertEqual(ModelEvent.objects.count(), 0) + + settings.AUTOMATED_LOGGING['model']['exclude']['applications'] = [ + 'gl:automated_*' + ] + conf.load.cache_clear() + cached_model_exclusion.cache_clear() + + OrdinaryTest().save() + self.assertEqual(ModelEvent.objects.count(), 0) + + settings.AUTOMATED_LOGGING['request']['exclude']['applications'] = [ + 're:automated.*' + ] + self.bypass_request_restrictions() + conf.load.cache_clear() + cached_model_exclusion.cache_clear() + + self.request('GET', self.view) + self.assertEqual(RequestEvent.objects.count(), 0) + + def test_module(self): + from django.conf import settings + from automated_logging.settings import settings as conf + + settings.AUTOMATED_LOGGING['globals']['exclude']['applications'] = [] + settings.AUTOMATED_LOGGING['model']['exclude']['models'] = [ + 'automated_logging.tests.models' + ] + conf.load.cache_clear() + cached_model_exclusion.cache_clear() + self.clear() + + OrdinaryTest().save() + self.assertEqual(ModelEvent.objects.count(), 0) + + def test_mock_sender(self): + from django.conf import settings + from automated_logging.settings import settings as conf + + class MockModel: + __module__ = '[TEST]' + __name__ = 'MockModel' + + class MockMeta: + app_label = None + + settings.AUTOMATED_LOGGING['model']['exclude']['unknown'] = True + conf.load.cache_clear() + + self.assertTrue(model_exclusion(MockModel, MockMeta, Operation.CREATE)) + + settings.AUTOMATED_LOGGING['request']['exclude']['unknown'] = True + conf.load.cache_clear() + + self.assertTrue( + request_exclusion(RequestEvent(application=Application(name=None))) + ) + + def test_m2m_exclusion(self): + from django.conf import settings + from automated_logging.settings import settings as conf + + settings.AUTOMATED_LOGGING['model']['exclude']['applications'] = [ + 'gl:automated*' + ] + conf.load.cache_clear() + cached_model_exclusion.cache_clear() + + instance = M2MTest() + instance.save() + + children = [OrdinaryTest()] + [c.save() for c in children] + + self.clear() + + instance.relationship.add(*children) + instance.save() + + self.assertEqual(ModelEvent.objects.count(), 0) + + +class ClassBasedExclusionsTestCase(BaseTestCase): + def test_complete(self): + self.clear() + + FullClassBasedExclusionTest().save() + self.assertEqual(ModelEvent.objects.count(), 0) + + def test_partial(self): + self.clear() + + subject = PartialClassBasedExclusionTest(random=random_string()) + subject.save() + + events = ModelEvent.objects.all() + self.assertEqual(events.count(), 1) + + event = events[0] + self.assertEqual(event.modifications.count(), 1) + self.assertEqual(event.modifications.all()[0].field.name, 'id') + + self.clear() + + subject.delete() + self.assertEqual(ModelEvent.objects.count(), 0) + + +class DecoratorBasedExclusionsTestCase(BaseTestCase): + def test_exclude_model(self): + self.clear() + + # manual here as the cache clear, clears the first appliance + FullDecoratorBasedExclusionTest.__dal_register__() + subject = FullDecoratorBasedExclusionTest() + subject.save() + + self.assertEqual(ModelEvent.objects.count(), 0) + + subject = PartialDecoratorBasedExclusionTest.__dal_register__()( + random=random_string() + ) + subject.save() + + events = ModelEvent.objects.all() + self.assertEqual(events.count(), 1) + + event = events[0] + self.assertEqual(event.modifications.count(), 1) + + self.clear() + subject.delete() + + self.assertEqual(ModelEvent.objects.count(), 0) + + def test_include_model(self): + self.clear() + + subject = DecoratorOverrideExclusionTest.__dal_register__()( + random=random_string() + ) + subject.save() + + self.assertEqual(ModelEvent.objects.count(), 1) + + self.clear() + + # test if overriding works + include_model(FullClassBasedExclusionTest, operations=['delete'])() + + subject = FullClassBasedExclusionTest(random=random_string()) + subject.save() + + self.assertEqual(ModelEvent.objects.count(), 0) + + subject.delete() + + self.assertEqual(ModelEvent.objects.count(), 1) + + # just to make sure we clean up + clear_cache() + + @staticmethod + @exclude_view + def complete_exclusion(request): + return JsonResponse({}) + + @staticmethod + @exclude_view(methods=['GET']) + def partial_exclusion(request): + return JsonResponse({}) + + def test_exclude_view(self): + from django.conf import settings + from automated_logging.settings import settings as conf + + settings.AUTOMATED_LOGGING['request']['exclude']['methods'] = [] + settings.AUTOMATED_LOGGING['request']['exclude']['status'] = [] + conf.load.cache_clear() + self.clear() + + self.request('GET', self.complete_exclusion) + self.assertEqual(RequestEvent.objects.count(), 0) + + self.request('GET', self.partial_exclusion) + self.assertEqual(RequestEvent.objects.count(), 0) + self.request('POST', self.partial_exclusion) + self.assertEqual(RequestEvent.objects.count(), 1) + + @staticmethod + @include_view + def complete_inclusion(request): + return JsonResponse({}) + + @staticmethod + @include_view(methods=['POST']) + def partial_inclusion(request): + return JsonResponse({}) + + def test_include_view(self): + from django.conf import settings + from automated_logging.settings import settings as conf + + self.clear() + # settings default to ignoring 200/GET, include_model should still record + + self.request('GET', self.complete_inclusion) + self.assertEqual(RequestEvent.objects.count(), 1) + self.clear() + + settings.AUTOMATED_LOGGING['request']['exclude']['status'] = [] + settings.AUTOMATED_LOGGING['request']['exclude']['methods'] = ['GET', 'POST'] + conf.load.cache_clear() + + self.request('GET', self.partial_inclusion) + self.assertEqual(RequestEvent.objects.count(), 0) + self.request('POST', self.partial_inclusion) + self.assertEqual(RequestEvent.objects.count(), 1) + self.clear() + + settings.AUTOMATED_LOGGING['request']['exclude']['methods'] = [] + conf.load.cache_clear() + + # test if include_view has higher priority than exclude_view + view = include_view(self.complete_exclusion, methods=['GET']) + self.request('GET', view) + self.assertEqual(RequestEvent.objects.count(), 1) + self.request('POST', view) + self.assertEqual(RequestEvent.objects.count(), 1) + + clear_cache() + + def test_partial_decorator(self): + self.clear() + Model = include_model(operations=['create'])(OrdinaryTest) + Model().save() + self.assertEqual(ModelEvent.objects.count(), 1) + + def test_layering(self): + Model = include_model(operations=['modify'], fields=['random2'])( + include_model(operations=['create'], fields=['random2'])(OrdinaryTest) + ) + + self.clear() + subject = Model() + subject.save() + + subject.random = random_string() + subject.save() + + self.assertEqual(ModelEvent.objects.count(), 2) + + clear_cache() + + Model = exclude_model(operations=['modify'], fields=['random2'])( + exclude_model(operations=['create'], fields=['random2'])(OrdinaryTest) + ) + + self.clear() + + subject = Model() + subject.save() + + subject.random = random_string() + subject.save() + + self.assertEqual(ModelEvent.objects.count(), 0) + + def test_model_fields(self): + Model = include_model(operations=[], fields=['random'])( + exclude_model(operations=None, fields=['random2'])(OrdinaryTest) + ) + + subject = Model() + subject.save() + + self.clear() + + subject.random = random_string() + subject.save() + + self.assertEqual(ModelEvent.objects.count(), 1) + + subject.random2 = random_string() + subject.save() + + self.assertEqual(ModelEvent.objects.count(), 1) + + def test_model_admin(self): + FullDecoratorBasedExclusionTest.__dal_register__() + + admin.site.register(FullDecoratorBasedExclusionTest) diff --git a/galaxy_ng/_vendor/automated_logging/tests/test_handler.py b/galaxy_ng/_vendor/automated_logging/tests/test_handler.py new file mode 100644 index 0000000000..b934a5a6c1 --- /dev/null +++ b/galaxy_ng/_vendor/automated_logging/tests/test_handler.py @@ -0,0 +1,107 @@ +# test max_age (rework?) +# test save +# test module removal +import logging +import logging.config +from datetime import timedelta +import time + +from django.http import JsonResponse +from marshmallow import ValidationError + +from automated_logging.helpers.exceptions import CouldNotConvertError +from automated_logging.models import ModelEvent, RequestEvent, UnspecifiedEvent +from automated_logging.tests.models import OrdinaryTest +from automated_logging.tests.base import BaseTestCase + + +class TestDatabaseHandlerTestCase(BaseTestCase): + @staticmethod + def view(request): + return JsonResponse({}) + + def test_max_age(self): + from django.conf import settings + from automated_logging.settings import settings as conf + + duration = timedelta(seconds=1) + logger = logging.getLogger(__name__) + + settings.AUTOMATED_LOGGING['model']['max_age'] = duration + settings.AUTOMATED_LOGGING['request']['max_age'] = duration + settings.AUTOMATED_LOGGING['unspecified']['max_age'] = duration + + conf.load.cache_clear() + + self.clear() + self.bypass_request_restrictions() + + OrdinaryTest().save() + self.request('GET', self.view) + logger.info('I have the high ground Anakin!') + + self.assertEqual(ModelEvent.objects.count(), 1) + self.assertEqual(RequestEvent.objects.count(), 1) + self.assertEqual(UnspecifiedEvent.objects.count(), 1) + + time.sleep(2) + + logger.info('A surprise, to be sure, but a welcome one.') + + self.assertEqual(ModelEvent.objects.count(), 0) + self.assertEqual(RequestEvent.objects.count(), 0) + self.assertEqual(UnspecifiedEvent.objects.count(), 1) + + def test_max_age_input_methods(self): + from django.conf import settings + from automated_logging.settings import settings as conf + + logger = logging.getLogger(__name__) + + settings.AUTOMATED_LOGGING['unspecified']['max_age'] = timedelta(seconds=1) + conf.load.cache_clear() + self.clear() + + logger.info('I will do what I must.') + time.sleep(1) + logger.info('Hello There.') + self.assertEqual(UnspecifiedEvent.objects.count(), 1) + + settings.AUTOMATED_LOGGING['unspecified']['max_age'] = 1 + conf.load.cache_clear() + self.clear() + + logger.info('A yes, the negotiator.') + time.sleep(1) + logger.info('Your tactics confuse and frighten me, sir.') + self.assertEqual(UnspecifiedEvent.objects.count(), 1) + + settings.AUTOMATED_LOGGING['unspecified']['max_age'] = 'PT1S' + conf.load.cache_clear() + self.clear() + + logger.info('Don\'t make me kill you.') + time.sleep(1) + logger.info('An old friend from the dead.') + self.assertEqual(UnspecifiedEvent.objects.count(), 1) + + def test_batching(self): + from django.conf import settings + + logger = logging.getLogger(__name__) + + config = settings.LOGGING + + config['handlers']['db']['batch'] = 10 + logging.config.dictConfig(config) + + self.clear() + for _ in range(9): + logger.info('It\'s a trick. Send no reply') + + self.assertEqual(UnspecifiedEvent.objects.count(), 0) + logger.info('I can\'t see a thing. My cockpit\'s fogging') + self.assertEqual(UnspecifiedEvent.objects.count(), 10) + + config['handlers']['db']['batch'] = 1 + logging.config.dictConfig(config) diff --git a/galaxy_ng/_vendor/automated_logging/tests/test_m2m.py b/galaxy_ng/_vendor/automated_logging/tests/test_m2m.py new file mode 100644 index 0000000000..0a70ee5a53 --- /dev/null +++ b/galaxy_ng/_vendor/automated_logging/tests/test_m2m.py @@ -0,0 +1,231 @@ +""" Test all Many-To-Many related things """ + + +import random + +from automated_logging.helpers import Operation +from automated_logging.models import ModelEvent +from automated_logging.tests.models import ( + M2MTest, + OrdinaryTest, + OneToOneTest, + ForeignKeyTest, +) +from automated_logging.signals.m2m import find_m2m_rel +from automated_logging.tests.base import BaseTestCase +from automated_logging.tests.helpers import random_string + + +class LoggedOutM2MRelationshipsTestCase(BaseTestCase): + def setUp(self): + super().setUp() + + # delete all previous model events + ModelEvent.objects.all().delete() + + @staticmethod + def generate_children(samples=10): + """ generate X children that are going to be used in various tests """ + children = [OrdinaryTest(random=random_string()) for _ in range(samples)] + [c.save() for c in children] + + return children + + def test_add(self): + """ check if adding X members works correctly """ + + samples = 10 + children = self.generate_children(samples) + + m2m = M2MTest() + m2m.save() + + ModelEvent.objects.all().delete() + m2m.relationship.add(*children) + m2m.save() + + events = ModelEvent.objects.all() + self.assertEqual(events.count(), 1) + + event = events[0] + self.assertEqual(event.modifications.count(), 0) + self.assertEqual(event.relationships.count(), samples) + + children = {str(c.id): c for c in children} + + for relationship in event.relationships.all(): + self.assertEqual(relationship.operation, int(Operation.CREATE)) + self.assertEqual(relationship.field.name, 'relationship') + self.assertIn(relationship.entry.primary_key, children) + + def test_delete(self): + """ check if deleting X elements works correctly """ + + samples = 10 + removed = 5 + children = self.generate_children(samples) + + m2m = M2MTest() + m2m.save() + m2m.relationship.add(*children) + m2m.save() + ModelEvent.objects.all().delete() + + selected = random.sample(children, k=removed) + m2m.relationship.remove(*selected) + m2m.save() + + events = ModelEvent.objects.all() + self.assertEqual(events.count(), 1) + + event = events[0] + self.assertEqual(event.modifications.count(), 0) + self.assertEqual(event.relationships.count(), removed) + + children = {str(c.id): c for c in children} + for relationship in event.relationships.all(): + self.assertEqual(relationship.operation, int(Operation.DELETE)) + self.assertEqual(relationship.field.name, 'relationship') + self.assertIn(relationship.entry.primary_key, children) + + def test_clear(self): + """ test if clearing all elements works correctly """ + + samples = 10 + children = self.generate_children(samples) + + m2m = M2MTest() + m2m.save() + m2m.relationship.add(*children) + m2m.save() + ModelEvent.objects.all().delete() + + m2m.relationship.clear() + m2m.save() + + events = ModelEvent.objects.all() + self.assertEqual(events.count(), 1) + + event = events[0] + self.assertEqual(event.modifications.count(), 0) + self.assertEqual(event.relationships.count(), samples) + + children = {str(c.id): c for c in children} + for relationship in event.relationships.all(): + self.assertEqual(relationship.operation, int(Operation.DELETE)) + self.assertEqual(relationship.field.name, 'relationship') + self.assertIn(relationship.entry.primary_key, children) + + def test_one2one(self): + """ + test if OneToOne are correctly recognized, + should be handled by save.py + """ + + o2o = OneToOneTest() + o2o.save() + + subject = OrdinaryTest(random=random_string()) + subject.save() + ModelEvent.objects.all().delete() + + o2o.relationship = subject + o2o.save() + + events = ModelEvent.objects.all() + self.assertEqual(events.count(), 1) + + event = events[0] + + self.assertEqual(event.modifications.count(), 1) + self.assertEqual(event.relationships.count(), 0) + + modification = event.modifications.all()[0] + self.assertEqual(modification.field.name, 'relationship_id') + self.assertEqual(modification.current, repr(subject.pk)) + + def test_foreign(self): + """ + test if ForeignKey are correctly recognized. + + should be handled by save.py + """ + + fk = ForeignKeyTest() + fk.save() + + subject = OrdinaryTest(random=random_string()) + subject.save() + + ModelEvent.objects.all().delete() + + fk.relationship = subject + fk.save() + + events = ModelEvent.objects.all() + self.assertEqual(events.count(), 1) + + event = events[0] + + self.assertEqual(event.modifications.count(), 1) + self.assertEqual(event.relationships.count(), 0) + + modification = event.modifications.all()[0] + self.assertEqual(modification.field.name, 'relationship_id') + self.assertEqual(modification.current, repr(subject.pk)) + + # def test_no_change(self): + # samples = 10 + # children = self.generate_children(samples) + # + # subject = OrdinaryTest(random=random_string()) + # subject.save() + # + # m2m = M2MTest() + # m2m.save() + # m2m.relationship.add(*children) + # m2m.save() + # ModelEvent.objects.all().delete() + # + # m2m.relationship.remove(subject) + # m2m.save() + # + # events = ModelEvent.objects.all() + # # TODO: fails + # # self.assertEqual(events.count(), 0) + + def test_find(self): + m2m = M2MTest() + m2m.save() + + self.assertIsNotNone(find_m2m_rel(m2m.relationship.through, M2MTest)) + self.assertIsNone(find_m2m_rel(m2m.relationship.through, OrdinaryTest)) + + def test_reverse(self): + m2m = M2MTest() + m2m.save() + + subject = OrdinaryTest(random=random_string()) + subject.save() + + ModelEvent.objects.all().delete() + + subject.m2mtest_set.add(m2m) + subject.save() + + events = ModelEvent.objects.all() + self.assertEqual(events.count(), 1) + + event = events[0] + + self.assertEqual(event.modifications.count(), 0) + self.assertEqual(event.relationships.count(), 1) + self.assertEqual(event.entry.mirror.name, 'M2MTest') + + relationship = event.relationships.all()[0] + self.assertEqual(relationship.operation, int(Operation.CREATE)) + self.assertEqual(relationship.field.name, 'relationship') + self.assertEqual(relationship.entry.primary_key, str(subject.id)) + + +# TODO: test lazy_model_exclusion diff --git a/galaxy_ng/_vendor/automated_logging/tests/test_misc.py b/galaxy_ng/_vendor/automated_logging/tests/test_misc.py new file mode 100644 index 0000000000..8b20b596cc --- /dev/null +++ b/galaxy_ng/_vendor/automated_logging/tests/test_misc.py @@ -0,0 +1,61 @@ +from datetime import timedelta + +from marshmallow import ValidationError + +from automated_logging.signals import _function_model_exclusion +from automated_logging.tests.base import BaseTestCase + + +class MiscellaneousTestCase(BaseTestCase): + def test_no_sender(self): + self.assertIsNone(_function_model_exclusion(None, '', '')) + + def test_wrong_duration(self): + from django.conf import settings + from automated_logging.settings import settings as conf + + settings.AUTOMATED_LOGGING['unspecified']['max_age'] = complex(1, 1) + conf.load.cache_clear() + self.clear() + + self.assertRaises(ValidationError, conf.load) + + settings.AUTOMATED_LOGGING['unspecified']['max_age'] = ( + timedelta.max.total_seconds() + 1 + ) + conf.load.cache_clear() + self.clear() + + self.assertRaises(ValidationError, conf.load) + + settings.AUTOMATED_LOGGING['unspecified']['max_age'] = 'Haha, error go brrr' + conf.load.cache_clear() + self.clear() + + self.assertRaises(ValidationError, conf.load) + + def test_unsupported_search_string(self): + from django.conf import settings + from automated_logging.settings import settings as conf + + settings.AUTOMATED_LOGGING['unspecified']['exclude']['applications'] = [ + 'te:abc' + ] + conf.load.cache_clear() + self.clear() + + self.assertRaises(ValidationError, conf.load) + + # settings.AUTOMATED_LOGGING['unspecified']['exclude']['applications'] = [] + conf.load.cache_clear() + self.clear() + + def test_duration_none(self): + from django.conf import settings + from automated_logging.settings import settings as conf + + settings.AUTOMATED_LOGGING['unspecified']['max_age'] = None + conf.load.cache_clear() + self.clear() + + self.assertIsNone(conf.unspecified.max_age) diff --git a/galaxy_ng/_vendor/automated_logging/tests/test_request.py b/galaxy_ng/_vendor/automated_logging/tests/test_request.py new file mode 100644 index 0000000000..2d8a2f6191 --- /dev/null +++ b/galaxy_ng/_vendor/automated_logging/tests/test_request.py @@ -0,0 +1,153 @@ +""" Test everything related to requests """ +import json +from copy import deepcopy + +from django.http import JsonResponse + +from automated_logging.models import RequestEvent +from automated_logging.tests.base import BaseTestCase, USER_CREDENTIALS +from automated_logging.tests.helpers import random_string + + +class LoggedOutRequestsTestCase(BaseTestCase): + def setUp(self): + from django.conf import settings + from automated_logging.settings import settings as conf + + super().setUp() + + settings.AUTOMATED_LOGGING['request']['exclude']['applications'] = [] + conf.load.cache_clear() + + RequestEvent.objects.all().delete() + + @staticmethod + def view(request): + return JsonResponse({}) + + def test_simple(self): + self.bypass_request_restrictions() + + self.request('GET', self.view) + + events = RequestEvent.objects.all() + self.assertEqual(events.count(), 1) + + event = events[0] + + self.assertEqual(event.user, None) + + +class LoggedInRequestsTestCase(BaseTestCase): + def setUp(self): + from django.conf import settings + from automated_logging.settings import settings as conf + + super().setUp() + + settings.AUTOMATED_LOGGING['request']['exclude']['applications'] = [] + conf.load.cache_clear() + + self.client.login(**USER_CREDENTIALS) + + RequestEvent.objects.all().delete() + + @staticmethod + def view(request): + return JsonResponse({}) + + def test_simple(self): + self.bypass_request_restrictions() + + self.request('GET', self.view) + + events = RequestEvent.objects.all() + self.assertEqual(events.count(), 1) + + event = events[0] + + self.assertEqual(event.ip, '127.0.0.1') + self.assertEqual(event.user, self.user) + self.assertEqual(event.status, 200) + self.assertEqual(event.method, 'GET') + self.assertEqual(event.uri, '/') + + def test_404(self): + self.bypass_request_restrictions() + + self.client.get(f'/{random_string()}') + + events = RequestEvent.objects.all() + self.assertEqual(events.count(), 1) + + event = events[0] + self.assertEqual(event.status, 404) + + @staticmethod + def exception(request): + raise Exception + + def test_500(self): + self.bypass_request_restrictions() + + try: + self.request('GET', self.exception) + except: + pass + + events = RequestEvent.objects.all() + self.assertEqual(events.count(), 1) + + event = events[0] + self.assertGreaterEqual(event.status, 500) + + +class DataRecordingRequestsTestCase(BaseTestCase): + def setUp(self): + from django.conf import settings + from automated_logging.settings import settings as conf + + super().setUp() + + settings.AUTOMATED_LOGGING['request']['exclude']['applications'] = [] + settings.AUTOMATED_LOGGING['request']['data']['enabled'] = [ + 'response', + 'request', + ] + conf.load.cache_clear() + + self.client.login(**USER_CREDENTIALS) + + RequestEvent.objects.all().delete() + + @staticmethod + def view(request): + return JsonResponse({'test': 'example'}) + + def test_payload(self): + # TODO: preliminary until request/response parsing is implemented + from django.conf import settings + from automated_logging.settings import settings as conf + + self.bypass_request_restrictions() + + settings.AUTOMATED_LOGGING['request']['data']['enabled'] = [ + 'request', + 'response', + ] + conf.load.cache_clear() + + self.request('GET', self.view, data=json.dumps({'X': 'Y'})) + + events = RequestEvent.objects.all() + self.assertEqual(events.count(), 1) + + response = json.dumps({'test': 'example'}) + request = json.dumps({'X': 'Y'}) + event = events[0] + self.assertEqual(event.response.content.decode(), response) + self.assertEqual(event.request.content.decode(), request) + + def test_exclusion_by_application(self): + self.request('GET', self.view) + self.assertEqual(RequestEvent.objects.count(), 0) diff --git a/galaxy_ng/_vendor/automated_logging/tests/test_save.py b/galaxy_ng/_vendor/automated_logging/tests/test_save.py new file mode 100644 index 0000000000..c3fa6de879 --- /dev/null +++ b/galaxy_ng/_vendor/automated_logging/tests/test_save.py @@ -0,0 +1,259 @@ +""" Test the save functionality """ +import datetime + +from django.http import JsonResponse + +from automated_logging.helpers import Operation +from automated_logging.models import ModelEvent +from automated_logging.tests.base import BaseTestCase, USER_CREDENTIALS +from automated_logging.tests.helpers import random_string +from automated_logging.tests.models import OrdinaryTest + + +class LoggedOutSaveModificationsTestCase(BaseTestCase): + def setUp(self): + super().setUp() + + # delete all previous model events + ModelEvent.objects.all().delete() + + def test_create_simple_value(self): + """ + test if creation results in the correct fields + :return: + """ + self.bypass_request_restrictions() + value = random_string() + + instance = OrdinaryTest() + instance.random = value + instance.save() + + events = ModelEvent.objects.all() + self.assertEqual(events.count(), 1) + + event = events[0] + + self.assertEqual(event.operation, int(Operation.CREATE)) + self.assertEqual(event.user, None) + + self.assertEqual(event.entry.primary_key, str(instance.pk)) + self.assertEqual(event.entry.value, repr(instance)) + + self.assertEqual(event.entry.mirror.name, 'OrdinaryTest') + self.assertEqual(event.entry.mirror.application.name, 'automated_logging') + + modifications = event.modifications.all() + # pk and random added and modified + self.assertEqual(modifications.count(), 2) + self.assertEqual({m.field.name for m in modifications}, {'random', 'id'}) + + modification = [m for m in modifications if m.field.name == 'random'][0] + self.assertEqual(modification.operation, int(Operation.CREATE)) + self.assertEqual(modification.previous, None) + self.assertEqual(modification.current, value) + self.assertEqual(modification.event, event) + + self.assertEqual(modification.field.name, 'random') + self.assertEqual(modification.field.type, 'CharField') + + relationships = event.relationships.all() + self.assertEqual(relationships.count(), 0) + + def test_modify(self): + """ + test if modification results + in proper delete and create field operations + :return: + """ + previous, current = random_string(10), random_string(10) + + # create instance + instance = OrdinaryTest() + instance.random = previous + instance.save() + + # delete all stuff related to the instance events to have a clean slate + ModelEvent.objects.all().delete() + + instance.random = current + instance.save() + + events = ModelEvent.objects.all() + self.assertEqual(events.count(), 1) + + event = events[0] + + self.assertEqual(event.user, None) + self.assertEqual(event.operation, int(Operation.MODIFY)) + + modifications = event.modifications.all() + self.assertEqual(modifications.count(), 1) + + modification = modifications[0] + self.assertEqual(modification.operation, int(Operation.MODIFY)) + self.assertEqual(modification.previous, previous) + self.assertEqual(modification.current, current) + + relationships = event.relationships.all() + self.assertEqual(relationships.count(), 0) + + def test_honor_save(self): + """ + test if saving honors the only attribute + + :return: + """ + previous1, random1, random2 = ( + random_string(10), + random_string(10), + random_string(10), + ) + + # create instance + instance = OrdinaryTest() + instance.random = previous1 + instance.save() + + ModelEvent.objects.all().delete() + + instance.random = random1 + instance.random2 = random2 + instance.save(update_fields=['random2']) + + events = ModelEvent.objects.all() + self.assertEqual(events.count(), 1) + + event = events[0] + modifications = event.modifications.all() + self.assertEqual(modifications.count(), 1) + + modification = modifications[0] + self.assertEqual(modification.operation, int(Operation.CREATE)) + self.assertEqual(modification.field.name, 'random2') + + def test_delete(self): + """ + test if deletion is working correctly and records all the changes + :return: + """ + value = random_string(10) + + # create instance + instance = OrdinaryTest() + instance.random = value + instance.save() + + pk = instance.pk + re = repr(instance) + + ModelEvent.objects.all().delete() + + instance.delete() + + # DUP of save + events = ModelEvent.objects.all() + self.assertEqual(events.count(), 1) + + event = events[0] + self.assertEqual(event.operation, int(Operation.DELETE)) + self.assertEqual(event.user, None) + + self.assertEqual(event.entry.primary_key, str(pk)) + self.assertEqual(event.entry.value, re) + + # DELETE does currently not record deleted values + modifications = event.modifications.all() + self.assertEqual(modifications.count(), 0) + + def test_reproducibility(self): + """ + test if all the changes where done + correctly so that you can properly derive the + current state from all the accumulated changes + + TODO + """ + pass + + def test_performance(self): + """ + test if setting the performance parameter works correctly + + :return: + """ + from django.conf import settings + from automated_logging.settings import settings as conf + + self.bypass_request_restrictions() + + settings.AUTOMATED_LOGGING['model']['performance'] = True + conf.load.cache_clear() + + ModelEvent.objects.all().delete() + instance = OrdinaryTest() + instance.random = random_string(10) + checkpoint = datetime.datetime.now() + instance.save() + checkpoint = datetime.datetime.now() - checkpoint + + events = ModelEvent.objects.all() + self.assertEqual(events.count(), 1) + + event = events[0] + self.assertIsNotNone(event.performance) + self.assertLess(event.performance.total_seconds(), checkpoint.total_seconds()) + + def test_snapshot(self): + from django.conf import settings + from automated_logging.settings import settings as conf + + self.bypass_request_restrictions() + + settings.AUTOMATED_LOGGING['model']['snapshot'] = True + conf.load.cache_clear() + + instance = OrdinaryTest(random=random_string()) + instance.save() + + events = ModelEvent.objects.all() + self.assertEqual(events.count(), 1) + + event = events[0] + self.assertIsNotNone(event.snapshot) + self.assertEqual(instance, event.snapshot) + + +class LoggedInSaveModificationsTestCase(BaseTestCase): + def setUp(self): + super().setUp() + + self.client.login(**USER_CREDENTIALS) + + self.clear() + + @staticmethod + def view(request): + value = random_string() + + instance = OrdinaryTest() + instance.random = value + instance.save() + + return JsonResponse({}) + + def test_user(self): + """ Test if DAL recognizes the user through the middleware """ + self.bypass_request_restrictions() + + response = self.request('GET', self.view) + self.assertEqual(response.content, b'{}') + + events = ModelEvent.objects.all() + self.assertEqual(events.count(), 1) + + event = events[0] + self.assertEqual(event.user, self.user) + + +# TODO: test snapshot diff --git a/galaxy_ng/_vendor/automated_logging/urls.py b/galaxy_ng/_vendor/automated_logging/urls.py new file mode 100644 index 0000000000..637600f58a --- /dev/null +++ b/galaxy_ng/_vendor/automated_logging/urls.py @@ -0,0 +1 @@ +urlpatterns = [] diff --git a/galaxy_ng/_vendor/automated_logging/views.py b/galaxy_ng/_vendor/automated_logging/views.py new file mode 100644 index 0000000000..357400eee3 --- /dev/null +++ b/galaxy_ng/_vendor/automated_logging/views.py @@ -0,0 +1,4 @@ +""" +Automated Logging specific views, +currently unused except for testing redirection +""" diff --git a/galaxy_ng/app/dynaconf_hooks.py b/galaxy_ng/app/dynaconf_hooks.py index 78b12b33d3..d031ef550b 100755 --- a/galaxy_ng/app/dynaconf_hooks.py +++ b/galaxy_ng/app/dynaconf_hooks.py @@ -250,11 +250,9 @@ def configure_logging(settings: Dynaconf) -> Dict[str, Any]: ) } if data["GALAXY_ENABLE_API_ACCESS_LOG"]: - data["INSTALLED_APPS"] = ["dynaconf_merge"] + data["INSTALLED_APPS"] = ["galaxy_ng._vendor.automated_logging", "dynaconf_merge"] data["MIDDLEWARE"] = [ - # We want to re-add this separately with a vendored copy - # since the upstream is no longer maintained. - # "automated_logging.middleware.AutomatedLoggingMiddleware", + "automated_logging.middleware.AutomatedLoggingMiddleware", "dynaconf_merge", ] data["LOGGING"] = { diff --git a/pyproject.toml b/pyproject.toml index d52a5843b0..0d200c541e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,4 +75,5 @@ ignore = [ "pr_check.sh", "lint_requirements.txt", "profiles/**", + "galaxy_ng/_vendor/**", ] diff --git a/requirements/requirements.common.txt b/requirements/requirements.common.txt index e09cdc0b71..da0e9e4607 100644 --- a/requirements/requirements.common.txt +++ b/requirements/requirements.common.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.10 +# This file is autogenerated by pip-compile with Python 3.11 # by the following command: # # pip-compile --output-file=requirements/requirements.common.txt setup.py @@ -99,6 +99,7 @@ django==4.2.3 # django-guid # django-import-export # django-lifecycle + # django-picklefield # djangorestframework # drf-nested-routers # drf-spectacular @@ -113,8 +114,12 @@ django-guid==3.3.0 # via pulpcore django-import-export==3.1.0 # via pulpcore +django-ipware==3.0.7 + # via galaxy-ng (setup.py) django-lifecycle==1.0.0 # via pulpcore +django-picklefield==3.1 + # via galaxy-ng (setup.py) django-prometheus==2.3.1 # via galaxy-ng (setup.py) djangorestframework==3.14.0 @@ -201,6 +206,8 @@ markuppy==1.14 # via tablib markupsafe==2.1.2 # via jinja2 +marshmallow==3.19.0 + # via galaxy-ng (setup.py) mccabe==0.7.0 # via flake8 mdurl==0.1.2 @@ -282,6 +289,7 @@ packaging==23.1 # black # bleach # django-lifecycle + # marshmallow # pulp-glue parsley==1.3 # via bindep @@ -419,8 +427,6 @@ subprocess-tee==0.4.1 # via ansible-lint tablib[html,ods,xls,xlsx,yaml]==3.4.0 # via django-import-export -tomli==2.0.1 - # via black types-cryptography==3.3.23.2 # via pyjwt types-setuptools==67.7.0.2 diff --git a/requirements/requirements.insights.txt b/requirements/requirements.insights.txt index 4fd0ee42f8..7d79533c4a 100644 --- a/requirements/requirements.insights.txt +++ b/requirements/requirements.insights.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.10 +# This file is autogenerated by pip-compile with Python 3.11 # by the following command: # # pip-compile --output-file=requirements/requirements.insights.txt requirements/requirements.insights.in setup.py @@ -105,6 +105,7 @@ django==4.2.3 # django-guid # django-import-export # django-lifecycle + # django-picklefield # django-storages # djangorestframework # drf-nested-routers @@ -120,8 +121,12 @@ django-guid==3.3.0 # via pulpcore django-import-export==3.1.0 # via pulpcore +django-ipware==3.0.7 + # via galaxy-ng (setup.py) django-lifecycle==1.0.0 # via pulpcore +django-picklefield==3.1 + # via galaxy-ng (setup.py) django-prometheus==2.3.1 # via galaxy-ng (setup.py) django-storages[boto3]==1.13.2 @@ -212,6 +217,8 @@ markuppy==1.14 # via tablib markupsafe==2.1.2 # via jinja2 +marshmallow==3.19.0 + # via galaxy-ng (setup.py) mccabe==0.7.0 # via flake8 mdurl==0.1.2 @@ -293,6 +300,7 @@ packaging==23.1 # black # bleach # django-lifecycle + # marshmallow # pulp-glue parsley==1.3 # via bindep @@ -430,8 +438,6 @@ subprocess-tee==0.4.1 # via ansible-lint tablib[html,ods,xls,xlsx,yaml]==3.4.0 # via django-import-export -tomli==2.0.1 - # via black types-cryptography==3.3.23.2 # via pyjwt types-setuptools==67.7.0.2 diff --git a/requirements/requirements.standalone.txt b/requirements/requirements.standalone.txt index ae559ef417..9c6a689487 100644 --- a/requirements/requirements.standalone.txt +++ b/requirements/requirements.standalone.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.10 +# This file is autogenerated by pip-compile with Python 3.11 # by the following command: # # pip-compile --output-file=requirements/requirements.standalone.txt requirements/requirements.standalone.in setup.py @@ -99,6 +99,7 @@ django==4.2.3 # django-guid # django-import-export # django-lifecycle + # django-picklefield # djangorestframework # drf-nested-routers # drf-spectacular @@ -113,8 +114,12 @@ django-guid==3.3.0 # via pulpcore django-import-export==3.1.0 # via pulpcore +django-ipware==3.0.7 + # via galaxy-ng (setup.py) django-lifecycle==1.0.0 # via pulpcore +django-picklefield==3.1 + # via galaxy-ng (setup.py) django-prometheus==2.3.1 # via galaxy-ng (setup.py) djangorestframework==3.14.0 @@ -201,6 +206,8 @@ markuppy==1.14 # via tablib markupsafe==2.1.2 # via jinja2 +marshmallow==3.19.0 + # via galaxy-ng (setup.py) mccabe==0.7.0 # via flake8 mdurl==0.1.2 @@ -282,6 +289,7 @@ packaging==23.1 # black # bleach # django-lifecycle + # marshmallow # pulp-glue parsley==1.3 # via bindep @@ -419,8 +427,6 @@ subprocess-tee==0.4.1 # via ansible-lint tablib[html,ods,xls,xlsx,yaml]==3.4.0 # via django-import-export -tomli==2.0.1 - # via black types-cryptography==3.3.23.2 # via pyjwt types-setuptools==67.7.0.2 diff --git a/setup.py b/setup.py index 6a04165c8d..c0856de6ef 100644 --- a/setup.py +++ b/setup.py @@ -117,9 +117,6 @@ def _format_pulp_requirement(plugin, specifier=None, ref=None, gh_namespace="pul "django-prometheus>=2.0.0", "drf-spectacular", "pulp-container>=2.15.0,<2.17.0", - # We want to re-add this later with a vendored copy since - # upstream is no longer maintained. - # "django-automated-logging@git+https://github.com/jctanner/django-automated-logging@DJANGO_4x", "social-auth-core>=4.4.2", "social-auth-app-django>=5.2.0", "dynaconf>=3.1.12", @@ -127,6 +124,10 @@ def _format_pulp_requirement(plugin, specifier=None, ref=None, gh_namespace="pul "insights_analytics_collector>=0.3.0", "boto3", "distro", + # From vendored automated_logging + "marshmallow<4.0.0,>=3.6.1", + "django-picklefield<4.0.0,>=3.0.1", + "django-ipware<4.0.0,>=3.0.0", ]