From 0be8e568713c92f3c4a2e371be2ad6bd046d96fe Mon Sep 17 00:00:00 2001 From: Andrew Marwood Date: Fri, 1 May 2020 11:00:58 +1000 Subject: [PATCH 1/8] [SDAN-657] Make HATOAS consistant (#1042) * [SDAN-657] Make HATOAS consistant * Add search and feed links in HATOAS for products * Add authorization on the item end point --- features/news_api_item.feature | 19 ++++++++-- features/news_api_products.feature | 6 +-- newsroom/news_api/api_audit/__init__.py | 1 + newsroom/news_api/items/__init__.py | 13 +++++++ newsroom/news_api/news/__init__.py | 21 ---------- newsroom/news_api/news/feed/service.py | 7 +++- newsroom/news_api/news/item/__init__.py | 21 ---------- newsroom/news_api/news/item/item.py | 23 ++++++----- newsroom/news_api/news/search/resource.py | 3 +- newsroom/news_api/news/search/service.py | 21 +++++++++- newsroom/news_api/products/__init__.py | 2 +- newsroom/news_api/products/resource.py | 38 ++++++++++--------- newsroom/news_api/section_filters/__init__.py | 13 +++++++ newsroom/news_api/settings.py | 5 +-- newsroom/wire/formatters/nitf.py | 2 +- tests/news_api/test_api_audit.py | 13 ++++--- tests/test_download.py | 2 +- 17 files changed, 117 insertions(+), 93 deletions(-) create mode 100644 newsroom/news_api/items/__init__.py delete mode 100644 newsroom/news_api/news/__init__.py delete mode 100644 newsroom/news_api/news/item/__init__.py create mode 100644 newsroom/news_api/section_filters/__init__.py diff --git a/features/news_api_item.feature b/features/news_api_item.feature index 87eae5ae9..4f8a1ee66 100644 --- a/features/news_api_item.feature +++ b/features/news_api_item.feature @@ -1,5 +1,16 @@ Feature: News API Item + Background: Initial setup + Given "companies" + """ + [{"name": "Test Company", "is_enabled" : true}] + """ + Given "news_api_tokens" + """ + [{"company" : "#companies._id#", "enabled" : true}] + """ + When we save API token + Scenario: Retrieve an item Given "items" """ @@ -65,8 +76,8 @@ Feature: News API Item "headline": "Headline of the story"} """ - Scenario: Attempt to retrieve an expired item - Given "items" + Scenario: Attempt to retrieve an expired item + Given "items" """ [{ "_id": "111", @@ -75,5 +86,5 @@ Feature: News API Item "versioncreated": "2018-11-01T03:01:40.000Z" }] """ - When we get "v1/news/item/#items._id#?format=NINJSFormatter" - Then we get response code 404 + When we get "v1/news/item/#items._id#?format=NINJSFormatter" + Then we get response code 404 diff --git a/features/news_api_products.feature b/features/news_api_products.feature index a91379f5e..17a7bb6d8 100644 --- a/features/news_api_products.feature +++ b/features/news_api_products.feature @@ -38,7 +38,7 @@ Feature: News API Products ] }] """ - When we get "newsapi_products" + When we get "account/products" Then we get list with 1 items """ {"_items": [{ @@ -74,7 +74,7 @@ Feature: News API Products "sd_product_id" : null }] """ - When we get "newsapi_products/#products._id#" + When we get "account/products/#products._id#" Then we get OK response Then we get existing resource """ @@ -103,5 +103,5 @@ Feature: News API Products "sd_product_id" : null }] """ - When we get "newsapi_products/#products._id#" + When we get "account/products/#products._id#" Then we get error 401 diff --git a/newsroom/news_api/api_audit/__init__.py b/newsroom/news_api/api_audit/__init__.py index 50057f17f..151e31405 100644 --- a/newsroom/news_api/api_audit/__init__.py +++ b/newsroom/news_api/api_audit/__init__.py @@ -30,6 +30,7 @@ class NewsApiAuditResource(Resource): 'source': 'api_audit', 'search_backend': 'elastic' } + internal_resource = True def init_app(app): diff --git a/newsroom/news_api/items/__init__.py b/newsroom/news_api/items/__init__.py new file mode 100644 index 000000000..88ed11574 --- /dev/null +++ b/newsroom/news_api/items/__init__.py @@ -0,0 +1,13 @@ +import superdesk +from content_api.items import ItemsResource, ItemsService + + +def init_app(app): + superdesk.register_resource('items', NewsAPIItemsResource, ItemsService, _app=app) + + +class NewsAPIItemsResource(ItemsResource): + """ + Overload the content api items resource so we can set it to be an internal resource + """ + internal_resource = True diff --git a/newsroom/news_api/news/__init__.py b/newsroom/news_api/news/__init__.py deleted file mode 100644 index a3e23f5e4..000000000 --- a/newsroom/news_api/news/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -import superdesk -import logging -from newsroom import Resource, Service - -logger = logging.getLogger(__name__) - - -class NewsResource(Resource): - """ - Resource just the be a place marker in the hateoas - """ - pass - - -class NewsService(Service): - pass - - -def init_app(app): - if app.config.get('NEWS_API_ENABLED'): - superdesk.register_resource('news', NewsResource, NewsService, _app=app) diff --git a/newsroom/news_api/news/feed/service.py b/newsroom/news_api/news/feed/service.py index 863e65320..14bc965e9 100644 --- a/newsroom/news_api/news/feed/service.py +++ b/newsroom/news_api/news/feed/service.py @@ -63,9 +63,12 @@ def on_fetched(self, doc): def _enhance_hateoas(self, doc): doc.setdefault('_links', {}) doc['_links']['parent'] = { - 'title': 'News Items', - 'href': 'news' + 'title': 'Home', + 'href': '/' } + # Remove the next and last page references + doc['_links'].pop('last', None) + doc['_links'].pop('next', None) doc.setdefault('_meta', {}) doc['_meta'].pop('page', None) diff --git a/newsroom/news_api/news/item/__init__.py b/newsroom/news_api/news/item/__init__.py deleted file mode 100644 index eae0125cb..000000000 --- a/newsroom/news_api/news/item/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -import superdesk -import logging -from newsroom import Resource, Service - -logger = logging.getLogger(__name__) - - -class ItemResource(Resource): - """ - Resource just the be a place marker in the hateoas - """ - endpoint_name = 'news/item' - - -class ItemService(Service): - pass - - -def init_app(app): - if app.config.get('NEWS_API_ENABLED'): - superdesk.register_resource('news/item', ItemResource, ItemService, _app=app) diff --git a/newsroom/news_api/news/item/item.py b/newsroom/news_api/news/item/item.py index d33b56262..3a4dddaf7 100644 --- a/newsroom/news_api/news/item/item.py +++ b/newsroom/news_api/news/item/item.py @@ -2,19 +2,24 @@ from newsroom.news_api.settings import URL_PREFIX import flask from superdesk import get_resource_service -from flask import current_app as app +from flask import current_app as app, abort +from flask_babel import gettext from newsroom.news_api.utils import post_api_audit +from newsroom.news_api.api_tokens import CompanyTokenAuth blueprint = superdesk.Blueprint('news/item', __name__) @blueprint.route('/{}/news/item/'.format(URL_PREFIX), methods=['GET']) def get_item(item_id): - _format = flask.request.args.get('format', 'NITFFormatter') - _version = flask.request.args.get('version') - service = get_resource_service('formatters') - formatted = service.get_version(item_id, _version, _format) - mimetype = formatted.get('mimetype') - response = app.response_class(response=formatted.get('formatted_item'), status=200, mimetype=mimetype) - post_api_audit({'_items': [{'_id': item_id}]}) - return response + if CompanyTokenAuth().check_auth(flask.request.headers.get('Authorization'), None, None, 'GET'): + _format = flask.request.args.get('format', 'NINJSFormatter') + _version = flask.request.args.get('version') + service = get_resource_service('formatters') + formatted = service.get_version(item_id, _version, _format) + mimetype = formatted.get('mimetype') + response = app.response_class(response=formatted.get('formatted_item'), status=200, mimetype=mimetype) + post_api_audit({'_items': [{'_id': item_id}]}) + return response + else: + abort(401, gettext('Invalid token')) diff --git a/newsroom/news_api/news/search/resource.py b/newsroom/news_api/news/search/resource.py index db48b58a5..d2ec1c4a2 100644 --- a/newsroom/news_api/news/search/resource.py +++ b/newsroom/news_api/news/search/resource.py @@ -2,10 +2,9 @@ class NewsAPISearchResource(Resource): + resource_title = 'News Search' datasource = { 'search_backend': 'elastic', 'source': 'items', } - - item_methods = ['GET'] resource_methods = ['GET'] diff --git a/newsroom/news_api/news/search/service.py b/newsroom/news_api/news/search/service.py index 861bf8151..18704b823 100644 --- a/newsroom/news_api/news/search/service.py +++ b/newsroom/news_api/news/search/service.py @@ -2,4 +2,23 @@ class NewsAPISearchService(NewsAPINewsService): - pass + def on_fetched(self, doc): + self._enhance_hateoas(doc) + super().on_fetched(doc) + + def _enhance_hateoas(self, doc): + doc.setdefault('_links', {}) + doc['_links']['parent'] = { + 'title': 'Home', + 'href': '/' + } + self._hateoas_set_item_links(doc) + + def _hateoas_set_item_links(self, doc): + for item in doc.get('_items') or []: + doc_id = str(item['_id']) + item.setdefault('_links', {}) + item['_links']['self'] = { + 'href': 'news/item/{}'.format(doc_id), + 'title': 'News Item' + } diff --git a/newsroom/news_api/products/__init__.py b/newsroom/news_api/products/__init__.py index 7fb516bac..d2a1aa8af 100644 --- a/newsroom/news_api/products/__init__.py +++ b/newsroom/news_api/products/__init__.py @@ -3,4 +3,4 @@ def init_app(app): - superdesk.register_resource('newsapi_products', NewsAPIProductsResource, NewsAPIProductsService, _app=app) + superdesk.register_resource('account/products', NewsAPIProductsResource, NewsAPIProductsService, _app=app) diff --git a/newsroom/news_api/products/resource.py b/newsroom/news_api/products/resource.py index fd3699716..02d65ec22 100644 --- a/newsroom/news_api/products/resource.py +++ b/newsroom/news_api/products/resource.py @@ -1,4 +1,4 @@ -from flask import g, current_app as app +from flask import g from newsroom.products import ProductsService, ProductsResource from bson import ObjectId import json @@ -10,6 +10,7 @@ class NewsAPIProductsResource(ProductsResource): internal_resource = False query_objectid_as_string = True item_methods = ['GET'] + resource_title = 'Account Products' class NewsAPIProductsService(ProductsService): @@ -17,18 +18,6 @@ class NewsAPIProductsService(ProductsService): # The subset of fields that are returned by the API allowed_fields = json.dumps({'_id': 1, 'name': 1, 'description': 1}) - def _prefix_hateoas(self, links): - """Set the api and version prefix on the HATEOAS - - :param links: - :return: - """ - for (k, v) in links.items(): - if k == 'href': - links[k] = app.config.get('URL_PREFIX') + v if v[0] == '/' else app.config.get('URL_PREFIX') + '/' + v - elif isinstance(v, dict): - self._prefix_hateoas(v) - def find_one(self, req, **lookup): req.projection = self.allowed_fields if '_id' in lookup and isinstance(lookup['_id'], str): @@ -48,12 +37,25 @@ def get(self, req, lookup): return super().get(req=req, lookup=lookup) def on_fetched(self, doc): - self._prefix_hateoas(doc.get('_links', {})) - for item in doc.get('_items', []): - self._prefix_hateoas(item) - + self._enhance_hateoas(doc) post_api_audit(doc) + def _enhance_links(self, item): + product_id = str(item['_id']) + item.setdefault('_links', {}) + item['_links']['search'] = { + 'href': 'news/search/?products={}'.format(product_id), + 'title': 'News Search' + } + item['_links']['feed'] = { + 'href': 'news/feed/?products={}'.format(product_id), + 'title': 'News Feed' + } + + def _enhance_hateoas(self, doc): + for item in doc.get('_items') or []: + self._enhance_links(item) + def on_fetched_item(self, doc): - self._prefix_hateoas(doc.get('_links', {})) + self._enhance_links(doc) post_api_audit(doc) diff --git a/newsroom/news_api/section_filters/__init__.py b/newsroom/news_api/section_filters/__init__.py new file mode 100644 index 000000000..1702b3f76 --- /dev/null +++ b/newsroom/news_api/section_filters/__init__.py @@ -0,0 +1,13 @@ +import superdesk +from newsroom.section_filters import SectionFiltersService, SectionFiltersResource + + +def init_app(app): + superdesk.register_resource('section_filters', NewsAPISectionFilterResource, SectionFiltersService, _app=app) + + +class NewsAPISectionFilterResource(SectionFiltersResource): + """ + Overload the newsroom section filter resource so we can set it to be an internal resource + """ + internal_resource = True diff --git a/newsroom/news_api/settings.py b/newsroom/news_api/settings.py index 046e636bc..69a8e2272 100644 --- a/newsroom/news_api/settings.py +++ b/newsroom/news_api/settings.py @@ -8,10 +8,9 @@ 'newsroom.news_api', 'newsroom.news_api.api_tokens', 'newsroom.companies', - 'content_api.items', + 'newsroom.news_api.items', 'content_api.items_versions', - 'newsroom.wire', - 'newsroom.section_filters', + 'newsroom.news_api.section_filters', 'newsroom.news_api.products', 'newsroom.news_api.formatters', 'newsroom.news_api.news', diff --git a/newsroom/wire/formatters/nitf.py b/newsroom/wire/formatters/nitf.py index fa25e0787..af44987ed 100644 --- a/newsroom/wire/formatters/nitf.py +++ b/newsroom/wire/formatters/nitf.py @@ -7,7 +7,7 @@ class NITFFormatter(BaseFormatter): - MIMETYPE = 'application/vnd.nitf' + MIMETYPE = 'application/xml' FILE_EXTENSION = 'xml' encoding = 'utf-8' diff --git a/tests/news_api/test_api_audit.py b/tests/news_api/test_api_audit.py index 933345385..dfc23c973 100644 --- a/tests/news_api/test_api_audit.py +++ b/tests/news_api/test_api_audit.py @@ -40,24 +40,25 @@ def test_get_item_audit_creation(client, app): "pubstatus": "usable", "headline": "Headline of the story" }]) - - response = client.get('api/v1/news/item/111?format=NINJSFormatter') + app.data.insert('news_api_tokens', [{"company": "company_123", "enabled": True}]) + token = app.data.find_one('news_api_tokens', req=None, company='company_123') + response = client.get('api/v1/news/item/111?format=NINJSFormatter', headers={'Authorization': token.get('token')}) assert response.status_code == 200 audit_check('111') def test_get_all_company_products_audit_creation(client, app): - with app.test_request_context(path='/newsapi_products/'): + with app.test_request_context(path='/account/products/'): g.user = 'company_123' - response = get_internal('newsapi_products') + response = get_internal('account/products') assert len(response[0]['_items']) == 1 audit_check('5ab03a87bdd78169bb6d0783') def test_get_single_product_audit_creation(client, app): - with app.test_request_context(path='/newsapi_products/'): + with app.test_request_context(path='/account/products/'): g.user = 'company_123' - response = getitem_internal('newsapi_products', _id='5ab03a87bdd78169bb6d0783') + response = getitem_internal('account/products', _id='5ab03a87bdd78169bb6d0783') assert str(response[0]['_id']) == '5ab03a87bdd78169bb6d0783' audit_check('5ab03a87bdd78169bb6d0783') diff --git a/tests/test_download.py b/tests/test_download.py index 0b5f6e568..6014cff50 100644 --- a/tests/test_download.py +++ b/tests/test_download.py @@ -79,7 +79,7 @@ def filename(filename, item): }, { 'format': 'nitf', - 'mimetype': 'application/vnd.nitf', + 'mimetype': 'application/xml', 'filename': filename('amazon-bookstore-opening.xml', item), 'test_content': nitf_content_test, }, From 5092ecb19492bc8474277716dacf710e49918387 Mon Sep 17 00:00:00 2001 From: Andrew Marwood Date: Wed, 6 May 2020 17:13:12 +1000 Subject: [PATCH 2/8] fix(news-api) remove unwanted fields (#1047) --- newsroom/news_api/news/feed/service.py | 3 +++ newsroom/news_api/news/search/service.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/newsroom/news_api/news/feed/service.py b/newsroom/news_api/news/feed/service.py index 14bc965e9..8c896aa5e 100644 --- a/newsroom/news_api/news/feed/service.py +++ b/newsroom/news_api/news/feed/service.py @@ -84,6 +84,9 @@ def _hateoas_set_item_links(self, doc): 'href': 'news/item/{}'.format(doc_id), 'title': 'News Item' } + item.pop('_updated', None) + item.pop('_created', None) + item.pop('_etag', None) def _hateoas_set_next_page_links(self, doc): args = request.args.to_dict() diff --git a/newsroom/news_api/news/search/service.py b/newsroom/news_api/news/search/service.py index 18704b823..c09cce11f 100644 --- a/newsroom/news_api/news/search/service.py +++ b/newsroom/news_api/news/search/service.py @@ -22,3 +22,6 @@ def _hateoas_set_item_links(self, doc): 'href': 'news/item/{}'.format(doc_id), 'title': 'News Item' } + item.pop('_updated', None) + item.pop('_created', None) + item.pop('_etag', None) From e3ec98628f253531c7c6ddd1df2b6cb5dee54a97 Mon Sep 17 00:00:00 2001 From: Pablo P Varela Date: Wed, 6 May 2020 19:25:26 +0200 Subject: [PATCH 3/8] fix: Missing userType to add remove action (#1045) --- assets/home/components/HomeApp.jsx | 3 ++- assets/home/reducers.js | 1 + newsroom/wire/views.py | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/assets/home/components/HomeApp.jsx b/assets/home/components/HomeApp.jsx index 0426f6a9e..968266f32 100644 --- a/assets/home/components/HomeApp.jsx +++ b/assets/home/components/HomeApp.jsx @@ -177,6 +177,7 @@ HomeApp.propTypes = { itemsByCard: PropTypes.object, products: PropTypes.array, user: PropTypes.string, + userType: PropTypes.string, company: PropTypes.string, format: PropTypes.array, itemToOpen: PropTypes.object, @@ -199,6 +200,7 @@ const mapStateToProps = (state) => ({ itemsByCard: state.itemsByCard, products: state.products, user: state.user, + userType: state.userType, company: state.company, format: PropTypes.format, itemToOpen: state.itemToOpen, @@ -217,7 +219,6 @@ const mapDispatchToProps = (dispatch) => ({ fetchCardExternalItems: (cardId, cardLabel) => dispatch(fetchCardExternalItems(cardId, cardLabel)), followStory: (item) => followStory(item, 'wire'), downloadVideo: (href, id, mimeType) => dispatch(downloadVideo(href, id, mimeType)), - }); diff --git a/assets/home/reducers.js b/assets/home/reducers.js index 5c2a34b25..18dcf670d 100644 --- a/assets/home/reducers.js +++ b/assets/home/reducers.js @@ -26,6 +26,7 @@ export default function homeReducer(state = initialState, action) { itemsByCard: action.data.itemsByCard, products: action.data.products, user: action.data.user, + userType: action.data.userType, company: action.data.company, formats: action.data.formats || [], userSections: action.data.userSections, diff --git a/newsroom/wire/views.py b/newsroom/wire/views.py index 4486b686b..da5a866df 100644 --- a/newsroom/wire/views.py +++ b/newsroom/wire/views.py @@ -113,6 +113,7 @@ def get_home_data(): 'itemsByCard': items_by_card, 'products': get_products_by_company(company_id), 'user': str(user['_id']) if user else None, + 'userType': user.get('user_type'), 'company': company_id, 'formats': [{'format': f['format'], 'name': f['name'], 'types': f['types'], 'assets': f['assets']} for f in app.download_formatters.values()], From cd5f0910e1b8957bd9a59261eb900e3dc9489269 Mon Sep 17 00:00:00 2001 From: Andrew Marwood Date: Wed, 13 May 2020 15:14:21 +1000 Subject: [PATCH 4/8] News API, Add the evolvedfrom field to search and feed response (#1048) --- newsroom/news_api/news/search_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/newsroom/news_api/news/search_service.py b/newsroom/news_api/news/search_service.py index 089a3491b..e0bf80d37 100644 --- a/newsroom/news_api/news/search_service.py +++ b/newsroom/news_api/news/search_service.py @@ -44,7 +44,7 @@ class NewsAPINewsService(BaseSearchService): default_fields = { '_id', 'uri', 'embargoed', 'pubstatus', 'ednote', 'signal', 'copyrightnotice', 'copyrightholder', - 'versioncreated' + 'versioncreated', 'evolvedfrom' } # set of fields that will be removed from all responses, we are not currently supporting associations and From 6881467cf7f70d6808ee16aaca3d274237699fb0 Mon Sep 17 00:00:00 2001 From: Andrew Marwood Date: Wed, 13 May 2020 15:28:23 +1000 Subject: [PATCH 5/8] fix(news api) text formatter was not working (#1049) * fix(news api) text formatter was not working * Fix flake errors --- features/news_api_item.feature | 12 ++++++++++++ newsroom/agenda/utils.py | 2 +- newsroom/agenda/views.py | 2 +- newsroom/news_api/app/__init__.py | 21 +++++++++++++++++++++ newsroom/utils.py | 2 +- tests/test_auth.py | 2 +- 6 files changed, 37 insertions(+), 4 deletions(-) diff --git a/features/news_api_item.feature b/features/news_api_item.feature index 4f8a1ee66..2866ecb24 100644 --- a/features/news_api_item.feature +++ b/features/news_api_item.feature @@ -88,3 +88,15 @@ Feature: News API Item """ When we get "v1/news/item/#items._id#?format=NINJSFormatter" Then we get response code 404 + + Scenario: Retrieve an item in text format + Given "items" + """ + [{ + "_id": "111", + "pubstatus": "usable", + "headline": "Headline of the story" + }] + """ + When we get "v1/news/item/#items._id#?format=TextFormatter" + Then we get OK response diff --git a/newsroom/agenda/utils.py b/newsroom/agenda/utils.py index 157ac9dee..5553495bc 100644 --- a/newsroom/agenda/utils.py +++ b/newsroom/agenda/utils.py @@ -52,7 +52,7 @@ def get_location_string(agenda): location[0].get('address', {}).get('country'), ] - return ', '.join([l for l in location_items if l]) + return ', '.join([location_part for location_part in location_items if location_part]) def get_public_contacts(agenda): diff --git a/newsroom/agenda/views.py b/newsroom/agenda/views.py index 8ae71aec1..7a97ce479 100644 --- a/newsroom/agenda/views.py +++ b/newsroom/agenda/views.py @@ -148,7 +148,7 @@ def follow(): c['watches'].remove(user_id) if request.method == 'POST': - updates = {'watches': list(set((item.get('watches')or []) + [user_id]))} + updates = {'watches': list(set((item.get('watches') or []) + [user_id]))} if item.get('coverages'): updates.update(coverage_updates) diff --git a/newsroom/news_api/app/__init__.py b/newsroom/news_api/app/__init__.py index 13b05e972..d07e8fc46 100644 --- a/newsroom/news_api/app/__init__.py +++ b/newsroom/news_api/app/__init__.py @@ -1,6 +1,7 @@ import os import logging import flask +import jinja2 from werkzeug.exceptions import HTTPException from superdesk.errors import SuperdeskApiError @@ -8,9 +9,15 @@ from newsroom.factory import NewsroomApp from newsroom.news_api.api_tokens import CompanyTokenAuth from superdesk.utc import utcnow +from newsroom.template_filters import ( + datetime_short, datetime_long, time_short, date_short, + plain_text, word_count, char_count, date_header +) logger = logging.getLogger(__name__) +API_DIR = os.path.abspath(os.path.dirname(__file__)) + class NewsroomNewsAPI(NewsroomApp): AUTH_SERVICE = CompanyTokenAuth @@ -21,6 +28,20 @@ def __init__(self, import_name=__package__, config=None, **kwargs): super(NewsroomNewsAPI, self).__init__(import_name=import_name, config=config, **kwargs) + template_folder = os.path.abspath(os.path.join(API_DIR, '../../templates')) + + self.add_template_filter(datetime_short) + self.add_template_filter(datetime_long) + self.add_template_filter(date_header) + self.add_template_filter(plain_text) + self.add_template_filter(time_short) + self.add_template_filter(date_short) + self.add_template_filter(word_count) + self.add_template_filter(char_count) + self.jinja_loader = jinja2.ChoiceLoader([ + jinja2.FileSystemLoader(template_folder), + ]) + def load_app_config(self): super(NewsroomNewsAPI, self).load_app_config() self.config.from_object('newsroom.news_api.settings') diff --git a/newsroom/utils.py b/newsroom/utils.py index 4ccddd3c5..f179d1c45 100644 --- a/newsroom/utils.py +++ b/newsroom/utils.py @@ -205,7 +205,7 @@ def get_location_string(agenda): location[0].get('address', {}).get('country'), ] - return ', '.join([l for l in location_items if l]) + return ', '.join([location_part for location_part in location_items if location_part]) def get_public_contacts(agenda): diff --git a/tests/test_auth.py b/tests/test_auth.py index b5c536cf9..aef33de88 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -304,7 +304,7 @@ def test_account_appears_locked_for_non_existing_user(client): for i in range(1, 10): response = client.post( url_for('auth.login'), - data={'email': 'xyz@abc.org'.format(i), 'password': 'abc'}, + data={'email': 'xyz@abc.org', 'password': 'abc'}, follow_redirects=True ) if i <= 5: From 44d98718b4ccbb1775212669332db795e30acb7f Mon Sep 17 00:00:00 2001 From: Andrew Marwood Date: Mon, 18 May 2020 13:56:20 +1000 Subject: [PATCH 6/8] fix(textformatter) None breaking spaces where missing (#1050) --- features/news_api_item.feature | 5 ++++- features/steps/steps.py | 10 ++++++++++ newsroom/template_filters.py | 2 +- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/features/news_api_item.feature b/features/news_api_item.feature index 2866ecb24..1c005e97c 100644 --- a/features/news_api_item.feature +++ b/features/news_api_item.feature @@ -95,8 +95,11 @@ Feature: News API Item [{ "_id": "111", "pubstatus": "usable", - "headline": "Headline of the story" + "headline": "Headline of the story", + "body_html": "

test test

" }] """ When we get "v1/news/item/#items._id#?format=TextFormatter" Then we get OK response + Then we get "test test" in text response + diff --git a/features/steps/steps.py b/features/steps/steps.py index f2cd1bb36..11284534a 100644 --- a/features/steps/steps.py +++ b/features/steps/steps.py @@ -12,6 +12,9 @@ from superdesk.tests import set_placeholder from behave import when, then import json +from wooper.general import ( + get_body +) @when('we save API token') @@ -44,3 +47,10 @@ def step_store_next_page_from_response(context): href = ((data.get('_links') or {}).get('next_page') or {}).get('href') assert href, data set_placeholder(context, 'NEXT_PAGE', href) + + +@then('we get "{text}" in text response') +def we_get_text_in_response(context, text): + with context.app.test_request_context(context.app.config['URL_PREFIX']): + assert(isinstance(get_body(context.response), str)) + assert(text in get_body(context.response)) diff --git a/newsroom/template_filters.py b/newsroom/template_filters.py index 47976cdbe..c4436569a 100644 --- a/newsroom/template_filters.py +++ b/newsroom/template_filters.py @@ -47,7 +47,7 @@ def date_short(datetime): def plain_text(html): - return get_text(html, lf_on_block=True) if html else '' + return get_text(html, content='html', lf_on_block=True) if html else '' def word_count(html): From fe3f880c8028a8e17442512f3e34ca1a7c3b8719 Mon Sep 17 00:00:00 2001 From: Andrew Marwood Date: Tue, 26 May 2020 16:59:20 +1000 Subject: [PATCH 7/8] [SDAN-669] Add product subscription report (#1051) --- assets/company-reports/actions.js | 30 ++++- .../components/CompanyReportsApp.jsx | 6 +- .../components/ProductCompanies.jsx | 110 ++++++++++++++++++ assets/company-reports/reducers.js | 7 ++ assets/company-reports/utils.js | 2 + newsroom/reports/__init__.py | 3 +- newsroom/reports/reports.py | 32 +++++ tests/test_reports.py | 26 +++++ 8 files changed, 213 insertions(+), 3 deletions(-) create mode 100644 assets/company-reports/components/ProductCompanies.jsx diff --git a/assets/company-reports/actions.js b/assets/company-reports/actions.js index c7f15276a..46ab5e2ca 100644 --- a/assets/company-reports/actions.js +++ b/assets/company-reports/actions.js @@ -11,6 +11,7 @@ export const REPORTS_NAMES = { 'SUBSCRIBER_ACTIVITY': 'subscriber-activity', 'CONTENT_ACTIVITY': 'content-activity', 'COMPANY_NEWS_API_USAGE': 'company-news-api-usage', + 'PRODUCT_COMPANIES': 'product-companies', }; @@ -23,6 +24,7 @@ export const REPORTS = { [REPORTS_NAMES.SUBSCRIBER_ACTIVITY]: '/reports/subscriber-activity', [REPORTS_NAMES.CONTENT_ACTIVITY]: '/reports/content-activity', [REPORTS_NAMES.COMPANY_NEWS_API_USAGE]: '/reports/company-news-api-usage', + [REPORTS_NAMES.PRODUCT_COMPANIES]: '/reports/product-companies', }; function getReportQueryString(currentState, next, exportReport, notify) { @@ -48,6 +50,10 @@ function getReportQueryString(currentState, next, exportReport, notify) { params.section = get(getItemFromArray(params.section, currentState.sections, 'name'), '_id'); } + if (params.product) { + params.product = get(getItemFromArray(params.product, currentState.products, 'name'), '_id'); + } + if (exportReport) { params.export = true; } @@ -63,7 +69,10 @@ function getReportQueryString(currentState, next, exportReport, notify) { export const INIT_DATA = 'INIT_DATA'; export function initData(data) { - return {type: INIT_DATA, data}; + return function (dispatch) { + dispatch(fetchProducts()); + dispatch({type: INIT_DATA, data}); + }; } export const QUERY_REPORT = 'QUERY_REPORT'; @@ -96,6 +105,11 @@ export function isLoading(data = false) { return {type: SET_IS_LOADING, data}; } +export const GET_PRODUCTS = 'GET_PRODUCTS'; +export function getProducts(data) { + return {type: GET_PRODUCTS, data}; +} + export function runReport() { return function (dispatch, getState) { dispatch(queryReport()); @@ -191,3 +205,17 @@ export function printReport() { return Promise.resolve(); }; } + +/** + * Fetches products + * + */ +export function fetchProducts() { + return function (dispatch) { + return server.get('/products/search') + .then((data) => { + dispatch(getProducts(data)); + }) + .catch((error) => errorHandler(error, dispatch, setError)); + }; +} diff --git a/assets/company-reports/components/CompanyReportsApp.jsx b/assets/company-reports/components/CompanyReportsApp.jsx index 49cc3d03f..c290ae001 100644 --- a/assets/company-reports/components/CompanyReportsApp.jsx +++ b/assets/company-reports/components/CompanyReportsApp.jsx @@ -20,6 +20,8 @@ const options = [ {value: REPORTS_NAMES.COMPANY, text: gettext('Company')}, {value: REPORTS_NAMES.SUBSCRIBER_ACTIVITY, text: gettext('Subscriber activity')}, {value: REPORTS_NAMES.CONTENT_ACTIVITY, text: gettext('Content activity')}, + {value: REPORTS_NAMES.PRODUCT_COMPANIES, text: gettext('Companies per Product')}, + ]; @@ -87,6 +89,7 @@ CompanyReportsApp.propTypes = { printReport: PropTypes.func, isLoading: PropTypes.bool, apiEnabled: PropTypes.bool, + products: PropTypes.array, }; const mapStateToProps = (state) => ({ @@ -96,7 +99,8 @@ const mapStateToProps = (state) => ({ apiEnabled: state.apiEnabled, reportParams: state.reportParams, isLoading: state.isLoading, - resultHeaders: state.resultHeaders + resultHeaders: state.resultHeaders, + products: state.products, }); const mapDispatchToProps = { diff --git a/assets/company-reports/components/ProductCompanies.jsx b/assets/company-reports/components/ProductCompanies.jsx new file mode 100644 index 000000000..6a7275c6c --- /dev/null +++ b/assets/company-reports/components/ProductCompanies.jsx @@ -0,0 +1,110 @@ +import React, {Fragment} from 'react'; +import PropTypes from 'prop-types'; +import {connect} from 'react-redux'; +import { get } from 'lodash'; +import DropdownFilter from '../../components/DropdownFilter'; +import ReportsTable from './ReportsTable'; +import {toggleFilterAndQuery, runReport} from '../actions'; + +import { gettext } from 'utils'; + +class ProductCompanies extends React.Component { + constructor(props, context) { + super(props, context); + + this.products = [...this.props.products.map((p) => ({...p, 'label': p.name}))]; + this.state = { product: this.props.products[0] }; + + this.filters = [{ + label: gettext('All Products'), + field: 'product' + }]; + this.getDropdownItems = this.getDropdownItems.bind(this); + this.results = []; + } + + getDropdownItems(filter) { + const { toggleFilterAndQuery } = this.props; + let getName = (text) => (text); + let itemsArray = []; + switch (filter.field) { + case 'product': + itemsArray = this.products; + break; + } + + return itemsArray.map((item, i) => ()); + } + + getFilterLabel(filter, activeFilter) { + if (activeFilter[filter.field]) { + return activeFilter[filter.field]; + } else { + return filter.label; + } + } + + render() { + const {results, print, reportParams, toggleFilterAndQuery} = this.props; + const headers = [gettext('Product'), gettext('Active Companies'), gettext('Disabled Companies')]; + const list = get(results, 'length', 0) > 0 ? results.map((item) => + + {item.product} + {item.enabled_companies.map((company) => ( + + {company}
+
+ ))} + {item.disabled_companies.map((company) => ( + + {company}
+
+ ))} + + ) : ([( + + + + )]); + + let filterNodes = this.filters.map((filter) => ( + + )); + const filterSection = (
{filterNodes}
); + + return [filterSection, + ()]; + + } +} + +ProductCompanies.propTypes = { + results: PropTypes.array, + print: PropTypes.bool, + products: PropTypes.array, + reportParams: PropTypes.object, + toggleFilterAndQuery: PropTypes.func, + runReport: PropTypes.func, + isLoading: PropTypes.bool, +}; + +const mapStateToProps = (state) => ({ + products: state.products, + reportParams: state.reportParams, + isLoading: state.isLoading, +}); + +const mapDispatchToProps = { toggleFilterAndQuery, runReport}; + +export default connect(mapStateToProps, mapDispatchToProps)(ProductCompanies); diff --git a/assets/company-reports/reducers.js b/assets/company-reports/reducers.js index fdb66f64f..bda39af84 100644 --- a/assets/company-reports/reducers.js +++ b/assets/company-reports/reducers.js @@ -10,6 +10,7 @@ import { TOGGLE_REPORT_FILTER, ADD_RESULTS, SET_IS_LOADING, + GET_PRODUCTS, } from './actions'; const initialState = { @@ -20,6 +21,7 @@ const initialState = { aggregations: null, companies: [], sections: [], + products: [], reportParams: { date_from: Date.now(), date_to: Date.now(), @@ -27,6 +29,7 @@ const initialState = { company: null, action: null, section: null, + product: null, } }; @@ -40,6 +43,7 @@ export default function companyReportReducer(state = initialState, action) { companies: action.data.companies, sections: action.data.sections, apiEnabled: action.data.api_enabled || false, + products: action.data.products, }; case QUERY_REPORT: { @@ -92,6 +96,9 @@ export default function companyReportReducer(state = initialState, action) { isLoading: action.data }; + case GET_PRODUCTS: + return {...state, products: action.data}; + case 'RECEIVE_REPORT_AGGREGATIONS': return { ...state, diff --git a/assets/company-reports/utils.js b/assets/company-reports/utils.js index 38a503d9b..372052682 100644 --- a/assets/company-reports/utils.js +++ b/assets/company-reports/utils.js @@ -6,6 +6,7 @@ import Company from './components/Company'; import SubscriberActivity from './components/SubscriberActivity'; import ContentActivity from './components/ContentActivity'; import ComapnyNewsApiUsage from './components/ComapnyNewsApiUsage'; +import ProductCompanies from './components/ProductCompanies'; import {REPORTS_NAMES} from './actions'; export const panels = { @@ -17,4 +18,5 @@ export const panels = { [REPORTS_NAMES.SUBSCRIBER_ACTIVITY]: SubscriberActivity, [REPORTS_NAMES.CONTENT_ACTIVITY]: ContentActivity, [REPORTS_NAMES.COMPANY_NEWS_API_USAGE]: ComapnyNewsApiUsage, + [REPORTS_NAMES.PRODUCT_COMPANIES]: ProductCompanies, }; diff --git a/newsroom/reports/__init__.py b/newsroom/reports/__init__.py index 7ffc0539d..60c510455 100644 --- a/newsroom/reports/__init__.py +++ b/newsroom/reports/__init__.py @@ -1,7 +1,7 @@ from flask import Blueprint from .reports import get_company_saved_searches, get_subscriber_activity_report, \ get_user_saved_searches, get_company_products, get_product_stories, get_company_report, \ - get_content_activity_report, get_company_api_usage + get_content_activity_report, get_company_api_usage, get_product_company blueprint = Blueprint('reports', __name__) @@ -15,6 +15,7 @@ 'subscriber-activity': get_subscriber_activity_report, 'content-activity': get_content_activity_report, 'company-news-api-usage': get_company_api_usage, + 'product-companies': get_product_company, } diff --git a/newsroom/reports/reports.py b/newsroom/reports/reports.py index 9bfdcf003..2f1d25993 100644 --- a/newsroom/reports/reports.py +++ b/newsroom/reports/reports.py @@ -320,3 +320,35 @@ def get_company_api_usage(): 'result_headers': unique_endpoints, } return results + + +def get_company_names(companies): + service = superdesk.get_resource_service('companies') + enabled_companies = [] + disabled_companies = [] + for company in companies: + company = service.find_one(req=None, _id=company) + if company: + if not company.get('is_enabled'): + disabled_companies.append(company.get('name')) + else: + enabled_companies.append(company.get('name')) + return {'enabled_companies': enabled_companies, 'disabled_companies': disabled_companies} + + +def get_product_company(): + args = deepcopy(request.args.to_dict()) + lookup = {'_id': ObjectId(args.get('product'))} if args.get('product') else None + products = query_resource('products', lookup=lookup) + + res = [{'_id': product.get('_id'), 'product': product.get('name'), 'companies': product.get('companies', [])} for + product in products] + + for r in res: + r.update(get_company_names(r.get('companies', []))) + + results = { + 'results': res, + 'name': gettext('Companies permissioned per product') + } + return results diff --git a/tests/test_reports.py b/tests/test_reports.py index 26246070f..516827af4 100644 --- a/tests/test_reports.py +++ b/tests/test_reports.py @@ -118,3 +118,29 @@ def test_company_products(client, app): assert len(report['results'][0]['products']) == 1 assert report['results'][1]['name'] == 'Press Co.' assert len(report['results'][1]['products']) == 2 + + +def test_product_companies(client, app): + app.data.insert('products', [{ + '_id': 'p-1', + 'name': 'Sport', + 'description': 'sport product', + 'companies': ['59bc460f1d41c8fa815cc2c2'], + 'is_enabled': True, + }, { + '_id': 'p-2', + 'name': 'News', + 'description': 'news product', + 'companies': ['59bc460f1d41c8fa815cc2c2', '59c38b965057fb87d7eda9ab'], + 'is_enabled': True, + }]) + + test_login_succeeds_for_admin(client) + resp = client.get('reports/product-companies') + report = json.loads(resp.get_data()) + assert report['name'] == 'Companies permissioned per product' + assert len(report['results']) == 2 + assert report['results'][0]['product'] == 'News' + assert len(report['results'][0]['enabled_companies']) == 2 + assert report['results'][1]['product'] == 'Sport' + assert len(report['results'][1]['enabled_companies']) == 1 From 92e5f4b026bb4566bb3d0821ee813ec7aaad58c7 Mon Sep 17 00:00:00 2001 From: MarkLark86 Date: Mon, 1 Jun 2020 16:44:34 +1000 Subject: [PATCH 8/8] [SDCP-221] Add product to url filters (#1053) Show product, category and subject name in the breadcrumbs --- .../cards/render/MoreNewsButton.jsx | 4 +-- assets/layout/components/BaseApp.jsx | 8 ++++- assets/search/actions.js | 11 ++++++ assets/search/reducers.js | 10 ++++++ assets/search/selectors.js | 15 ++++++-- assets/search/utils.js | 14 +++++++- assets/wire/actions.js | 3 +- assets/wire/components/PreviewTags.jsx | 6 ++-- assets/wire/components/WireApp.jsx | 19 +++++++++- assets/wire/reducers.js | 7 +++- newsroom/products/products.py | 17 +++++++++ newsroom/search.py | 26 ++++++++++---- newsroom/static/dist/manifest.json | 36 +++++++++++-------- newsroom/wire/views.py | 8 +++-- 14 files changed, 148 insertions(+), 36 deletions(-) diff --git a/assets/components/cards/render/MoreNewsButton.jsx b/assets/components/cards/render/MoreNewsButton.jsx index 9219daccb..6f329323d 100644 --- a/assets/components/cards/render/MoreNewsButton.jsx +++ b/assets/components/cards/render/MoreNewsButton.jsx @@ -1,6 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { gettext, getProductQuery } from 'utils'; +import {gettext} from 'utils'; function MoreNewsButton({title, product, photoUrl, photoUrlLabel}) { return ([
@@ -8,7 +8,7 @@ function MoreNewsButton({title, product, photoUrl, photoUrlLabel}) {
,
{product && - + {gettext('More news')} } {photoUrl && diff --git a/assets/layout/components/BaseApp.jsx b/assets/layout/components/BaseApp.jsx index 5479c38d1..4b1080825 100644 --- a/assets/layout/components/BaseApp.jsx +++ b/assets/layout/components/BaseApp.jsx @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import { get } from 'lodash'; import { createPortal } from 'react-dom'; import { isTouchDevice, gettext, isDisplayed } from 'utils'; +import {getSingleFilterValue} from 'search/utils'; // tabs import TopicsTab from 'search/components/TopicsTab'; @@ -59,7 +60,7 @@ export default class BaseApp extends React.Component { } } - renderNavBreadcrumb(navigations, activeNavigation, activeTopic) { + renderNavBreadcrumb(navigations, activeNavigation, activeTopic, activeProduct = null, activeFilter = null) { const dest = document.getElementById('nav-breadcrumb'); if (!dest) { return null; @@ -67,6 +68,7 @@ export default class BaseApp extends React.Component { let name; const numNavigations = get(activeNavigation, 'length', 0); + const filterValue = getSingleFilterValue(activeFilter, ['genre', 'subject']); if (activeTopic) { name = `/ ${activeTopic.label}`; @@ -74,6 +76,10 @@ export default class BaseApp extends React.Component { name = '/ ' + gettext('Custom View'); } else if (numNavigations === 1) { name = '/ ' + get(navigations.find((nav) => nav._id === activeNavigation[0]), 'name', ''); + } else if (activeProduct != null) { + name = `/ ${activeProduct.name}`; + } else if (filterValue !== null) { + name = `/ ${filterValue}`; } else { name = ''; } diff --git a/assets/search/actions.js b/assets/search/actions.js index c0ea92409..98c60f08b 100644 --- a/assets/search/actions.js +++ b/assets/search/actions.js @@ -100,6 +100,7 @@ export function toggleNavigation(navigation, disableSameNavigationDeselect) { created: null, navigation: getNavigationUrlParam(newNavigation, false), filter: null, + product: null, }, state ); @@ -342,6 +343,11 @@ export function setSearchCreated(created) { return {type: SET_SEARCH_CREATED, payload: created}; } +export const SET_SEARCH_PRODUCT = 'SET_SEARCH_PRODUCT'; +export function setSearchProduct(productId) { + return {type: SET_SEARCH_PRODUCT, payload: productId}; +} + export const RESET_SEARCH_PARAMS = 'RESET_SEARCH_PARAMS'; export function resetSearchParams() { return {type: RESET_SEARCH_PARAMS}; @@ -364,6 +370,10 @@ export function setParams(params) { if (get(params, 'filter')) { dispatch(setSearchFilters(params.filter)); } + + if (get(params, 'product')) { + dispatch(setSearchProduct(params.product)); + } }; } @@ -379,6 +389,7 @@ export function initParams(params) { created: params.get('created') ? JSON.parse(params.get('created')) : null, navigation: params.get('navigation') ? JSON.parse(params.get('navigation')) : null, filter: params.get('filter') ? JSON.parse(params.get('filter')) : null, + product: params.get('product'), }; let topic = {}; diff --git a/assets/search/reducers.js b/assets/search/reducers.js index 986631d91..ed5914d3b 100644 --- a/assets/search/reducers.js +++ b/assets/search/reducers.js @@ -14,6 +14,7 @@ import { SET_SEARCH_QUERY, SET_SEARCH_FILTERS, SET_SEARCH_CREATED, + SET_SEARCH_PRODUCT, RESET_SEARCH_PARAMS, } from './actions'; @@ -25,8 +26,10 @@ const INITIAL_STATE = { activeQuery: '', activeFilter: {}, createdFilter: {}, + productId: null, navigations: [], + products: [], activeView: EXTENDED_VIEW, }; @@ -124,6 +127,12 @@ export function searchReducer(state=INITIAL_STATE, action) { createdFilter: action.payload, }; + case SET_SEARCH_PRODUCT: + return { + ...state, + productId: action.payload, + }; + case RESET_SEARCH_PARAMS: return { ...state, @@ -132,6 +141,7 @@ export function searchReducer(state=INITIAL_STATE, action) { activeQuery: INITIAL_STATE.activeQuery, activeFilter: INITIAL_STATE.activeFilter, createdFilter: INITIAL_STATE.createdFilter, + productId: INITIAL_STATE.productId, }; default: diff --git a/assets/search/selectors.js b/assets/search/selectors.js index e64b5545d..90c0c703c 100644 --- a/assets/search/selectors.js +++ b/assets/search/selectors.js @@ -6,22 +6,29 @@ export const searchFilterSelector = (state) => get(state, 'search.activeFilter') export const searchCreatedSelector = (state) => get(state, 'search.createdFilter'); export const searchNavigationSelector = (state) => get(state, 'search.activeNavigation') || []; export const searchTopicIdSelector = (state) => get(state, 'search.activeTopic') || null; +export const searchProductSelector = (state) => get(state, 'search.productId') || null; export const activeViewSelector = (state) => get(state, 'search.activeView'); export const navigationsSelector = (state) => get(state, 'search.navigations') || []; export const topicsSelector = (state) => get(state, 'topics') || []; +export const productsSelector = (state) => get(state, 'search.products') || []; export const activeTopicSelector = createSelector( [searchTopicIdSelector, topicsSelector], (topicId, topics) => find(topics, {'_id': topicId}) ); +export const activeProductSelector = createSelector( + [searchProductSelector, productsSelector], + (productId, products) => find(products, {'_id': productId}) +); + export const resultsFilteredSelector = (state) => state.resultsFiltered; export const searchParamsSelector = createSelector( - [searchQuerySelector, searchCreatedSelector, searchNavigationSelector, searchFilterSelector], - (query, created, navigation, filter) => { + [searchQuerySelector, searchCreatedSelector, searchNavigationSelector, searchFilterSelector, searchProductSelector], + (query, created, navigation, filter, product) => { const params = {}; if (!isEmpty(query)) { @@ -36,6 +43,10 @@ export const searchParamsSelector = createSelector( params.navigation = navigation; } + if (product) { + params.product = product; + } + if (filter && Object.keys(filter).length > 0) { params.filter = {}; Object.keys(filter).forEach((key) => { diff --git a/assets/search/utils.js b/assets/search/utils.js index 1d63e599a..391c63076 100644 --- a/assets/search/utils.js +++ b/assets/search/utils.js @@ -17,7 +17,7 @@ export const getNavigationUrlParam = (activeNavigation, ignoreEmpty = true, useJ export const getSearchParams = (custom, topic) => { const params = {}; - ['query', 'created', 'navigation', 'filter'].forEach( + ['query', 'created', 'navigation', 'filter', 'product'].forEach( (field) => { if (get(custom, field)) { params[field] = custom[field]; @@ -29,3 +29,15 @@ export const getSearchParams = (custom, topic) => { return params; }; + +export const getSingleFilterValue = (activeFilter, fields) => { + const filterKeys = Object.keys(activeFilter || {}); + + if (filterKeys.length !== 1 || !fields.includes(filterKeys[0])) { + return null; + } else if (activeFilter[filterKeys[0]].length === 1) { + return activeFilter[filterKeys[0]][0]; + } + + return null; +}; diff --git a/assets/wire/actions.js b/assets/wire/actions.js index 1e7ff4c87..3389e1372 100644 --- a/assets/wire/actions.js +++ b/assets/wire/actions.js @@ -232,7 +232,8 @@ export function search(state, next) { created_from: createdFilter.from, created_to, timezone_offset: getTimezoneOffset(), - newsOnly + newsOnly, + product: searchParams.product }; const queryString = Object.keys(params) diff --git a/assets/wire/components/PreviewTags.jsx b/assets/wire/components/PreviewTags.jsx index 89902374f..98dd03a61 100644 --- a/assets/wire/components/PreviewTags.jsx +++ b/assets/wire/components/PreviewTags.jsx @@ -11,15 +11,15 @@ import ArticleSlugline from 'ui/components/ArticleSlugline'; function formatCV(items, field) { return items && uniqBy(items, (item) => item.code).map((item) => ( )); } function PreviewTags({item, isItemDetail, displayConfig}) { - const genres = item.genre && formatCV(item.genre, 'genre.name'); - const subjects = item.subject && formatCV(item.subject, 'subject.name'); + const genres = item.genre && formatCV(item.genre, 'genre'); + const subjects = item.subject && formatCV(item.subject, 'subject'); return ( diff --git a/assets/wire/components/WireApp.jsx b/assets/wire/components/WireApp.jsx index f184d0563..882cd4cf0 100644 --- a/assets/wire/components/WireApp.jsx +++ b/assets/wire/components/WireApp.jsx @@ -5,6 +5,7 @@ import {connect} from 'react-redux'; import {get, isEqual} from 'lodash'; import {gettext, getItemFromArray, DISPLAY_NEWS_ONLY} from 'utils'; +import {getSingleFilterValue} from 'search/utils'; import { fetchItems, @@ -27,6 +28,8 @@ import { navigationsSelector, searchNavigationSelector, activeTopicSelector, + activeProductSelector, + searchFilterSelector, searchParamsSelector, showSaveTopicSelector, } from 'search/selectors'; @@ -88,6 +91,7 @@ class WireApp extends BaseApp { let showTotalItems = false; let showTotalLabel = false; let totalItemsLabel; + const filterValue = getSingleFilterValue(this.props.activeFilter, ['genre', 'subject']); if (get(this.props, 'context') === 'wire') { if (get(this.props, 'activeTopic.label')) { @@ -99,6 +103,12 @@ class WireApp extends BaseApp { this.props.navigations ), 'name') || ''; showTotalItems = showTotalLabel = true; + } else if (get(this.props, 'activeProduct.name')) { + totalItemsLabel = this.props.activeProduct.name; + showTotalItems = showTotalLabel = true; + } else if (filterValue !== null) { + totalItemsLabel = filterValue; + showTotalItems = showTotalLabel = true; } else if (numNavigations > 1) { totalItemsLabel = gettext('Custom View'); showTotalItems = showTotalLabel = true; @@ -225,7 +235,10 @@ class WireApp extends BaseApp { this.renderNavBreadcrumb( this.props.navigations, this.props.activeNavigation, - this.props.activeTopic), + this.props.activeTopic, + this.props.activeProduct, + this.props.activeFilter + ), this.renderSavedItemsCount(), ]) ); @@ -262,6 +275,8 @@ WireApp.propTypes = { toggleNews: PropTypes.func, newsOnly: PropTypes.bool, activeTopic: PropTypes.object, + activeProduct: PropTypes.object, + activeFilter: PropTypes.object, savedItemsCount: PropTypes.number, userSections: PropTypes.object, context: PropTypes.string.isRequired, @@ -295,6 +310,8 @@ const mapStateToProps = (state) => ({ savedItemsCount: state.savedItemsCount, userSections: state.userSections, activeTopic: activeTopicSelector(state), + activeProduct: activeProductSelector(state), + activeFilter: searchFilterSelector(state), context: state.context, previewConfig: previewConfigSelector(state), detailsConfig: detailsConfigSelector(state), diff --git a/assets/wire/reducers.js b/assets/wire/reducers.js index 2e7ed3d04..a70f41cfe 100644 --- a/assets/wire/reducers.js +++ b/assets/wire/reducers.js @@ -113,6 +113,8 @@ export default function wireReducer(state = initialState, action) { case INIT_DATA: { const navigations = get(action, 'wireData.navigations', []); + const products = get(action, 'wireData.products', []); + return { ...state, readItems: action.readData || {}, @@ -124,7 +126,10 @@ export default function wireReducer(state = initialState, action) { formats: action.wireData.formats || [], secondaryFormats: get(action, 'wireData.secondary_formats') || [], wire: Object.assign({}, state.wire, {newsOnly: action.newsOnly}), - search: Object.assign({}, state.search, {navigations}), + search: Object.assign({}, state.search, { + navigations, + products, + }), context: action.wireData.context || 'wire', savedItemsCount: action.wireData.saved_items || null, userSections: action.wireData.userSections || {}, diff --git a/newsroom/products/products.py b/newsroom/products/products.py index 2e5596fe5..4cb1297ba 100644 --- a/newsroom/products/products.py +++ b/newsroom/products/products.py @@ -1,3 +1,5 @@ +from bson import ObjectId + import newsroom import superdesk @@ -75,6 +77,21 @@ def get_products_by_navigation(navigation_id, product_type=None): return list(superdesk.get_resource_service('products').get(req=None, lookup=lookup)) +def get_product_by_id(product_id, product_type=None, company_id=None): + lookup = { + '_id': ObjectId(product_id), + 'is_enabled': True + } + + if company_id is not None: + lookup['companies'] = str(company_id) + + if product_type is not None: + lookup['product_type'] = product_type + + return list(superdesk.get_resource_service('products').get(req=None, lookup=lookup)) + + def get_products_by_company(company_id, navigation_id=None, product_type=None): """Get the list of products for a company diff --git a/newsroom/search.py b/newsroom/search.py index cd43f409e..4afb70c2b 100644 --- a/newsroom/search.py +++ b/newsroom/search.py @@ -7,7 +7,7 @@ from content_api.errors import BadParameterValueError from newsroom import Service -from newsroom.products.products import get_products_by_navigation, get_products_by_company +from newsroom.products.products import get_products_by_navigation, get_products_by_company, get_product_by_id from newsroom.auth import get_user from newsroom.companies import get_user_company from newsroom.settings import get_setting @@ -277,16 +277,28 @@ def prefill_search_products(self, search): search.navigation_ids, product_type=search.section ) + elif search.args.get('product'): + search.products = get_product_by_id( + search.args['product'], + product_type=search.section + ) else: # admin will see everything by default, # regardless of company products search.products = [] elif search.company: - search.products = get_products_by_company( - search.company.get('_id'), - search.navigation_ids, - product_type=search.section - ) + if search.args.get('product'): + search.products = get_product_by_id( + search.args['product'], + product_type=search.section, + company_id=search.company + ) + else: + search.products = get_products_by_company( + search.company.get('_id'), + search.navigation_ids, + product_type=search.section + ) else: search.products = [] @@ -374,7 +386,7 @@ def apply_products_filter(self, search): :param SearchQuery search: the search query instance """ - if search.is_admin and not len(search.navigation_ids): + if search.is_admin and not len(search.navigation_ids) and not search.args.get('product'): # admin will see everything by default return diff --git a/newsroom/static/dist/manifest.json b/newsroom/static/dist/manifest.json index 8b934364f..069e4a290 100644 --- a/newsroom/static/dist/manifest.json +++ b/newsroom/static/dist/manifest.json @@ -1,16 +1,24 @@ { - "cards_js.js": "cards_js.f36bf311dcf5de326a31.js", - "common.js": "common.e988ae75b277acdcd4d6.js", - "companies_js.js": "companies_js.b5338013ccf073c0065a.js", - "company_reports_js.js": "company_reports_js.89e6e78a1993adbe5357.js", - "home_js.js": "home_js.2fafea79fb57de57d868.js", - "navigations_js.js": "navigations_js.31b1d8ef90beacd22462.js", - "newsroom_css.js": "newsroom_css.1592fdbd2085211a5faa.js", - "newsroom_js.js": "newsroom_js.90888570fc351d16c21d.js", - "notifications_js.js": "notifications_js.dd3d41a0820ce20f4ee8.js", - "print_reports_js.js": "print_reports_js.b90d0eb0e7e46426a7de.js", - "products_js.js": "products_js.6272f395248d7d8d1695.js", - "user_profile_js.js": "user_profile_js.172f2ff380ad41f3bfea.js", - "users_js.js": "users_js.cd559ef9a6321e2658b3.js", - "wire_js.js": "wire_js.6364888360c00858da45.js" + "agenda_js.js": "agenda_js.fbd42c77dd732b5fa956.js", + "am_news_css.js": "am_news_css.2a33171ad508b2bd0892.js", + "am_news_js.js": "am_news_js.06396022bf280e22ae7c.js", + "cards_js.js": "cards_js.ebb7095f0ff47bfd9576.js", + "common.js": "common.55fe48df9cc426024dfb.js", + "companies_js.js": "companies_js.4c5445eae9b30ef31478.js", + "company_reports_js.js": "company_reports_js.b7215de1260f7d62dd21.js", + "general-settings_js.js": "general-settings_js.1fbf0e87867687790425.js", + "home_js.js": "home_js.5ddaf71fa28bdc93dbfd.js", + "market_place_js.js": "market_place_js.9ae8ecbdaf90b976d8d8.js", + "media_releases_js.js": "media_releases_js.a3ad3a63e4234a4b0fd7.js", + "monitoring_js.js": "monitoring_js.156ede6f3907d5bfea23.js", + "navigations_js.js": "navigations_js.27ac51cc4c1f7b534ab2.js", + "newsroom_css.js": "newsroom_css.54dd25cf99451b8df553.js", + "newsroom_js.js": "newsroom_js.ec3c80e982c9033dfa88.js", + "notifications_js.js": "notifications_js.f3bd07ad6c8401f5162a.js", + "print_reports_js.js": "print_reports_js.b1a123ac18a80b123f76.js", + "products_js.js": "products_js.c84f5c4e67e43b26fb5d.js", + "section-filters_js.js": "section-filters_js.6c25d01df2b96b8582de.js", + "user_profile_js.js": "user_profile_js.8235cca5fefa98000dda.js", + "users_js.js": "users_js.e00307e17543783643f0.js", + "wire_js.js": "wire_js.a6a0a91aa08042071b97.js" } \ No newline at end of file diff --git a/newsroom/wire/views.py b/newsroom/wire/views.py index da5a866df..6a674e168 100644 --- a/newsroom/wire/views.py +++ b/newsroom/wire/views.py @@ -67,16 +67,18 @@ def set_item_permission(item, permitted=True): def get_view_data(): user = get_user() topics = get_user_topics(user['_id']) if user else [] + company_id = str(user['company']) if user and user.get('company') else None + return { 'user': str(user['_id']) if user else None, 'user_type': (user or {}).get('user_type') or 'public', - 'company': str(user['company']) if user and user.get('company') else None, + 'company': company_id, 'topics': [t for t in topics if t.get('topic_type') == 'wire'], 'formats': [{'format': f['format'], 'name': f['name'], 'assets': f['assets']} for f in app.download_formatters.values() if 'wire' in f['types']], - 'navigations': get_navigations_by_company(str(user['company']) if user and user.get('company') else None, - product_type='wire'), + 'navigations': get_navigations_by_company(company_id, product_type='wire'), + 'products': get_products_by_company(company_id), 'saved_items': get_bookmarks_count(user['_id'], 'wire'), 'context': 'wire', 'ui_config': get_resource_service('ui_config').getSectionConfig('wire'),