update related cards/forms on digest change (#68427) #1241

Merged
fpeters merged 1 commits from wip/68427-update-relations into main 2024-03-15 07:21:52 +01:00
6 changed files with 453 additions and 5 deletions

View File

@ -604,10 +604,10 @@ def test_item_field_from_custom_view_on_cards(pub):
resp = app.get(formdef.data_class().select()[0].get_url())
assert resp.pyquery('.field-type-item .value').text() == 'Yattr%sZ' % baz_id
# remove card (back to value stored at first)
# remove card, the value is still displayed
carddef.data_class().wipe()
resp = app.get(formdef.data_class().select()[0].get_url())
assert resp.pyquery('.field-type-item .value').text() == 'Xattr%sY' % baz_id
assert resp.pyquery('.field-type-item .value').text() == 'Yattr%sZ' % baz_id

Dans ce test on vérifiait le fallback sur la valeur stockée en _display en cas de suppression de la fiche; avec cette PR qui met à jour la valeur en _display ça ne marche plus pareil, on peut juste être content que la valeur s'affiche sans planter, ce qui était l'intention initiale de toute façon.

Dans ce test on vérifiait le fallback sur la valeur stockée en _display en cas de suppression de la fiche; avec cette PR qui met à jour la valeur en _display ça ne marche plus pareil, on peut juste être content que la valeur s'affiche sans planter, ce qui était l'intention initiale de toute façon.
def test_item_field_from_custom_view_on_cards_filter_status(pub):

View File

@ -13,6 +13,7 @@ from wcs.blocks import BlockDef
from wcs.carddef import CardDef
from wcs.categories import Category
from wcs.data_sources import NamedDataSource
from wcs.formdef import FormDef
from wcs.wf.create_formdata import Mapping
from wcs.workflows import Workflow
@ -634,6 +635,7 @@ def test_form_page_item_with_variable_data_source_prefill(pub):
def test_form_page_item_with_card_with_custom_id_prefill(pub):
create_user(pub)
CardDef.wipe()
FormDef.wipe()

Tests ajoutés récemment, il y a un carddata.store() exécuté avec les restes de reverse_relations sans rapport, il faut nettoyer correctement.

Tests ajoutés récemment, il y a un carddata.store() exécuté avec les restes de reverse_relations sans rapport, il faut nettoyer correctement.
carddef = CardDef()
carddef.name = 'Test'
@ -678,6 +680,7 @@ def test_form_page_item_with_card_with_custom_id_prefill(pub):
def test_form_page_block_with_item_with_card_with_custom_id_prefill(pub):
create_user(pub)
CardDef.wipe()
FormDef.wipe()
carddef = CardDef()
carddef.name = 'Test'

View File

@ -5,6 +5,7 @@ import xml.etree.ElementTree as ET
import pytest
from wcs.blocks import BlockDef
from wcs.carddata import UpdateRelationsAfterJob
from wcs.carddef import CardDef
from wcs.categories import CardDefCategory
from wcs.data_sources import NamedDataSource
@ -1328,3 +1329,346 @@ def test_card_custom_id_format(pub):
assert data_class.force_valid_id_characters('_Fôô bar-') == '_Foo-bar-'
assert data_class.force_valid_id_characters('_Fôô bar☭-') == '_Foo-bar-'
assert data_class.force_valid_id_characters('_Fôô bar❗') == '_Foo-bar'
def test_card_update_related(pub):
BlockDef.wipe()
CardDef.wipe()
FormDef.wipe()
carddef = CardDef()
carddef.name = 'foo'
carddef.fields = [
StringField(id='1', label='Test', varname='foo'),
]
carddef.digest_templates = {'default': '{{ form_var_foo }}'}
carddef.store()
carddef.data_class().wipe()
carddata1 = carddef.data_class()()
carddata1.data = {'1': 'card1'}
carddata1.just_created()
carddata1.store()
carddata2 = carddef.data_class()()
carddata2.data = {'1': 'card2'}
carddata2.just_created()
carddata2.store()
# check update against item field
formdef = FormDef()
formdef.name = 'foo'
formdef.fields = [
ItemField(id='1', label='Test', data_source={'type': 'carddef:foo'}),
]
formdef.store()
formdata = formdef.data_class()()
formdata.data = {'1': '1'}
formdata.data['1_display'] = formdef.fields[0].store_display_value(formdata.data, formdef.fields[0].id)
assert formdata.data['1_display'] == 'card1'
formdata.just_created()
formdata.store()
pub.cleanup()
carddef = carddef.get(carddef.id)
carddata1 = carddef.data_class().get(carddata1.id)
carddata1.data = {'1': 'card1-change1'}
carddata1.store()
formdata.refresh_from_storage()
assert formdata.data['1_display'] == 'card1-change1'
# check update against items field
formdef = FormDef()
formdef.name = 'foo'
formdef.fields = [
ItemsField(id='1', label='Test', data_source={'type': 'carddef:foo'}),
]
formdef.store()
formdata = formdef.data_class()()
formdata.data = {'1': ['1', '2']}
formdata.data['1_display'] = formdef.fields[0].store_display_value(formdata.data, formdef.fields[0].id)
assert formdata.data['1_display'] == 'card1-change1, card2'
formdata.just_created()
formdata.store()
pub.cleanup()
carddef = carddef.get(carddef.id)
carddata1 = carddef.data_class().get(carddata1.id)
carddata1.data = {'1': 'card1-change2'}
carddata1.store()
formdata.refresh_from_storage()
assert formdata.data['1_display'] == 'card1-change2, card2'
# check update against block field
blockdef = BlockDef()
blockdef.name = 'foo'
blockdef.fields = [
ItemField(id='1', label='Test', varname='bar', data_source={'type': 'carddef:foo'}),
]
blockdef.digest_template = 'bloc:{{ block_var_bar }}'
blockdef.store()
formdef = FormDef()
formdef.name = 'foo2'
formdef.fields = [
BlockField(id='1', label='Test', block_slug=blockdef.slug),
]
formdef.store()
formdata = formdef.data_class()()
formdata.data = {
'1': {
'data': [
{
'1': '1',
'1_display': 'card1-change2',
},
{
'1': '2',
'1_display': 'card2',
},
],
'schema': {},
}
}
formdata.data['1_display'] = formdef.fields[0].store_display_value(formdata.data, formdef.fields[0].id)
assert formdata.data['1_display'] == 'bloc:card1-change2, bloc:card2'
formdata.just_created()
formdata.store()
pub.cleanup()
carddef = carddef.get(carddef.id)
carddata1 = carddef.data_class().get(carddata1.id)
carddata1.data = {'1': 'card1-change3'}
carddata1.store()
formdata.refresh_from_storage()
assert formdata.data['1']['data'][0]['1'] == '1'
assert formdata.data['1']['data'][0]['1_display'] == 'card1-change3'
assert formdata.data['1']['data'][1]['1'] == '2'
assert formdata.data['1']['data'][1]['1_display'] == 'card2'
assert formdata.data['1_display'] == 'bloc:card1-change3, bloc:card2'
def test_card_update_related_with_custom_view(pub):
CardDef.wipe()
FormDef.wipe()
pub.custom_view_class.wipe()
carddef = CardDef()
carddef.name = 'foo'
carddef.fields = [
StringField(id='1', label='Test', varname='foo'),
]
carddef.digest_templates = {
'default': '{{ form_var_foo }}',
'custom-view:view': 'view-{{ form_var_foo }}',
}
carddef.store()
carddef.data_class().wipe()
carddata1 = carddef.data_class()()
carddata1.data = {'1': 'card1'}
carddata1.just_created()
carddata1.store()
carddata2 = carddef.data_class()()
carddata2.data = {'1': 'card2'}
carddata2.just_created()
carddata2.store()
custom_view = pub.custom_view_class()
custom_view.title = 'view'
custom_view.formdef = carddef
custom_view.columns = {'list': [{'id': 'id'}]}
custom_view.filters = {}
custom_view.visibility = 'datasource'
custom_view.store()
formdef = FormDef()
formdef.name = 'foo'
formdef.fields = [
ItemField(id='1', label='Test', data_source={'type': 'carddef:foo:view'}),
]
formdef.store()
formdata = formdef.data_class()()
formdata.data = {'1': '1'}
formdata.data['1_display'] = formdef.fields[0].store_display_value(formdata.data, formdef.fields[0].id)
assert formdata.data['1_display'] == 'view-card1'
formdata.just_created()
formdata.store()
pub.cleanup()
carddef = carddef.get(carddef.id)
carddata1 = carddef.data_class().get(carddata1.id)
carddata1.data = {'1': 'card1-change1'}
carddata1.store()
formdata.refresh_from_storage()

Vérification que sur une source de données sur une vue personnalisée, on obtient le résultat du digest template de la vue.

Vérification que sur une source de données sur une vue personnalisée, on obtient le résultat du digest template de la vue.
assert formdata.data['1_display'] == 'view-card1-change1'
def test_card_update_related_cascading(pub):
BlockDef.wipe()
CardDef.wipe()
FormDef.wipe()
carddef = CardDef()
carddef.name = 'foo'
carddef.fields = [
StringField(id='1', label='Test', varname='foo'),
]
carddef.digest_templates = {'default': '{{ form_var_foo }}'}
carddef.store()
carddef.data_class().wipe()
carddata1 = carddef.data_class()()
carddata1.data = {'1': 'card1'}
carddata1.just_created()
carddata1.store()
carddef2 = CardDef()
carddef2.name = 'bar'
carddef2.fields = [
ItemField(id='1', label='Test', varname='foo', data_source={'type': 'carddef:foo'}),
]

Un digest template qui reprend la valeur _display d'une autre fiche, un changement sur la valeur de cette autre fiche amènera donc un changement sur cette fiche, le test vérifie que ça cascade bien ainsi.

Un digest template qui reprend la valeur _display d'une autre fiche, un changement sur la valeur de cette autre fiche amènera donc un changement sur cette fiche, le test vérifie que ça cascade bien ainsi.
carddef2.digest_templates = {'default': 'bar-{{ form_var_foo }}'}
carddef2.store()
carddef2.data_class().wipe()
carddata2 = carddef2.data_class()()
carddata2.data = {'1': '1'}
carddata2.data['1_display'] = carddef2.fields[0].store_display_value(
carddata2.data, carddef2.fields[0].id
)
carddata2.just_created()
carddata2.store()
formdef = FormDef()
formdef.name = 'foo'
formdef.fields = [
ItemField(id='1', label='Test', data_source={'type': 'carddef:bar'}),
]
formdef.store()
formdata = formdef.data_class()()
formdata.data = {'1': '1'}
formdata.data['1_display'] = formdef.fields[0].store_display_value(formdata.data, formdef.fields[0].id)
assert formdata.data['1_display'] == 'bar-card1'
formdata.just_created()
formdata.store()
pub.cleanup()
carddef = carddef.get(carddef.id)
carddata1 = carddef.data_class().get(carddata1.id)
carddata1.data = {'1': 'card1-change1'}
carddata1.store()
formdata.refresh_from_storage()
assert formdata.data['1_display'] == 'bar-card1-change1'
def test_card_update_related_cascading_loop(pub):
BlockDef.wipe()
CardDef.wipe()
FormDef.wipe()
carddef = CardDef()
carddef.name = 'foo'
carddef.fields = [
StringField(id='1', label='Test', varname='foo'),
ItemField(id='2', label='Test', varname='x', data_source={'type': 'carddef:bar'}),
]
carddef.digest_templates = {'default': '{{ form_var_foo }} {{ form_var_x }}'}
carddef.store()
carddef.data_class().wipe()
carddef2 = CardDef()
carddef2.name = 'bar'
carddef2.fields = [
StringField(id='1', label='Test', varname='foo'),
ItemField(id='2', label='Test', varname='x', data_source={'type': 'carddef:foo'}),
]
carddef2.digest_templates = {'default': '{{ form_var_foo }} {{ form_var_x }}'}
carddef2.store()
carddef2.data_class().wipe()
carddata1 = carddef.data_class()()
carddata1.data = {'1': 'card1'}
carddata1.just_created()
carddata1.store()
carddata2 = carddef2.data_class()()
carddata2.data = {'1': 'card2', '2': '1'}
carddata2.data['2_display'] = carddef2.fields[1].store_display_value(
carddata2.data, carddef2.fields[1].id
)
assert carddata2.data['2_display'] == 'card1 None'
carddata2.just_created()
carddata2.store()
pub.cleanup()
carddef = carddef.get(carddef.id)
carddata1 = carddef.data_class().get(carddata1.id)
carddata1.data['2'] = str(carddata2.id)
carddata1.data['2_display'] = carddef.fields[1].store_display_value(carddata1.data, carddef.fields[1].id)
carddata1.store()
# check it will have stopped once getting back to carddata2
carddata2.refresh_from_storage()

Mais il faut éviter que la cascade culbute sans fin.

Mais il faut éviter que la cascade culbute sans fin.
assert carddata2.data['2_display'] == 'card1 card2 card1 None'
def test_card_update_related_deleted(pub):
BlockDef.wipe()
CardDef.wipe()
FormDef.wipe()
carddef = CardDef()
carddef.name = 'foo'
carddef.fields = [
StringField(id='1', label='Test', varname='foo'),
]
carddef.digest_templates = {'default': 'card-{{ form_var_foo }}'}
carddef.store()
carddef.data_class().wipe()
carddata1 = carddef.data_class()()
carddata1.data = {'1': 'card1'}
carddata1.just_created()
carddata1.store()
formdef = FormDef()
formdef.name = 'foo'
formdef.fields = [
ItemField(id='1', label='Test', data_source={'type': 'carddef:foo'}),
]
formdef.store()
formdata = formdef.data_class()()
formdata.data = {'1': '1'}
formdata.data['1_display'] = formdef.fields[0].store_display_value(formdata.data, formdef.fields[0].id)
assert formdata.data['1_display'] == 'card-card1'
formdata.just_created()
formdata.store()
formdef.remove_self()
pub.cleanup()
carddef = carddef.get(carddef.id)
carddata1 = carddef.data_class().get(carddata1.id)
carddata1.data = {'1': 'card1-change1'}
carddata1.store() # do not crash looking for related formdef that has been deleted
# check the job doesn't fail if the carddef or carddata have been removed
job = UpdateRelationsAfterJob(carddata=carddata1)
carddata1.remove_self()
job.execute()
carddef.remove_self()
job.execute()

View File

@ -14,12 +14,13 @@
# You should have received a copy of the GNU General Public License
# along with this program; if not, see <http://www.gnu.org/licenses/>.
from quixote import get_publisher, get_request, get_session
from quixote import get_publisher, get_request, get_response, get_session
from wcs.formdata import FormData
from .qommon import _
from .sql_criterias import Equal
from .qommon.afterjobs import AfterJob
from .sql_criterias import Equal, Null, Or, get_field_id
class CardData(FormData):
@ -133,3 +134,98 @@ class CardData(FormData):
}
token = get_session().create_token('card-file-by-token', context)
return '/api/card-file-by-token/%s' % token.id
def update_related(self):
if self.formdef.reverse_relations:
job = UpdateRelationsAfterJob(carddata=self)
if get_response():
job.store()
get_response().add_after_job(job)
fpeters marked this conversation as resolved Outdated

