diff --git a/e2e/cypress/e2e/wire/topics.cy.js b/e2e/cypress/e2e/wire/topics.cy.js index 05ee08bda..28758eed2 100644 --- a/e2e/cypress/e2e/wire/topics.cy.js +++ b/e2e/cypress/e2e/wire/topics.cy.js @@ -99,14 +99,10 @@ describe('Wire - Topics', function () { profileTopics.createNewFolder('Weather'); profileTopics.createNewFolder('Traffic'); - // TODO-ASYNC: The dragTopicToFolder action triggers a PATCH action over a nested resource. - // the url of the PATCH looks something like /api/users/445460066f6a58e1c6b11541/topics/66607674e471296eb3dde17c - // I suspect this issue should be solved by https://github.com/superdesk/superdesk-core/pull/2694 - // so I'm leaving it commented out for now - // profileTopics.dragTopicToFolder('Sofab Weather', 'Weather'); - // profileTopics - // .getTopicCardAction('Sofab Weather', 'Remove from folder') - // .should('exist'); + profileTopics.dragTopicToFolder('Sofab Weather', 'Weather'); + profileTopics + .getTopicCardAction('Sofab Weather', 'Remove from folder') + .should('exist'); // Open the Topic for editing, and check the search params etc profileTopics.getTopicCardAction('Sofab Weather', 'Edit').click(); diff --git a/e2e/cypress/e2e/wire/wire_topic.cy.js b/e2e/cypress/e2e/wire/wire_topic.cy.js index 7534c5944..89e0fc590 100644 --- a/e2e/cypress/e2e/wire/wire_topic.cy.js +++ b/e2e/cypress/e2e/wire/wire_topic.cy.js @@ -72,7 +72,7 @@ describe('Wire - Topic', function () { it('Rename folder in My Wire Topics', () => { addResources([ { - resource: 'topic_folders', + resource: 'user_topic_folders', items: [ { "_id": "652d2535b7e10e09ec704d6d", @@ -108,7 +108,7 @@ describe('Wire - Topic', function () { it('Delete a folder with content in My Wire Topic', () => { addResources([ { - resource: 'topic_folders', + resource: 'user_topic_folders', items: [ { "_id": "652d2535b7e10e09ec704d6d", @@ -122,7 +122,7 @@ describe('Wire - Topic', function () { resource: 'topics', items: [ { - "_id": "672d3d26f27b4d56d8d5a27s", + "_id": "672d3d26f27b4d56d8d5a272", "query": "Topic 1", "topic_type": "wire", "label": "Topic 1", @@ -169,16 +169,21 @@ describe('Wire - Topic', function () { beforeEach(() => { addResources([ { - resource: 'topic_folders', + resource: 'user_topic_folders', items: [ { - "_id": "652d2535b7e10e09ec704d6d", + "_id": "652d2535b7e10e09ec704d64", "name": "user folder", "section": "wire", "user": USERS.foobar.admin._id, }, + ], + }, + { + resource: 'company_topic_folders', + items: [ { - "_id": "672d3d26f27b4d52d8d5a87s", + "_id": "672d3d26f27b4d52d8d5a874", "section": "wire", "name": "company folder", "company": COMPANIES.foobar._id, @@ -241,7 +246,7 @@ describe('Wire - Topic', function () { it('Move My Topic to another folder', () => { addResources([ { - resource: 'topic_folders', + resource: 'user_topic_folders', items: [ { "_id": "652d2535b7e10e09ec704d6d", @@ -297,7 +302,7 @@ describe('Wire - Topic', function () { it('Remove My Topic from folder ', () => { addResources([ { - resource: 'topic_folders', + resource: 'user_topic_folders', items: [ { "_id": "652d2535b7e10e09ec704d6d", diff --git a/newsroom/agenda/views.py b/newsroom/agenda/views.py index 52e182713..6d61778d2 100644 --- a/newsroom/agenda/views.py +++ b/newsroom/agenda/views.py @@ -14,7 +14,8 @@ from newsroom.auth.utils import check_user_has_products from newsroom.products.products import get_products_by_company from newsroom.template_filters import is_admin_or_internal, is_admin -from newsroom.topics import get_company_folders, get_user_folders, get_user_topics +from newsroom.topics import get_user_topics +from newsroom.topics_folders import get_company_folders, get_user_folders from newsroom.navigations import get_navigations from newsroom.auth import get_company, get_user, get_user_id, get_user_required from newsroom.decorator import login_required, section @@ -129,7 +130,7 @@ async def search(): async def get_view_data() -> Dict: user = get_user_required() - topics = get_user_topics(user["_id"]) if user else [] + topics = await get_user_topics(user["_id"]) if user else [] company = get_company(user) products = get_products_by_company(company, product_type="agenda") if company else [] @@ -153,8 +154,8 @@ async def get_view_data() -> Dict: "ui_config": await ui_config_service.get_section_config("agenda"), "groups": get_groups(get_app_config("AGENDA_GROUPS", []), company), "has_agenda_featured_items": get_resource_service("agenda_featured").find_one(req=None) is not None, - "user_folders": get_user_folders(user, "agenda") if user else [], - "company_folders": get_company_folders(company, "agenda") if company else [], + "user_folders": await get_user_folders(user, "agenda") if user else [], + "company_folders": await get_company_folders(company, "agenda") if company else [], "date_filters": get_app_config("AGENDA_TIME_FILTERS", []), } diff --git a/newsroom/core/resources/service.py b/newsroom/core/resources/service.py index 01b3fd88f..4d0aca4f0 100644 --- a/newsroom/core/resources/service.py +++ b/newsroom/core/resources/service.py @@ -17,6 +17,7 @@ async def on_create(self, docs: list[NewshubResourceModelType]) -> None: await super().on_create(docs) for doc in docs: doc.original_creator = get_user_id() + doc.version_creator = get_user_id() async def on_update(self, updates: dict[str, Any], original: NewshubResourceModelType) -> None: await super().on_update(updates, original) diff --git a/newsroom/market_place/views.py b/newsroom/market_place/views.py index 0c2958da5..702e820ae 100644 --- a/newsroom/market_place/views.py +++ b/newsroom/market_place/views.py @@ -34,7 +34,7 @@ async def get_view_data(): """Get the view data""" user = get_user() - topics = get_user_topics(user["_id"]) if user else [] + topics = await get_user_topics(user["_id"]) if user else [] navigations = await get_navigations_by_company( str(user["company"]) if user and user.get("company") else None, product_type=SECTION_ID, diff --git a/newsroom/notifications/send_scheduled_notifications.py b/newsroom/notifications/send_scheduled_notifications.py index c3e41d6a7..c51a2e388 100644 --- a/newsroom/notifications/send_scheduled_notifications.py +++ b/newsroom/notifications/send_scheduled_notifications.py @@ -14,7 +14,7 @@ from newsroom.utils import get_user_dict, get_company_dict from newsroom.email import send_user_email from newsroom.celery_app import celery -from newsroom.topics.topics import get_user_id_to_topic_for_subscribers, TopicNotificationType +from newsroom.topics.topics_async import get_user_id_to_topic_for_subscribers, NotificationType from newsroom.gettext import get_session_timezone, set_session_timezone logger = logging.getLogger(__name__) @@ -47,7 +47,7 @@ async def run_schedules(self, force: bool): now_utc = utcnow().replace(second=0, microsecond=0) companies = get_company_dict(False) users = get_user_dict(False) - user_topic_map = get_user_id_to_topic_for_subscribers(TopicNotificationType.SCHEDULED.value) + user_topic_map = await get_user_id_to_topic_for_subscribers(NotificationType.SCHEDULED) schedules: List[NotificationQueue] = get_resource_service("notification_queue").get(req=None, lookup={}) except Exception as e: diff --git a/newsroom/push.py b/newsroom/push.py index a4700c5ba..bc58ab532 100644 --- a/newsroom/push.py +++ b/newsroom/push.py @@ -23,7 +23,7 @@ from superdesk.lock import lock, unlock from newsroom.notifications import push_notification, save_user_notifications, NotificationQueueService -from newsroom.topics.topics import ( +from newsroom.topics.topics_async import ( get_agenda_notification_topics_for_query_by_id, get_topics_with_subscribers, ) @@ -932,7 +932,7 @@ async def send_user_notification_emails(item, user_matches, users, section): async def notify_wire_topic_matches(item, users_dict, companies_dict) -> Set[ObjectId]: - topics = get_topics_with_subscribers("wire") + topics = await get_topics_with_subscribers("wire") topic_matches = superdesk.get_resource_service("wire_search").get_matching_topics( item["_id"], topics, users_dict, companies_dict ) @@ -945,7 +945,7 @@ async def notify_wire_topic_matches(item, users_dict, companies_dict) -> Set[Obj async def notify_agenda_topic_matches(item, users_dict, companies_dict) -> Set[ObjectId]: - topics = get_topics_with_subscribers("agenda") + topics = await get_topics_with_subscribers("agenda") topic_matches = superdesk.get_resource_service("agenda").get_matching_topics( item["_id"], topics, users_dict, companies_dict ) @@ -954,7 +954,7 @@ async def notify_agenda_topic_matches(item, users_dict, companies_dict) -> Set[O topic_matches.extend( [ topic - for topic in get_agenda_notification_topics_for_query_by_id(item, users_dict) + for topic in await get_agenda_notification_topics_for_query_by_id(item, users_dict) if topic.get("_id") not in topic_matches ] ) diff --git a/newsroom/reports/reports.py b/newsroom/reports/reports.py index f537caec1..ec456bd61 100644 --- a/newsroom/reports/reports.py +++ b/newsroom/reports/reports.py @@ -33,6 +33,8 @@ def get_company_saved_searches(): company_topics = defaultdict(int) companies = get_entity_dict(query_resource("companies")) users = get_entity_dict(query_resource("users")) + + # TODO-Async:- update when this reports resource convert to async topics = query_resource("topics") for topic in topics: @@ -60,6 +62,8 @@ def get_user_saved_searches(): user_topics = defaultdict(int) companies = get_entity_dict(query_resource("companies")) users = get_entity_dict(query_resource("users")) + + # TODO-Async:- update when this reports resource convert to async topics = query_resource("topics") for topic in topics: diff --git a/newsroom/topics/__init__.py b/newsroom/topics/__init__.py index b243b2295..744962c4b 100644 --- a/newsroom/topics/__init__.py +++ b/newsroom/topics/__init__.py @@ -1,45 +1,19 @@ -import superdesk -from superdesk.flask import Blueprint +from superdesk.core.module import Module -from .topics import get_user_topics # noqa -from . import folders, topics +from .topics_async import topic_resource_config, topic_endpoints, get_user_topics +from . import topics - -blueprint = Blueprint("topics", __name__) +__all__ = ["get_user_topics", "topic_endpoints", "topic_resource_config"] def init_app(app): topics.TopicsResource("topics", app, topics.topics_service) - folders.FoldersResource("topic_folders", app, folders.folders_service) - - superdesk.register_resource("user_topic_folders", folders.UserFoldersResource, folders.UserFoldersService, _app=app) - superdesk.register_resource( - "company_topic_folders", folders.CompanyFoldersResource, folders.CompanyFoldersService, _app=app - ) - - -def get_user_folders(user, section): - return list( - superdesk.get_resource_service("user_topic_folders").get( - req=None, - lookup={ - "user": user["_id"], - "section": section, - }, - ) - ) - -def get_company_folders(company, section): - return list( - superdesk.get_resource_service("company_topic_folders").get( - req=None, - lookup={ - "company": company["_id"], - "section": section, - }, - ) - ) +module = Module( + name="newsroom.topics", + resources=[topic_resource_config], + endpoints=[topic_endpoints], +) from . import views # noqa diff --git a/newsroom/topics/folders.py b/newsroom/topics/folders.py deleted file mode 100644 index de4b02ea3..000000000 --- a/newsroom/topics/folders.py +++ /dev/null @@ -1,91 +0,0 @@ -import newsroom -import superdesk - -from newsroom.user_roles import UserRole -from newsroom.signals import user_deleted - -from . import topics - - -class FoldersResource(newsroom.Resource): - resource_title = "topic_folders" - resource_methods = ["GET"] - item_methods = ["GET"] - collation = True - datasource = {"source": "topic_folders", "default_sort": [("name", 1)]} - schema = { - "name": {"type": "string", "required": True}, - "parent": newsroom.Resource.rel("topic_folders", nullable=True), - "section": { - "type": "string", - "required": True, - "allowed": ["wire", "agenda", "monitoring"], - }, - } - - mongo_indexes: newsroom.MongoIndexes = { - "unique_topic_folder_name": ( - [ - ("company", 1), - ("user", 1), - ("section", 1), - ("parent", 1), - ("name", 1), - ], - {"unique": True, "collation": {"locale": "en", "strength": 2}}, - ), - } - allowed_roles = [role for role in UserRole] - allowed_item_roles = allowed_roles - - -class UserFoldersResource(FoldersResource): - url = 'users//topic_folders' - regex_url = "users/([a-f0-9]{24})/topic_folders" - resource_title = "user_topic_folders" - resource_methods = ["GET", "POST"] - item_methods = ["GET", "PATCH", "DELETE"] - schema = FoldersResource.schema.copy() - schema.update( - { - "user": newsroom.Resource.rel("users", required=True), - } - ) - - -class CompanyFoldersResource(FoldersResource): - url = 'companies//topic_folders' - regex_url = "companies/([a-f0-9]{24})/topic_folders" - resource_title = "company_topic_folders" - resource_methods = ["GET", "POST"] - item_methods = ["GET", "PATCH", "DELETE"] - schema = FoldersResource.schema.copy() - schema.update( - { - "company": newsroom.Resource.rel("companies", required=True), - } - ) - - -class FoldersService(newsroom.Service): - def __init__(self, datasource: str, backend=None): - super().__init__(datasource, backend) - user_deleted.connect(self.on_user_deleted) - - def on_deleted(self, doc): - self.delete_action({"parent": doc["_id"]}) - topics.topics_service.delete_action({"folder": doc["_id"]}) - - def on_user_deleted(self, sender, user, **kwargs): - self.delete_action({"user": user["_id"]}) - - -class UserFoldersService(FoldersService): - pass - - -class CompanyFoldersService(FoldersService): - pass - - -folders_service = FoldersService("topic_folders", superdesk.get_backend()) diff --git a/newsroom/topics/topics.py b/newsroom/topics/topics.py index ab5795c0e..88450d31c 100644 --- a/newsroom/topics/topics.py +++ b/newsroom/topics/topics.py @@ -1,3 +1,5 @@ +# TODO-ASYNC :- Remove this resource when Reports module is converted to async + from typing import Optional, List, Dict, Any import enum @@ -65,6 +67,7 @@ class TopicsResource(newsroom.Resource): datasource = {"source": "topics", "default_sort": [("label", 1)]} allowed_roles = [role for role in UserRole] allowed_item_roles = allowed_roles + internal_resource = True class TopicsService(newsroom.Service): diff --git a/newsroom/topics/topics_async.py b/newsroom/topics/topics_async.py new file mode 100644 index 000000000..5c18ad489 --- /dev/null +++ b/newsroom/topics/topics_async.py @@ -0,0 +1,259 @@ +from enum import Enum, unique +from bson import ObjectId +from pydantic import Field +from typing import Optional, List, Dict, Any, Annotated, Union + +from newsroom import MONGO_PREFIX +from newsroom.users.utils import get_user_or_abort + +# from newsroom.signals import user_deleted + +from newsroom.users.service import UsersService +from newsroom.core.resources.model import NewshubResourceModel +from newsroom.core.resources.service import NewshubAsyncResourceService +from newsroom.types import User, Topic + +from superdesk.core.web import EndpointGroup +from superdesk.core.resources import dataclass + +# from superdesk.core.module import SuperdeskAsyncApp +from superdesk.core.resources.fields import ObjectId as ObjectIdField +from superdesk.core.resources import ResourceConfig, MongoResourceConfig, RestEndpointConfig, RestParentLink +from superdesk.core.resources.validators import validate_data_relation_async + + +@unique +class NotificationType(str, Enum): + NONE = "none" + REAL_TIME = "real-time" + SCHEDULED = "scheduled" + + +@unique +class TopicType(str, Enum): + WIRE = "wire" + AGENDA = "agenda" + + +@dataclass +class TopicSubscriber: + user_id: Annotated[ObjectIdField, validate_data_relation_async("users")] + notification_type: NotificationType = NotificationType.REAL_TIME + + +class TopicResourceModel(NewshubResourceModel): + label: str + query: Optional[str] = None + filter: Optional[Dict[str, Any]] = None + created_filter: Annotated[Optional[Dict[str, Any]], Field(alias="created")] = None + user: Annotated[Optional[ObjectIdField], validate_data_relation_async("users")] = None + company: Annotated[Optional[ObjectIdField], validate_data_relation_async("companies")] = None + is_global: bool = False + subscribers: Optional[List[TopicSubscriber]] = [] + timezone_offset: Optional[int] = None + topic_type: TopicType + navigation: Optional[List[Annotated[ObjectIdField, validate_data_relation_async("navigations")]]] = None + folder: Annotated[Optional[ObjectIdField], validate_data_relation_async("topic_folders")] = None + advanced: Optional[Dict[str, Any]] = None + + +class TopicService(NewshubAsyncResourceService[TopicResourceModel]): + async def on_create(self, docs: List[TopicResourceModel]) -> None: + return await super().on_create(docs) + + async def on_update(self, updates: Dict[str, Any], original: TopicResourceModel) -> None: + await super().on_update(updates, original) + # If ``is_global`` has been turned off, then remove all subscribers + # except for the owner of the Topic + if original.is_global and "is_global" in updates and not updates.get("is_global"): + # First find the subscriber entry for the original user + subscriber = next( + ( + subscriber + for subscriber in (updates.get("subscribers") or original.subscribers or []) + if subscriber.user_id == original.user + ), + None, + ) + + # Then construct new array with either subscriber found or empty list + updates["subscribers"] = [subscriber] if subscriber is not None else [] + + if updates.get("folder"): + updates["folder"] = ObjectId(updates["folder"]) + + async def on_updated(self, updates: Dict[str, Any], original: TopicResourceModel) -> None: + await super().on_updated(updates, original) + current_user = await get_user_or_abort() + + if current_user: + user_dict = current_user.to_dict() + await auto_enable_user_emails(updates, original, user_dict) + + async def on_delete(self, doc: TopicResourceModel): + await super().on_delete(doc) + users = await UsersService().search(lookup={"dashboards.topic_ids": doc.id}) + async for user in users: + updates = {"dashboards": user.dashboards.copy()} + updated_dashboards = [] + + for dashboard in updates["dashboards"]: + dashboard_dict = dashboard.to_dict() + # Remove the deleted topic id from topic_ids + dashboard_dict["topic_ids"] = [ + topic_id for topic_id in dashboard_dict["topic_ids"] if topic_id != doc.id + ] + updated_dashboards.append(dashboard_dict) + + updates["dashboards"] = updated_dashboards + await UsersService().system_update(user.id, updates=updates) + + async def on_user_deleted(self, sender, user: User, **kwargs): + """ + Handle the cleanup of user-related topics when a user is deleted. + + This function is tbriggered by the `user_deleted` signal + + """ + # delete user private topics + await self.delete_many(lookup={"is_global": False, "user": user["_id"]}) + + # remove user topic subscriptions from existing topics + + topics = await self.search(lookup={"subscribers.user_id": user["_id"]}) + + user_object_id = ObjectId(user["_id"]) + + async for topic in topics: + updates = dict( + subscribers=[s for s in topic.subscribers if s["user_id"] != user_object_id], + ) + + if topic.user == user_object_id: + topic.user = None + + self.update(topic.id, updates) + + # remove user as a topic creator for the rest + user_topics = await self.search(lookup={"user": user["_id"]}) + async for topic in user_topics: + await self.update(topic.id, {"user": None}) + + +async def get_user_topics(user_id: Union[ObjectId, str, None]) -> List[Topic]: + if not user_id: + return [] + user = await UsersService().find_by_id(user_id) + data = await TopicService().find( + { + "$or": [ + {"user": user.id}, + {"$and": [{"company": user.company}, {"is_global": True}]}, + ] + } + ) + return await data.to_list_raw() + + +async def get_topics_with_subscribers(topic_type: Optional[str] = None) -> List[Topic]: + lookup: Dict[str, Any] = ( + {"subscribers": {"$exists": True, "$ne": []}} + if topic_type is None + else { + "$and": [ + {"subscribers": {"$exists": True, "$ne": []}}, + {"topic_type": topic_type}, + ] + } + ) + + mongo_cursor = await TopicService().search(lookup=lookup) + + return await mongo_cursor.to_list_raw() + + +async def get_user_id_to_topic_for_subscribers( + notification_type: Optional[str] = None, +) -> Dict[ObjectId, Dict[ObjectId, Topic]]: + user_topic_map: Dict[ObjectId, Dict[ObjectId, Topic]] = {} + for topic in await get_topics_with_subscribers(): + for subscriber in topic.get("subscribers") or []: + if notification_type is not None and subscriber.get("notification_type") != notification_type: + continue + user_topic_map.setdefault(subscriber["user_id"], {}) + user_topic_map[subscriber["user_id"]][topic["_id"]] = topic + + return user_topic_map + + +async def get_agenda_notification_topics_for_query_by_id(item, users): + """ + Returns active topics for a given agenda item + :param item: agenda item + :param users: active users dict + :return: list of topics + """ + lookup = { + "$and": [ + {"subscribers": {"$exists": True, "$ne": []}}, + {"topic_type": "agenda"}, + {"query": item["_id"]}, + ] + } + + mongo_cursor = await TopicService().search(lookup=lookup) + topics = await mongo_cursor.to_list_raw() + + # filter out the topics those belong to inactive users + return [t for t in topics if users.get(str(t["user"]))] + + +async def auto_enable_user_emails( + updates: Topic | dict[str, Any], + original: TopicResourceModel | dict[str, Any], + user: Optional[User | dict[str, Any]], +): + if not updates.get("subscribers"): + return + + if not user: + return + + # If current user is already subscribed to this topic, + # then no need to enable their email notifications + data = original.to_dict() if isinstance(original, TopicResourceModel) else original + for subscriber in data.get("subscribers", []): + if str(subscriber.get("user_id")) == str(user["_id"]): + return # User already subscribed, no need to enable emails + + user_newly_subscribed = False + for subscriber in updates.get("subscribers", []): + if str(subscriber.get("user_id")) == str(user["_id"]): + user_newly_subscribed = True + break + + if not user_newly_subscribed: + return + + # The current user subscribed to this topic in this update + # Enable their email notifications now + await UsersService().update(user["_id"], updates={"receive_email": True}) + + +# TODO-ASYNC, need to wait for SDESK-7376 + +# async def init(app: SuperdeskAsyncApp): +# user_deleted.connect(await TopicService().on_user_deleted) # type: ignore + + +topic_resource_config = ResourceConfig( + name="topics", + data_class=TopicResourceModel, + service=TopicService, + mongo=MongoResourceConfig(prefix=MONGO_PREFIX), + rest_endpoints=RestEndpointConfig( + parent_links=[RestParentLink(resource_name="users", model_id_field="user")], url="topics" + ), +) + +topic_endpoints = EndpointGroup("topic", __name__) diff --git a/newsroom/topics/views.py b/newsroom/topics/views.py index a891a75d5..1e0287481 100644 --- a/newsroom/topics/views.py +++ b/newsroom/topics/views.py @@ -1,70 +1,100 @@ from bson import ObjectId +from typing import Optional +from pydantic import BaseModel +from superdesk.utc import utcnow from superdesk.core import json, get_app_config -from superdesk import get_resource_service -from superdesk.flask import jsonify, abort, url_for, session +from superdesk.flask import url_for, session +from superdesk.core.web import Request, Response from newsroom.types import Topic -from newsroom.topics import blueprint -from newsroom.topics.topics import get_user_topics as _get_user_topics, auto_enable_user_emails -from newsroom.auth import get_user, get_user_id +from newsroom.email import send_user_email from newsroom.decorator import login_required +from newsroom.auth import get_user, get_user_id +from newsroom.topics.topics_async import get_user_topics as _get_user_topics, auto_enable_user_emails from newsroom.utils import get_json_or_400, get_entity_or_404 -from newsroom.email import send_user_email from newsroom.notifications import push_user_notification, push_company_notification, save_user_notifications +from .topics_async import topic_endpoints, TopicService +from newsroom.users.service import UsersService +from newsroom.users.utils import get_user_or_abort -@blueprint.route("/users/<_id>/topics", methods=["GET"]) +class RouteArguments(BaseModel): + user_id: Optional[str] = None + topic_id: Optional[str] = None + + +@topic_endpoints.endpoint("/users//topics", methods=["GET"]) @login_required -async def get_topics(_id): +async def get_topics(args: RouteArguments, params: None, request: Request): """Returns list of followed topics of given user""" - if session["user"] != str(_id): - abort(403) - return jsonify({"_items": _get_user_topics(_id)}), 200 + if session["user"] != str(args.user_id): + await request.abort(403) + + topics = await _get_user_topics(args.user_id) + return Response({"_items": topics}) -@blueprint.route("/users/<_id>/topics", methods=["POST"]) +@topic_endpoints.endpoint("/users//topics", methods=["POST"]) @login_required -async def post_topic(_id): +async def post_topic(args: RouteArguments, params: None, request: Request): """Creates a user topic""" - user = get_user() - if str(user["_id"]) != str(_id): - abort(403) + current_user = await get_user_or_abort() + + if str(current_user.id) != args.user_id: + await request.abort(403) topic = await get_json_or_400() - topic["user"] = user["_id"] - topic["company"] = user.get("company") + topic.update( + dict( + # TODO-ASYNC: Remove this once auto-generate ID feature is merged in superdesk-core + _id=ObjectId(), + user=current_user.id, + company=current_user.company, + # `_created` needs to be set otherwise there is a clash given `TopicResourceModel` and + # the base `ResourceModel` both have the same member (`created`). Without this + # `created_filter` does not get converted/saved + _created=utcnow(), + ) + ) for subscriber in topic.get("subscribers") or []: subscriber["user_id"] = ObjectId(subscriber["user_id"]) - ids = get_resource_service("topics").post([topic]) + ids = await TopicService().create([topic]) - auto_enable_user_emails(topic, {}, user) + await auto_enable_user_emails(topic, {}, current_user.to_dict()) if topic.get("is_global"): - push_company_notification("topic_created", user_id=str(user["_id"])) + push_company_notification("topic_created", user_id=str(current_user.id)) else: push_user_notification("topic_created") - return jsonify({"success": True, "_id": ids[0]}), 201 + return Response({"success": True, "_id": ids[0]}, 201) -@blueprint.route("/topics/my_topics", methods=["GET"]) + +@topic_endpoints.endpoint("/topics/my_topics", methods=["GET"]) @login_required -async def get_list_my_topics(): - return jsonify(_get_user_topics(get_user_id())), 200 +async def get_list_my_topics(args: RouteArguments, params: None, request: Request): + topics = await _get_user_topics(get_user_id()) + return Response(topics) -@blueprint.route("/topics/", methods=["POST"]) +@topic_endpoints.endpoint("/topics/", methods=["POST"]) @login_required -async def update_topic(topic_id): +async def update_topic(args: RouteArguments, params: None, request: Request): """Updates a followed topic""" data = await get_json_or_400() - current_user = get_user(required=True) - original = get_resource_service("topics").find_one(req=None, _id=ObjectId(topic_id)) - if not can_edit_topic(original, current_user): - abort(403) + current_user = await get_user_or_abort() + + if current_user: + user_dict = current_user.to_dict() + + original = await TopicService().find_by_id(args.topic_id) + + if not user_dict or not await can_edit_topic(original, user_dict): + await request.abort(403) updates: Topic = { "label": data.get("label"), @@ -72,7 +102,7 @@ async def update_topic(topic_id): "created": data.get("created"), "filter": data.get("filter"), "navigation": data.get("navigation"), - "company": current_user.get("company"), + "company": user_dict.get("company", None), "subscribers": data.get("subscribers") or [], "is_global": data.get("is_global", False), "folder": data.get("folder", None), @@ -82,75 +112,66 @@ async def update_topic(topic_id): for subscriber in updates["subscribers"]: subscriber["user_id"] = ObjectId(subscriber["user_id"]) - if ( - original - and updates.get("is_global") != original.get("is_global") - and original.get("folder") == updates.get("folder") - ): + if original and updates.get("is_global") != original.is_global and original.folder == updates.get("folder"): # reset folder when going from company to user and vice versa updates["folder"] = None - response = get_resource_service("topics").patch(id=ObjectId(topic_id), updates=updates) + await TopicService().update(args.topic_id, updates) + + topic = await TopicService().find_by_id(args.topic_id) - auto_enable_user_emails(updates, original, current_user) + await auto_enable_user_emails(updates, original, user_dict) - if response.get("is_global") or updates.get("is_global", False) != original.get("is_global", False): + if topic.is_global or updates.get("is_global", False) != original.is_global: push_company_notification("topics") else: push_user_notification("topics") - return jsonify({"success": True}), 200 + return Response({"success": True}) -@blueprint.route("/topics/", methods=["DELETE"]) + +@topic_endpoints.endpoint("/topics/", methods=["DELETE"]) @login_required -async def delete(topic_id): +async def delete(args: RouteArguments, params: None, request: Request): """Deletes a followed topic by given id""" + service = TopicService() current_user = get_user(required=True) - original = get_resource_service("topics").find_one(req=None, _id=ObjectId(topic_id)) + original = await service.find_by_id(args.topic_id) + + if not await can_edit_topic(original, current_user): + await request.abort(403) - if not can_edit_topic(original, current_user): - abort(403) + await service.delete(original) - get_resource_service("topics").delete_action({"_id": ObjectId(topic_id)}) - if original.get("is_global"): + if original.is_global: push_company_notification("topics") else: push_user_notification("topics") - return jsonify({"success": True}), 200 + + return Response({"success": True}) -def can_user_manage_topic(topic, user): +async def can_user_manage_topic(topic, user): """ Checks if the topic can be managed by the provided user """ return ( - topic.get("is_global") - and str(topic.get("company")) == str(user.get("company")) + topic.is_global + and str(topic.company) == str(user.get("company")) and (user.get("user_type") == "administrator" or user.get("manage_company_topics")) ) -def can_edit_topic(topic, user): +async def can_edit_topic(topic, user): """ Checks if the topic can be edited by the user """ - user_ids = [user.get("id") for user in topic.get("users") or []] - if topic and (str(topic.get("user")) == str(user["_id"]) or str(user["_id"]) in user_ids): + if topic and (str(topic.user) == str(user["_id"])): return True - return can_user_manage_topic(topic, user) + return await can_user_manage_topic(topic, user) -def is_user_or_company_topic(topic, user): - """Checks if the topic is owned by the user or global to the users company""" - - if topic.get("user") == user.get("_id"): - return True - elif topic.get("company") and topic.get("is_global", False): - return user.get("company") == topic.get("company") - return False - - -def get_topic_url(topic): +async def get_topic_url(topic): url_params = {} if topic.get("query"): url_params["q"] = topic.get("query") @@ -171,49 +192,51 @@ def get_topic_url(topic): ) -@blueprint.route("/topic_share", methods=["POST"]) +@topic_endpoints.endpoint("/topic_share", methods=["POST"]) @login_required -async def share(): +async def share(args: RouteArguments, params: None, request: Request): current_user = get_user(required=True) data = await get_json_or_400() assert data.get("users") assert data.get("items") topic = get_entity_or_404(data.get("items")["_id"], "topics") for user_id in data["users"]: - user = get_resource_service("users").find_one(req=None, _id=user_id) + user_data = await UsersService().find_by_id(user_id) + user = user_data.to_dict() if not user or not user.get("email"): continue - topic_url = get_topic_url(topic) - await save_user_notifications( - [ - dict( - user=user["_id"], - action="share", - resource="topic", - item=topic["_id"], - data=dict( - shared_by=dict( - _id=current_user["_id"], - first_name=current_user["first_name"], - last_name=current_user["last_name"], + topic_url = await get_topic_url(topic) + if current_user: + await save_user_notifications( + [ + dict( + user=user["_id"], + action="share", + resource="topic", + item=topic["_id"], + data=dict( + shared_by=dict( + _id=current_user["_id"], + first_name=current_user["first_name"], + last_name=current_user["last_name"], + ), + url=topic_url, ), - url=topic_url, - ), - ) - ] - ) - template_kwargs = { - "recipient": user, - "sender": current_user, - "topic": topic, - "url": topic_url, - "message": data.get("message"), - "app_name": get_app_config("SITE_NAME"), - } - await send_user_email( - user, - template="share_topic", - template_kwargs=template_kwargs, - ) - return jsonify(), 201 + ) + ] + ) + template_kwargs = { + "recipient": user, + "sender": current_user, + "topic": topic, + "url": topic_url, + "message": data.get("message"), + "app_name": get_app_config("SITE_NAME"), + } + await send_user_email( + user, + template="share_topic", + template_kwargs=template_kwargs, + ) + return Response({"success": True}, 201) diff --git a/newsroom/topics_folders/__init__.py b/newsroom/topics_folders/__init__.py new file mode 100644 index 000000000..97b201d44 --- /dev/null +++ b/newsroom/topics_folders/__init__.py @@ -0,0 +1,37 @@ +from superdesk.core.module import Module +from newsroom.types import Company, User +from typing import List, Dict, Any + +from .folders import ( + company_topic_folder_resource_config, + user_topic_folders_resource_config, + topic_folders_resource_config, + CompanyFoldersResourceService, + UserFoldersResourceService, +) + + +module = Module( + name="newsroom.topics_folders", + resources=[company_topic_folder_resource_config, user_topic_folders_resource_config, topic_folders_resource_config], +) + + +async def get_user_folders(user: User, section: str) -> List[Dict[str, Any]]: + mongo_cursor = await UserFoldersResourceService().search( + lookup={ + "user": user["_id"], + "section": section, + }, + ) + return await mongo_cursor.to_list_raw() + + +async def get_company_folders(company: Company, section: str) -> List[Dict[str, Any]]: + mongo_cursor = await CompanyFoldersResourceService().search( + lookup={ + "company": company["_id"], + "section": section, + }, + ) + return await mongo_cursor.to_list_raw() diff --git a/newsroom/topics_folders/folders.py b/newsroom/topics_folders/folders.py new file mode 100644 index 000000000..b3284c1bf --- /dev/null +++ b/newsroom/topics_folders/folders.py @@ -0,0 +1,116 @@ +from enum import Enum, unique +from typing import Optional, Annotated + +from newsroom import MONGO_PREFIX +from newsroom.core.resources.model import NewshubResourceModel +from newsroom.core.resources.service import NewshubAsyncResourceService + +# from newsroom.signals import user_deleted + +from newsroom.topics.topics_async import TopicService + +from superdesk.core.resources.fields import ObjectId as ObjectIdField +from superdesk.core.resources import ( + ResourceConfig, + MongoIndexOptions, + MongoResourceConfig, + RestEndpointConfig, + RestParentLink, +) +from superdesk.core.resources.validators import validate_data_relation_async + +# from superdesk.core.module import SuperdeskAsyncApp + + +@unique +class SectionType(str, Enum): + WIRE = "wire" + AGENDA = "agenda" + MONITORING = "monitoring" + + +class FolderResourceModel(NewshubResourceModel): + name: str + parent: Annotated[Optional[ObjectIdField], validate_data_relation_async("topic_folders")] = None + section: SectionType + + +class FolderResourceService(NewshubAsyncResourceService[FolderResourceModel]): + async def on_deleted(self, doc): + await self.delete_many(lookup={"parent": doc.id}) + await TopicService().delete_many(lookup={"folder": doc.id}) + + async def on_user_deleted(self, sender, user, **kwargs): + await self.delete_many(lookup={"user": user["_id"]}) + + +# TODO-ASYNC, need to wait for SDESK-7376 + +# async def init(app: SuperdeskAsyncApp): +# user_deleted.connect(await FolderResourceService().on_user_deleted) # type: ignore + + +topic_folders_resource_config = ResourceConfig( + name="topic_folders", + data_class=FolderResourceModel, + service=FolderResourceService, + mongo=MongoResourceConfig( + prefix=MONGO_PREFIX, + indexes=[ + MongoIndexOptions( + name="unique_topic_folder_name", + keys=[("company", 1), ("user", 1), ("section", 1), ("parent", 1), ("name", 1)], + unique=True, + collation={"locale": "en", "strength": 2}, + ) + ], + ), +) + + +class UserFoldersResourceModel(FolderResourceModel): + """ + User Based FolderResource Model + """ + + user: Annotated[ObjectIdField, validate_data_relation_async("users")] + + +class UserFoldersResourceService(FolderResourceService): + pass + + +user_topic_folders_resource_config = ResourceConfig( + name="user_topic_folders", + data_class=UserFoldersResourceModel, + service=UserFoldersResourceService, + mongo=MongoResourceConfig(prefix=MONGO_PREFIX), + datasource_name="topic_folders", + rest_endpoints=RestEndpointConfig( + parent_links=[RestParentLink(resource_name="users", model_id_field="user")], url="topic_folders" + ), +) + + +class CompanyFoldersResourceModel(FolderResourceModel): + """ + Company Based FolderResource Model + """ + + company: Annotated[ObjectIdField, validate_data_relation_async("companies")] + + +class CompanyFoldersResourceService(FolderResourceService): + pass + + +company_topic_folder_resource_config = ResourceConfig( + name="company_topic_folders", + data_class=CompanyFoldersResourceModel, + service=CompanyFoldersResourceService, + mongo=MongoResourceConfig(prefix=MONGO_PREFIX), + datasource_name="topic_folders", + rest_endpoints=RestEndpointConfig( + parent_links=[RestParentLink(resource_name="companies", model_id_field="company")], url="topic_folders" + ), +) diff --git a/newsroom/types.py b/newsroom/types.py index 2631f1e1a..17014cbff 100644 --- a/newsroom/types.py +++ b/newsroom/types.py @@ -211,14 +211,14 @@ class Topic(TypedDict, total=False): filter: Dict[str, Any] created: Dict[str, Any] user: ObjectId - company: ObjectId + company: Optional[ObjectId] is_global: bool timezone_offset: int topic_type: Section navigation: NavigationIds original_creator: ObjectId version_creator: ObjectId - folder: ObjectId + folder: Optional[ObjectId] advanced: Dict[str, Any] subscribers: List[TopicSubscriber] diff --git a/newsroom/users/model.py b/newsroom/users/model.py index c2f358260..05ae62817 100644 --- a/newsroom/users/model.py +++ b/newsroom/users/model.py @@ -2,6 +2,7 @@ from pydantic import Field from typing import Annotated, List, Optional +from dataclasses import asdict from newsroom.user_roles import UserRole from newsroom.companies.companies_async import CompanyProduct @@ -23,6 +24,9 @@ class Dashboard: type: str topic_ids: Annotated[list[ObjectIdField], validate_data_relation_async("topics")] + def to_dict(self): + return asdict(self) + @dataclass class NotificationSchedule: diff --git a/newsroom/users/module.py b/newsroom/users/module.py index 1767f6283..ff870d104 100644 --- a/newsroom/users/module.py +++ b/newsroom/users/module.py @@ -19,7 +19,7 @@ keys=[("email", 1)], unique=True, collation={"locale": "en", "strength": 2}, - ) + ), ], ), ) diff --git a/newsroom/users/service.py b/newsroom/users/service.py index 7f2972705..011cc3b06 100644 --- a/newsroom/users/service.py +++ b/newsroom/users/service.py @@ -164,3 +164,10 @@ async def approve_user(self, user: UserResourceModel): def _get_password_hash(self, password): return get_hash(password, get_app_config("BCRYPT_GENSALT_WORK_FACTOR", 12)) + + async def system_update(self, item_id, updates): + await self.mongo.update_one({"_id": item_id}, {"$set": updates}) + try: + await self.elastic.update(item_id, updates) + except KeyError: + pass diff --git a/newsroom/users/utils.py b/newsroom/users/utils.py index e30d064e3..917b0606d 100644 --- a/newsroom/users/utils.py +++ b/newsroom/users/utils.py @@ -1,5 +1,5 @@ from datetime import datetime, timedelta -from typing import TYPE_CHECKING, Dict, Optional, TypedDict, Union +from typing import TYPE_CHECKING, Dict, Optional, TypedDict, Union, cast from bson import ObjectId from pydantic import BaseModel @@ -46,7 +46,7 @@ async def get_user_async(required=False) -> Optional[UserResourceModel]: return user -async def get_user_or_abort() -> Optional[UserResourceModel]: +async def get_user_or_abort() -> UserResourceModel: """Use when there must be a user authenticated.""" user = await get_user_async(True) @@ -54,7 +54,8 @@ async def get_user_or_abort() -> Optional[UserResourceModel]: if not user: abort(401) - return user + # Force MyPy to realise this is not None here + return cast(UserResourceModel, user) async def get_company_from_user( diff --git a/newsroom/users/views.py b/newsroom/users/views.py index f1a511aee..e2faa9fc2 100644 --- a/newsroom/users/views.py +++ b/newsroom/users/views.py @@ -90,7 +90,7 @@ async def get_view_data(): view_data = { "user": user_as_dict, "company": getattr(company, "id", ""), - "topics": get_user_topics(user.id) if user else [], + "topics": await get_user_topics(user.id) if user else [], "companyName": getattr(user_company, "name", ""), "locators": get_vocabulary("locators"), "ui_configs": await ui_config_service.get_all_config(), diff --git a/newsroom/web/default_settings.py b/newsroom/web/default_settings.py index d93ed9522..9ee325700 100644 --- a/newsroom/web/default_settings.py +++ b/newsroom/web/default_settings.py @@ -120,7 +120,6 @@ "newsroom.design", "newsroom.history", "newsroom.push", - "newsroom.topics", "newsroom.products", "newsroom.reports", "newsroom.public", @@ -171,10 +170,12 @@ "newsroom.companies", "newsroom.assets", "newsroom.users", + "newsroom.topics", "newsroom.section_filters", "newsroom.cards.module", "newsroom.navigations", "newsroom.notifications", + "newsroom.topics_folders", ] SITE_NAME = "Newshub" diff --git a/newsroom/wire/views.py b/newsroom/wire/views.py index 2135005d7..4b9ba1b5d 100644 --- a/newsroom/wire/views.py +++ b/newsroom/wire/views.py @@ -26,7 +26,8 @@ from newsroom.wire.utils import update_action_list from newsroom.auth import get_company, get_user, get_user_id, get_user_required from newsroom.decorator import login_required, admin_only, section, clear_session_and_redirect_to_login -from newsroom.topics import get_user_topics, get_user_folders, get_company_folders +from newsroom.topics import get_user_topics +from newsroom.topics_folders import get_user_folders, get_company_folders from newsroom.email import get_language_template_name, send_user_email from newsroom.utils import ( get_entity_or_404, @@ -92,10 +93,10 @@ def set_item_permission(item, permitted=True): async def get_view_data() -> Dict: user = get_user_required() company = get_company(user) - topics = get_user_topics(user["_id"]) if user else [] + topics = await get_user_topics(user["_id"]) if user else [] company_id = str(user["company"]) if user and user.get("company") else None - user_folders = get_user_folders(user, "wire") if user else [] - company_folders = get_company_folders(company, "wire") if company else [] + user_folders = await get_user_folders(user, "wire") if user else [] + company_folders = await get_company_folders(company, "wire") if company else [] products = get_products_by_company(company, product_type="wire") if company else [] ui_config_service = UiConfigResourceService() @@ -185,7 +186,7 @@ async def get_home_data(): company = get_company(user) cards = await (await CardsResourceService().find({"dashboard": "newsroom"})).to_list_raw() company_id = str(user["company"]) if user and user.get("company") else None - topics = get_user_topics(user["_id"]) if user else [] + topics = await get_user_topics(user["_id"]) if user else [] ui_config_service = UiConfigResourceService() return { diff --git a/tests/core/test_home.py b/tests/core/test_home.py index cf3f136bb..ef27cff05 100644 --- a/tests/core/test_home.py +++ b/tests/core/test_home.py @@ -2,6 +2,8 @@ from newsroom.wire.views import get_home_data from newsroom.tests.fixtures import PUBLIC_USER_ID +from tests.core.utils import create_entries_for +from bson import ObjectId async def test_personal_dashboard_data(client, app, company_products): @@ -13,10 +15,10 @@ async def test_personal_dashboard_data(client, app, company_products): assert user topics = [ - {"name": "label", "query": "weather", "user": PUBLIC_USER_ID, "topic_type": "wire"}, + {"_id": ObjectId(), "label": "fooo", "query": "weather", "user": PUBLIC_USER_ID, "topic_type": "wire"}, ] - app.data.insert("topics", topics) + await create_entries_for("topics", topics) app.data.update( "users", diff --git a/tests/core/test_notification_queue.py b/tests/core/test_notification_queue.py index f4848a28a..95685b280 100644 --- a/tests/core/test_notification_queue.py +++ b/tests/core/test_notification_queue.py @@ -18,7 +18,7 @@ async def test_adding_and_clearing_notification_queue(): ids = await create_entries_for( "topics", [ - {"name": "label", "query": "weather", "user": PUBLIC_USER_ID, "topic_type": "wire"}, + {"_id": ObjectId(), "label": "topic", "query": "weather", "user": PUBLIC_USER_ID, "topic_type": "wire"}, ], ) diff --git a/tests/core/test_push.py b/tests/core/test_push.py index 8136b1ff1..ff9513d28 100644 --- a/tests/core/test_push.py +++ b/tests/core/test_push.py @@ -12,7 +12,7 @@ from newsroom.tests.fixtures import TEST_USER_ID # noqa - Fix cyclic import when running single test file from newsroom.tests import markers from newsroom.utils import get_company_dict, get_entity_or_404, get_user_dict -from tests.core.utils import add_company_products +from tests.core.utils import add_company_products, create_entries_for from ..fixtures import COMPANY_1_ID, PUBLIC_USER_ID from ..utils import mock_send_email @@ -643,21 +643,11 @@ async def test_notify_checks_service_subscriptions(client, app, mocker): ], ) - app.data.insert( + await create_entries_for( "topics", [ - { - "label": "topic-1", - "query": "test", - "user": user_ids[0], - "notifications": True, - }, - { - "label": "topic-2", - "query": "mock", - "user": user_ids[0], - "notifications": True, - }, + {"_id": bson.ObjectId(), "label": "topic-1", "query": "test", "user": user_ids[0], "topic_type": "wire"}, + {"_id": bson.ObjectId(), "label": "topic-2", "query": "mock", "user": user_ids[0], "topic_type": "agenda"}, ], ) diff --git a/tests/core/test_realtime_notifications.py b/tests/core/test_realtime_notifications.py index 025e141ab..46e2ec50c 100644 --- a/tests/core/test_realtime_notifications.py +++ b/tests/core/test_realtime_notifications.py @@ -46,10 +46,11 @@ async def test_realtime_notifications_wire(app, mocker, company_products): updates = {"navigations": [navigations[0]["_id"]]} app.data.update("products", product["_id"], updates, product) - app.data.insert( + await create_entries_for( "topics", [ { + "_id": ObjectId(), "user": user.id, "label": "Cheesy Stuff", "query": "cheese", @@ -62,6 +63,7 @@ async def test_realtime_notifications_wire(app, mocker, company_products): ], }, { + "_id": ObjectId(), "user": user.id, "label": "Onions", "query": "onions", @@ -74,6 +76,7 @@ async def test_realtime_notifications_wire(app, mocker, company_products): ], }, { + "_id": ObjectId(), "user": user.id, "label": "Company products", "query": "*:*", @@ -143,10 +146,11 @@ async def test_realtime_notifications_wire(app, mocker, company_products): @mock.patch("newsroom.email.send_email", mock_send_email) async def test_realtime_notifications_agenda(app, mocker): - app.data.insert( + await create_entries_for( "topics", [ { + "_id": ObjectId(), "user": ADMIN_USER_ID, "label": "Cheesy Stuff", "query": "cheese", @@ -162,6 +166,7 @@ async def test_realtime_notifications_agenda(app, mocker): }, }, { + "_id": ObjectId(), "user": ADMIN_USER_ID, "label": "Onions", "query": "onions", @@ -174,6 +179,7 @@ async def test_realtime_notifications_agenda(app, mocker): ], }, { + "_id": ObjectId(), "user": PUBLIC_USER_ID, "label": "Test", "query": "cheese", @@ -186,6 +192,7 @@ async def test_realtime_notifications_agenda(app, mocker): ], }, { + "_id": ObjectId(), "user": ADMIN_USER_ID, "label": "Should not match anything", "query": None, @@ -350,10 +357,11 @@ async def test_pause_notifications(app, mocker, company_products): ], ) - app.data.insert( + await create_entries_for( "topics", [ { + "_id": ObjectId(), "user": PUBLIC_USER_ID, "label": "All wire", "query": "*:*", @@ -366,6 +374,7 @@ async def test_pause_notifications(app, mocker, company_products): ], }, { + "_id": ObjectId(), "user": PUBLIC_USER_ID, "label": "All agenda", "query": "*:*", diff --git a/tests/core/test_reports.py b/tests/core/test_reports.py index b748d77a3..324d66991 100644 --- a/tests/core/test_reports.py +++ b/tests/core/test_reports.py @@ -3,6 +3,7 @@ from bson import ObjectId from datetime import datetime, timedelta from newsroom.tests.fixtures import COMPANY_1_ID +from tests.core.utils import create_entries_for @fixture(autouse=True) @@ -11,7 +12,7 @@ async def init(app): "users", [ { - "_id": "u-1", + "_id": ObjectId("5cc94454bc43165c045ffec0"), "email": "foo@foo.com", "first_name": "Foo", "last_name": "Smith", @@ -19,14 +20,14 @@ async def init(app): "company": COMPANY_1_ID, }, { - "_id": "u-2", + "_id": ObjectId("5cc94454bc43165c045ffec1"), "email": "bar@bar.com", "first_name": "Bar", "last_name": "Brown", "is_enabled": True, }, { - "_id": "u-3", + "_id": ObjectId("5cc94454bc43165c045ffec2"), "email": "baz@bar.com", "first_name": "Bar", "last_name": "Brown", @@ -80,15 +81,32 @@ async def init(app): async def test_company_saved_searches(client, app): - app.data.insert( + await create_entries_for( "topics", [ - {"label": "Foo", "query": "foo", "notifications": False, "user": "u-1"}, - {"label": "Foo", "query": "foo", "notifications": False, "user": "u-2"}, - {"label": "Foo", "query": "foo", "notifications": False, "user": "u-3"}, + { + "_id": ObjectId(), + "label": "Foo", + "query": "foo", + "topic_type": "wire", + "user": "5cc94454bc43165c045ffec0", + }, + { + "_id": ObjectId(), + "label": "Foo", + "query": "foo", + "topic_type": "wire", + "user": "5cc94454bc43165c045ffec1", + }, + { + "_id": ObjectId(), + "label": "Foo", + "query": "foo", + "topic_type": "wire", + "user": "5cc94454bc43165c045ffec2", + }, ], ) - resp = await client.get("reports/company-saved-searches") report = json.loads(await resp.get_data()) assert report["name"] == "Saved searches per company" @@ -98,12 +116,30 @@ async def test_company_saved_searches(client, app): async def test_user_saved_searches(client, app): - app.data.insert( + await create_entries_for( "topics", [ - {"label": "Foo", "query": "foo", "notifications": False, "user": "u-1"}, - {"label": "Foo", "query": "foo", "notifications": False, "user": "u-2"}, - {"label": "Foo", "query": "foo", "notifications": False, "user": "u-1"}, + { + "_id": ObjectId(), + "label": "Foo", + "query": "foo", + "topic_type": "wire", + "user": ObjectId("5cc94454bc43165c045ffec0"), + }, + { + "_id": ObjectId(), + "label": "Foo", + "query": "foo", + "topic_type": "wire", + "user": ObjectId("5cc94454bc43165c045ffec1"), + }, + { + "_id": ObjectId(), + "label": "Foo", + "query": "foo", + "topic_type": "wire", + "user": ObjectId("5cc94454bc43165c045ffec0"), + }, ], ) diff --git a/tests/core/test_send_scheduled_notifications.py b/tests/core/test_send_scheduled_notifications.py index a5d5bc625..46c096830 100644 --- a/tests/core/test_send_scheduled_notifications.py +++ b/tests/core/test_send_scheduled_notifications.py @@ -9,6 +9,8 @@ from newsroom.notifications.send_scheduled_notifications import SendScheduledNotificationEmails from newsroom.tests.users import ADMIN_USER_ID +from tests.core.utils import create_entries_for +from newsroom.topics.topics_async import TopicService def test_convert_schedule_times(): @@ -94,17 +96,22 @@ def test_get_queue_entries_for_section(): async def test_get_latest_item_from_topic_queue(app): user = app.data.find_one("users", req=None, _id=ADMIN_USER_ID) - topic_id = app.data.insert( + topic_ids = await create_entries_for( "topics", [ { + "_id": ObjectId(), "label": "Cheesy Stuff", "query": "cheese", "topic_type": "wire", } ], - )[0] - topic: Topic = app.data.find_one("topics", req=None, _id=topic_id) + ) + topic_id = topic_ids[0] + topic = await TopicService().find_by_id(topic_id) + if topic: + topic_dict = topic.model_dump(by_alias=True) + app.data.insert( "items", [ @@ -126,7 +133,7 @@ async def test_get_latest_item_from_topic_queue(app): } command = SendScheduledNotificationEmails() - item = command._get_latest_item_from_topic_queue(topic_queue, topic, user, None, set()) + item = command._get_latest_item_from_topic_queue(topic_queue, topic_dict, user, None, set()) assert item["_id"] == "topic1_item1" assert 'cheese' in item["es_highlight"]["body_html"][0] @@ -135,22 +142,27 @@ async def test_get_latest_item_from_topic_queue(app): async def test_get_topic_entries_and_match_table(app): user = app.data.find_one("users", req=None, _id=ADMIN_USER_ID) - topic_ids: List[ObjectId] = app.data.insert( + topic_ids: List[ObjectId] = await create_entries_for( "topics", [ { + "_id": ObjectId(), "label": "Cheesy Stuff", "query": "cheese", "topic_type": "wire", }, { + "_id": ObjectId(), "label": "Onions", "query": "onions", "topic_type": "wire", }, ], ) - user_topics: Dict[ObjectId, Topic] = {topic["_id"]: topic for topic in app.data.find_all("topics")} + topics = await TopicService().search(lookup={}) + if topics: + topics_list = await topics.to_list_raw() + user_topics: Dict[ObjectId, Topic] = {topic["_id"]: topic for topic in topics_list} app.data.insert( "items", [ diff --git a/tests/core/test_topics.py b/tests/core/test_topics.py index b40920d32..0c993a08f 100644 --- a/tests/core/test_topics.py +++ b/tests/core/test_topics.py @@ -1,8 +1,12 @@ from quart import json from unittest import mock from copy import deepcopy +from bson import ObjectId +from tests.core.utils import create_entries_for +import pymongo from newsroom.topics.views import get_topic_url + from newsroom.users.model import UserResourceModel from newsroom.users.service import UsersService from ..fixtures import ( # noqa: F401 @@ -13,23 +17,25 @@ TEST_USER_ID, COMPANY_1_ID, ) -from ..utils import mock_send_email, get_resource_by_id +from ..utils import mock_send_email, get_resource_by_id # noqa from tests import utils +from newsroom.topics.topics_async import TopicService +from newsroom.topics_folders.folders import UserFoldersResourceService base_topic = { + "_id": ObjectId(), "label": "Foo", "query": "foo", - "notifications": False, "topic_type": "wire", - "navigation": ["xyz"], + "navigation": [ObjectId("5cc94454bc43165c045ffec9")], } agenda_topic = { + "_id": ObjectId(), "label": "Foo", "query": "foo", - "notifications": False, "topic_type": "agenda", - "navigation": ["abc"], + "navigation": [ObjectId("5cc94454bc43165c045ffec3")], } user_id = str(PUBLIC_USER_ID) @@ -114,7 +120,7 @@ async def test_delete_topic(client): @mock.patch("newsroom.email.send_email", mock_send_email) async def test_share_wire_topics(client, app): topic = deepcopy(base_topic) - topic_ids = app.data.insert("topics", [topic]) + topic_ids = await create_entries_for("topics", [topic]) topic["_id"] = topic_ids[0] await utils.login(client, {"email": PUBLIC_USER_EMAIL}) @@ -142,7 +148,7 @@ async def test_share_wire_topics(client, app): @mock.patch("newsroom.email.send_email", mock_send_email) async def test_share_agenda_topics(client, app): - topic_ids = app.data.insert("topics", [agenda_topic]) + topic_ids = await create_entries_for("topics", [agenda_topic]) agenda_topic["_id"] = topic_ids[0] await utils.login(client, {"email": PUBLIC_USER_EMAIL}) @@ -170,22 +176,24 @@ async def test_share_agenda_topics(client, app): async def test_get_topic_share_url(app): topic = {"topic_type": "wire", "query": "art exhibition"} - assert get_topic_url(topic) == "http://localhost:5050/wire?q=art+exhibition" + assert await get_topic_url(topic) == "http://localhost:5050/wire?q=art+exhibition" topic = {"topic_type": "wire", "filter": {"location": [["Sydney"]]}} - assert get_topic_url(topic) == "http://localhost:5050/wire?filter=%7B%22location%22:+%5B%5B%22Sydney%22%5D%5D%7D" + assert ( + await get_topic_url(topic) == "http://localhost:5050/wire?filter=%7B%22location%22:+%5B%5B%22Sydney%22%5D%5D%7D" + ) topic = {"topic_type": "wire", "navigation": ["123"]} - assert get_topic_url(topic) == "http://localhost:5050/wire?navigation=%5B%22123%22%5D" + assert await get_topic_url(topic) == "http://localhost:5050/wire?navigation=%5B%22123%22%5D" topic = {"topic_type": "wire", "navigation": ["123", "456"]} - assert get_topic_url(topic) == "http://localhost:5050/wire?navigation=%5B%22123%22,+%22456%22%5D" + assert await get_topic_url(topic) == "http://localhost:5050/wire?navigation=%5B%22123%22,+%22456%22%5D" topic = {"topic_type": "wire", "created": {"from": "2018-06-01"}} - assert get_topic_url(topic) == "http://localhost:5050/wire?created=%7B%22from%22:+%222018-06-01%22%7D" + assert await get_topic_url(topic) == "http://localhost:5050/wire?created=%7B%22from%22:+%222018-06-01%22%7D" topic = {"topic_type": "wire", "advanced": {"all": "Weather Sydney", "fields": ["headline", "body_html"]}} - assert get_topic_url(topic) == ( + assert await get_topic_url(topic) == ( "http://localhost:5050/wire?advanced=" "%7B%22all%22:+%22Weather+Sydney%22,+%22fields%22:+%5B%22headline%22,+%22body_html%22%5D%7D" ) @@ -199,7 +207,7 @@ async def test_get_topic_share_url(app): "advanced": {"all": "Weather Sydney", "fields": ["headline", "body_html"]}, } assert ( - get_topic_url(topic) == "http://localhost:5050/wire?" + await get_topic_url(topic) == "http://localhost:5050/wire?" "q=art+exhibition" "&filter=%7B%22urgency%22:+%5B3%5D%7D" "&navigation=%5B%22123%22%5D" @@ -281,26 +289,46 @@ async def test_topic_folders_unique_validation(client): resp = await client.post(user_topic_folders_url, json=folder) assert 201 == resp.status_code, await resp.get_data(as_text=True) - # second one fails - resp = await client.post(user_topic_folders_url, json=folder) - assert 409 == resp.status_code, await resp.get_data(as_text=True) + # second one should raise DuplicateKeyError + try: + resp = await client.post(user_topic_folders_url, json=folder) + except pymongo.errors.DuplicateKeyError: + # assert that the DuplicateKeyError occurred as expected + print("DuplicateKeyError for user topic folder as expected") + else: + # If no exception is raised, fail the test + assert False, "Expected DuplicateKeyError for user topic folder, but got success" # create company topic with same name resp = await client.post(company_topic_folders_url, json=folder) assert 201 == resp.status_code, await resp.get_data(as_text=True) - # second fails - resp = await client.post(company_topic_folders_url, json=folder) - assert 409 == resp.status_code, await resp.get_data(as_text=True) - - # check is case insensitive + # second one should raise DuplicateKeyError for company topic + try: + resp = await client.post(company_topic_folders_url, json=folder) + except pymongo.errors.DuplicateKeyError: + # assert that the DuplicateKeyError occurred as expected + print("DuplicateKeyError for company topic folder as expected") + else: + # If no exception is raised, fail the test + assert False, "Expected DuplicateKeyError for company topic folder, but got success" + + # check case-insensitive uniqueness for user topic folder["name"] = "Test" - resp = await client.post(user_topic_folders_url, json=folder) - assert 409 == resp.status_code, await resp.get_data(as_text=True) - - # for both - resp = await client.post(company_topic_folders_url, json=folder) - assert 409 == resp.status_code, await resp.get_data(as_text=True) + try: + resp = await client.post(user_topic_folders_url, json=folder) + except pymongo.errors.DuplicateKeyError: + print("DuplicateKeyError for case-insensitive user topic folder as expected") + else: + assert False, "Expected DuplicateKeyError for case-insensitive user topic folder, but got success" + + # check case-insensitive uniqueness for company topic + try: + resp = await client.post(company_topic_folders_url, json=folder) + except pymongo.errors.DuplicateKeyError: + print("DuplicateKeyError for case-insensitive company topic folder as expected") + else: + assert False, "Expected DuplicateKeyError for case-insensitive company topic folder, but got success" async def test_topic_subscriber_auto_enable_user_emails(app, client): @@ -316,43 +344,49 @@ async def disable_user_emails(): # Make sure we start with user emails disabled await disable_user_emails() - user = get_resource_by_id("users", PUBLIC_USER_ID) + user = await UsersService().find_by_id(PUBLIC_USER_ID) + user = json.loads(user.model_dump_json()) assert user["receive_email"] is False # Create a new topic, with the current user as a subscriber topic["subscribers"] = [ { - "user_id": user["_id"], + "user_id": user["id"], "notification_type": "real-time", } ] resp = await client.post(topics_url, json=topic) assert resp.status_code == 201, await resp.get_data(as_text=True) topic_id = (await resp.get_json())["_id"] - topic = get_resource_by_id("topics", topic_id) + topic = await TopicService().find_by_id(topic_id) + topic = json.loads(topic.model_dump_json()) # Make sure user emails are enabled after creating the topic - user = get_resource_by_id("users", PUBLIC_USER_ID) + user = await UsersService().find_by_id(PUBLIC_USER_ID) + user = json.loads(user.model_dump_json()) assert user["receive_email"] is True # Disable the user emails again await disable_user_emails() - user = get_resource_by_id("users", PUBLIC_USER_ID) + user = await UsersService().find_by_id(PUBLIC_USER_ID) + user = json.loads(user.model_dump_json()) assert user["receive_email"] is False # Update the topic, this time removing the user as a subscriber topic["subscribers"] = [] + topic.pop("created") resp = await client.post(f"/topics/{topic_id}", json=topic) assert resp.status_code == 200, await resp.get_data(as_text=True) # Make sure user emails are still disabled - user = get_resource_by_id("users", PUBLIC_USER_ID) + user = await UsersService().find_by_id(PUBLIC_USER_ID) + user = json.loads(user.model_dump_json()) assert user["receive_email"] is False # Update the topic, this time adding the user as a subscriber topic["subscribers"] = [ { - "user_id": user["_id"], + "user_id": user["id"], "notification_type": "real-time", } ] @@ -360,21 +394,20 @@ async def disable_user_emails(): assert resp.status_code == 200, await resp.get_data(as_text=True) # And make sure user emails are re-enabled again - user = get_resource_by_id("users", PUBLIC_USER_ID) + user = await UsersService().find_by_id(PUBLIC_USER_ID) + user = json.loads(user.model_dump_json()) assert user["receive_email"] is True async def test_remove_user_topics_on_user_delete(client, app): - app.data.insert( + await create_entries_for( "topics", [ + {"_id": ObjectId(), "label": "test1", "user": PUBLIC_USER_ID, "is_global": False, "topic_type": "wire"}, { - "label": "test1", - "user": PUBLIC_USER_ID, - "is_global": False, - }, - { + "_id": ObjectId(), "label": "test2", + "topic_type": "wire", "subscribers": [ { "user_id": PUBLIC_USER_ID, @@ -387,7 +420,9 @@ async def test_remove_user_topics_on_user_delete(client, app): ], }, { + "_id": ObjectId(), "label": "test3", + "topic_type": "wire", "user": PUBLIC_USER_ID, "is_global": True, "subscribers": [ @@ -404,33 +439,77 @@ async def test_remove_user_topics_on_user_delete(client, app): ], ) - app.data.insert( + await create_entries_for( "user_topic_folders", [ - {"name": "delete", "user": PUBLIC_USER_ID}, - {"name": "skip", "user": TEST_USER_ID}, + {"_id": ObjectId(), "name": "delete", "user": PUBLIC_USER_ID, "section": "wire"}, + {"_id": ObjectId(), "name": "skip", "user": TEST_USER_ID, "section": "wire"}, ], ) - topics, _ = app.data.find("topics", req=None, lookup=None) - assert 3 == topics.count() + cursor = await TopicService().search(lookup={}) + topics = await cursor.to_list_raw() + assert 3 == len(topics) + + cursor = await UserFoldersResourceService().search(lookup={}) + folders = await cursor.to_list_raw() + assert 2 == len(folders) + + # TODO-ASYNC:- Test cases based on signal + + # await client.delete(f"/users/{PUBLIC_USER_ID}") + + # # make sure it's editable later + # resp = await client.get(f"/api/users/{PUBLIC_USER_ID}/topics") + # assert 200 == resp.status_code - folders, _ = app.data.find("user_topic_folders", req=None, lookup=None) - assert 2 == folders.count() + # cursor = await TopicService().search(lookup={}) + # topics = await cursor.to_list_raw() + # assert 2 == len(topics) + # assert "test2" == topics[0]["label"] + # assert 1 == len(topics[0]["subscribers"]) + # assert "test3" == topics[1]["label"] + # assert None is topics[1].get("user") - await client.delete(f"/users/{PUBLIC_USER_ID}") + # cursor = await UserFoldersResourceService().search(lookup={}) + # folders = await cursor.to_list_raw() + # assert 1 == len(folders) + # assert "skip" == folders[0]["name"] - # make sure it's editable later - resp = await client.get(f"/api/users/{PUBLIC_USER_ID}/topics") + +async def test_created_field_in_topic_url(client): + topic_payload = { + "_id": ObjectId(), + "label": "Foo", + "query": "foo", + "topic_type": "wire", + "created": {"date_filter": "last_week"}, + } + await utils.login(client, {"email": PUBLIC_USER_EMAIL}) + resp = await client.post(topics_url, json=deepcopy(topic_payload)) + assert 201 == resp.status_code + resp = await client.get(topics_url) + assert 200 == resp.status_code + data = json.loads(await resp.get_data()) + assert 1 == len(data["_items"]) + assert "Foo" == data["_items"][0]["label"] + + assert ( + await get_topic_url(data["_items"][0]) + == "http://localhost:5050/wire?q=foo&created=%7B%22date_filter%22:+%22last_week%22%7D" + ) + + resp = await client.post( + "topics/{}".format(data["_items"][0]["_id"]), + json={"label": "test123", "created": {"date_filter": "today"}}, + ) assert 200 == resp.status_code - topics, _ = app.data.find("topics", req=None, lookup=None) - assert 2 == topics.count() - assert "test2" == topics[0]["label"] - assert 1 == len(topics[0]["subscribers"]) - assert "test3" == topics[1]["label"] - assert None is topics[1].get("user") + resp = await client.get(topics_url) + data = json.loads(await resp.get_data()) - folders, _ = app.data.find("user_topic_folders", req=None, lookup=None) - assert 1 == folders.count() - assert "skip" == folders[0]["name"] + assert "test123" == data["_items"][0]["label"] + assert ( + await get_topic_url(data["_items"][0]) + == "http://localhost:5050/wire?created=%7B%22date_filter%22:+%22today%22%7D" + ) diff --git a/tests/core/test_user_dashboards.py b/tests/core/test_user_dashboards.py index 6fa2ea4d0..85ffbda25 100644 --- a/tests/core/test_user_dashboards.py +++ b/tests/core/test_user_dashboards.py @@ -3,11 +3,14 @@ from newsroom.wire.views import get_personal_dashboards_data from datetime import datetime +from tests.core.utils import create_entries_for async def test_user_dashboards(app, client, public_user, public_company, company_products): - topics = [{"label": "test", "user": public_user["_id"], "query": "bar"}] - app.data.insert("topics", topics) + topics = [ + {"_id": bson.ObjectId(), "label": "test", "user": public_user["_id"], "query": "bar", "topic_type": "agenda"} + ] + await create_entries_for("topics", topics) app.data.remove("products") products = [{"name": "test", "query": "foo", "is_enabled": True, "product_type": "wire"}]