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