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:
parent
f54e56bad8
commit
e19dd27099
@ -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.
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user