Merge branch 'master' into dillo
This commit is contained in:
commit
812d911195
@ -119,6 +119,8 @@ def activity_subscribe(user_id, context_object_type, context_object_id):
|
|||||||
|
|
||||||
# If no subscription exists, we create one
|
# If no subscription exists, we create one
|
||||||
if not subscription:
|
if not subscription:
|
||||||
|
# Workaround for issue: https://github.com/pyeve/eve/issues/1174
|
||||||
|
lookup['notifications'] = {}
|
||||||
current_app.post_internal('activities-subscriptions', lookup)
|
current_app.post_internal('activities-subscriptions', lookup)
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import copy
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@ -6,36 +5,12 @@ from bson import ObjectId, tz_util
|
|||||||
from eve.io.mongo import Validator
|
from eve.io.mongo import Validator
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
|
|
||||||
import pillar.markdown
|
from pillar import markdown
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class ValidateCustomFields(Validator):
|
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.
|
# TODO: split this into a convert_property(property, schema) and call that from this function.
|
||||||
def convert_properties(self, properties, node_schema):
|
def convert_properties(self, properties, node_schema):
|
||||||
@ -137,8 +112,7 @@ class ValidateCustomFields(Validator):
|
|||||||
if val:
|
if val:
|
||||||
# This ensures the modifications made by v's coercion rules are
|
# This ensures the modifications made by v's coercion rules are
|
||||||
# visible to this validator's output.
|
# visible to this validator's output.
|
||||||
# TODO(fsiddi): this no longer works due to Cerberus internal changes.
|
self.document[field] = v.document
|
||||||
# self.current[field] = v.current
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
log.warning('Error validating properties for node %s: %s', self.document, v.errors)
|
log.warning('Error validating properties for node %s: %s', self.document, v.errors)
|
||||||
@ -183,36 +157,18 @@ class ValidateCustomFields(Validator):
|
|||||||
if ip.prefixlen() == 0:
|
if ip.prefixlen() == 0:
|
||||||
self._error(field_name, 'Zero-length prefix is not allowed')
|
self._error(field_name, 'Zero-length prefix is not allowed')
|
||||||
|
|
||||||
def _validator_markdown(self, field, value):
|
def _normalize_coerce_markdown(self, markdown_field: str) -> str:
|
||||||
"""Convert MarkDown.
|
|
||||||
"""
|
"""
|
||||||
my_log = log.getChild('_validator_markdown')
|
Cache markdown as html.
|
||||||
|
|
||||||
# Find this field inside the original document
|
:param markdown_field: name of the field containing mark down
|
||||||
my_subdoc = self._subdoc_in_real_document()
|
:return: html string
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
my_subdoc = getattr(self, 'persisted_document') or self.__real_document
|
my_log = log.getChild('_normalize_coerce_markdown')
|
||||||
for item in self.document_path:
|
mdown = self.document.get(markdown_field, '')
|
||||||
my_subdoc = my_subdoc[item]
|
html = markdown.markdown(mdown)
|
||||||
return my_subdoc
|
my_log.debug('Generated html for markdown field %s in doc with id %s', markdown_field, id(self.document))
|
||||||
|
return html
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
|
from pillar.api.node_types.utils import markdown_fields
|
||||||
|
|
||||||
STORAGE_BACKENDS = ["local", "pillar", "cdnsun", "gcs", "unittest"]
|
STORAGE_BACKENDS = ["local", "pillar", "cdnsun", "gcs", "unittest"]
|
||||||
URL_PREFIX = 'api'
|
URL_PREFIX = 'api'
|
||||||
|
|
||||||
@ -184,12 +186,7 @@ organizations_schema = {
|
|||||||
'maxlength': 128,
|
'maxlength': 128,
|
||||||
'required': True
|
'required': True
|
||||||
},
|
},
|
||||||
'description': {
|
**markdown_fields('description', maxlength=256),
|
||||||
'type': 'string',
|
|
||||||
'maxlength': 256,
|
|
||||||
'validator': 'markdown',
|
|
||||||
},
|
|
||||||
'_description_html': {'type': 'string'},
|
|
||||||
'website': {
|
'website': {
|
||||||
'type': 'string',
|
'type': 'string',
|
||||||
'maxlength': 256,
|
'maxlength': 256,
|
||||||
@ -322,11 +319,7 @@ nodes_schema = {
|
|||||||
'maxlength': 128,
|
'maxlength': 128,
|
||||||
'required': True,
|
'required': True,
|
||||||
},
|
},
|
||||||
'description': {
|
**markdown_fields('description'),
|
||||||
'type': 'string',
|
|
||||||
'validator': 'markdown',
|
|
||||||
},
|
|
||||||
'_description_html': {'type': 'string'},
|
|
||||||
'picture': _file_embedded_schema,
|
'picture': _file_embedded_schema,
|
||||||
'order': {
|
'order': {
|
||||||
'type': 'integer',
|
'type': 'integer',
|
||||||
@ -576,11 +569,7 @@ projects_schema = {
|
|||||||
'maxlength': 128,
|
'maxlength': 128,
|
||||||
'required': True,
|
'required': True,
|
||||||
},
|
},
|
||||||
'description': {
|
**markdown_fields('description'),
|
||||||
'type': 'string',
|
|
||||||
'validator': 'markdown',
|
|
||||||
},
|
|
||||||
'_description_html': {'type': 'string'},
|
|
||||||
# Short summary for the project
|
# Short summary for the project
|
||||||
'summary': {
|
'summary': {
|
||||||
'type': 'string',
|
'type': 'string',
|
||||||
|
@ -23,14 +23,6 @@ attachments_embedded_schema = {
|
|||||||
'type': 'objectid',
|
'type': 'objectid',
|
||||||
'required': True,
|
'required': True,
|
||||||
},
|
},
|
||||||
'link': {
|
|
||||||
'type': 'string',
|
|
||||||
'allowed': ['self', 'none', 'custom'],
|
|
||||||
'default': 'self',
|
|
||||||
},
|
|
||||||
'link_custom': {
|
|
||||||
'type': 'string',
|
|
||||||
},
|
|
||||||
'collection': {
|
'collection': {
|
||||||
'type': 'string',
|
'type': 'string',
|
||||||
'allowed': ['files'],
|
'allowed': ['files'],
|
||||||
|
@ -1,17 +1,15 @@
|
|||||||
from pillar.api.node_types import attachments_embedded_schema
|
from pillar.api.node_types import attachments_embedded_schema
|
||||||
|
from pillar.api.node_types.utils import markdown_fields
|
||||||
|
|
||||||
node_type_comment = {
|
node_type_comment = {
|
||||||
'name': 'comment',
|
'name': 'comment',
|
||||||
'description': 'Comments for asset nodes, pages, etc.',
|
'description': 'Comments for asset nodes, pages, etc.',
|
||||||
'dyn_schema': {
|
'dyn_schema': {
|
||||||
# The actual comment content
|
# The actual comment content
|
||||||
'content': {
|
**markdown_fields(
|
||||||
'type': 'string',
|
'content',
|
||||||
'minlength': 5,
|
minlength=5,
|
||||||
'required': True,
|
required=True),
|
||||||
'validator': 'markdown',
|
|
||||||
},
|
|
||||||
'_content_html': {'type': 'string'},
|
|
||||||
'status': {
|
'status': {
|
||||||
'type': 'string',
|
'type': 'string',
|
||||||
'allowed': [
|
'allowed': [
|
||||||
|
@ -1,17 +1,14 @@
|
|||||||
from pillar.api.node_types import attachments_embedded_schema
|
from pillar.api.node_types import attachments_embedded_schema
|
||||||
|
from pillar.api.node_types.utils import markdown_fields
|
||||||
|
|
||||||
node_type_post = {
|
node_type_post = {
|
||||||
'name': 'post',
|
'name': 'post',
|
||||||
'description': 'A blog post, for any project',
|
'description': 'A blog post, for any project',
|
||||||
'dyn_schema': {
|
'dyn_schema': {
|
||||||
'content': {
|
**markdown_fields('content',
|
||||||
'type': 'string',
|
minlength=5,
|
||||||
'minlength': 5,
|
maxlength=90000,
|
||||||
'maxlength': 90000,
|
required=True),
|
||||||
'required': True,
|
|
||||||
'validator': 'markdown',
|
|
||||||
},
|
|
||||||
'_content_html': {'type': 'string'},
|
|
||||||
'status': {
|
'status': {
|
||||||
'type': 'string',
|
'type': 'string',
|
||||||
'allowed': [
|
'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',
|
||||||
|
}
|
||||||
|
}
|
@ -248,14 +248,12 @@ def setup_app(app, url_prefix):
|
|||||||
app.on_fetched_resource_nodes += eve_hooks.before_returning_nodes
|
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.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.texture_sort_files
|
||||||
app.on_replace_nodes += eve_hooks.deduct_content_type_and_duration
|
app.on_replace_nodes += eve_hooks.deduct_content_type_and_duration
|
||||||
app.on_replace_nodes += eve_hooks.node_set_default_picture
|
app.on_replace_nodes += eve_hooks.node_set_default_picture
|
||||||
app.on_replaced_nodes += eve_hooks.after_replacing_node
|
app.on_replaced_nodes += eve_hooks.after_replacing_node
|
||||||
|
|
||||||
app.on_insert_nodes += eve_hooks.before_inserting_nodes
|
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_deduct_content_type_and_duration
|
||||||
app.on_insert_nodes += eve_hooks.nodes_set_default_picture
|
app.on_insert_nodes += eve_hooks.nodes_set_default_picture
|
||||||
app.on_insert_nodes += eve_hooks.textures_sort_files
|
app.on_insert_nodes += eve_hooks.textures_sort_files
|
||||||
|
@ -122,39 +122,42 @@ def before_inserting_nodes(items):
|
|||||||
# Default the 'user' property to the current user.
|
# Default the 'user' property to the current user.
|
||||||
item.setdefault('user', current_user.user_id)
|
item.setdefault('user', current_user.user_id)
|
||||||
|
|
||||||
|
def get_comment_verb_and_context_object_id(comment):
|
||||||
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).
|
|
||||||
# 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']
|
nodes_collection = current_app.data.driver.db['nodes']
|
||||||
parent = nodes_collection.find_one({'_id': item['parent']})
|
verb = 'commented'
|
||||||
# Always subscribe to the parent node
|
parent = nodes_collection.find_one({'_id': comment['parent']})
|
||||||
activity_subscribe(item['user'], 'node', item['parent'])
|
context_object_id = comment['parent']
|
||||||
if parent['node_type'] == 'comment':
|
while parent['node_type'] == 'comment':
|
||||||
# If the parent is a comment, we provide its own parent as
|
# 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
|
# context. We do this in order to point the user to an asset
|
||||||
# or group when viewing the notification.
|
# or group when viewing the notification.
|
||||||
verb = 'replied'
|
verb = 'replied'
|
||||||
context_object_id = parent['parent']
|
context_object_id = parent['parent']
|
||||||
# Subscribe to the parent of the parent comment (post or group)
|
parent = nodes_collection.find_one({'_id': parent['parent']})
|
||||||
activity_subscribe(item['user'], 'node', parent['parent'])
|
return verb, context_object_id
|
||||||
else:
|
|
||||||
activity_subscribe(item['user'], 'node', item['_id'])
|
|
||||||
verb = 'commented'
|
|
||||||
elif item['node_type'] in PILLAR_NAMED_NODE_TYPES:
|
|
||||||
verb = 'posted'
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
|
def after_inserting_nodes(items):
|
||||||
|
for item in items:
|
||||||
|
context_object_id = None
|
||||||
|
# TODO: support should be added for mixed context
|
||||||
|
if item['node_type'] in PILLAR_NAMED_NODE_TYPES:
|
||||||
|
activity_subscribe(item['user'], 'node', item['_id'])
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
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(
|
activity_object_add(
|
||||||
item['user'],
|
item['user'],
|
||||||
verb,
|
verb,
|
||||||
@ -322,46 +325,6 @@ def textures_sort_files(nodes):
|
|||||||
texture_sort_files(node)
|
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):
|
def short_link_info(short_code):
|
||||||
"""Returns the short link info in a dict."""
|
"""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
|
"""Removes any key that starts with an underscore, returns result as new
|
||||||
dictionary.
|
dictionary.
|
||||||
"""
|
"""
|
||||||
doc_copy = copy.deepcopy(document)
|
def do_remove(doc):
|
||||||
for key in list(doc_copy.keys()):
|
for key in list(doc.keys()):
|
||||||
if key.startswith('_'):
|
if key.startswith('_'):
|
||||||
del doc_copy[key]
|
del doc[key]
|
||||||
|
elif isinstance(doc[key], dict):
|
||||||
|
doc[key] = do_remove(doc[key])
|
||||||
|
return doc
|
||||||
|
|
||||||
|
doc_copy = copy.deepcopy(document)
|
||||||
|
do_remove(doc_copy)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
del doc_copy['allowed_methods']
|
del doc_copy['allowed_methods']
|
||||||
|
@ -739,113 +739,6 @@ def iter_markdown(proj_node_types: dict, some_node: dict, callback: typing.Calla
|
|||||||
doc[key] = new_value
|
doc[key] = new_value
|
||||||
|
|
||||||
|
|
||||||
@manager_maintenance.option('-p', '--project', dest='proj_url', nargs='?',
|
|
||||||
help='Project URL')
|
|
||||||
@manager_maintenance.option('-a', '--all', dest='all_projects', action='store_true', default=False,
|
|
||||||
help='Replace on all projects.')
|
|
||||||
@manager_maintenance.option('-g', '--go', dest='go', action='store_true', default=False,
|
|
||||||
help='Actually perform the changes (otherwise just show as dry-run).')
|
|
||||||
def upgrade_attachment_usage(proj_url=None, all_projects=False, go=False):
|
|
||||||
"""Replaces '@[slug]' with '{attachment slug}'.
|
|
||||||
|
|
||||||
Also moves links from the attachment dict to the attachment shortcode.
|
|
||||||
"""
|
|
||||||
if bool(proj_url) == all_projects:
|
|
||||||
log.error('Use either --project or --all.')
|
|
||||||
return 1
|
|
||||||
|
|
||||||
import html
|
|
||||||
from pillar.api.projects.utils import node_type_dict
|
|
||||||
from pillar.api.utils import remove_private_keys
|
|
||||||
from pillar.api.utils.authentication import force_cli_user
|
|
||||||
|
|
||||||
force_cli_user()
|
|
||||||
|
|
||||||
nodes_coll = current_app.db('nodes')
|
|
||||||
total_nodes = 0
|
|
||||||
failed_node_ids = set()
|
|
||||||
|
|
||||||
# Use a mixture of the old slug RE that still allowes spaces in the slug
|
|
||||||
# name and the new RE that allows dashes.
|
|
||||||
old_slug_re = re.compile(r'@\[([a-zA-Z0-9_\- ]+)\]')
|
|
||||||
for proj in _db_projects(proj_url, all_projects, go=go):
|
|
||||||
proj_id = proj['_id']
|
|
||||||
proj_url = proj.get('url', '-no-url-')
|
|
||||||
nodes = nodes_coll.find({
|
|
||||||
'_deleted': {'$ne': True},
|
|
||||||
'project': proj_id,
|
|
||||||
'properties.attachments': {'$exists': True},
|
|
||||||
})
|
|
||||||
node_count = nodes.count()
|
|
||||||
if node_count == 0:
|
|
||||||
log.debug('Skipping project %s (%s)', proj_url, proj_id)
|
|
||||||
continue
|
|
||||||
|
|
||||||
proj_node_types = node_type_dict(proj)
|
|
||||||
|
|
||||||
for node in nodes:
|
|
||||||
attachments = node['properties']['attachments']
|
|
||||||
replaced = False
|
|
||||||
|
|
||||||
# Inner functions because of access to the node's attachments.
|
|
||||||
def replace(match):
|
|
||||||
nonlocal replaced
|
|
||||||
slug = match.group(1)
|
|
||||||
log.debug(' - OLD STYLE attachment slug %r', slug)
|
|
||||||
try:
|
|
||||||
att = attachments[slug]
|
|
||||||
except KeyError:
|
|
||||||
log.info("Attachment %r not found for node %s", slug, node['_id'])
|
|
||||||
link = ''
|
|
||||||
else:
|
|
||||||
link = att.get('link', '')
|
|
||||||
if link == 'self':
|
|
||||||
link = " link='self'"
|
|
||||||
elif link == 'custom':
|
|
||||||
url = att.get('link_custom')
|
|
||||||
if url:
|
|
||||||
link = " link='%s'" % html.escape(url)
|
|
||||||
replaced = True
|
|
||||||
return '{attachment %r%s}' % (slug.replace(' ', '-'), link)
|
|
||||||
|
|
||||||
def update_markdown(value: str) -> str:
|
|
||||||
return old_slug_re.sub(replace, value)
|
|
||||||
|
|
||||||
iter_markdown(proj_node_types, node, update_markdown)
|
|
||||||
|
|
||||||
# Remove no longer used properties from attachments
|
|
||||||
new_attachments = {}
|
|
||||||
for slug, attachment in attachments.items():
|
|
||||||
replaced |= 'link' in attachment # link_custom implies link
|
|
||||||
attachment.pop('link', None)
|
|
||||||
attachment.pop('link_custom', None)
|
|
||||||
new_attachments[slug.replace(' ', '-')] = attachment
|
|
||||||
node['properties']['attachments'] = new_attachments
|
|
||||||
|
|
||||||
if replaced:
|
|
||||||
total_nodes += 1
|
|
||||||
else:
|
|
||||||
# Nothing got replaced,
|
|
||||||
continue
|
|
||||||
|
|
||||||
if go:
|
|
||||||
# Use Eve to PUT, so we have schema checking.
|
|
||||||
db_node = remove_private_keys(node)
|
|
||||||
r, _, _, status = current_app.put_internal('nodes', db_node, _id=node['_id'])
|
|
||||||
if status != 200:
|
|
||||||
log.error('Error %i storing altered node %s %s', status, node['_id'], r)
|
|
||||||
failed_node_ids.add(node['_id'])
|
|
||||||
# raise SystemExit('Error storing node; see log.')
|
|
||||||
log.debug('Updated node %s: %s', node['_id'], r)
|
|
||||||
|
|
||||||
log.info('Project %s (%s) has %d nodes with attachments',
|
|
||||||
proj_url, proj_id, node_count)
|
|
||||||
log.info('%s %d nodes', 'Updated' if go else 'Would update', total_nodes)
|
|
||||||
if failed_node_ids:
|
|
||||||
log.warning('Failed to update %d of %d nodes: %s', len(failed_node_ids), total_nodes,
|
|
||||||
', '.join(str(nid) for nid in failed_node_ids))
|
|
||||||
|
|
||||||
|
|
||||||
def _db_projects(proj_url: str, all_projects: bool, project_id='', *, go: bool) \
|
def _db_projects(proj_url: str, all_projects: bool, project_id='', *, go: bool) \
|
||||||
-> typing.Iterable[dict]:
|
-> typing.Iterable[dict]:
|
||||||
"""Yields a subset of the projects in the database.
|
"""Yields a subset of the projects in the database.
|
||||||
@ -1374,3 +1267,69 @@ def fix_projects_for_files(filepath: Path, go=False):
|
|||||||
|
|
||||||
log.info('Done updating %d files (found %d, modified %d) on %d projects',
|
log.info('Done updating %d files (found %d, modified %d) on %d projects',
|
||||||
len(mapping), total_matched, total_modified, len(project_to_file_ids))
|
len(mapping), total_matched, total_modified, len(project_to_file_ids))
|
||||||
|
|
||||||
|
|
||||||
|
@manager_maintenance.option('-u', '--user', dest='user', nargs='?',
|
||||||
|
help='Update subscriptions for single user.')
|
||||||
|
@manager_maintenance.option('-o', '--object', dest='context_object', nargs='?',
|
||||||
|
help='Update subscriptions for context_object.')
|
||||||
|
@manager_maintenance.option('-g', '--go', dest='go', action='store_true', default=False,
|
||||||
|
help='Actually perform the changes (otherwise just show as dry-run).')
|
||||||
|
def fix_missing_activities_subscription_defaults(user=None, context_object=None, go=False):
|
||||||
|
"""Assign default values to activities-subscriptions documents where values are missing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
subscriptions_collection = current_app.db('activities-subscriptions')
|
||||||
|
lookup_is_subscribed = {
|
||||||
|
'is_subscribed': {'$exists': False},
|
||||||
|
}
|
||||||
|
|
||||||
|
lookup_notifications = {
|
||||||
|
'notifications.web': {'$exists': False},
|
||||||
|
}
|
||||||
|
|
||||||
|
if user:
|
||||||
|
lookup_is_subscribed['user'] = ObjectId(user)
|
||||||
|
lookup_notifications['user'] = ObjectId(user)
|
||||||
|
|
||||||
|
if context_object:
|
||||||
|
lookup_is_subscribed['context_object'] = ObjectId(context_object)
|
||||||
|
lookup_notifications['context_object'] = ObjectId(context_object)
|
||||||
|
|
||||||
|
num_need_is_subscribed_update = subscriptions_collection.count(lookup_is_subscribed)
|
||||||
|
log.info("Found %d documents that needs to be update 'is_subscribed'", num_need_is_subscribed_update)
|
||||||
|
num_need_notification_web_update = subscriptions_collection.count(lookup_notifications)
|
||||||
|
log.info("Found %d documents that needs to be update 'notifications.web'", num_need_notification_web_update)
|
||||||
|
|
||||||
|
if not go:
|
||||||
|
return
|
||||||
|
|
||||||
|
if num_need_is_subscribed_update > 0:
|
||||||
|
log.info("Updating 'is_subscribed'")
|
||||||
|
resp = subscriptions_collection.update(
|
||||||
|
lookup_is_subscribed,
|
||||||
|
{
|
||||||
|
'$set': {'is_subscribed': True}
|
||||||
|
},
|
||||||
|
multi=True,
|
||||||
|
upsert=False
|
||||||
|
)
|
||||||
|
if resp['nModified'] is not num_need_is_subscribed_update:
|
||||||
|
log.warning("Expected % documents to be update, was %d",
|
||||||
|
num_need_is_subscribed_update, resp['nModified'])
|
||||||
|
|
||||||
|
if num_need_notification_web_update > 0:
|
||||||
|
log.info("Updating 'notifications.web'")
|
||||||
|
resp = subscriptions_collection.update(
|
||||||
|
lookup_notifications,
|
||||||
|
{
|
||||||
|
'$set': {'notifications.web': True}
|
||||||
|
},
|
||||||
|
multi=True,
|
||||||
|
upsert=False
|
||||||
|
)
|
||||||
|
if resp['nModified'] is not num_need_notification_web_update:
|
||||||
|
log.warning("Expected % documents to be update, was %d",
|
||||||
|
num_need_notification_web_update, resp['nModified'])
|
||||||
|
|
||||||
|
log.info("Done updating 'activities-subscriptions' documents")
|
||||||
|
@ -109,6 +109,7 @@ def posts_view(project_id=None, project_url=None, url=None, *, archive=False, pa
|
|||||||
project.blog_archive_prev = None
|
project.blog_archive_prev = None
|
||||||
|
|
||||||
navigation_links = project_navigation_links(project, api)
|
navigation_links = project_navigation_links(project, api)
|
||||||
|
extension_sidebar_links = current_app.extension_sidebar_links(project)
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
template_path,
|
template_path,
|
||||||
@ -121,6 +122,7 @@ def posts_view(project_id=None, project_url=None, url=None, *, archive=False, pa
|
|||||||
node_type_post=project.get_node_type('post'),
|
node_type_post=project.get_node_type('post'),
|
||||||
can_create_blog_posts=can_create_blog_posts,
|
can_create_blog_posts=can_create_blog_posts,
|
||||||
navigation_links=navigation_links,
|
navigation_links=navigation_links,
|
||||||
|
extension_sidebar_links=extension_sidebar_links,
|
||||||
api=api)
|
api=api)
|
||||||
|
|
||||||
|
|
||||||
|
@ -415,9 +415,8 @@ def render_project(project, api, extra_context=None, template_name=None):
|
|||||||
embed_string = ''
|
embed_string = ''
|
||||||
template_name = "projects/view{0}.html".format(embed_string)
|
template_name = "projects/view{0}.html".format(embed_string)
|
||||||
|
|
||||||
extension_sidebar_links = current_app.extension_sidebar_links(project)
|
|
||||||
|
|
||||||
navigation_links = project_navigation_links(project, api)
|
navigation_links = project_navigation_links(project, api)
|
||||||
|
extension_sidebar_links = current_app.extension_sidebar_links(project)
|
||||||
|
|
||||||
return render_template(template_name,
|
return render_template(template_name,
|
||||||
api=api,
|
api=api,
|
||||||
@ -490,12 +489,14 @@ def view_node(project_url, node_id):
|
|||||||
raise wz_exceptions.NotFound('No such project')
|
raise wz_exceptions.NotFound('No such project')
|
||||||
|
|
||||||
navigation_links = []
|
navigation_links = []
|
||||||
|
extension_sidebar_links = ''
|
||||||
og_picture = node.picture = utils.get_file(node.picture, api=api)
|
og_picture = node.picture = utils.get_file(node.picture, api=api)
|
||||||
if project:
|
if project:
|
||||||
if not node.picture:
|
if not node.picture:
|
||||||
og_picture = utils.get_file(project.picture_header, api=api)
|
og_picture = utils.get_file(project.picture_header, api=api)
|
||||||
project.picture_square = utils.get_file(project.picture_square, api=api)
|
project.picture_square = utils.get_file(project.picture_square, api=api)
|
||||||
navigation_links = project_navigation_links(project, api)
|
navigation_links = project_navigation_links(project, api)
|
||||||
|
extension_sidebar_links = current_app.extension_sidebar_links(project)
|
||||||
|
|
||||||
# Append _theatre to load the proper template
|
# Append _theatre to load the proper template
|
||||||
theatre = '_theatre' if theatre_mode else ''
|
theatre = '_theatre' if theatre_mode else ''
|
||||||
@ -506,10 +507,9 @@ def view_node(project_url, node_id):
|
|||||||
node=node,
|
node=node,
|
||||||
project=project,
|
project=project,
|
||||||
navigation_links=navigation_links,
|
navigation_links=navigation_links,
|
||||||
|
extension_sidebar_links=extension_sidebar_links,
|
||||||
og_picture=og_picture,)
|
og_picture=og_picture,)
|
||||||
|
|
||||||
extension_sidebar_links = current_app.extension_sidebar_links(project)
|
|
||||||
|
|
||||||
return render_template('projects/view{}.html'.format(theatre),
|
return render_template('projects/view{}.html'.format(theatre),
|
||||||
api=api,
|
api=api,
|
||||||
project=project,
|
project=project,
|
||||||
@ -518,7 +518,7 @@ def view_node(project_url, node_id):
|
|||||||
show_project=False,
|
show_project=False,
|
||||||
og_picture=og_picture,
|
og_picture=og_picture,
|
||||||
navigation_links=navigation_links,
|
navigation_links=navigation_links,
|
||||||
extension_sidebar_links=extension_sidebar_links)
|
extension_sidebar_links=extension_sidebar_links,)
|
||||||
|
|
||||||
|
|
||||||
def find_project_or_404(project_url, embedded=None, api=None):
|
def find_project_or_404(project_url, embedded=None, api=None):
|
||||||
|
@ -1 +1,3 @@
|
|||||||
export { thenMarkdownToHtml } from './markdown'
|
export { thenMarkdownToHtml } from './markdown'
|
||||||
|
export { thenGetProject } from './projects'
|
||||||
|
export { thenGetNodes } from './nodes'
|
||||||
|
9
src/scripts/js/es6/common/api/nodes.js
Normal file
9
src/scripts/js/es6/common/api/nodes.js
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
function thenGetNodes(where, embedded={}, sort='') {
|
||||||
|
let encodedWhere = encodeURIComponent(JSON.stringify(where));
|
||||||
|
let encodedEmbedded = encodeURIComponent(JSON.stringify(embedded));
|
||||||
|
let encodedSort = encodeURIComponent(sort);
|
||||||
|
|
||||||
|
return $.get(`/api/nodes?where=${encodedWhere}&embedded=${encodedEmbedded}&sort=${encodedSort}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { thenGetNodes }
|
5
src/scripts/js/es6/common/api/projects.js
Normal file
5
src/scripts/js/es6/common/api/projects.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
function thenGetProject(projectId) {
|
||||||
|
return $.get(`/api/projects/${projectId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { thenGetProject }
|
92
src/scripts/js/es6/common/events/Nodes.js
Normal file
92
src/scripts/js/es6/common/events/Nodes.js
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
class EventName {
|
||||||
|
static parentCreated(parentId, node_type) {
|
||||||
|
return `pillar:node:${parentId}:created-${node_type}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static globalCreated(node_type) {
|
||||||
|
return `pillar:node:created-${node_type}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static updated(nodeId) {
|
||||||
|
return `pillar:node:${nodeId}:updated`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static deleted(nodeId) {
|
||||||
|
return `pillar:node:${nodeId}:deleted`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Nodes {
|
||||||
|
static triggerCreated(node) {
|
||||||
|
if (node.parent) {
|
||||||
|
$('body').trigger(
|
||||||
|
EventName.parentCreated(node.parent, node.node_type),
|
||||||
|
node);
|
||||||
|
}
|
||||||
|
$('body').trigger(
|
||||||
|
EventName.globalCreated(node.node_type),
|
||||||
|
node);
|
||||||
|
}
|
||||||
|
|
||||||
|
static onParentCreated(parentId, node_type, cb){
|
||||||
|
$('body').on(
|
||||||
|
EventName.parentCreated(parentId, node_type),
|
||||||
|
cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
static offParentCreated(parentId, node_type, cb){
|
||||||
|
$('body').off(
|
||||||
|
EventName.parentCreated(parentId, node_type),
|
||||||
|
cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
static onCreated(node_type, cb){
|
||||||
|
$('body').on(
|
||||||
|
EventName.globalCreated(node_type),
|
||||||
|
cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
static offCreated(node_type, cb){
|
||||||
|
$('body').off(
|
||||||
|
EventName.globalCreated(node_type),
|
||||||
|
cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
static triggerUpdated(node) {
|
||||||
|
$('body').trigger(
|
||||||
|
EventName.updated(node._id),
|
||||||
|
node);
|
||||||
|
}
|
||||||
|
|
||||||
|
static onUpdated(nodeId, cb) {
|
||||||
|
$('body').on(
|
||||||
|
EventName.updated(nodeId),
|
||||||
|
cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
static offUpdated(nodeId, cb) {
|
||||||
|
$('body').off(
|
||||||
|
EventName.updated(nodeId),
|
||||||
|
cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
static triggerDeleted(nodeId) {
|
||||||
|
$('body').trigger(
|
||||||
|
EventName.deleted(nodeId),
|
||||||
|
nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
static onDeleted(nodeId, cb) {
|
||||||
|
$('body').on(
|
||||||
|
EventName.deleted(nodeId),
|
||||||
|
cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
static offDeleted(nodeId, cb) {
|
||||||
|
$('body').off(
|
||||||
|
EventName.deleted(nodeId),
|
||||||
|
cb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Nodes }
|
1
src/scripts/js/es6/common/events/init.js
Normal file
1
src/scripts/js/es6/common/events/init.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
export {Nodes} from './Nodes'
|
@ -1,5 +1,4 @@
|
|||||||
import { prettyDate } from '../../utils/prettydate';
|
import { prettyDate } from '../../utils/prettydate';
|
||||||
import { thenLoadImage } from '../utils';
|
|
||||||
import { ComponentCreatorInterface } from '../component/ComponentCreatorInterface'
|
import { ComponentCreatorInterface } from '../component/ComponentCreatorInterface'
|
||||||
|
|
||||||
export class NodesBase extends ComponentCreatorInterface {
|
export class NodesBase extends ComponentCreatorInterface {
|
||||||
@ -20,7 +19,7 @@ export class NodesBase extends ComponentCreatorInterface {
|
|||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
$(window).trigger('pillar:workStart');
|
$(window).trigger('pillar:workStart');
|
||||||
thenLoadImage(node.picture)
|
pillar.utils.thenLoadImage(node.picture)
|
||||||
.fail(warnNoPicture)
|
.fail(warnNoPicture)
|
||||||
.then((imgVariation) => {
|
.then((imgVariation) => {
|
||||||
let img = $('<img class="card-img-top">')
|
let img = $('<img class="card-img-top">')
|
||||||
|
@ -1,24 +1,5 @@
|
|||||||
function thenLoadImage(imgId, size = 'm') {
|
|
||||||
return $.get('/api/files/' + imgId)
|
|
||||||
.then((resp)=> {
|
|
||||||
var show_variation = null;
|
|
||||||
if (typeof resp.variations != 'undefined') {
|
|
||||||
for (var variation of resp.variations) {
|
|
||||||
if (variation.size != size) continue;
|
|
||||||
show_variation = variation;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (show_variation == null) {
|
|
||||||
throw 'Image not found: ' + imgId + ' size: ' + size;
|
|
||||||
}
|
|
||||||
return show_variation;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function thenLoadVideoProgress(nodeId) {
|
function thenLoadVideoProgress(nodeId) {
|
||||||
return $.get('/api/users/video/' + nodeId + '/progress')
|
return $.get('/api/users/video/' + nodeId + '/progress')
|
||||||
}
|
}
|
||||||
|
|
||||||
export { thenLoadImage, thenLoadVideoProgress };
|
export { thenLoadVideoProgress };
|
||||||
|
20
src/scripts/js/es6/common/utils/files.js
Normal file
20
src/scripts/js/es6/common/utils/files.js
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
function thenLoadImage(imgId, size = 'm') {
|
||||||
|
return $.get('/api/files/' + imgId)
|
||||||
|
.then((resp)=> {
|
||||||
|
var show_variation = null;
|
||||||
|
if (typeof resp.variations != 'undefined') {
|
||||||
|
for (var variation of resp.variations) {
|
||||||
|
if (variation.size != size) continue;
|
||||||
|
show_variation = variation;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (show_variation == null) {
|
||||||
|
throw 'Image not found: ' + imgId + ' size: ' + size;
|
||||||
|
}
|
||||||
|
return show_variation;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export { thenLoadImage }
|
@ -1,6 +1,7 @@
|
|||||||
export { transformPlaceholder } from './placeholder'
|
export { transformPlaceholder } from './placeholder'
|
||||||
export { prettyDate } from './prettydate'
|
export { prettyDate } from './prettydate'
|
||||||
export { getCurrentUser, initCurrentUser } from './currentuser'
|
export { getCurrentUser, initCurrentUser } from './currentuser'
|
||||||
|
export { thenLoadImage } from './files'
|
||||||
|
|
||||||
|
|
||||||
export function debounced(fn, delay=1000) {
|
export function debounced(fn, delay=1000) {
|
||||||
|
@ -29,7 +29,7 @@ export function prettyDate(time, detail=false) {
|
|||||||
else
|
else
|
||||||
pretty = "in " + week_count +" weeks";
|
pretty = "in " + week_count +" weeks";
|
||||||
}
|
}
|
||||||
else if (day_diff < -1)
|
else if (day_diff < 0)
|
||||||
// "next Tuesday"
|
// "next Tuesday"
|
||||||
pretty = 'next ' + theDate.toLocaleDateString('en-NL',{weekday: 'long'});
|
pretty = 'next ' + theDate.toLocaleDateString('en-NL',{weekday: 'long'});
|
||||||
else if (day_diff === 0) {
|
else if (day_diff === 0) {
|
||||||
|
@ -3,7 +3,7 @@ import { UnitOfWorkTracker } from '../mixins/UnitOfWorkTracker'
|
|||||||
import { thenVoteComment } from '../../api/comments'
|
import { thenVoteComment } from '../../api/comments'
|
||||||
const TEMPLATE = `
|
const TEMPLATE = `
|
||||||
<div class="comment-rating"
|
<div class="comment-rating"
|
||||||
:class="{rated: hasRating, positive: isPositive }"
|
:class="{rated: currentUserHasRated, positive: currentUserRatedPositive }"
|
||||||
>
|
>
|
||||||
<div class="comment-rating-value" title="Number of likes">{{ rating }}</div>
|
<div class="comment-rating-value" title="Number of likes">{{ rating }}</div>
|
||||||
<div class="comment-action-rating up" title="Like comment"
|
<div class="comment-action-rating up" title="Like comment"
|
||||||
@ -27,11 +27,11 @@ Vue.component('comment-rating', {
|
|||||||
rating() {
|
rating() {
|
||||||
return this.positiveRating - this.negativeRating;
|
return this.positiveRating - this.negativeRating;
|
||||||
},
|
},
|
||||||
isPositive() {
|
currentUserRatedPositive() {
|
||||||
return this.rating > 0;
|
return this.comment.current_user_rating === true;
|
||||||
},
|
},
|
||||||
hasRating() {
|
currentUserHasRated() {
|
||||||
return (this.positiveRating || this.negativeRating) !== 0;
|
return typeof this.comment.current_user_rating === "boolean" ;
|
||||||
},
|
},
|
||||||
canVote() {
|
canVote() {
|
||||||
return this.comment.user.id !== pillar.utils.getCurrentUser().user_id;
|
return this.comment.user.id !== pillar.utils.getCurrentUser().user_id;
|
||||||
|
@ -0,0 +1,17 @@
|
|||||||
|
// Code from https://stackoverflow.com/a/42389266
|
||||||
|
|
||||||
|
Vue.directive('click-outside', {
|
||||||
|
bind: function (el, binding, vnode) {
|
||||||
|
el.clickOutsideEvent = function (event) {
|
||||||
|
// here I check that click was outside the el and his childrens
|
||||||
|
if (!(el == event.target || el.contains(event.target))) {
|
||||||
|
// and if it did, call method provided in attribute value
|
||||||
|
vnode.context[binding.expression](event);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.body.addEventListener('click', el.clickOutsideEvent)
|
||||||
|
},
|
||||||
|
unbind: function (el) {
|
||||||
|
document.body.removeEventListener('click', el.clickOutsideEvent)
|
||||||
|
},
|
||||||
|
});
|
@ -1 +1,38 @@
|
|||||||
import './comments/CommentTree'
|
import './comments/CommentTree'
|
||||||
|
import './customdirectives/click-outside'
|
||||||
|
import { UnitOfWorkTracker } from './mixins/UnitOfWorkTracker'
|
||||||
|
import { PillarTable } from './table/Table'
|
||||||
|
import { CellPrettyDate } from './table/cells/renderer/CellPrettyDate'
|
||||||
|
import { CellDefault } from './table/cells/renderer/CellDefault'
|
||||||
|
import { ColumnBase } from './table/columns/ColumnBase'
|
||||||
|
import { ColumnFactoryBase } from './table/columns/ColumnFactoryBase'
|
||||||
|
import { RowObjectsSourceBase } from './table/rows/RowObjectsSourceBase'
|
||||||
|
import { RowBase } from './table/rows/RowObjectBase'
|
||||||
|
import { RowFilter } from './table/filter/RowFilter'
|
||||||
|
|
||||||
|
let mixins = {
|
||||||
|
UnitOfWorkTracker
|
||||||
|
}
|
||||||
|
|
||||||
|
let table = {
|
||||||
|
PillarTable,
|
||||||
|
columns: {
|
||||||
|
ColumnBase,
|
||||||
|
ColumnFactoryBase,
|
||||||
|
},
|
||||||
|
cells: {
|
||||||
|
renderer: {
|
||||||
|
CellDefault,
|
||||||
|
CellPrettyDate
|
||||||
|
}
|
||||||
|
},
|
||||||
|
rows: {
|
||||||
|
RowObjectsSourceBase,
|
||||||
|
RowBase
|
||||||
|
},
|
||||||
|
filter: {
|
||||||
|
RowFilter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { mixins, table }
|
||||||
|
42
src/scripts/js/es6/common/vuecomponents/menu/DropDown.js
Normal file
42
src/scripts/js/es6/common/vuecomponents/menu/DropDown.js
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
const TEMPLATE =`
|
||||||
|
<div class="pillar-dropdown">
|
||||||
|
<div class="pillar-dropdown-button"
|
||||||
|
:class="buttonClasses"
|
||||||
|
@click="toggleShowMenu"
|
||||||
|
>
|
||||||
|
<slot name="button"/>
|
||||||
|
</div>
|
||||||
|
<div class="pillar-dropdown-menu"
|
||||||
|
v-show="showMenu"
|
||||||
|
v-click-outside="closeMenu"
|
||||||
|
>
|
||||||
|
<slot name="menu"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
let DropDown = Vue.component('pillar-dropdown', {
|
||||||
|
template: TEMPLATE,
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
showMenu: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
buttonClasses() {
|
||||||
|
return {'is-open': this.showMenu};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
toggleShowMenu(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
this.showMenu = !this.showMenu;
|
||||||
|
},
|
||||||
|
closeMenu(event) {
|
||||||
|
this.showMenu = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export { DropDown }
|
@ -42,7 +42,15 @@ var UnitOfWorkTracker = {
|
|||||||
methods: {
|
methods: {
|
||||||
unitOfWork(promise) {
|
unitOfWork(promise) {
|
||||||
this.unitOfWorkBegin();
|
this.unitOfWorkBegin();
|
||||||
|
if (promise.always) {
|
||||||
|
// jQuery Promise
|
||||||
return promise.always(this.unitOfWorkDone);
|
return promise.always(this.unitOfWorkDone);
|
||||||
|
}
|
||||||
|
if (promise.finally) {
|
||||||
|
// Native js Promise
|
||||||
|
return promise.finally(this.unitOfWorkDone);
|
||||||
|
}
|
||||||
|
throw Error('Unsupported promise type');
|
||||||
},
|
},
|
||||||
unitOfWorkBegin() {
|
unitOfWorkBegin() {
|
||||||
this.unitOfWorkCounter++;
|
this.unitOfWorkCounter++;
|
||||||
|
89
src/scripts/js/es6/common/vuecomponents/table/Table.js
Normal file
89
src/scripts/js/es6/common/vuecomponents/table/Table.js
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import './rows/renderer/Head'
|
||||||
|
import './rows/renderer/Row'
|
||||||
|
import './filter/ColumnFilter'
|
||||||
|
import './filter/RowFilter'
|
||||||
|
import {UnitOfWorkTracker} from '../mixins/UnitOfWorkTracker'
|
||||||
|
|
||||||
|
const TEMPLATE =`
|
||||||
|
<div class="pillar-table-container"
|
||||||
|
:class="$options.name"
|
||||||
|
>
|
||||||
|
<div class="pillar-table-menu">
|
||||||
|
<pillar-table-row-filter
|
||||||
|
:rowObjects="rowObjects"
|
||||||
|
@visibleRowObjectsChanged="onVisibleRowObjectsChanged"
|
||||||
|
/>
|
||||||
|
<pillar-table-actions/>
|
||||||
|
<pillar-table-column-filter
|
||||||
|
:columns="columns"
|
||||||
|
@visibleColumnsChanged="onVisibleColumnsChanged"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="pillar-table">
|
||||||
|
<pillar-table-head
|
||||||
|
:columns="visibleColumns"
|
||||||
|
@sort="onSort"
|
||||||
|
/>
|
||||||
|
<transition-group name="pillar-table-row" tag="div" class="pillar-table-row-group">
|
||||||
|
<pillar-table-row
|
||||||
|
v-for="rowObject in visibleRowObjects"
|
||||||
|
:columns="visibleColumns"
|
||||||
|
:rowObject="rowObject"
|
||||||
|
:key="rowObject.getId()"
|
||||||
|
/>
|
||||||
|
</transition-group>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
let PillarTable = Vue.component('pillar-table-base', {
|
||||||
|
template: TEMPLATE,
|
||||||
|
mixins: [UnitOfWorkTracker],
|
||||||
|
// columnFactory,
|
||||||
|
// rowsSource,
|
||||||
|
props: {
|
||||||
|
projectId: String
|
||||||
|
},
|
||||||
|
data: function() {
|
||||||
|
return {
|
||||||
|
columns: [],
|
||||||
|
visibleColumns: [],
|
||||||
|
visibleRowObjects: [],
|
||||||
|
rowsSource: {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
rowObjects() {
|
||||||
|
return this.rowsSource.rowObjects || [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
let columnFactory = new this.$options.columnFactory(this.projectId);
|
||||||
|
this.rowsSource = new this.$options.rowsSource(this.projectId);
|
||||||
|
this.unitOfWork(
|
||||||
|
Promise.all([
|
||||||
|
columnFactory.thenGetColumns(),
|
||||||
|
this.rowsSource.thenInit()
|
||||||
|
])
|
||||||
|
.then((resp) => {
|
||||||
|
this.columns = resp[0];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
onVisibleColumnsChanged(visibleColumns) {
|
||||||
|
this.visibleColumns = visibleColumns;
|
||||||
|
},
|
||||||
|
onVisibleRowObjectsChanged(visibleRowObjects) {
|
||||||
|
this.visibleRowObjects = visibleRowObjects;
|
||||||
|
},
|
||||||
|
onSort(column, direction) {
|
||||||
|
function compareRows(r1, r2) {
|
||||||
|
return column.compareRows(r1, r2) * direction;
|
||||||
|
}
|
||||||
|
this.rowObjects.sort(compareRows);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export { PillarTable }
|
@ -0,0 +1,21 @@
|
|||||||
|
const TEMPLATE =`
|
||||||
|
<div>
|
||||||
|
{{ cellValue }}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
let CellDefault = Vue.component('pillar-cell-default', {
|
||||||
|
template: TEMPLATE,
|
||||||
|
props: {
|
||||||
|
column: Object,
|
||||||
|
rowObject: Object,
|
||||||
|
rawCellValue: Object
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
cellValue() {
|
||||||
|
return this.rawCellValue;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export { CellDefault }
|
@ -0,0 +1,12 @@
|
|||||||
|
import { CellDefault } from './CellDefault'
|
||||||
|
|
||||||
|
let CellPrettyDate = Vue.component('pillar-cell-pretty-date', {
|
||||||
|
extends: CellDefault,
|
||||||
|
computed: {
|
||||||
|
cellValue() {
|
||||||
|
return pillar.utils.prettyDate(this.rawCellValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export { CellPrettyDate }
|
@ -0,0 +1,34 @@
|
|||||||
|
const TEMPLATE =`
|
||||||
|
<component class="pillar-cell"
|
||||||
|
:class="cellClasses"
|
||||||
|
:title="cellTitle"
|
||||||
|
:is="cellRenderer"
|
||||||
|
:rowObject="rowObject"
|
||||||
|
:column="column"
|
||||||
|
:rawCellValue="rawCellValue"
|
||||||
|
/>
|
||||||
|
`;
|
||||||
|
|
||||||
|
let CellProxy = Vue.component('pillar-cell-proxy', {
|
||||||
|
template: TEMPLATE,
|
||||||
|
props: {
|
||||||
|
column: Object,
|
||||||
|
rowObject: Object
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
rawCellValue() {
|
||||||
|
return this.column.getRawCellValue(this.rowObject) || '';
|
||||||
|
},
|
||||||
|
cellRenderer() {
|
||||||
|
return this.column.getCellRenderer(this.rowObject);
|
||||||
|
},
|
||||||
|
cellClasses() {
|
||||||
|
return this.column.getCellClasses(this.rawCellValue, this.rowObject);
|
||||||
|
},
|
||||||
|
cellTitle() {
|
||||||
|
return this.column.getCellTitle(this.rawCellValue, this.rowObject);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export { CellProxy }
|
@ -0,0 +1,43 @@
|
|||||||
|
const TEMPLATE =`
|
||||||
|
<div class="pillar-cell header-cell"
|
||||||
|
:class="cellClasses"
|
||||||
|
@mouseenter="onMouseEnter"
|
||||||
|
@mouseleave="onMouseLeave"
|
||||||
|
>
|
||||||
|
<div class="cell-content">
|
||||||
|
{{ column.displayName }}
|
||||||
|
<div class="column-sort"
|
||||||
|
v-if="column.isSortable"
|
||||||
|
>
|
||||||
|
<i class="sort-action pi-angle-up"
|
||||||
|
title="Sort Ascending"
|
||||||
|
@click="$emit('sort', column, 1)"
|
||||||
|
/>
|
||||||
|
<i class="sort-action pi-angle-down"
|
||||||
|
title="Sort Descending"
|
||||||
|
@click="$emit('sort', column, -1)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
Vue.component('pillar-head-cell', {
|
||||||
|
template: TEMPLATE,
|
||||||
|
props: {
|
||||||
|
column: Object
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
cellClasses() {
|
||||||
|
return this.column.getHeaderCellClasses();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
onMouseEnter() {
|
||||||
|
this.column.highlightColumn(true);
|
||||||
|
},
|
||||||
|
onMouseLeave() {
|
||||||
|
this.column.highlightColumn(false);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
@ -0,0 +1,85 @@
|
|||||||
|
import { CellDefault } from '../cells/renderer/CellDefault'
|
||||||
|
|
||||||
|
let nextColumnId = 0;
|
||||||
|
export class ColumnBase {
|
||||||
|
constructor(displayName, columnType) {
|
||||||
|
this._id = nextColumnId++;
|
||||||
|
this.displayName = displayName;
|
||||||
|
this.columnType = columnType;
|
||||||
|
this.isMandatory = false;
|
||||||
|
this.isSortable = true;
|
||||||
|
this.isHighLighted = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {*} rowObject
|
||||||
|
* @returns {String} Name of the Cell renderer component
|
||||||
|
*/
|
||||||
|
getCellRenderer(rowObject) {
|
||||||
|
return CellDefault.options.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
getRawCellValue(rowObject) {
|
||||||
|
// Should be overridden
|
||||||
|
throw Error('Not implemented');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cell tooltip
|
||||||
|
* @param {Any} rawCellValue
|
||||||
|
* @param {RowObject} rowObject
|
||||||
|
* @returns {String}
|
||||||
|
*/
|
||||||
|
getCellTitle(rawCellValue, rowObject) {
|
||||||
|
// Should be overridden
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Object with css classes to use on the header cell
|
||||||
|
* @returns {Any} Object with css classes
|
||||||
|
*/
|
||||||
|
getHeaderCellClasses() {
|
||||||
|
// Should be overridden
|
||||||
|
let classes = {}
|
||||||
|
classes[this.columnType] = true;
|
||||||
|
return classes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Object with css classes to use on the cell
|
||||||
|
* @param {*} rawCellValue
|
||||||
|
* @param {*} rowObject
|
||||||
|
* @returns {Any} Object with css classes
|
||||||
|
*/
|
||||||
|
getCellClasses(rawCellValue, rowObject) {
|
||||||
|
// Should be overridden
|
||||||
|
let classes = {}
|
||||||
|
classes[this.columnType] = true;
|
||||||
|
classes['highlight'] = !!this.isHighLighted;
|
||||||
|
return classes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare two rows to sort them. Can be overridden for more complex situations.
|
||||||
|
*
|
||||||
|
* @param {RowObject} rowObject1
|
||||||
|
* @param {RowObject} rowObject2
|
||||||
|
* @returns {Number} -1, 0, 1
|
||||||
|
*/
|
||||||
|
compareRows(rowObject1, rowObject2) {
|
||||||
|
let rawCellValue1 = this.getRawCellValue(rowObject1);
|
||||||
|
let rawCellValue2 = this.getRawCellValue(rowObject2);
|
||||||
|
if (rawCellValue1 === rawCellValue2) return 0;
|
||||||
|
return rawCellValue1 < rawCellValue2 ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {Boolean}
|
||||||
|
*/
|
||||||
|
highlightColumn(value) {
|
||||||
|
this.isHighLighted += !!value ? 1 : -1;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,21 @@
|
|||||||
|
class ColumnFactoryBase{
|
||||||
|
constructor(projectId) {
|
||||||
|
this.projectId = projectId;
|
||||||
|
this.projectPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override this
|
||||||
|
thenGetColumns() {
|
||||||
|
throw Error('Not implemented')
|
||||||
|
}
|
||||||
|
|
||||||
|
thenGetProject() {
|
||||||
|
if (this.projectPromise) {
|
||||||
|
return this.projectPromise;
|
||||||
|
}
|
||||||
|
this.projectPromise = pillar.api.thenGetProject(this.projectId);
|
||||||
|
return this.projectPromise;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ColumnFactoryBase }
|
@ -0,0 +1,10 @@
|
|||||||
|
const TEMPLATE =`
|
||||||
|
<div class="pillar-table-column"/>
|
||||||
|
`;
|
||||||
|
|
||||||
|
Vue.component('pillar-table-column', {
|
||||||
|
template: TEMPLATE,
|
||||||
|
props: {
|
||||||
|
column: Object
|
||||||
|
},
|
||||||
|
});
|
@ -0,0 +1,80 @@
|
|||||||
|
import '../../menu/DropDown'
|
||||||
|
|
||||||
|
const TEMPLATE =`
|
||||||
|
<div class="pillar-table-column-filter">
|
||||||
|
<pillar-dropdown>
|
||||||
|
<i class="pi-cog"
|
||||||
|
slot="button"
|
||||||
|
title="Table Settings"/>
|
||||||
|
|
||||||
|
<ul class="settings-menu"
|
||||||
|
slot="menu"
|
||||||
|
>
|
||||||
|
Columns:
|
||||||
|
<li class="attract-column-select"
|
||||||
|
v-for="c in columnStates"
|
||||||
|
:key="c._id"
|
||||||
|
>
|
||||||
|
<input type="checkbox"
|
||||||
|
v-model="c.isVisible"
|
||||||
|
/>
|
||||||
|
{{ c.displayName }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</pillar-dropdown>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
let Filter = Vue.component('pillar-table-column-filter', {
|
||||||
|
template: TEMPLATE,
|
||||||
|
props: {
|
||||||
|
columns: Array,
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
columnStates: [],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
visibleColumns() {
|
||||||
|
return this.columns.filter((candidate) => {
|
||||||
|
return candidate.isMandatory || this.isColumnStateVisible(candidate);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
columns() {
|
||||||
|
this.columnStates = this.setColumnStates();
|
||||||
|
},
|
||||||
|
visibleColumns(visibleColumns) {
|
||||||
|
this.$emit('visibleColumnsChanged', visibleColumns);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.$emit('visibleColumnsChanged', this.visibleColumns);
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
setColumnStates() {
|
||||||
|
return this.columns.reduce((states, c) => {
|
||||||
|
if (!c.isMandatory) {
|
||||||
|
states.push({
|
||||||
|
_id: c._id,
|
||||||
|
displayName: c.displayName,
|
||||||
|
isVisible: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return states;
|
||||||
|
}, [])
|
||||||
|
},
|
||||||
|
isColumnStateVisible(column) {
|
||||||
|
for (let state of this.columnStates) {
|
||||||
|
if (state._id === column._id) {
|
||||||
|
return state.isVisible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export { Filter }
|
@ -0,0 +1,45 @@
|
|||||||
|
const TEMPLATE =`
|
||||||
|
<div class="pillar-table-row-filter">
|
||||||
|
<input
|
||||||
|
placeholder="Filter by name"
|
||||||
|
v-model="nameQuery"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
let RowFilter = Vue.component('pillar-table-row-filter', {
|
||||||
|
template: TEMPLATE,
|
||||||
|
props: {
|
||||||
|
rowObjects: Array
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
nameQuery: '',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
nameQueryLoweCase() {
|
||||||
|
return this.nameQuery.toLowerCase();
|
||||||
|
},
|
||||||
|
visibleRowObjects() {
|
||||||
|
return this.rowObjects.filter((row) => {
|
||||||
|
return this.filterByName(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
visibleRowObjects(visibleRowObjects) {
|
||||||
|
this.$emit('visibleRowObjectsChanged', visibleRowObjects);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.$emit('visibleRowObjectsChanged', this.visibleRowObjects);
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
filterByName(rowObject) {
|
||||||
|
return rowObject.getName().toLowerCase().indexOf(this.nameQueryLoweCase) !== -1;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export { RowFilter }
|
@ -0,0 +1,31 @@
|
|||||||
|
class RowBase {
|
||||||
|
constructor(underlyingObject) {
|
||||||
|
this.underlyingObject = underlyingObject;
|
||||||
|
this.isInitialized = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
thenInit() {
|
||||||
|
this.isInitialized = true
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
getName() {
|
||||||
|
return this.underlyingObject.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
getId() {
|
||||||
|
return this.underlyingObject._id;
|
||||||
|
}
|
||||||
|
|
||||||
|
getProperties() {
|
||||||
|
return this.underlyingObject.properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
getRowClasses() {
|
||||||
|
return {
|
||||||
|
"is-busy": !this.isInitialized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { RowBase }
|
@ -0,0 +1,13 @@
|
|||||||
|
class RowObjectsSourceBase {
|
||||||
|
constructor(projectId) {
|
||||||
|
this.projectId = projectId;
|
||||||
|
this.rowObjects = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override this
|
||||||
|
thenInit() {
|
||||||
|
throw Error('Not implemented');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { RowObjectsSourceBase }
|
@ -0,0 +1,18 @@
|
|||||||
|
import '../../cells/renderer/HeadCell'
|
||||||
|
const TEMPLATE =`
|
||||||
|
<div class="pillar-table-head">
|
||||||
|
<pillar-head-cell
|
||||||
|
v-for="c in columns"
|
||||||
|
:column="c"
|
||||||
|
key="c._id"
|
||||||
|
@sort="(column, direction) => $emit('sort', column, direction)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
Vue.component('pillar-table-head', {
|
||||||
|
template: TEMPLATE,
|
||||||
|
props: {
|
||||||
|
columns: Array
|
||||||
|
}
|
||||||
|
});
|
@ -0,0 +1,27 @@
|
|||||||
|
import '../../cells/renderer/CellProxy'
|
||||||
|
|
||||||
|
const TEMPLATE =`
|
||||||
|
<div class="pillar-table-row"
|
||||||
|
:class="rowClasses"
|
||||||
|
>
|
||||||
|
<pillar-cell-proxy
|
||||||
|
v-for="c in columns"
|
||||||
|
:rowObject="rowObject"
|
||||||
|
:column="c"
|
||||||
|
:key="c._id"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
Vue.component('pillar-table-row', {
|
||||||
|
template: TEMPLATE,
|
||||||
|
props: {
|
||||||
|
rowObject: Object,
|
||||||
|
columns: Array
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
rowClasses() {
|
||||||
|
return this.rowObject.getRowClasses();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
@ -11,6 +11,9 @@ body
|
|||||||
max-width: 100%
|
max-width: 100%
|
||||||
min-width: auto
|
min-width: auto
|
||||||
|
|
||||||
|
.page-body
|
||||||
|
height: 100%
|
||||||
|
|
||||||
body.has-overlay
|
body.has-overlay
|
||||||
overflow: hidden
|
overflow: hidden
|
||||||
padding-right: 5px
|
padding-right: 5px
|
||||||
@ -24,6 +27,7 @@ body.has-overlay
|
|||||||
|
|
||||||
.page-content
|
.page-content
|
||||||
background-color: $white
|
background-color: $white
|
||||||
|
height: 100%
|
||||||
|
|
||||||
.container-box
|
.container-box
|
||||||
+container-box
|
+container-box
|
||||||
|
@ -306,3 +306,35 @@ body.has-overlay
|
|||||||
visibility: hidden
|
visibility: hidden
|
||||||
display: none !important
|
display: none !important
|
||||||
|
|
||||||
|
.navbar
|
||||||
|
.pi-blender-cloud-logo
|
||||||
|
/* Ugly hack to make the logo line up with the other links */
|
||||||
|
width: 5.5em
|
||||||
|
font-size: 1.6em
|
||||||
|
margin-top: calc((-1.6em + 1em) / 2) // use same vertical space as font-size 1em
|
||||||
|
margin-bottom: calc((-1.6em + 1em) / 2) //
|
||||||
|
|
||||||
|
.navbar-toggler
|
||||||
|
border: none
|
||||||
|
|
||||||
|
// Mobile layout
|
||||||
|
@include media-breakpoint-down(sm)
|
||||||
|
.navbar
|
||||||
|
.navbar-collapse
|
||||||
|
position: absolute
|
||||||
|
background-color: $body-bg
|
||||||
|
top: 100%
|
||||||
|
flex-direction: column
|
||||||
|
align-items: start
|
||||||
|
transition: unset
|
||||||
|
box-shadow: 1px 1px 0 rgba(black, .1), 0 5px 50px rgba(black, .25)
|
||||||
|
|
||||||
|
.dropdown-menu
|
||||||
|
top: 0
|
||||||
|
left: 100%
|
||||||
|
|
||||||
|
.project
|
||||||
|
.navbar
|
||||||
|
.navbar-collapse
|
||||||
|
.nav-link
|
||||||
|
color: $color-text !important
|
||||||
|
@ -23,15 +23,18 @@
|
|||||||
#qs-toggle
|
#qs-toggle
|
||||||
opacity: 1
|
opacity: 1
|
||||||
visibility: visible
|
visibility: visible
|
||||||
|
display: block
|
||||||
|
|
||||||
.quick-search
|
.quick-search
|
||||||
opacity: 0
|
opacity: 0
|
||||||
transition: opacity $short-transition
|
transition: opacity $short-transition
|
||||||
visibility: hidden
|
visibility: hidden
|
||||||
|
display: none
|
||||||
|
|
||||||
.quick-search.show
|
&.show
|
||||||
opacity: 1
|
opacity: 1
|
||||||
visibility: visible
|
visibility: visible
|
||||||
|
display: block
|
||||||
|
|
||||||
.qs-input
|
.qs-input
|
||||||
&.show input
|
&.show input
|
||||||
|
@ -13,6 +13,12 @@ li.dropdown
|
|||||||
| {% if not current_user.has_role('protected') %}
|
| {% if not current_user.has_role('protected') %}
|
||||||
| {% block menu_list %}
|
| {% block menu_list %}
|
||||||
|
|
||||||
|
li
|
||||||
|
a.navbar-item.px-2(
|
||||||
|
href="{{ url_for('projects.home_project') }}"
|
||||||
|
title="My cloud")
|
||||||
|
| #[i.pi-home] My cloud
|
||||||
|
|
||||||
li
|
li
|
||||||
a.navbar-item.px-2(
|
a.navbar-item.px-2(
|
||||||
href="{{ url_for('settings.profile') }}"
|
href="{{ url_for('settings.profile') }}"
|
||||||
|
@ -4,13 +4,20 @@
|
|||||||
|
|
||||||
| {% set title = 'blog' %}
|
| {% set title = 'blog' %}
|
||||||
|
|
||||||
|
| {% block css %}
|
||||||
|
| {{ super() }}
|
||||||
|
| {% if project.url != 'blender-cloud' %}
|
||||||
|
link(href="{{ url_for('static_cloud', filename='assets/css/project-main.css') }}", rel="stylesheet")
|
||||||
|
| {% endif %}
|
||||||
|
| {% endblock %}
|
||||||
|
|
||||||
| {% block page_title %}Blog{% endblock%}
|
| {% block page_title %}Blog{% endblock%}
|
||||||
|
|
||||||
| {% block navigation_tabs %}
|
| {% block navigation_tabs %}
|
||||||
| {% if project.url == 'blender-cloud' %}
|
| {% if project.url == 'blender-cloud' %}
|
||||||
| {{ navigation_homepage(title) }}
|
| {{ navigation_homepage(title) }}
|
||||||
| {% else %}
|
| {% else %}
|
||||||
| {{ navigation_project(project, navigation_links, title) }}
|
| {{ navigation_project(project, navigation_links, extension_sidebar_links, title) }}
|
||||||
| {% endif %}
|
| {% endif %}
|
||||||
| {% endblock navigation_tabs %}
|
| {% endblock navigation_tabs %}
|
||||||
|
|
||||||
|
@ -11,7 +11,7 @@ include ../mixins/components
|
|||||||
|
|
||||||
| {% block navigation_tabs %}
|
| {% block navigation_tabs %}
|
||||||
| {% if project %}
|
| {% if project %}
|
||||||
| {{ navigation_project(project, navigation_links, title) }}
|
| {{ navigation_project(project, navigation_links, extension_sidebar_links, title) }}
|
||||||
| {% else %}
|
| {% else %}
|
||||||
| {{ navigation_homepage(title) }}
|
| {{ navigation_homepage(title) }}
|
||||||
| {% endif %}
|
| {% endif %}
|
||||||
@ -50,23 +50,6 @@ script.
|
|||||||
| {% endif %}
|
| {% endif %}
|
||||||
|
|
||||||
#search-container.d-flex(class="{% if project %}search-project{% endif %}")
|
#search-container.d-flex(class="{% if project %}search-project{% endif %}")
|
||||||
| {% if project %}
|
|
||||||
#project_sidebar.bg-white
|
|
||||||
ul.project-tabs.p-0
|
|
||||||
li.tabs-browse(
|
|
||||||
title="Browse",
|
|
||||||
data-toggle="tooltip",
|
|
||||||
data-placement="right")
|
|
||||||
a(href="{{url_for('projects.view', project_url=project.url, _external=True)}}")
|
|
||||||
i.pi-folder
|
|
||||||
|
|
||||||
li.tabs-search.active(
|
|
||||||
title="Search",
|
|
||||||
data-toggle="tooltip",
|
|
||||||
data-placement="right")
|
|
||||||
a(href="{{url_for('projects.search', project_url=project.url, _external=True)}}")
|
|
||||||
i.pi-search
|
|
||||||
| {% endif %}
|
|
||||||
|
|
||||||
.search-settings#search-sidebar.bg-light
|
.search-settings#search-sidebar.bg-light
|
||||||
input.search-field.p-2.bg-white(
|
input.search-field.p-2.bg-white(
|
||||||
|
@ -6,30 +6,12 @@ include ../mixins/components
|
|||||||
| {% block page_title %}Edit {{ project.name }}{% endblock %}
|
| {% block page_title %}Edit {{ project.name }}{% endblock %}
|
||||||
|
|
||||||
| {% block navigation_tabs %}
|
| {% block navigation_tabs %}
|
||||||
| {{ navigation_project(project, navigation_links, title) }}
|
| {{ navigation_project(project, navigation_links, extension_sidebar_links, title) }}
|
||||||
| {% endblock navigation_tabs %}
|
| {% endblock navigation_tabs %}
|
||||||
|
|
||||||
| {% block body %}
|
| {% block body %}
|
||||||
#project-container
|
#project-container
|
||||||
#project-side-container
|
#project-side-container
|
||||||
#project_sidebar
|
|
||||||
ul.project-tabs.p-0
|
|
||||||
li.tabs-browse(
|
|
||||||
title="Browse",
|
|
||||||
data-toggle="tooltip",
|
|
||||||
data-placement="left")
|
|
||||||
a(href="{{url_for('projects.view', project_url=project.url, _external=True)}}")
|
|
||||||
i.pi-folder
|
|
||||||
|
|
||||||
| {% if project.has_method('PUT') %}
|
|
||||||
li.active(
|
|
||||||
title="Edit Project",
|
|
||||||
data-toggle="tooltip",
|
|
||||||
data-placement="right")
|
|
||||||
a(href="{{ url_for('projects.edit', project_url=project.url) }}")
|
|
||||||
i.pi-cog
|
|
||||||
| {% endif %}
|
|
||||||
|
|
||||||
|
|
||||||
#project_nav
|
#project_nav
|
||||||
#project_nav-container
|
#project_nav-container
|
||||||
|
@ -4,6 +4,8 @@ This'll help us upgrade to new versions of Cerberus.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
|
from pillar.api.node_types.utils import markdown_fields
|
||||||
from pillar.tests import AbstractPillarTest
|
from pillar.tests import AbstractPillarTest
|
||||||
|
|
||||||
from bson import ObjectId
|
from bson import ObjectId
|
||||||
@ -219,10 +221,9 @@ class MarkdownValidatorTest(AbstractSchemaValidationTest):
|
|||||||
'schema': {
|
'schema': {
|
||||||
'type': 'dict',
|
'type': 'dict',
|
||||||
'schema': {
|
'schema': {
|
||||||
'content': {'type': 'string', 'required': True, 'validator': 'markdown'},
|
**markdown_fields('content', required=True),
|
||||||
'_content_html': {'type': 'string'},
|
**markdown_fields('descr', required=True),
|
||||||
'descr': {'type': 'string', 'required': True, 'validator': 'markdown'},
|
'my_default_value': {'type': 'string', 'default': 'my default value'},
|
||||||
'_descr_html': {'type': 'string'},
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
@ -239,6 +240,7 @@ class MarkdownValidatorTest(AbstractSchemaValidationTest):
|
|||||||
'_content_html': '<h1>Header</h1>\n<p>Some text</p>\n',
|
'_content_html': '<h1>Header</h1>\n<p>Some text</p>\n',
|
||||||
'descr': 'je moeder',
|
'descr': 'je moeder',
|
||||||
'_descr_html': '<p>je moeder</p>\n',
|
'_descr_html': '<p>je moeder</p>\n',
|
||||||
|
'my_default_value': 'my default value'
|
||||||
}]}
|
}]}
|
||||||
|
|
||||||
self.assertEqual(expect, doc)
|
self.assertEqual(expect, self.validator.document)
|
||||||
|
193
tests/test_api/test_notifications.py
Normal file
193
tests/test_api/test_notifications.py
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
import bson
|
||||||
|
import flask
|
||||||
|
from bson import ObjectId
|
||||||
|
|
||||||
|
from pillar.tests import AbstractPillarTest
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationsTest(AbstractPillarTest):
|
||||||
|
def setUp(self, **kwargs):
|
||||||
|
super().setUp(**kwargs)
|
||||||
|
self.project_id, _ = self.ensure_project_exists()
|
||||||
|
self.user1_id = self.create_user(user_id=str(bson.ObjectId()))
|
||||||
|
self.user2_id = self.create_user(user_id=str(bson.ObjectId()))
|
||||||
|
|
||||||
|
def test_create_node(self):
|
||||||
|
"""When a node is created, a subscription should also be created"""
|
||||||
|
|
||||||
|
with self.app.app_context():
|
||||||
|
self.login_api_as(self.user1_id, roles={'subscriber', 'admin'},
|
||||||
|
# This group is hardcoded in the EXAMPLE_PROJECT.
|
||||||
|
group_ids=[ObjectId('5596e975ea893b269af85c0e')])
|
||||||
|
|
||||||
|
node_id = self.post_node(self.user1_id)
|
||||||
|
|
||||||
|
self.assertSubscribed(node_id, self.user1_id)
|
||||||
|
self.assertNotSubscribed(node_id, self.user2_id)
|
||||||
|
|
||||||
|
def test_comment_on_own_node(self):
|
||||||
|
"""A comment on my own node should not give me any notifications"""
|
||||||
|
|
||||||
|
with self.app.app_context():
|
||||||
|
self.login_api_as(self.user1_id, roles={'subscriber', 'admin'},
|
||||||
|
# This group is hardcoded in the EXAMPLE_PROJECT.
|
||||||
|
group_ids=[ObjectId('5596e975ea893b269af85c0e')])
|
||||||
|
|
||||||
|
node_id = self.post_node(self.user1_id)
|
||||||
|
comment_id = self.post_comment(node_id)
|
||||||
|
|
||||||
|
self.assertSubscribed(comment_id, self.user1_id)
|
||||||
|
self.assertNotSubscribed(comment_id, self.user2_id)
|
||||||
|
|
||||||
|
with self.login_as(self.user1_id):
|
||||||
|
notification = self.notification_for_object(comment_id)
|
||||||
|
self.assertIsNone(notification)
|
||||||
|
|
||||||
|
def test_comment_on_node(self):
|
||||||
|
"""A comment on some one else's node should give them a notification, and subscriptions should be created"""
|
||||||
|
|
||||||
|
with self.app.app_context():
|
||||||
|
self.login_api_as(self.user1_id, roles={'subscriber', 'admin'},
|
||||||
|
# This group is hardcoded in the EXAMPLE_PROJECT.
|
||||||
|
group_ids=[ObjectId('5596e975ea893b269af85c0e')])
|
||||||
|
|
||||||
|
node_id = self.post_node(self.user1_id)
|
||||||
|
|
||||||
|
with self.app.app_context():
|
||||||
|
self.login_api_as(self.user2_id, roles={'subscriber', 'admin'},
|
||||||
|
# This group is hardcoded in the EXAMPLE_PROJECT.
|
||||||
|
group_ids=[ObjectId('5596e975ea893b269af85c0e')])
|
||||||
|
comment_id = self.post_comment(node_id)
|
||||||
|
|
||||||
|
self.assertSubscribed(comment_id, self.user2_id)
|
||||||
|
self.assertNotSubscribed(comment_id, self.user1_id)
|
||||||
|
self.assertSubscribed(node_id, self.user1_id, self.user2_id)
|
||||||
|
|
||||||
|
with self.login_as(self.user1_id):
|
||||||
|
notification = self.notification_for_object(comment_id)
|
||||||
|
self.assertIsNotNone(notification)
|
||||||
|
|
||||||
|
def test_mark_notification_as_read(self):
|
||||||
|
with self.app.app_context():
|
||||||
|
self.login_api_as(self.user1_id, roles={'subscriber', 'admin'},
|
||||||
|
# This group is hardcoded in the EXAMPLE_PROJECT.
|
||||||
|
group_ids=[ObjectId('5596e975ea893b269af85c0e')])
|
||||||
|
|
||||||
|
node_id = self.post_node(self.user1_id)
|
||||||
|
|
||||||
|
with self.app.app_context():
|
||||||
|
self.login_api_as(self.user2_id, roles={'subscriber', 'admin'},
|
||||||
|
# This group is hardcoded in the EXAMPLE_PROJECT.
|
||||||
|
group_ids=[ObjectId('5596e975ea893b269af85c0e')])
|
||||||
|
comment_id = self.post_comment(node_id)
|
||||||
|
|
||||||
|
with self.login_as(self.user1_id):
|
||||||
|
notification = self.notification_for_object(comment_id)
|
||||||
|
self.assertFalse(notification['is_read'])
|
||||||
|
|
||||||
|
is_read_toggle_url = flask.url_for('notifications.action_read_toggle', notification_id=notification['_id'])
|
||||||
|
self.get(is_read_toggle_url)
|
||||||
|
|
||||||
|
notification = self.notification_for_object(comment_id)
|
||||||
|
self.assertTrue(notification['is_read'])
|
||||||
|
|
||||||
|
self.get(is_read_toggle_url)
|
||||||
|
|
||||||
|
notification = self.notification_for_object(comment_id)
|
||||||
|
self.assertFalse(notification['is_read'])
|
||||||
|
|
||||||
|
def test_unsubscribe(self):
|
||||||
|
"""It should be possible to unsubscribe to notifications"""
|
||||||
|
with self.app.app_context():
|
||||||
|
self.login_api_as(self.user1_id, roles={'subscriber', 'admin'},
|
||||||
|
# This group is hardcoded in the EXAMPLE_PROJECT.
|
||||||
|
group_ids=[ObjectId('5596e975ea893b269af85c0e')])
|
||||||
|
|
||||||
|
node_id = self.post_node(self.user1_id)
|
||||||
|
|
||||||
|
with self.app.app_context():
|
||||||
|
self.login_api_as(self.user2_id, roles={'subscriber', 'admin'},
|
||||||
|
# This group is hardcoded in the EXAMPLE_PROJECT.
|
||||||
|
group_ids=[ObjectId('5596e975ea893b269af85c0e')])
|
||||||
|
comment_id = self.post_comment(node_id)
|
||||||
|
|
||||||
|
with self.login_as(self.user1_id):
|
||||||
|
notification = self.notification_for_object(comment_id)
|
||||||
|
self.assertTrue(notification['is_subscribed'])
|
||||||
|
|
||||||
|
is_subscribed_toggle_url =\
|
||||||
|
flask.url_for('notifications.action_subscription_toggle', notification_id=notification['_id'])
|
||||||
|
self.get(is_subscribed_toggle_url)
|
||||||
|
|
||||||
|
notification = self.notification_for_object(comment_id)
|
||||||
|
self.assertFalse(notification['is_subscribed'])
|
||||||
|
|
||||||
|
with self.app.app_context():
|
||||||
|
self.login_api_as(self.user2_id, roles={'subscriber', 'admin'},
|
||||||
|
# This group is hardcoded in the EXAMPLE_PROJECT.
|
||||||
|
group_ids=[ObjectId('5596e975ea893b269af85c0e')])
|
||||||
|
comment2_id = self.post_comment(node_id)
|
||||||
|
|
||||||
|
with self.login_as(self.user1_id):
|
||||||
|
notification = self.notification_for_object(comment2_id)
|
||||||
|
self.assertFalse(notification['is_subscribed'])
|
||||||
|
|
||||||
|
def assertSubscribed(self, node_id, *user_ids):
|
||||||
|
subscriptions_col = self.app.data.driver.db['activities-subscriptions']
|
||||||
|
|
||||||
|
lookup = {
|
||||||
|
'context_object': node_id,
|
||||||
|
'notifications.web': True,
|
||||||
|
}
|
||||||
|
subscriptions = list(subscriptions_col.find(lookup))
|
||||||
|
self.assertEquals(len(subscriptions), len(user_ids))
|
||||||
|
for s in subscriptions:
|
||||||
|
self.assertIn(s['user'], user_ids)
|
||||||
|
self.assertEquals(s['context_object_type'], 'node')
|
||||||
|
|
||||||
|
def assertNotSubscribed(self, node_id, user_id):
|
||||||
|
subscriptions_col = self.app.data.driver.db['activities-subscriptions']
|
||||||
|
|
||||||
|
lookup = {
|
||||||
|
'context_object': node_id,
|
||||||
|
}
|
||||||
|
subscriptions = subscriptions_col.find(lookup)
|
||||||
|
for s in subscriptions:
|
||||||
|
self.assertNotEquals(s['user'], user_id)
|
||||||
|
|
||||||
|
def notification_for_object(self, node_id):
|
||||||
|
notifications_url = flask.url_for('notifications.index')
|
||||||
|
notification_items = self.get(notifications_url).json['items']
|
||||||
|
|
||||||
|
object_url = flask.url_for('nodes.redirect_to_context', node_id=str(node_id), _external=False)
|
||||||
|
for candidate in notification_items:
|
||||||
|
if candidate['object_url'] == object_url:
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
def post_node(self, user_id):
|
||||||
|
node_doc = {'description': '',
|
||||||
|
'project': self.project_id,
|
||||||
|
'node_type': 'asset',
|
||||||
|
'user': user_id,
|
||||||
|
'properties': {'status': 'published',
|
||||||
|
'tags': [],
|
||||||
|
'order': 0,
|
||||||
|
'categories': '',
|
||||||
|
},
|
||||||
|
'name': 'My first test node'}
|
||||||
|
|
||||||
|
r, _, _, status = self.app.post_internal('nodes', node_doc)
|
||||||
|
self.assertEqual(status, 201, r)
|
||||||
|
node_id = r['_id']
|
||||||
|
return node_id
|
||||||
|
|
||||||
|
def post_comment(self, node_id):
|
||||||
|
comment_url = flask.url_for('nodes_api.post_node_comment', node_path=str(node_id))
|
||||||
|
comment = self.post(
|
||||||
|
comment_url,
|
||||||
|
json={
|
||||||
|
'msg': 'je möder lives at [home](https://cloud.blender.org/)',
|
||||||
|
},
|
||||||
|
expected_status=201,
|
||||||
|
)
|
||||||
|
return ObjectId(comment.json['id'])
|
@ -114,147 +114,6 @@ class UpgradeAttachmentSchemaTest(AbstractPillarTest):
|
|||||||
self.assertEqual({}, db_node_type['form_schema'])
|
self.assertEqual({}, db_node_type['form_schema'])
|
||||||
|
|
||||||
|
|
||||||
class UpgradeAttachmentUsageTest(AbstractPillarTest):
|
|
||||||
def setUp(self, **kwargs):
|
|
||||||
super().setUp(**kwargs)
|
|
||||||
self.pid, self.uid = self.create_project_with_admin(user_id=24 * 'a')
|
|
||||||
|
|
||||||
with self.app.app_context():
|
|
||||||
files_coll = self.app.db('files')
|
|
||||||
|
|
||||||
res = files_coll.insert_one({
|
|
||||||
**ctd.EXAMPLE_FILE,
|
|
||||||
'project': self.pid,
|
|
||||||
'user': self.uid,
|
|
||||||
})
|
|
||||||
self.fid = res.inserted_id
|
|
||||||
|
|
||||||
def test_image_link(self):
|
|
||||||
with self.app.app_context():
|
|
||||||
nodes_coll = self.app.db('nodes')
|
|
||||||
res = nodes_coll.insert_one({
|
|
||||||
**ctd.EXAMPLE_NODE,
|
|
||||||
'picture': self.fid,
|
|
||||||
'project': self.pid,
|
|
||||||
'user': self.uid,
|
|
||||||
'description': "# Title\n\n@[slug 0]\n@[slug1]\n@[slug2]\nEitje van Fabergé.",
|
|
||||||
'properties': {
|
|
||||||
'status': 'published',
|
|
||||||
'content_type': 'image',
|
|
||||||
'file': self.fid,
|
|
||||||
'attachments': {
|
|
||||||
'slug 0': {
|
|
||||||
'oid': self.fid,
|
|
||||||
'link': 'self',
|
|
||||||
},
|
|
||||||
'slug1': {
|
|
||||||
'oid': self.fid,
|
|
||||||
'link': 'custom',
|
|
||||||
'link_custom': 'https://cloud.blender.org/',
|
|
||||||
},
|
|
||||||
'slug2': {
|
|
||||||
'oid': self.fid,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
nid = res.inserted_id
|
|
||||||
|
|
||||||
from pillar.cli.maintenance import upgrade_attachment_usage
|
|
||||||
|
|
||||||
with self.app.app_context():
|
|
||||||
upgrade_attachment_usage(proj_url=ctd.EXAMPLE_PROJECT['url'], go=True)
|
|
||||||
node = nodes_coll.find_one({'_id': nid})
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
"# Title\n\n"
|
|
||||||
"{attachment 'slug-0' link='self'}\n"
|
|
||||||
"{attachment 'slug1' link='https://cloud.blender.org/'}\n"
|
|
||||||
"{attachment 'slug2'}\n"
|
|
||||||
"Eitje van Fabergé.",
|
|
||||||
node['description'],
|
|
||||||
'The description should be updated')
|
|
||||||
self.assertEqual(
|
|
||||||
"<h1>Title</h1>\n"
|
|
||||||
"<!-- {attachment 'slug-0' link='self'} -->\n"
|
|
||||||
"<!-- {attachment 'slug1' link='https://cloud.blender.org/'} -->\n"
|
|
||||||
"<!-- {attachment 'slug2'} -->\n"
|
|
||||||
"<p>Eitje van Fabergé.</p>\n",
|
|
||||||
node['_description_html'],
|
|
||||||
'The _description_html should be updated')
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
{'slug-0': {'oid': self.fid},
|
|
||||||
'slug1': {'oid': self.fid},
|
|
||||||
'slug2': {'oid': self.fid},
|
|
||||||
},
|
|
||||||
node['properties']['attachments'],
|
|
||||||
'The link should have been removed from the attachment')
|
|
||||||
|
|
||||||
def test_post(self):
|
|
||||||
"""This requires checking the dynamic schema of the node."""
|
|
||||||
with self.app.app_context():
|
|
||||||
nodes_coll = self.app.db('nodes')
|
|
||||||
res = nodes_coll.insert_one({
|
|
||||||
**ctd.EXAMPLE_NODE,
|
|
||||||
'node_type': 'post',
|
|
||||||
'project': self.pid,
|
|
||||||
'user': self.uid,
|
|
||||||
'picture': self.fid,
|
|
||||||
'description': "meh",
|
|
||||||
'properties': {
|
|
||||||
'status': 'published',
|
|
||||||
'content': "# Title\n\n@[slug0]\n@[slug1]\n@[slug2]\nEitje van Fabergé.",
|
|
||||||
'attachments': {
|
|
||||||
'slug0': {
|
|
||||||
'oid': self.fid,
|
|
||||||
'link': 'self',
|
|
||||||
},
|
|
||||||
'slug1': {
|
|
||||||
'oid': self.fid,
|
|
||||||
'link': 'custom',
|
|
||||||
'link_custom': 'https://cloud.blender.org/',
|
|
||||||
},
|
|
||||||
'slug2': {
|
|
||||||
'oid': self.fid,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
nid = res.inserted_id
|
|
||||||
|
|
||||||
from pillar.cli.maintenance import upgrade_attachment_usage
|
|
||||||
|
|
||||||
with self.app.app_context():
|
|
||||||
upgrade_attachment_usage(proj_url=ctd.EXAMPLE_PROJECT['url'], go=True)
|
|
||||||
node = nodes_coll.find_one({'_id': nid})
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
"# Title\n\n"
|
|
||||||
"{attachment 'slug0' link='self'}\n"
|
|
||||||
"{attachment 'slug1' link='https://cloud.blender.org/'}\n"
|
|
||||||
"{attachment 'slug2'}\n"
|
|
||||||
"Eitje van Fabergé.",
|
|
||||||
node['properties']['content'],
|
|
||||||
'The content should be updated')
|
|
||||||
self.assertEqual(
|
|
||||||
"<h1>Title</h1>\n"
|
|
||||||
"<!-- {attachment 'slug0' link='self'} -->\n"
|
|
||||||
"<!-- {attachment 'slug1' link='https://cloud.blender.org/'} -->\n"
|
|
||||||
"<!-- {attachment 'slug2'} -->\n"
|
|
||||||
"<p>Eitje van Fabergé.</p>\n",
|
|
||||||
node['properties']['_content_html'],
|
|
||||||
'The _content_html should be updated')
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
{'slug0': {'oid': self.fid},
|
|
||||||
'slug1': {'oid': self.fid},
|
|
||||||
'slug2': {'oid': self.fid},
|
|
||||||
},
|
|
||||||
node['properties']['attachments'],
|
|
||||||
'The link should have been removed from the attachment')
|
|
||||||
|
|
||||||
|
|
||||||
class ReconcileNodeDurationTest(AbstractPillarTest):
|
class ReconcileNodeDurationTest(AbstractPillarTest):
|
||||||
def setUp(self, **kwargs):
|
def setUp(self, **kwargs):
|
||||||
super().setUp(**kwargs)
|
super().setUp(**kwargs)
|
||||||
@ -459,3 +318,52 @@ class DeleteProjectlessFilesTest(AbstractPillarTest):
|
|||||||
self.assertEqual(file1_doc, found1)
|
self.assertEqual(file1_doc, found1)
|
||||||
self.assertEqual(file3_doc, found3)
|
self.assertEqual(file3_doc, found3)
|
||||||
self.assertEqual(file3_doc, found3)
|
self.assertEqual(file3_doc, found3)
|
||||||
|
|
||||||
|
|
||||||
|
class FixMissingActivitiesSubscription(AbstractPillarTest):
|
||||||
|
def test_fix_missing_activities_subscription(self):
|
||||||
|
from pillar.cli.maintenance import fix_missing_activities_subscription_defaults
|
||||||
|
|
||||||
|
with self.app.app_context():
|
||||||
|
subscriptions_collection = self.app.db('activities-subscriptions')
|
||||||
|
|
||||||
|
invalid_subscription = {
|
||||||
|
'user': ObjectId(),
|
||||||
|
'context_object_type': 'node',
|
||||||
|
'context_object': ObjectId(),
|
||||||
|
}
|
||||||
|
|
||||||
|
valid_subscription = {
|
||||||
|
'user': ObjectId(),
|
||||||
|
'context_object_type': 'node',
|
||||||
|
'context_object': ObjectId(),
|
||||||
|
'is_subscribed': False,
|
||||||
|
'notifications': {'web': False, }
|
||||||
|
}
|
||||||
|
|
||||||
|
result = subscriptions_collection.insert_one(
|
||||||
|
invalid_subscription,
|
||||||
|
bypass_document_validation=True)
|
||||||
|
|
||||||
|
id_invalid = result.inserted_id
|
||||||
|
|
||||||
|
result = subscriptions_collection.insert_one(
|
||||||
|
valid_subscription,
|
||||||
|
bypass_document_validation=True)
|
||||||
|
|
||||||
|
id_valid = result.inserted_id
|
||||||
|
|
||||||
|
fix_missing_activities_subscription_defaults() # Dry run. Nothing should change
|
||||||
|
invalid_subscription = subscriptions_collection.find_one({'_id': id_invalid})
|
||||||
|
self.assertNotIn('is_subscribed', invalid_subscription.keys())
|
||||||
|
self.assertNotIn('notifications', invalid_subscription.keys())
|
||||||
|
|
||||||
|
fix_missing_activities_subscription_defaults(go=True) # Real run. Invalid should be updated
|
||||||
|
invalid_subscription = subscriptions_collection.find_one({'_id': id_invalid})
|
||||||
|
self.assertTrue(invalid_subscription['is_subscribed'])
|
||||||
|
self.assertTrue(invalid_subscription['notifications']['web'])
|
||||||
|
|
||||||
|
# Was already ok. Should not have been updated
|
||||||
|
valid_subscription = subscriptions_collection.find_one({'_id': id_valid})
|
||||||
|
self.assertFalse(valid_subscription['is_subscribed'])
|
||||||
|
self.assertFalse(valid_subscription['notifications']['web'])
|
||||||
|
Loading…
x
Reference in New Issue
Block a user