|
|
|
@ -5,6 +5,7 @@ from copy import deepcopy
|
|
|
|
|
from collections import Counter
|
|
|
|
|
from datetime import timezone
|
|
|
|
|
|
|
|
|
|
import inspect
|
|
|
|
|
import json
|
|
|
|
|
import logging
|
|
|
|
|
import yaml
|
|
|
|
@ -669,20 +670,15 @@ class Storage:
|
|
|
|
|
|
|
|
|
|
context = {}
|
|
|
|
|
|
|
|
|
|
def _bind(self, key, bind):
|
|
|
|
|
if bind is True:
|
|
|
|
|
return (self.__class__, key)
|
|
|
|
|
if isinstance(bind, str):
|
|
|
|
|
return (get_schema(self.recall(bind).__class__), key)
|
|
|
|
|
return (bind, key)
|
|
|
|
|
|
|
|
|
|
def store(self, key, value, bind=None):
|
|
|
|
|
def store(self, key, value):
|
|
|
|
|
""" store value under key """
|
|
|
|
|
self.context.setdefault('_track', {})[self._bind(key, bind)]= value
|
|
|
|
|
key = f'{self.__class__.__name__}.{key}'
|
|
|
|
|
self.context.setdefault('_track', {})[key] = value
|
|
|
|
|
|
|
|
|
|
def recall(self, key, bind=None):
|
|
|
|
|
def recall(self, key):
|
|
|
|
|
""" recall value from key """
|
|
|
|
|
return self.context['_track'][self._bind(key, bind)]
|
|
|
|
|
key = f'{self.__class__.__name__}.{key}'
|
|
|
|
|
return self.context['_track'][key]
|
|
|
|
|
|
|
|
|
|
class BaseOpts(SQLAlchemyAutoSchemaOpts):
|
|
|
|
|
""" Option class with sqla session
|
|
|
|
@ -790,10 +786,16 @@ class BaseSchema(ma.SQLAlchemyAutoSchema, Storage):
|
|
|
|
|
for key, value in data.items()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
def _call_and_store(self, *args, **kwargs):
|
|
|
|
|
""" track current parent field for pruning """
|
|
|
|
|
self.store('field', kwargs['field_name'], True)
|
|
|
|
|
return super()._call_and_store(*args, **kwargs)
|
|
|
|
|
def get_parent(self):
|
|
|
|
|
""" helper to determine parent of current object """
|
|
|
|
|
for x in inspect.stack():
|
|
|
|
|
loc = x[0].f_locals
|
|
|
|
|
if 'ret_d' in loc:
|
|
|
|
|
if isinstance(loc['self'], MailuSchema):
|
|
|
|
|
return self.context.get('config'), loc['attr_name']
|
|
|
|
|
else:
|
|
|
|
|
return loc['self'].get_instance(loc['ret_d']), loc['attr_name']
|
|
|
|
|
return None, None
|
|
|
|
|
|
|
|
|
|
# this is only needed to work around the declared attr "email" primary key in model
|
|
|
|
|
def get_instance(self, data):
|
|
|
|
@ -803,9 +805,13 @@ class BaseSchema(ma.SQLAlchemyAutoSchema, Storage):
|
|
|
|
|
if keys := getattr(self.Meta, 'primary_keys', None):
|
|
|
|
|
filters = {key: data.get(key) for key in keys}
|
|
|
|
|
if None not in filters.values():
|
|
|
|
|
res= self.session.query(self.opts.model).filter_by(**filters).first()
|
|
|
|
|
return res
|
|
|
|
|
res= super().get_instance(data)
|
|
|
|
|
try:
|
|
|
|
|
res = self.session.query(self.opts.model).filter_by(**filters).first()
|
|
|
|
|
except sqlalchemy.exc.StatementError as exc:
|
|
|
|
|
raise ValidationError(f'Invalid {keys[0]}: {data.get(keys[0])!r}', data.get(keys[0])) from exc
|
|
|
|
|
else:
|
|
|
|
|
return res
|
|
|
|
|
res = super().get_instance(data)
|
|
|
|
|
return res
|
|
|
|
|
|
|
|
|
|
@pre_load(pass_many=True)
|
|
|
|
@ -829,6 +835,10 @@ class BaseSchema(ma.SQLAlchemyAutoSchema, Storage):
|
|
|
|
|
want_prune = []
|
|
|
|
|
def patch(count, data):
|
|
|
|
|
|
|
|
|
|
# we only process objects here
|
|
|
|
|
if type(data) is not dict:
|
|
|
|
|
raise ValidationError(f'Invalid item. {self.Meta.model.__tablename__.title()} needs to be an object.', f'{data!r}')
|
|
|
|
|
|
|
|
|
|
# don't allow __delete__ coming from input
|
|
|
|
|
if '__delete__' in data:
|
|
|
|
|
raise ValidationError('Unknown field.', f'{count}.__delete__')
|
|
|
|
@ -882,10 +892,10 @@ class BaseSchema(ma.SQLAlchemyAutoSchema, Storage):
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
# remember if prune was requested for _prune_items@post_load
|
|
|
|
|
self.store('prune', bool(want_prune), True)
|
|
|
|
|
self.store('prune', bool(want_prune))
|
|
|
|
|
|
|
|
|
|
# remember original items to stabilize password-changes in _add_instance@post_load
|
|
|
|
|
self.store('original', items, True)
|
|
|
|
|
self.store('original', items)
|
|
|
|
|
|
|
|
|
|
return items
|
|
|
|
|
|
|
|
|
@ -909,23 +919,18 @@ class BaseSchema(ma.SQLAlchemyAutoSchema, Storage):
|
|
|
|
|
# stabilize import of auto-increment primary keys (not required),
|
|
|
|
|
# by matching import data to existing items and setting primary key
|
|
|
|
|
if not self._primary in data:
|
|
|
|
|
parent = self.recall('parent')
|
|
|
|
|
parent, field = self.get_parent()
|
|
|
|
|
if parent is not None:
|
|
|
|
|
for item in getattr(parent, self.recall('field', 'parent')):
|
|
|
|
|
for item in getattr(parent, field):
|
|
|
|
|
existing = self.dump(item, many=False)
|
|
|
|
|
this = existing.pop(self._primary)
|
|
|
|
|
if data == existing:
|
|
|
|
|
instance = item
|
|
|
|
|
self.instance = item
|
|
|
|
|
data[self._primary] = this
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
# try to load instance
|
|
|
|
|
instance = self.instance or self.get_instance(data)
|
|
|
|
|
|
|
|
|
|
# remember instance as parent for pruning siblings
|
|
|
|
|
if not self.Meta.sibling and self.context.get('update'):
|
|
|
|
|
self.store('parent', instance)
|
|
|
|
|
|
|
|
|
|
if instance is None:
|
|
|
|
|
|
|
|
|
|
if '__delete__' in data:
|
|
|
|
@ -1001,7 +1006,7 @@ class BaseSchema(ma.SQLAlchemyAutoSchema, Storage):
|
|
|
|
|
return items
|
|
|
|
|
|
|
|
|
|
# get prune flag from _patch_many@pre_load
|
|
|
|
|
want_prune = self.recall('prune', True)
|
|
|
|
|
want_prune = self.recall('prune')
|
|
|
|
|
|
|
|
|
|
# prune: determine if existing items in db need to be added or marked for deletion
|
|
|
|
|
add_items = False
|
|
|
|
@ -1018,16 +1023,17 @@ class BaseSchema(ma.SQLAlchemyAutoSchema, Storage):
|
|
|
|
|
del_items = True
|
|
|
|
|
|
|
|
|
|
if add_items or del_items:
|
|
|
|
|
parent = self.recall('parent')
|
|
|
|
|
parent, field = self.get_parent()
|
|
|
|
|
if parent is not None:
|
|
|
|
|
existing = {item[self._primary] for item in items if self._primary in item}
|
|
|
|
|
for item in getattr(parent, self.recall('field', 'parent')):
|
|
|
|
|
for item in getattr(parent, field):
|
|
|
|
|
key = getattr(item, self._primary)
|
|
|
|
|
if key not in existing:
|
|
|
|
|
if add_items:
|
|
|
|
|
items.append({self._primary: key})
|
|
|
|
|
else:
|
|
|
|
|
items.append({self._primary: key, '__delete__': '?'})
|
|
|
|
|
if self.context.get('update'):
|
|
|
|
|
self.opts.sqla_session.delete(self.instance or self.get_instance({self._primary: key}))
|
|
|
|
|
|
|
|
|
|
return items
|
|
|
|
|
|
|
|
|
@ -1048,7 +1054,7 @@ class BaseSchema(ma.SQLAlchemyAutoSchema, Storage):
|
|
|
|
|
# did we hash a new plaintext password?
|
|
|
|
|
original = None
|
|
|
|
|
pkey = getattr(item, self._primary)
|
|
|
|
|
for data in self.recall('original', True):
|
|
|
|
|
for data in self.recall('original'):
|
|
|
|
|
if 'hash_password' in data and data.get(self._primary) == pkey:
|
|
|
|
|
original = data['password']
|
|
|
|
|
break
|
|
|
|
@ -1244,12 +1250,6 @@ class MailuSchema(Schema, Storage):
|
|
|
|
|
if field in fieldlist:
|
|
|
|
|
fieldlist[field] = fieldlist.pop(field)
|
|
|
|
|
|
|
|
|
|
def _call_and_store(self, *args, **kwargs):
|
|
|
|
|
""" track current parent and field for pruning """
|
|
|
|
|
self.store('field', kwargs['field_name'], True)
|
|
|
|
|
self.store('parent', self.context.get('config'))
|
|
|
|
|
return super()._call_and_store(*args, **kwargs)
|
|
|
|
|
|
|
|
|
|
@pre_load
|
|
|
|
|
def _clear_config(self, data, many, **kwargs): # pylint: disable=unused-argument
|
|
|
|
|
""" create config object in context if missing
|
|
|
|
|