API endpoint /api/nodes/tagged/<tag>

This endpoint returns nodes in public projects that have the given tag.
The returned JSON is cached for 5 minutes.
This commit is contained in:
Sybren A. Stüvel 2018-09-06 12:19:03 +02:00
parent f54e56bad8
commit e19dd27099
3 changed files with 135 additions and 3 deletions

View File

@ -760,6 +760,8 @@ class PillarServer(BlinkerCompatibleEve):
coll.create_index([('properties.status', pymongo.ASCENDING), coll.create_index([('properties.status', pymongo.ASCENDING),
('node_type', pymongo.ASCENDING), ('node_type', pymongo.ASCENDING),
('_created', pymongo.DESCENDING)]) ('_created', pymongo.DESCENDING)])
# Used for asset tags
coll.create_index([('properties.tags', pymongo.ASCENDING)])
coll = db['projects'] coll = db['projects']
# This index is used for statistics, and for fetching public projects. # This index is used for statistics, and for fetching public projects.

View File

@ -1,6 +1,7 @@
import base64 import base64
import functools import functools
import logging import logging
import typing
import urllib.parse import urllib.parse
import pymongo.errors import pymongo.errors
@ -89,6 +90,48 @@ def share_node(node_id):
return jsonify(short_link_info(short_code), status=status) return jsonify(short_link_info(short_code), status=status)
@blueprint.route('/tagged/')
@blueprint.route('/tagged/<tag>')
def tagged(tag=''):
"""Return all tagged nodes of public projects as JSON."""
# We explicitly register the tagless endpoint to raise a 404, otherwise the PATCH
# handler on /api/nodes/<node_id> will return a 405 Method Not Allowed.
if not tag:
raise wz_exceptions.NotFound()
return _tagged(tag)
def _tagged(tag: str):
"""Fetch all public nodes with the given tag.
This function is cached, see setup_app().
"""
nodes_coll = current_app.db('nodes')
agg = nodes_coll.aggregate([
{'$match': {'properties.tags': tag,
'_deleted': {'$ne': True}}},
# Only get nodes from public projects. This is done after matching the
# tagged nodes, because most likely nobody else will be able to tag
# nodes anyway.
{'$lookup': {
'from': 'projects',
'localField': 'project',
'foreignField': '_id',
'as': '_project',
}},
{'$match': {'_project.is_private': False}},
# Don't return the entire project for each node.
{'$project': {'_project': False}},
{'$sort': {'_created': -1}}
])
return jsonify(list(agg))
def generate_and_store_short_code(node): def generate_and_store_short_code(node):
nodes_coll = current_app.data.driver.db['nodes'] nodes_coll = current_app.data.driver.db['nodes']
node_id = node['_id'] node_id = node['_id']
@ -442,6 +485,11 @@ def parse_markdowns(items):
def setup_app(app, url_prefix): def setup_app(app, url_prefix):
global _tagged
cached = app.cache.memoize(timeout=300)
_tagged = cached(_tagged)
from . import patch from . import patch
patch.setup_app(app, url_prefix=url_prefix) patch.setup_app(app, url_prefix=url_prefix)

View File

@ -1,11 +1,14 @@
import json import json
import typing
from unittest import mock
import pillar.tests.common_test_data as ctd import flask
from bson import ObjectId from bson import ObjectId
from mock import mock
from pillar.tests import AbstractPillarTest
from werkzeug.exceptions import UnprocessableEntity from werkzeug.exceptions import UnprocessableEntity
from pillar.tests import AbstractPillarTest
import pillar.tests.common_test_data as ctd
class NodeContentTypeTest(AbstractPillarTest): class NodeContentTypeTest(AbstractPillarTest):
def mkfile(self, file_id, content_type): def mkfile(self, file_id, content_type):
@ -474,3 +477,82 @@ class TextureSortFilesTest(AbstractPillarTest):
node = resp.get_json() node = resp.get_json()
self.assertNotIn('files', node['properties']) self.assertNotIn('files', node['properties'])
class TaggedNodesTest(AbstractPillarTest):
def test_tagged_nodes_api(self):
from pillar.api.utils import utcnow
from datetime import timedelta
pid, _ = self.ensure_project_exists()
file_id, _ = self.ensure_file_exists()
uid = self.create_user()
now = utcnow()
base_node = {
'name': 'Just a node name',
'project': pid,
'description': '',
'node_type': 'asset',
'user': uid,
}
base_props = {'status': 'published',
'file': file_id,
'content_type': 'video',
'order': 0}
# No tags, should never be returned.
self.create_node({
'_created': now,
'properties': base_props,
**base_node})
# Empty tag list, should never be returned.
self.create_node({
'_created': now + timedelta(seconds=1),
'properties': {'tags': [], **base_props},
**base_node})
# Empty string as tag, should never be returned.
self.create_node({
'_created': now + timedelta(seconds=1),
'properties': {'tags': [''], **base_props},
**base_node})
nid_single_tag = self.create_node({
'_created': now + timedelta(seconds=2),
# 'एनिमेशन' is 'animation' in Hindi.
'properties': {'tags': ['एनिमेशन'], **base_props},
**base_node,
})
nid_double_tag = self.create_node({
'_created': now + timedelta(hours=3),
'properties': {'tags': ['एनिमेशन', 'rigging'], **base_props},
**base_node,
})
nid_other_tag = self.create_node({
'_deleted': False,
'_created': now + timedelta(days=4),
'properties': {'tags': ['producción'], **base_props},
**base_node,
})
# Matching tag but deleted node, should never be returned.
self.create_node({
'_created': now + timedelta(seconds=1),
'_deleted': True,
'properties': {'tags': ['एनिमेशन'], **base_props},
**base_node})
def do_query(tag_name: str, expected_ids: typing.List[ObjectId]):
with self.app.app_context():
url = flask.url_for('nodes_api.tagged', tag=tag_name)
resp = self.get(url)
resp_ids = [ObjectId(node['_id']) for node in resp.json]
self.assertEqual(expected_ids, resp_ids)
# Should return the newest node first.
do_query('एनिमेशन', [nid_double_tag, nid_single_tag])
do_query('rigging', [nid_double_tag])
do_query('producción', [nid_other_tag])
do_query('nonexistant', [])
do_query(' ', [])
# Empty tag should not be allowed.
with self.app.app_context():
invalid_url = flask.url_for('nodes_api.tagged', tag='')
self.get(invalid_url, expected_status=404)