Render attachments with shortcodes rather than slugs

The attachments should now be rendered using `{attachment slug}` instead
of `@[slug]`. The `link` attribute can be specified in the shortcode
(for attachments that support it), rather than in the attachment itself.

The attachment subdocument is now reduced to `{oid: File ObjectID}`, and
nodes without attachments should NOT have an `attachment` property at
all (previously it would be an empty dict). This makes querying for
nodes with/out attachments easier.

The CLI command `upgrade_attachment_schema` can do dry-run and remove
empty attachments:

- Added --go to actually perform the database changes.
- Remove empty attachments, so that a node either has one or more
  attachments or no attachments sub-document at all.

The CLI command `upgrade_attachment_usage` converts `@[slug]` to
`{attachment slug}`. It also takes into account 'link' and 'link_custom'
fields on the attachment. After conversion those fields are removed from
the attachment itself.

Simplified maintentance CLI commands that iterate over all projects:
I've moved the common approach (either run on one project or all of
them, skipping deleted ones, giving a message upon dry-run, and showing
duration of the command) to a new _db_projects() function. The new
function is now used by two recently-touched CLI commands; more of them
could be migrated to use this.
This commit is contained in:
2018-03-28 17:13:42 +02:00
parent f4e0b9185b
commit 3b452d14ce
10 changed files with 524 additions and 199 deletions

View File

@@ -1,4 +1,5 @@
import logging import logging
import typing
from bson import ObjectId from bson import ObjectId
from werkzeug import exceptions as wz_exceptions from werkzeug import exceptions as wz_exceptions
@@ -135,6 +136,14 @@ def get_node_type(project, node_type_name):
if nt['name'] == node_type_name), None) if nt['name'] == node_type_name), None)
def node_type_dict(project: dict) -> typing.Dict[str, dict]:
"""Return the node types of the project as dictionary.
The returned dictionary will be keyed by the node type name.
"""
return {nt['name']: nt for nt in project['node_types']}
def project_id(project_url: str) -> ObjectId: def project_id(project_url: str) -> ObjectId:
"""Returns the object ID, or raises a ValueError when not found.""" """Returns the object ID, or raises a ValueError when not found."""

View File