un print qui traine

un print qui traine
else:
job.execute()
self._has_changed_digest = False
class UpdateRelationsAfterJob(AfterJob):
label = _('Updating relations')
def __init__(self, carddata):
super().__init__(carddef_id=carddata.formdef.id, carddata_id=carddata.id)
def execute(self):
from .carddef import CardDef
from .formdef import FormDef
if getattr(get_publisher(), '_update_related_seen', None) is None:
get_publisher()._update_related_seen = set()
# keep track of objects that have been updated, to avoid cycles
update_related_seen = get_publisher()._update_related_seen
try:
carddef = CardDef.get(self.kwargs['carddef_id'])
carddata = carddef.data_class().get(self.kwargs['carddata_id'])

et si le job est exécuté alors que la fiche en question a été supprimée ?

et si le job est exécuté alors que la fiche en question a été supprimée ?

Ça serait vraiment pas de chance, mais en effet, j'ai géré la suppression de carddata ou carddef dans ce commit supplémentaire : aaedccb39f (il bouge un peu le dernier test, qui contenait un deuxième modèle de fiche pour rien).

Ça serait vraiment pas de chance, mais en effet, j'ai géré la suppression de carddata ou carddef dans ce commit supplémentaire : https://git.entrouvert.org/entrouvert/wcs/commit/aaedccb39f471556466d4747c6075c05b84c4402 (il bouge un peu le dernier test, qui contenait un deuxième modèle de fiche pour rien).
except KeyError:
# card got removed (probably the afterjob met some unexpected delay), ignore.
return
klass = {'carddef': CardDef, 'formdef': FormDef}
# check all known reverse relations
for obj_ref in {x['obj'] for x in carddef.reverse_relations}:
obj_type, obj_slug = obj_ref.split(':')
obj_class = klass.get(obj_type)
try:
objdef = obj_class.get_by_slug(obj_slug)
except KeyError:
continue
criterias = []
fields = []
# get fields referencing the card model (only item and items fields, as string
# field with data source is just for completion, and computed field with data
# source, do not store a display value.
for field in objdef.iter_fields(include_block_fields=True):
if field.key not in ('item', 'items'):
continue
data_source = getattr(field, 'data_source', None)
if not data_source:
continue
data_source_type = data_source.get('type')
if (
not data_source_type.startswith('carddef:')
or data_source_type.split(':')[1] != carddef.slug
):
continue
fields.append(field)
criterias.append(Equal(get_field_id(field), carddata.identifier, field=field))
if not criterias:
continue
def update_data(field, data):
display_value = data.get(f'{field.id}_display')
field.set_value(data, data.get(field.id))
return bool(data.get(f'{field.id}_display') != display_value)
# look for all formdata, including drafts, excluding anonymised
select_criterias = [Null('anonymised'), Or(criterias)]
for objdata in objdef.data_class().select_iterator(clause=select_criterias, itersize=200):
objdata_seen_key = f'{objdata.formdef.xml_root_node}:{objdata.formdef.slug}:{objdata.id}'
if objdata_seen_key in update_related_seen:
# do not allow updates to cycle back
continue
objdata_changed = False
for field in fields:
if getattr(field, 'block_field', None):
blockdata_changed = False
for block_row_data in objdata.data[field.block_field.id]['data']:
blockdata_changed |= update_data(field, block_row_data)
if blockdata_changed:
# if block data changed, maybe block digest changed too
update_data(field.block_field, objdata.data)
objdata_changed |= blockdata_changed
else:
objdata_changed |= update_data(field, objdata.data)
if objdata_changed:
update_related_seen.add(objdata_seen_key)
objdata.store()

View File

@ -592,6 +592,7 @@ class WcsPublisher(QommonPublisher):
def cleanup(self):
self._cached_user_fields_formdef = None
self._update_related_seen = None
from . import sql
sql.cleanup_connection()

View File

@ -2267,6 +2267,7 @@ class SqlDataMixin(SqlMixin):
def __init__(self, id=None):
self.id = id
self.data = {}
self._has_changed_digest = False
_evolution = None
@ -2403,6 +2404,7 @@ class SqlDataMixin(SqlMixin):
def _set_auto_fields(self, cur):
if self.set_auto_fields():
self._has_changed_digest = True
sql_statement = (
'''UPDATE %s
SET id_display = %%(id_display)s,
@ -2848,7 +2850,9 @@ class SqlCardData(SqlDataMixin, wcs.carddata.CardData):
def store(self, *args, **kwargs):
if self.uuid is None:
self.uuid = str(uuid.uuid4())
return super().store(*args, **kwargs)
super().store(*args, **kwargs)
if self._has_changed_digest:
self.update_related()
class SqlUser(SqlMixin, wcs.users.User):