From e19dd2709916a5e52a7f75bae0ccebf8cb802177 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sybren=20A=2E=20St=C3=BCvel?= Date: Thu, 6 Sep 2018 12:19:03 +0200 Subject: [PATCH] API endpoint /api/nodes/tagged/ This endpoint returns nodes in public projects that have the given tag. The returned JSON is cached for 5 minutes. --- pillar/__init__.py | 2 + pillar/api/nodes/__init__.py | 48 ++++++++++++++++++++ tests/test_api/test_nodes.py | 88 ++++++++++++++++++++++++++++++++++-- 3 files changed, 135 insertions(+), 3 deletions(-) diff --git a/pillar/__init__.py b/pillar/__init__.py index 7910d58a..29f13749 100644 --- a/pillar/__init__.py +++ b/pillar/__init__.py @@ -760,6 +760,8 @@ class PillarServer(BlinkerCompatibleEve): coll.create_index([('properties.status', pymongo.ASCENDING), ('node_type', pymongo.ASCENDING), ('_created', pymongo.DESCENDING)]) + # Used for asset tags + coll.create_index([('properties.tags', pymongo.ASCENDING)]) coll = db['projects'] # This index is used for statistics, and for fetching public projects. diff --git a/pillar/api/nodes/__init__.py b/pillar/api/nodes/__init__.py index 025ad464..e61feb96 100644 --- a/pillar/api/nodes/__init__.py +++ b/pillar/api/nodes/__init__.py @@ -1,6 +1,7 @@ import base64 import functools import logging +import typing import urllib.parse import pymongo.errors @@ -89,6 +90,48 @@ def share_node(node_id): return jsonify(short_link_info(short_code), status=status) +@blueprint.route('/tagged/') +@blueprint.route('/tagged/') +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/ 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): nodes_coll = current_app.data.driver.db['nodes'] node_id = node['_id'] @@ -442,6 +485,11 @@ def parse_markdowns(items): def setup_app(app, url_prefix): + global _tagged + + cached = app.cache.memoize(timeout=300) + _tagged = cached(_tagged) + from . import patch patch.setup_app(app, url_prefix=url_prefix) diff --git a/tests/test_api/test_nodes.py b/tests/test_api/test_nodes.py index 631bd475..9537e460 100644 --- a/tests/test_api/test_nodes.py +++ b/tests/test_api/test_nodes.py @@ -1,11 +1,14 @@ import json +import typing +from unittest import mock -import pillar.tests.common_test_data as ctd +import flask from bson import ObjectId -from mock import mock -from pillar.tests import AbstractPillarTest from werkzeug.exceptions import UnprocessableEntity +from pillar.tests import AbstractPillarTest +import pillar.tests.common_test_data as ctd + class NodeContentTypeTest(AbstractPillarTest): def mkfile(self, file_id, content_type): @@ -474,3 +477,82 @@ class TextureSortFilesTest(AbstractPillarTest): node = resp.get_json() 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)