Notifications regression: Notifications not created
Notifications for when someone posted a comment on your node was not created. Root cause was that default values defined in schema was not set, resulting in activity subscriptions not being active. There were 2 bugs preventing them to be set: * The way the caching of markdown as html was implemented caused default values not to be set. * Eve/Cerberus regression causes nested default values to fail https://github.com/pyeve/eve/issues/1174 Also, a 3rd bug caused nodes without a parent not to have a subscription. Migration scripts: How markdown fields is cached has changed, and unused properties of attachments has been removed. ./manage.py maintenance replace_pillar_node_type_schemas Set the default values of activities-subscription ./manage.py maintenance fix_missing_activities_subscription_defaults
This commit is contained in:
@@ -119,6 +119,8 @@ def activity_subscribe(user_id, context_object_type, context_object_id):
|
||||
|
||||
# If no subscription exists, we create one
|
||||
if not subscription:
|
||||
# Workaround for issue: https://github.com/pyeve/eve/issues/1174
|
||||
lookup['notifications'] = {}
|
||||
current_app.post_internal('activities-subscriptions', lookup)
|
||||
|
||||
|
||||
|
@@ -1,4 +1,3 @@
|
||||
import copy
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
@@ -6,36 +5,12 @@ from bson import ObjectId, tz_util
|
||||
from eve.io.mongo import Validator
|
||||
from flask import current_app
|
||||
|
||||
import pillar.markdown
|
||||
from pillar import markdown
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ValidateCustomFields(Validator):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Will be reference to the actual document being validated, so that we can
|
||||
# modify it during validation.
|
||||
self.__real_document = None
|
||||
|
||||
def validate(self, document, *args, **kwargs):
|
||||
# Keep a reference to the actual document, because Cerberus validates copies.
|
||||
self.__real_document = document
|
||||
result = super().validate(document, *args, **kwargs)
|
||||
|
||||
# Store the in-place modified document as self.document, so that Eve's post_internal
|
||||
# can actually pick it up as the validated document. We need to make a copy so that
|
||||
# further modifications (like setting '_etag' etc.) aren't done in-place.
|
||||
self.document = copy.deepcopy(document)
|
||||
|
||||
return result
|
||||
|
||||
def _get_child_validator(self, *args, **kwargs):
|
||||
child = super()._get_child_validator(*args, **kwargs)
|
||||
# Pass along our reference to the actual document.
|
||||
child.__real_document = self.__real_document
|
||||
return child
|
||||
|
||||
# TODO: split this into a convert_property(property, schema) and call that from this function.
|
||||
def convert_properties(self, properties, node_schema):
|
||||
@@ -137,8 +112,7 @@ class ValidateCustomFields(Validator):
|
||||
if val:
|
||||
# This ensures the modifications made by v's coercion rules are
|
||||
# visible to this validator's output.
|
||||
# TODO(fsiddi): this no longer works due to Cerberus internal changes.
|
||||
# self.current[field] = v.current
|
||||
self.document[field] = v.document
|
||||
return True
|
||||
|
||||
log.warning('Error validating properties for node %s: %s', self.document, v.errors)
|
||||
@@ -183,36 +157,18 @@ class ValidateCustomFields(Validator):
|
||||
if ip.prefixlen() == 0:
|
||||
self._error(field_name, 'Zero-length prefix is not allowed')
|
||||
|
||||
def _validator_markdown(self, field, value):
|
||||
"""Convert MarkDown.
|
||||
def _normalize_coerce_markdown(self, markdown_field: str) -> str:
|
||||
"""
|
||||
my_log = log.getChild('_validator_markdown')
|
||||
Cache markdown as html.
|
||||
|
||||
# Find this field inside the original document
|
||||
my_subdoc = self._subdoc_in_real_document()
|
||||
if my_subdoc is None:
|
||||
# If self.update==True we are validating an update document, which
|
||||
# may not contain all fields, so then a missing field is fine.
|
||||
if not self.update:
|
||||
self._error(field, f'validator_markdown: unable to find sub-document '
|
||||
f'for path {self.document_path}')
|
||||
return
|
||||
|
||||
my_log.debug('validating field %r with value %r', field, value)
|
||||
save_to = pillar.markdown.cache_field_name(field)
|
||||
html = pillar.markdown.markdown(value)
|
||||
my_log.debug('saving result to %r in doc with id %s', save_to, id(my_subdoc))
|
||||
my_subdoc[save_to] = html
|
||||
|
||||
def _subdoc_in_real_document(self):
|
||||
"""Return a reference to the current sub-document inside the real document.
|
||||
|
||||
This allows modification of the document being validated.
|
||||
:param markdown_field: name of the field containing mark down
|
||||
:return: html string
|
||||
"""
|
||||
my_subdoc = getattr(self, 'persisted_document') or self.__real_document
|
||||
for item in self.document_path:
|
||||
my_subdoc = my_subdoc[item]
|
||||
return my_subdoc
|
||||
my_log = log.getChild('_normalize_coerce_markdown')
|
||||
mdown = self.document.get(markdown_field, '')
|
||||
html = markdown.markdown(mdown)
|
||||
my_log.debug('Generated html for markdown field %s in doc with id %s', markdown_field, id(self.document))
|
||||
return html
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
@@ -1,5 +1,7 @@
|
||||
import os
|
||||
|
||||
from pillar.api.node_types.utils import markdown_fields
|
||||
|
||||
STORAGE_BACKENDS = ["local", "pillar", "cdnsun", "gcs", "unittest"]
|
||||
URL_PREFIX = 'api'
|
||||
|
||||
@@ -184,12 +186,7 @@ organizations_schema = {
|
||||
'maxlength': 128,
|
||||
'required': True
|
||||
},
|
||||
'description': {
|
||||
'type': 'string',
|
||||
'maxlength': 256,
|
||||
'validator': 'markdown',
|
||||
},
|
||||
'_description_html': {'type': 'string'},
|
||||
**markdown_fields('description', maxlength=256),
|
||||
'website': {
|
||||
'type': 'string',
|
||||
'maxlength': 256,
|
||||
@@ -322,11 +319,7 @@ nodes_schema = {
|
||||
'maxlength': 128,
|
||||
'required': True,
|
||||
},
|
||||
'description': {
|
||||
'type': 'string',
|
||||
'validator': 'markdown',
|
||||
},
|
||||
'_description_html': {'type': 'string'},
|
||||
**markdown_fields('description'),
|
||||
'picture': _file_embedded_schema,
|
||||
'order': {
|
||||
'type': 'integer',
|
||||
@@ -576,11 +569,7 @@ projects_schema = {
|
||||
'maxlength': 128,
|
||||
'required': True,
|
||||
},
|
||||
'description': {
|
||||
'type': 'string',
|
||||
'validator': 'markdown',
|
||||
},
|
||||
'_description_html': {'type': 'string'},
|
||||
**markdown_fields('description'),
|
||||
# Short summary for the project
|
||||
'summary': {
|
||||
'type': 'string',
|
||||
|
@@ -23,14 +23,6 @@ attachments_embedded_schema = {
|
||||
'type': 'objectid',
|
||||
'required': True,
|
||||
},
|
||||
'link': {
|
||||
'type': 'string',
|
||||
'allowed': ['self', 'none', 'custom'],
|
||||
'default': 'self',
|
||||
},
|
||||
'link_custom': {
|
||||
'type': 'string',
|
||||
},
|
||||
'collection': {
|
||||
'type': 'string',
|
||||
'allowed': ['files'],
|
||||
|
@@ -1,17 +1,15 @@
|
||||
from pillar.api.node_types import attachments_embedded_schema
|
||||
from pillar.api.node_types.utils import markdown_fields
|
||||
|
||||
node_type_comment = {
|
||||
'name': 'comment',
|
||||
'description': 'Comments for asset nodes, pages, etc.',
|
||||
'dyn_schema': {
|
||||
# The actual comment content
|
||||
'content': {
|
||||
'type': 'string',
|
||||
'minlength': 5,
|
||||
'required': True,
|
||||
'validator': 'markdown',
|
||||
},
|
||||
'_content_html': {'type': 'string'},
|
||||
**markdown_fields(
|
||||
'content',
|
||||
minlength=5,
|
||||
required=True),
|
||||
'status': {
|
||||
'type': 'string',
|
||||
'allowed': [
|
||||
|
@@ -1,17 +1,14 @@
|
||||
from pillar.api.node_types import attachments_embedded_schema
|
||||
from pillar.api.node_types.utils import markdown_fields
|
||||
|
||||
node_type_post = {
|
||||
'name': 'post',
|
||||
'description': 'A blog post, for any project',
|
||||
'dyn_schema': {
|
||||
'content': {
|
||||
'type': 'string',
|
||||
'minlength': 5,
|
||||
'maxlength': 90000,
|
||||
'required': True,
|
||||
'validator': 'markdown',
|
||||
},
|
||||
'_content_html': {'type': 'string'},
|
||||
**markdown_fields('content',
|
||||
minlength=5,
|
||||
maxlength=90000,
|
||||
required=True),
|
||||
'status': {
|
||||
'type': 'string',
|
||||
'allowed': [
|
||||
|
34
pillar/api/node_types/utils.py
Normal file
34
pillar/api/node_types/utils.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from pillar import markdown
|
||||
|
||||
|
||||
def markdown_fields(field: str, **kwargs) -> dict:
|
||||
"""
|
||||
Creates a field for the markdown, and a field for the cached html.
|
||||
|
||||
Example usage:
|
||||
schema = {'myDoc': {
|
||||
'type': 'list',
|
||||
'schema': {
|
||||
'type': 'dict',
|
||||
'schema': {
|
||||
**markdown_fields('content', required=True),
|
||||
}
|
||||
},
|
||||
}}
|
||||
|
||||
:param field:
|
||||
:return:
|
||||
"""
|
||||
cache_field = markdown.cache_field_name(field)
|
||||
return {
|
||||
field: {
|
||||
'type': 'string',
|
||||
**kwargs
|
||||
},
|
||||
cache_field: {
|
||||
'type': 'string',
|
||||
'readonly': True,
|
||||
'default': field, # Name of the field containing the markdown. Will be input to the coerce function.
|
||||
'coerce': 'markdown',
|
||||
}
|
||||
}
|
@@ -247,14 +247,12 @@ def setup_app(app, url_prefix):
|
||||
app.on_fetched_resource_nodes += eve_hooks.before_returning_nodes
|
||||
|
||||
app.on_replace_nodes += eve_hooks.before_replacing_node
|
||||
app.on_replace_nodes += eve_hooks.parse_markdown
|
||||
app.on_replace_nodes += eve_hooks.texture_sort_files
|
||||
app.on_replace_nodes += eve_hooks.deduct_content_type_and_duration
|
||||
app.on_replace_nodes += eve_hooks.node_set_default_picture
|
||||
app.on_replaced_nodes += eve_hooks.after_replacing_node
|
||||
|
||||
app.on_insert_nodes += eve_hooks.before_inserting_nodes
|
||||
app.on_insert_nodes += eve_hooks.parse_markdowns
|
||||
app.on_insert_nodes += eve_hooks.nodes_deduct_content_type_and_duration
|
||||
app.on_insert_nodes += eve_hooks.nodes_set_default_picture
|
||||
app.on_insert_nodes += eve_hooks.textures_sort_files
|
||||
|
@@ -122,47 +122,50 @@ def before_inserting_nodes(items):
|
||||
# Default the 'user' property to the current user.
|
||||
item.setdefault('user', current_user.user_id)
|
||||
|
||||
def get_comment_verb_and_context_object_id(comment):
|
||||
nodes_collection = current_app.data.driver.db['nodes']
|
||||
verb = 'commented'
|
||||
parent = nodes_collection.find_one({'_id': comment['parent']})
|
||||
context_object_id = comment['parent']
|
||||
while parent['node_type'] == 'comment':
|
||||
# If the parent is a comment, we provide its own parent as
|
||||
# context. We do this in order to point the user to an asset
|
||||
# or group when viewing the notification.
|
||||
verb = 'replied'
|
||||
context_object_id = parent['parent']
|
||||
parent = nodes_collection.find_one({'_id': parent['parent']})
|
||||
return verb, context_object_id
|
||||
|
||||
|
||||
def after_inserting_nodes(items):
|
||||
for item in items:
|
||||
# Skip subscriptions for first level items (since the context is not a
|
||||
# node, but a project).
|
||||
context_object_id = None
|
||||
# TODO: support should be added for mixed context
|
||||
if 'parent' not in item:
|
||||
return
|
||||
context_object_id = item['parent']
|
||||
if item['node_type'] == 'comment':
|
||||
nodes_collection = current_app.data.driver.db['nodes']
|
||||
parent = nodes_collection.find_one({'_id': item['parent']})
|
||||
# Always subscribe to the parent node
|
||||
activity_subscribe(item['user'], 'node', item['parent'])
|
||||
if parent['node_type'] == 'comment':
|
||||
# If the parent is a comment, we provide its own parent as
|
||||
# context. We do this in order to point the user to an asset
|
||||
# or group when viewing the notification.
|
||||
verb = 'replied'
|
||||
context_object_id = parent['parent']
|
||||
# Subscribe to the parent of the parent comment (post or group)
|
||||
activity_subscribe(item['user'], 'node', parent['parent'])
|
||||
else:
|
||||
activity_subscribe(item['user'], 'node', item['_id'])
|
||||
verb = 'commented'
|
||||
elif item['node_type'] in PILLAR_NAMED_NODE_TYPES:
|
||||
verb = 'posted'
|
||||
if item['node_type'] in PILLAR_NAMED_NODE_TYPES:
|
||||
activity_subscribe(item['user'], 'node', item['_id'])
|
||||
else:
|
||||
# Don't automatically create activities for non-Pillar node types,
|
||||
# as we don't know what would be a suitable verb (among other things).
|
||||
continue
|
||||
verb = 'posted'
|
||||
context_object_id = item.get('parent')
|
||||
if item['node_type'] == 'comment':
|
||||
# Always subscribe to the parent node
|
||||
activity_subscribe(item['user'], 'node', item['parent'])
|
||||
verb, context_object_id = get_comment_verb_and_context_object_id(item)
|
||||
# Subscribe to the parent of the parent comment (post or group)
|
||||
activity_subscribe(item['user'], 'node', context_object_id)
|
||||
|
||||
activity_object_add(
|
||||
item['user'],
|
||||
verb,
|
||||
'node',
|
||||
item['_id'],
|
||||
'node',
|
||||
context_object_id
|
||||
)
|
||||
|
||||
if context_object_id and item['node_type'] in PILLAR_NAMED_NODE_TYPES:
|
||||
# * Skip activity for first level items (since the context is not a
|
||||
# node, but a project).
|
||||
# * Don't automatically create activities for non-Pillar node types,
|
||||
# as we don't know what would be a suitable verb (among other things).
|
||||
activity_object_add(
|
||||
item['user'],
|
||||
verb,
|
||||
'node',
|
||||
item['_id'],
|
||||
'node',
|
||||
context_object_id
|
||||
)
|
||||
|
||||
|
||||
def deduct_content_type_and_duration(node_doc, original=None):
|
||||
@@ -322,46 +325,6 @@ def textures_sort_files(nodes):
|
||||
texture_sort_files(node)
|
||||
|
||||
|
||||
def parse_markdown(node, original=None):
|
||||
import copy
|
||||
|
||||
projects_collection = current_app.data.driver.db['projects']
|
||||
project = projects_collection.find_one({'_id': node['project']}, {'node_types': 1})
|
||||
# Query node type directly using the key
|
||||
node_type = next(nt for nt in project['node_types']
|
||||
if nt['name'] == node['node_type'])
|
||||
|
||||
# Create a copy to not overwrite the actual schema.
|
||||
schema = copy.deepcopy(current_app.config['DOMAIN']['nodes']['schema'])
|
||||
schema['properties'] = node_type['dyn_schema']
|
||||
|
||||
def find_markdown_fields(schema, node):
|
||||
"""Find and process all makrdown validated fields."""
|
||||
for k, v in schema.items():
|
||||
if not isinstance(v, dict):
|
||||
continue
|
||||
|
||||
if v.get('validator') == 'markdown':
|
||||
# If there is a match with the validator: markdown pair, assign the sibling
|
||||
# property (following the naming convention _<property>_html)
|
||||
# the processed value.
|
||||
if k in node:
|
||||
html = pillar.markdown.markdown(node[k])
|
||||
field_name = pillar.markdown.cache_field_name(k)
|
||||
node[field_name] = html
|
||||
if isinstance(node, dict) and k in node:
|
||||
find_markdown_fields(v, node[k])
|
||||
|
||||
find_markdown_fields(schema, node)
|
||||
|
||||
return 'ok'
|
||||
|
||||
|
||||
def parse_markdowns(items):
|
||||
for item in items:
|
||||
parse_markdown(item)
|
||||
|
||||
|
||||
def short_link_info(short_code):
|
||||
"""Returns the short link info in a dict."""
|
||||
|
||||
|
@@ -44,10 +44,16 @@ def remove_private_keys(document):
|
||||
"""Removes any key that starts with an underscore, returns result as new
|
||||
dictionary.
|
||||
"""
|
||||
def do_remove(doc):
|
||||
for key in list(doc.keys()):
|
||||
if key.startswith('_'):
|
||||
del doc[key]
|
||||
elif isinstance(doc[key], dict):
|
||||
doc[key] = do_remove(doc[key])
|
||||
return doc
|
||||
|
||||
doc_copy = copy.deepcopy(document)
|
||||
for key in list(doc_copy.keys()):
|
||||
if key.startswith('_'):
|
||||
del doc_copy[key]
|
||||
do_remove(doc_copy)
|
||||
|
||||
try:
|
||||
del doc_copy['allowed_methods']
|
||||
|
Reference in New Issue
Block a user