@@ -31,6 +31,21 @@ manager_maintenance = Manager(
current_app, usage="Maintenance scripts, to update user groups") current_app, usage="Maintenance scripts, to update user groups")
def _single_logger(*args, level=logging.INFO, **kwargs):
"""Construct a logger function that's only logging once."""
shown = False
def log_once():
nonlocal shown
if shown:
return
log.log(level, *args, **kwargs)
shown = True
return log_once
@manager_maintenance.command @manager_maintenance.command
def find_duplicate_users(): def find_duplicate_users():
"""Finds users that have the same BlenderID user_id.""" """Finds users that have the same BlenderID user_id."""
@@ -471,24 +486,15 @@ def replace_pillar_node_type_schemas(project_url=None, all_projects=False, missi
Non-standard node types are left alone. Non-standard node types are left alone.
""" """
if sum([bool(project_url), all_projects, bool(project_id)]) != 1:
log.error('Use either --project, --id, or --all.')
return 1
from pillar.api.utils.authentication import force_cli_user from pillar.api.utils.authentication import force_cli_user
force_cli_user() force_cli_user()
from pillar.api.node_types import PILLAR_NAMED_NODE_TYPES from pillar.api.node_types import PILLAR_NAMED_NODE_TYPES
from pillar.api.utils import remove_private_keys, doc_diff from pillar.api.utils import remove_private_keys, doc_diff
projects_collection = current_app.db()['projects']
will_would = 'Will' if go else 'Would' will_would = 'Will' if go else 'Would'
projects_changed = projects_seen = 0 projects_changed = projects_seen = 0
for proj in _db_projects(project_url, all_projects, project_id, go=go):
def handle_project(proj):
nonlocal projects_changed, projects_seen
projects_seen += 1 projects_seen += 1
orig_proj = copy.deepcopy(proj) orig_proj = copy.deepcopy(proj)
@@ -548,27 +554,9 @@ def replace_pillar_node_type_schemas(project_url=None, all_projects=False, missi
raise SystemExit('Error storing project, see log.') raise SystemExit('Error storing project, see log.')
log.debug('Project saved succesfully.') log.debug('Project saved succesfully.')
if not go:
log.info('Not changing anything, use --go to actually go and change things.')
if all_projects:
for project in projects_collection.find({'_deleted': {'$ne': True}}):
handle_project(project)
log.info('%s %d of %d projects', log.info('%s %d of %d projects',
'Changed' if go else 'Would change', 'Changed' if go else 'Would change',
projects_changed, projects_seen) projects_changed, projects_seen)
return
if project_url:
project = projects_collection.find_one({'url': project_url})
else:
project = projects_collection.find_one({'_id': bson.ObjectId(project_id)})
if not project:
log.error('Project url=%s id=%s not found', project_url, project_id)
return 3
handle_project(project)
@manager_maintenance.command @manager_maintenance.command
@@ -615,17 +603,17 @@ def remarkdown_comments():
log.info('errors : %i', errors) log.info('errors : %i', errors)
@manager_maintenance.command
@manager_maintenance.option('-p', '--project', dest='proj_url', nargs='?', @manager_maintenance.option('-p', '--project', dest='proj_url', nargs='?',
help='Project URL') help='Project URL')
@manager_maintenance.option('-a', '--all', dest='all_projects', action='store_true', default=False, @manager_maintenance.option('-a', '--all', dest='all_projects', action='store_true', default=False,
help='Replace on all projects.') help='Replace on all projects.')
def upgrade_attachment_schema(proj_url=None, all_projects=False): @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_schema(proj_url=None, all_projects=False, go=False):
"""Replaces the project's attachments with the new schema. """Replaces the project's attachments with the new schema.
Updates both the schema definition and the nodes with attachments (asset, page, post). Updates both the schema definition and the nodes with attachments (asset, page, post).
""" """
if bool(proj_url) == all_projects: if bool(proj_url) == all_projects:
log.error('Use either --project or --all.') log.error('Use either --project or --all.')
return 1 return 1
@@ -637,28 +625,29 @@ def upgrade_attachment_schema(proj_url=None, all_projects=False):
from pillar.api.node_types.page import node_type_page from pillar.api.node_types.page import node_type_page
from pillar.api.node_types.post import node_type_post from pillar.api.node_types.post import node_type_post
from pillar.api.node_types import attachments_embedded_schema from pillar.api.node_types import attachments_embedded_schema
from pillar.api.utils import remove_private_keys from pillar.api.utils import remove_private_keys, doc_diff
# Node types that support attachments # Node types that support attachments
node_types = (node_type_asset, node_type_page, node_type_post) node_types = (node_type_asset, node_type_page, node_type_post)
nts_by_name = {nt['name']: nt for nt in node_types} nts_by_name = {nt['name']: nt for nt in node_types}
db = current_app.db() nodes_coll = current_app.db('nodes')
projects_coll = db['projects']
nodes_coll = db['nodes']
def handle_project(project):
log.info('Handling project %s', project['url'])
replace_schemas(project)
replace_attachments(project)
def replace_schemas(project): def replace_schemas(project):
log_proj = _single_logger('Upgrading schema project %s (%s)',
project['url'], project['_id'])
orig_proj = copy.deepcopy(project)
for proj_nt in project['node_types']: for proj_nt in project['node_types']:
nt_name = proj_nt['name'] nt_name = proj_nt['name']
if nt_name not in nts_by_name: if nt_name not in nts_by_name:
continue continue
if proj_nt['dyn_schema']['attachments'] == attachments_embedded_schema:
# Schema already up to date.
continue
log_proj()
log.info(' - replacing attachment schema on node type "%s"', nt_name) log.info(' - replacing attachment schema on node type "%s"', nt_name)
pillar_nt = nts_by_name[nt_name] pillar_nt = nts_by_name[nt_name]
proj_nt['dyn_schema']['attachments'] = copy.deepcopy(attachments_embedded_schema) proj_nt['dyn_schema']['attachments'] = copy.deepcopy(attachments_embedded_schema)
@@ -671,16 +660,44 @@ def upgrade_attachment_schema(proj_url=None, all_projects=False):
else: else:
proj_nt['form_schema']['attachments'] = pillar_form_schema proj_nt['form_schema']['attachments'] = pillar_form_schema
seen_changes = False
for key, val1, val2 in doc_diff(orig_proj, project):
if not seen_changes:
log.info('Schema changes to project %s (%s):', project['url'], project['_id'])
seen_changes = True
log.info(' - %30s: %s%s', key, val1, val2)
if go:
# Use Eve to PUT, so we have schema checking. # Use Eve to PUT, so we have schema checking.
db_proj = remove_private_keys(project) db_proj = remove_private_keys(project)
r, _, _, status = current_app.put_internal('projects', db_proj, _id=project['_id']) r, _, _, status = current_app.put_internal('projects', db_proj, _id=project['_id'])
if status != 200: if status != 200:
log.error('Error %i storing altered project %s %s', status, project['_id'], r) log.error('Error %i storing altered project %s %s', status, project['_id'], r)
raise SystemExit('Error storing project, see log.') raise SystemExit('Error storing project, see log.')
log.info('Project saved succesfully.') log.debug('Project saved succesfully.')
def replace_attachments(project): def replace_attachments(project):
log.info('Upgrading nodes for project %s', project['url']) log_proj = _single_logger('Upgrading nodes for project %s (%s)',
project['url'], project['_id'])
# Remove empty attachments
if go:
res = nodes_coll.update_many(
{'properties.attachments': {},
'project': project['_id']},
{'$unset': {'properties.attachments': 1}},
)
if res.matched_count > 0:
log_proj()
log.info('Removed %d empty attachment dicts', res.modified_count)
else:
to_remove = nodes_coll.count({'properties.attachments': {},
'project': project['_id']})
if to_remove:
log_proj()
log.info('Would remove %d empty attachment dicts', to_remove)
# Convert attachments.
nodes = nodes_coll.find({ nodes = nodes_coll.find({
'_deleted': False, '_deleted': False,
'project': project['_id'], 'project': project['_id'],
@@ -689,10 +706,22 @@ def upgrade_attachment_schema(proj_url=None, all_projects=False):
}) })
for node in nodes: for node in nodes:
attachments = node['properties']['attachments'] attachments = node['properties']['attachments']
if not attachments:
# If we're not modifying the database (e.g. go=False),
# any attachments={} will not be filtered out earlier.
if go or attachments != {}:
log_proj()
log.info(' - Node %s (%s) still has empty attachments %r',
node['_id'], node.get('name'), attachments)
continue
if isinstance(attachments, dict): if isinstance(attachments, dict):
# This node has already been upgraded. # This node has already been upgraded.
continue continue
# Upgrade from list [{'slug': 'xxx', 'oid': 'yyy'}, ...]
# to dict {'xxx': {'oid': 'yyy'}, ...}
log_proj()
log.info(' - Updating schema on node %s (%s)', node['_id'], node.get('name')) log.info(' - Updating schema on node %s (%s)', node['_id'], node.get('name'))
new_atts = {} new_atts = {}
for field_info in attachments: for field_info in attachments:
@@ -700,7 +729,9 @@ def upgrade_attachment_schema(proj_url=None, all_projects=False):
new_atts[attachment['slug']] = {'oid': attachment['file']} new_atts[attachment['slug']] = {'oid': attachment['file']}
node['properties']['attachments'] = new_atts node['properties']['attachments'] = new_atts
log.info(' from %s to %s', attachments, new_atts)
if go:
# Use Eve to PUT, so we have schema checking. # Use Eve to PUT, so we have schema checking.
db_node = remove_private_keys(node) db_node = remove_private_keys(node)
r, _, _, status = current_app.put_internal('nodes', db_node, _id=node['_id']) r, _, _, status = current_app.put_internal('nodes', db_node, _id=node['_id'])
@@ -708,17 +739,177 @@ def upgrade_attachment_schema(proj_url=None, all_projects=False):
log.error('Error %i storing altered node %s %s', status, node['_id'], r) log.error('Error %i storing altered node %s %s', status, node['_id'], r)
raise SystemExit('Error storing node; see log.') raise SystemExit('Error storing node; see log.')
for proj in _db_projects(proj_url, all_projects, go=go):
replace_schemas(proj)
replace_attachments(proj)
def iter_markdown(proj_node_types: dict, some_node: dict, callback: typing.Callable[[str], str]):
"""Calls the callback for each MarkDown value in the node.
Replaces the value in-place with the return value of the callback.
"""
from collections import deque
from pillar.api.eve_settings import nodes_schema
my_log = log.getChild('iter_markdown')
# Inspect the node type to find properties containing Markdown.
node_type_name = some_node['node_type']
try:
node_type = proj_node_types[node_type_name]
except KeyError:
raise KeyError(f'Project has no node type {node_type_name}')
to_visit = deque([
(some_node, nodes_schema),
(some_node['properties'], node_type['dyn_schema'])])
while to_visit:
doc, doc_schema = to_visit.popleft()
for key, definition in doc_schema.items():
if definition.get('type') == 'dict' and definition.get('schema'):
# This is a subdocument with its own schema, visit it later.
subdoc = doc.get(key)
if not subdoc:
continue
to_visit.append((subdoc, definition['schema']))
continue
if definition.get('coerce') != 'markdown':
continue
my_log.debug('I have to change %r of %s', key, doc)
old_value = doc.get(key)
if not old_value:
continue
new_value = callback(old_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.node_types import ATTACHMENT_SLUG_REGEX
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
old_slug_re = re.compile(r'@\[(%s)\]' % ATTACHMENT_SLUG_REGEX)
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
total_nodes += node_count
proj_node_types = node_type_dict(proj)
for node in nodes:
attachments = node['properties']['attachments']
# Inner functions because of access to the node's attachments.
def replace(match):
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)
return '{attachment %r%s}' % (slug, 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
for attachment in attachments.values():
attachment.pop('link', None)
attachment.pop('link_custom', None)
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)
raise SystemExit('Error storing node; see log.')
log.info('Project %s (%s) has %d nodes with attachments',
proj_url, proj_id, node_count)
if not go:
log.info('Would update %d nodes', total_nodes)
def _db_projects(proj_url: str, all_projects: bool, project_id='', *, go: bool) \
-> typing.Iterable[dict]:
"""Yields a subset of the projects in the database.
:param all_projects: when True, yields all projects.
:param proj_url: when all_projects is False, this denotes the project
to yield.
Handles soft-deleted projects as non-existing. This ensures that
the receiver can actually modify and save the project without any
issues.
Also shows duration and a note about dry-running when go=False.
"""
if sum([bool(proj_url), all_projects, bool(project_id)]) != 1:
log.error('Only use one way to specify a project / all projects')
raise SystemExit(1)
projects_coll = current_app.db('projects')
start = datetime.datetime.now()
if all_projects: if all_projects:
for proj in projects_coll.find(): yield from projects_coll.find({'_deleted': {'$ne': True}})
handle_project(proj) else:
return if proj_url:
q = {'url': proj_url}
proj = projects_coll.find_one({'url': proj_url}) else:
q = {'_id': bson.ObjectId(project_id)}
proj = projects_coll.find_one({**q, '_deleted': {'$ne': True}})
if not proj: if not proj:
log.error('Project url=%s not found', proj_url) log.error('Project %s not found', q)
return 3 raise SystemExit(3)
yield proj
handle_project(proj) if not go:
log.info('Dry run, use --go to perform the change.')
duration = datetime.datetime.now() - start
log.info('Command took %s', duration)
def _find_orphan_files() -> typing.Set[bson.ObjectId]: def _find_orphan_files() -> typing.Set[bson.ObjectId]:

View File

@@ -21,6 +21,7 @@ import typing
import urllib.parse import urllib.parse
import shortcodes import shortcodes
import pillarsdk
_parser: shortcodes.Parser = None _parser: shortcodes.Parser = None
_commented_parser: shortcodes.Parser = None _commented_parser: shortcodes.Parser = None
@@ -156,6 +157,105 @@ def iframe(context: typing.Any,
return html return html
@shortcode('attachment')
class Attachment:
class NoSuchSlug(ValueError):
"""Raised when there is no attachment with the given slug."""
class NoSuchFile(ValueError):
"""Raised when the file pointed to by the attachment doesn't exist."""
class NotSupported(ValueError):
"""Raised when an attachment is not pointing to a file."""
def __call__(self,
context: typing.Any,
content: str,
pargs: typing.List[str],
kwargs: typing.Dict[str, str]) -> str:
if isinstance(context, pillarsdk.Resource):
context = context.to_dict()
if not isinstance(context, dict):
return '{attachment context not a dictionary}'
try:
slug = pargs[0]
except KeyError:
return '{attachment No slug given}'
try:
file_doc = self.sdk_file(slug, context)
except self.NoSuchSlug:
return html_module.escape('{attachment %r does not exist}' % slug)
except self.NoSuchFile:
return html_module.escape('{attachment file for %r does not exist}' % slug)
except self.NotSupported as ex:
return html_module.escape('{attachment %s}' % ex)
return self.render(file_doc, kwargs)
def sdk_file(self, slug: str, node_properties: dict) -> pillarsdk.File:
"""Return the file document for the attachment with this slug."""
from pillar.web import system_util
attachments = node_properties.get('attachments', {})
attachment = attachments.get(slug)
if not attachment:
raise self.NoSuchSlug(slug)
object_id = attachment.get('oid')
if not object_id:
raise self.NoSuchFile(object_id)
# In theory attachments can also point to other collections.
# There is no support for that yet, though.
collection = attachment.get('collection', 'files')
if collection != 'files':
log.warning('Attachment %r points to ObjectID %s in unsupported collection %r',
slug, object_id, collection)
raise self.NotSupported(f'unsupported collection {collection!r}')
api = system_util.pillar_api()
sdk_file = pillarsdk.File.find(object_id, api=api)
return sdk_file
def render(self, sdk_file: pillarsdk.File, tag_args: dict) -> str:
file_renderers = {
'image': self.render_image,
'video': self.render_video,
}
mime_type_cat, _ = sdk_file.content_type.split('/', 1)
renderer = file_renderers.get(mime_type_cat, self.render_generic)
return renderer(sdk_file, tag_args)
def render_generic(self, sdk_file, tag_args):
import flask
return flask.render_template('nodes/attachments/file_generic.html',
file=sdk_file, tag_args=tag_args)
def render_image(self, sdk_file, tag_args):
"""Renders an image file."""
import flask
variations = {var.size: var for var in sdk_file.variations}
return flask.render_template('nodes/attachments/file_image.html',
file=sdk_file, vars=variations, tag_args=tag_args)
def render_video(self, sdk_file, tag_args):
"""Renders a video file."""
import flask
try:
# The very first variation is an mp4 file with max width of 1920px
default_variation = sdk_file.variations[0]
except IndexError:
log.error('Could not find variations for file %s' % sdk_file._id)
return flask.render_template('nodes/attachments/file_generic.html', file=sdk_file)
return flask.render_template('nodes/attachments/file_video.html',
file=sdk_file, var=default_variation, tag_args=tag_args)
def _get_parser() -> typing.Tuple[shortcodes.Parser, shortcodes.Parser]: def _get_parser() -> typing.Tuple[shortcodes.Parser, shortcodes.Parser]:
"""Return the shortcodes parser, create it if necessary.""" """Return the shortcodes parser, create it if necessary."""
global _parser, _commented_parser global _parser, _commented_parser
@@ -180,6 +280,7 @@ def render_commented(text: str, context: typing.Any = None) -> str:
except shortcodes.InvalidTagError as ex: except shortcodes.InvalidTagError as ex:
return html_module.escape('{%s}' % ex) return html_module.escape('{%s}' % ex)
except shortcodes.RenderingError as ex: except shortcodes.RenderingError as ex:
log.info('Error rendering tag', exc_info=True)
return html_module.escape('{unable to render tag: %s}' % str(ex.__cause__ or ex)) return html_module.escape('{unable to render tag: %s}' % str(ex.__cause__ or ex))

View File

@@ -1,109 +1,14 @@
import logging """Attachment form handling."""
import re
import logging
from bson import ObjectId
import flask
import pillarsdk
import wtforms import wtforms
from pillar.api.node_types import ATTACHMENT_SLUG_REGEX
from pillar.web.utils import system_util
from pillar.web.utils.forms import build_file_select_form, CustomFormField from pillar.web.utils.forms import build_file_select_form, CustomFormField
shortcode_re = re.compile(r'@\[(%s)\]' % ATTACHMENT_SLUG_REGEX)
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
def render_attachments(node, field_value):
"""Renders attachments referenced in the field value.
Returns the rendered field.
"""
# TODO: cache this based on the node's etag and attachment links expiry.
node_attachments = node.properties.attachments or {}
if isinstance(node_attachments, list):
log.warning('Old-style attachments property found on node %s. Ignoring them, '
'will result in attachments not being found.', node['_id'])
return field_value
if not node_attachments:
return field_value
def replace(match):
slug = match.group(1)
try:
att = node_attachments[slug]
except KeyError:
return '[attachment "%s" not found]' % slug
return render_attachment(att)
return shortcode_re.sub(replace, field_value)
def render_attachment(attachment):
"""Renders an attachment as HTML"""
oid = ObjectId(attachment['oid'])
collection = attachment.collection or 'files'
renderers = {
'files': render_attachment_file
}
try:
renderer = renderers[collection]
except KeyError:
log.error('Unable to render attachment from collection %s', collection)
return 'Unable to render attachment'
return renderer(attachment)
def render_attachment_file(attachment):
"""Renders a file attachment."""
api = system_util.pillar_api()
sdk_file = pillarsdk.File.find(attachment['oid'], api=api)
file_renderers = {
'image': render_attachment_file_image,
'video': render_attachment_file_video,
}
mime_type_cat, _ = sdk_file.content_type.split('/', 1)
try:
renderer = file_renderers[mime_type_cat]
except KeyError:
return flask.render_template('nodes/attachments/file_generic.html', file=sdk_file)
return renderer(sdk_file, attachment)
def render_attachment_file_image(sdk_file, attachment):
"""Renders an image file."""
variations = {var.size: var for var in sdk_file.variations}
return flask.render_template('nodes/attachments/file_image.html',
file=sdk_file, vars=variations, attachment=attachment)
def render_attachment_file_video(sdk_file, attachment):
"""Renders a video file."""
try:
# The very first variation is an mp4 file with max width of 1920px
default_variation = sdk_file.variations[0]
except IndexError:
log.error('Could not find variations for file %s' % sdk_file._id)
return flask.render_template('nodes/attachments/file_generic.html', file=sdk_file)
return flask.render_template('nodes/attachments/file_video.html',
file=sdk_file, var=default_variation, attachment=attachment)
def attachment_form_group_create(schema_prop): def attachment_form_group_create(schema_prop):
"""Creates a wtforms.FieldList for attachments.""" """Creates a wtforms.FieldList for attachments."""
@@ -118,8 +23,6 @@ def _attachment_build_single_field(schema_prop):
fake_schema = { fake_schema = {
'slug': schema_prop['propertyschema'], 'slug': schema_prop['propertyschema'],
'oid': schema_prop['valueschema']['schema']['oid'], 'oid': schema_prop['valueschema']['schema']['oid'],
'link': schema_prop['valueschema']['schema']['link'],
'link_custom': schema_prop['valueschema']['schema']['link_custom'],
} }
file_select_form_group = build_file_select_form(fake_schema) file_select_form_group = build_file_select_form(fake_schema)
return file_select_form_group return file_select_form_group
@@ -141,16 +44,10 @@ def attachment_form_group_set_data(db_prop_value, schema_prop, field_list):
# Even uglier hard-coded # Even uglier hard-coded
subform.slug = slug subform.slug = slug
subform.oid = att_data['oid'] subform.oid = att_data['oid']
subform.link = 'self'
subform.link_custom = None
if 'link' in att_data:
subform.link = att_data['link']
if 'link_custom' in att_data:
subform.link_custom = att_data['link_custom']
field_list.append_entry(subform) field_list.append_entry(subform)
def attachment_form_parse_post_data(data): def attachment_form_parse_post_data(data) -> dict:
"""Returns a dict that can be stored in the node.properties.attachments.""" """Returns a dict that can be stored in the node.properties.attachments."""
attachments = {} attachments = {}
@@ -159,18 +56,12 @@ def attachment_form_parse_post_data(data):
for allprops in data: for allprops in data:
oid = allprops['oid'] oid = allprops['oid']
slug = allprops['slug'] slug = allprops['slug']
link = allprops['link']
link_custom = allprops['link_custom']
if not allprops['slug'] and not oid: if not allprops['slug'] or not oid:
continue continue
if slug in attachments: if slug in attachments:
raise ValueError('Slug "%s" is used more than once' % slug) raise ValueError('Slug "%s" is used more than once' % slug)
attachments[slug] = {'oid': oid} attachments[slug] = {'oid': oid}
attachments[slug]['link'] = link
if link == 'custom':
attachments[slug]['link_custom'] = link_custom
return attachments return attachments

View File

@@ -86,19 +86,11 @@ def posts_view(project_id=None, project_url=None, url=None, *, archive=False, pa
if post is not None: if post is not None:
# If post is not published, check that the user is also the author of # If post is not published, check that the user is also the author of
# the post. If not, return 404. # the post. If not, return an error.
if post.properties.status != "published": if post.properties.status != "published":
if not (current_user.is_authenticated and post.has_method('PUT')): if not (current_user.is_authenticated and post.has_method('PUT')):
abort(403) abort(403)
try:
post_contents = post['properties']['content']
except KeyError:
log.warning('Blog post %s has no content', post._id)
else:
post['properties']['content'] = pillar.web.nodes.attachments.render_attachments(
post, post_contents)
can_create_blog_posts = project.node_type_has_method('post', 'POST', api=api) can_create_blog_posts = project.node_type_has_method('post', 'POST', api=api)
# Use functools.partial so we can later pass page=X. # Use functools.partial so we can later pass page=X.

View File

@@ -169,6 +169,8 @@ def process_node_form(form, node_id=None, node_type=None, user=None):
data = form[prop_name].data data = form[prop_name].data
if schema_prop['type'] == 'dict': if schema_prop['type'] == 'dict':
data = attachments.attachment_form_parse_post_data(data) data = attachments.attachment_form_parse_post_data(data)
if not data:
data = None
elif schema_prop['type'] == 'integer': elif schema_prop['type'] == 'integer':
if not data: if not data:
data = None data = None

View File

@@ -177,9 +177,6 @@ def view(node_id, extra_template_args: dict=None):
for child in children: for child in children:
child.picture = get_file(child.picture, api=api) child.picture = get_file(child.picture, api=api)
if 'description' in node:
node['description'] = attachments.render_attachments(node, node['description'])
# Overwrite the file length by the biggest variation, if any. # Overwrite the file length by the biggest variation, if any.
if node.file and node.file_variations: if node.file and node.file_variations:
node.file.length = max(var.length for var in node.file_variations) node.file.length = max(var.length for var in node.file_variations)

View File

@@ -1,9 +1,9 @@
| {% if 'link' in attachment and attachment['link'] != 'none' %} | {% if 'link' in tag_args and tag_args['link'] != 'none' %}
| {% if attachment['link'] == 'self' %} | {% if tag_args['link'] == 'self' %}
a(href="{{ vars['l'].link }}") a(href="{{ vars['l'].link }}")
img(src="{{ vars['l'].link }}", alt="{{ file.filename }}") img(src="{{ vars['l'].link }}", alt="{{ file.filename }}")
| {% elif attachment['link'] == 'custom' %} | {% else %}
a(href="{{ attachment['link_custom'] }}") a(href="{{ tag_args['link'] }}", target="_blank")
img(src="{{ vars['l'].link }}", alt="{{ file.filename }}") img(src="{{ vars['l'].link }}", alt="{{ file.filename }}")
| {% endif %} | {% endif %}
| {% else %} | {% else %}

View File

@@ -329,7 +329,7 @@ class UpgradeAttachmentSchemaTest(AbstractNodeReplacementTest):
group_perms = self.add_group_permission_to_asset_node_type() group_perms = self.add_group_permission_to_asset_node_type()
with self.app.test_request_context(): with self.app.test_request_context():
upgrade_attachment_schema(self.proj['url']) upgrade_attachment_schema(self.proj['url'], go=True)
dbproj = self.fetch_project_from_db() dbproj = self.fetch_project_from_db()

View File

@@ -1,6 +1,7 @@
from bson import ObjectId from bson import ObjectId
from pillar.tests import AbstractPillarTest from pillar.tests import AbstractPillarTest
from pillar.tests import common_test_data as ctd
class PurgeHomeProjectsTest(AbstractPillarTest): class PurgeHomeProjectsTest(AbstractPillarTest):
@@ -33,3 +34,144 @@ class PurgeHomeProjectsTest(AbstractPillarTest):
proj_coll = self.app.db('projects') proj_coll = self.app.db('projects')
self.assertEqual(True, proj_coll.find_one({'_id': ObjectId(home_a['_id'])})['_deleted']) self.assertEqual(True, proj_coll.find_one({'_id': ObjectId(home_a['_id'])})['_deleted'])
self.assertEqual(True, proj_coll.find_one({'_id': ObjectId(home_b['_id'])})['_deleted']) self.assertEqual(True, proj_coll.find_one({'_id': ObjectId(home_b['_id'])})['_deleted'])
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@[slug0]\n@[slug1]\n@[slug2]\nEitje van Fabergé.",
'properties': {
'status': 'published',
'content_type': 'image',
'file': self.fid,
'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['description'],
'The description 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['_description_html'],
'The _description_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')
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')