From cc6ef7a66217977426a1ab8248dbc051ee710a25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Ja=C5=A1ek?= Date: Mon, 18 Dec 2023 11:43:17 +0100 Subject: [PATCH 1/7] fix creation of planning items via ingest (#1889) there is no request --- server/planning/planning/planning.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/planning/planning/planning.py b/server/planning/planning/planning.py index 0ac13af90..8315e98e6 100644 --- a/server/planning/planning/planning.py +++ b/server/planning/planning/planning.py @@ -157,7 +157,7 @@ def on_create(self, docs): if is_ingested: history_service.on_item_created([doc]) - if event and strtobool(request.args.get("add_to_series", "false")): + if event and request and strtobool(request.args.get("add_to_series", "false")): new_plans = self._add_planning_to_event_series(doc, event) if is_ingested: history_service.on_item_created(new_plans) From 393bae8c0663026228aaa468b3daeb84dd106c1e Mon Sep 17 00:00:00 2001 From: MarkLark86 Date: Thu, 18 Jan 2024 19:57:59 +1100 Subject: [PATCH 2/7] [STTNHUB-312] Add planning_ingested signal (#1899) * [STTNHUB-312] Add planning_ingested signal * fix behave tests * fix(e2e): Use chrome in CI to run tests (#1891) --- e2e/package.json | 2 +- server/features/events_postpone.feature | 1 + server/features/planning_duplicate.feature | 3 +-- server/planning/planning/planning.py | 13 ++++++++++--- server/planning/signals.py | 2 ++ 5 files changed, 15 insertions(+), 6 deletions(-) diff --git a/e2e/package.json b/e2e/package.json index 952cea03e..4652fb99b 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -17,7 +17,7 @@ }, "scripts": { "cypress-ui": "cypress open", - "cypress-ci": "cypress run", + "cypress-ci": "cypress run --browser chrome", "clean": "grunt clean", "build": "npx @superdesk/build-tools build-root-repo ./", "serve": "node --max-old-space-size=8192 ./node_modules/.bin/grunt server" diff --git a/server/features/events_postpone.feature b/server/features/events_postpone.feature index 43c9d9380..897e365b7 100644 --- a/server/features/events_postpone.feature +++ b/server/features/events_postpone.feature @@ -499,6 +499,7 @@ Feature: Events Postpone @auth @notification + @planning_cvs Scenario: Published event gets updated after postpone Given we have sessions "/sessions" Given "desks" diff --git a/server/features/planning_duplicate.feature b/server/features/planning_duplicate.feature index 8e8e67949..66f36e792 100644 --- a/server/features/planning_duplicate.feature +++ b/server/features/planning_duplicate.feature @@ -357,8 +357,7 @@ Feature: Duplicate Planning }, "assigned_to": { "desk": "#desks._id#", - "user": "#CONTEXT_USER_ID#", - "assignment_id": "aaaaaaaaaaaaaaaaaaaaaaaa" + "user": "#CONTEXT_USER_ID#" } }], "expired": true diff --git a/server/planning/planning/planning.py b/server/planning/planning/planning.py index 8315e98e6..109ecbc7a 100644 --- a/server/planning/planning/planning.py +++ b/server/planning/planning/planning.py @@ -65,7 +65,7 @@ from superdesk import Resource from lxml import etree from io import BytesIO -from planning.signals import planning_created +from planning.signals import planning_created, planning_ingested logger = logging.getLogger(__name__) @@ -85,6 +85,8 @@ def post_in_mongo(self, docs, **kwargs): resolve_document_etag(docs, self.datasource) ids = self.backend.create_in_mongo(self.datasource, docs, **kwargs) self.on_created(docs) + for doc in docs: + planning_ingested.send(self, item=doc) return ids def patch_in_mongo(self, id, document, original): @@ -93,6 +95,7 @@ def patch_in_mongo(self, id, document, original): update_ingest_on_patch(document, original) response = self.backend.update_in_mongo(self.datasource, id, document, original) self.on_updated(document, original, from_ingest=True) + planning_ingested.send(self, item=document, original=original) return response def is_new_version(self, new_item, old_item): @@ -523,13 +526,17 @@ def remove_coverage_entity(self, coverage_entity, original_planning, entity_type def add_coverages(self, updates, original): planning_date = original.get("planning_date") or updates.get("planning_date") + original_coverage_ids = [ + coverage["coverage_id"] for coverage in original.get("coverages") or [] if coverage.get("coverage_id") + ] for coverage in updates.get("coverages") or []: coverage_id = coverage.get("coverage_id", "") - if not coverage_id or TEMP_ID_PREFIX in coverage_id: + if not coverage_id or TEMP_ID_PREFIX in coverage_id or coverage_id not in original_coverage_ids: if "duplicate" in coverage_id: self.duplicate_xmp_file(coverage) # coverage to be created - coverage["coverage_id"] = generate_guid(type=GUID_NEWSML) + if not coverage_id or TEMP_ID_PREFIX in coverage_id: + coverage["coverage_id"] = generate_guid(type=GUID_NEWSML) coverage["firstcreated"] = utcnow() # Make sure the coverage has a ``scheduled`` date diff --git a/server/planning/signals.py b/server/planning/signals.py index 2a343d06f..3c7097ac4 100644 --- a/server/planning/signals.py +++ b/server/planning/signals.py @@ -12,8 +12,10 @@ __all__ = [ "planning_created", + "planning_ingested", ] signals = blinker.Namespace() planning_created = signals.signal("planning:created") +planning_ingested = signals.signal("planning:ingested") From 1655909507737d34b42b4b64ab0a1a361180f266 Mon Sep 17 00:00:00 2001 From: MarkLark86 Date: Thu, 25 Jan 2024 09:22:20 +1100 Subject: [PATCH 3/7] [SDESK-7166] fix: Use `ingest_id` for coverage content when its auto published (#1903) * [SDESK-7166] fix: Use `ingest_id` for coverage content when its auto published * fix front-end tests * fix: Don't process coverages on updates if not provided * fix lint error * Revert "fix front-end tests" This reverts commit e6d4c7ed0f70c7ebd020a5a8db5cb2eebb0a41cc. --- .../output_formatters/json_planning.py | 18 +++++++++++ server/planning/planning/planning.py | 9 ++++++ .../output_formatters/json_planning_test.py | 32 +++++++++++++++++++ 3 files changed, 59 insertions(+) diff --git a/server/planning/output_formatters/json_planning.py b/server/planning/output_formatters/json_planning.py index 479c55545..a10d2d834 100644 --- a/server/planning/output_formatters/json_planning.py +++ b/server/planning/output_formatters/json_planning.py @@ -11,6 +11,7 @@ from superdesk.publish.formatters import Formatter import superdesk +from apps.archive.common import ARCHIVE import json from superdesk.utils import json_serialize_datetime_objectId from copy import deepcopy @@ -170,11 +171,28 @@ def _expand_delivery(self, coverage): ) deliveries = list(delivery_service.get(req=None, lookup={"coverage_id": coverage.get("coverage_id")})) + # Get the associated article(s) linked to the coverage(s) + query = {"$and": [{"_id": {"$in": [item["item_id"] for item in deliveries]}}]} + articles = {item["_id"]: item for item in get_resource_service(ARCHIVE).get_from_mongo(req=None, lookup=query)} + # Check to see if in this delivery chain, whether the item has been published at least once item_never_published = True for delivery in deliveries: for f in remove_fields: delivery.pop(f, None) + + # TODO: This is a hack, need to find a better way of doing this + # If the linked article was auto-published, then use the ``ingest_id`` for the article ID + # This is required when the article was published using the ``NewsroomNinjsFormatter`` + # Otherwise this coverage in Newshub would point to a non-existing wire item + article = articles.get(delivery["item_id"]) + if ( + article is not None + and article.get("ingest_id") + and (article.get("auto_publish") or (article.get("extra") or {}).get("publish_ingest_id_as_guid")) + ): + delivery["item_id"] = article["ingest_id"] + if delivery.get("item_state") == CONTENT_STATE.PUBLISHED: item_never_published = False diff --git a/server/planning/planning/planning.py b/server/planning/planning/planning.py index 109ecbc7a..02e69b80d 100644 --- a/server/planning/planning/planning.py +++ b/server/planning/planning/planning.py @@ -466,6 +466,9 @@ def get_all_items_in_relationship(self, item): return all_items def remove_coverages(self, updates, original): + if "coverages" not in updates: + return + for coverage in (original or {}).get("coverages") or []: updated_coverage = next( ( @@ -525,6 +528,9 @@ def remove_coverage_entity(self, coverage_entity, original_planning, entity_type self._create_update_assignment(original_planning, {}, updated_coverage_entity, coverage_entity) def add_coverages(self, updates, original): + if "coverages" not in updates: + return + planning_date = original.get("planning_date") or updates.get("planning_date") original_coverage_ids = [ coverage["coverage_id"] for coverage in original.get("coverages") or [] if coverage.get("coverage_id") @@ -606,6 +612,9 @@ def update_scheduled_updates(self, updates, original, coverage, original_coverag self._create_update_assignment(original, updates, s, original_scheduled_update, coverage) def update_coverages(self, updates, original): + if "coverages" not in updates: + return + for coverage in updates.get("coverages") or []: coverage_id = coverage.get("coverage_id") original_coverage = next( diff --git a/server/planning/tests/output_formatters/json_planning_test.py b/server/planning/tests/output_formatters/json_planning_test.py index 3ff9aa94f..52996069c 100644 --- a/server/planning/tests/output_formatters/json_planning_test.py +++ b/server/planning/tests/output_formatters/json_planning_test.py @@ -318,3 +318,35 @@ def test_matching_product_ids(self): output = formatter.format(item, {"name": "Test Subscriber"})[0] output_item = json.loads(output[1]) self.assertEqual(output_item["products"], [{"code": "prod-type-planning", "name": "planning-only"}]) + + def test_expand_delivery_uses_ingest_id(self): + self.app.data.insert("assignments", self.assignment) + self.app.data.insert("delivery", self.delivery) + formatter = JsonPlanningFormatter() + item_id = self.delivery[0]["item_id"] + ingest_id = "urn:newsml:localhost:2024-01-24-ingest-1" + article = { + "_id": item_id, + "type": "text", + "headline": "test headline", + "slugline": "test slugline", + "ingest_id": ingest_id, + } + + self.app.data.insert("archive", [article]) + deliveries, _ = formatter._expand_delivery(deepcopy(self.item["coverages"][0])) + self.assertNotEqual(deliveries[0]["item_id"], ingest_id) + + article = self.app.data.find_one("archive", req=None, _id=item_id) + self.app.data.update("archive", item_id, {"auto_publish": True}, article) + deliveries, _ = formatter._expand_delivery(deepcopy(self.item["coverages"][0])) + self.assertEqual(deliveries[0]["item_id"], ingest_id) + + article = self.app.data.find_one("archive", req=None, _id=item_id) + updates = { + "auto_publish": None, + "extra": {"publish_ingest_id_as_guid": True}, + } + self.app.data.update("archive", item_id, updates, article) + deliveries, _ = formatter._expand_delivery(deepcopy(self.item["coverages"][0])) + self.assertEqual(deliveries[0]["item_id"], ingest_id) From fb2956f3b59362a380aff015b852b77379f2d25e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Ja=C5=A1ek?= Date: Tue, 19 Dec 2023 09:56:14 +0100 Subject: [PATCH 4/7] add assigned user/desk info to planning json (#1888) * add assigned user/desk info to planning json CPCN-502 --- README.md | 3 ++ .../output_formatters/json_planning.py | 26 ++++++++++-- .../output_formatters/json_planning_test.py | 42 +++++++++++++++++++ 3 files changed, 67 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 65bf5a2c7..a5e2ef87b 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,9 @@ Below sections include the config options that can be defined in settings.py. * PLANNING_SEND_NOTIFICATION_FOR_SELF_ASSIGNMENT * Defaults to false * If true, sends a notification to a user on creating an assignment that is assigned to themselves +* PLANNING_JSON_ASSIGNED_INFO_EXTENDED + * Defaults to `false` + * If `true`, it will add to planning JSON output additional info for coverages like assigned desk name/email and assigned user name/email. ### Authoring Config * PLANNING_CHECK_FOR_ASSIGNMENT_ON_PUBLISH diff --git a/server/planning/output_formatters/json_planning.py b/server/planning/output_formatters/json_planning.py index a10d2d834..c23f4b23e 100644 --- a/server/planning/output_formatters/json_planning.py +++ b/server/planning/output_formatters/json_planning.py @@ -9,6 +9,7 @@ # at https://www.sourcefabric.org/superdesk/license +from flask import current_app as app from superdesk.publish.formatters import Formatter import superdesk from apps.archive.common import ARCHIVE @@ -196,12 +197,11 @@ def _expand_delivery(self, coverage): if delivery.get("item_state") == CONTENT_STATE.PUBLISHED: item_never_published = False - if item_never_published: - deliveries = [] - return deliveries, assignment.get("assigned_to").get("state") def _expand_coverage_contacts(self, coverage): + EXTENDED_INFO = bool(app.config.get("PLANNING_JSON_ASSIGNED_INFO_EXTENDED")) + if (coverage.get("assigned_to") or {}).get("contact"): expanded_contacts = expand_contact_info([coverage["assigned_to"]["contact"]]) if expanded_contacts: @@ -212,8 +212,26 @@ def _expand_coverage_contacts(self, coverage): if (coverage.get("assigned_to") or {}).get("user"): user = get_resource_service("users").find_one(req=None, _id=coverage["assigned_to"]["user"]) - if user: + if user and not user.get("private"): coverage["assigned_user"] = { "first_name": user.get("first_name"), "last_name": user.get("last_name"), + "display_name": user.get("display_name"), + } + + if EXTENDED_INFO: + coverage["assigned_user"].update( + email=user.get("email"), + ) + + if (coverage.get("assigned_to") or {}).get("desk"): + desk = get_resource_service("desks").find_one(req=None, _id=coverage["assigned_to"]["desk"]) + if desk: + coverage["assigned_desk"] = { + "name": desk.get("name"), } + + if EXTENDED_INFO: + coverage["assigned_desk"].update( + email=desk.get("email"), + ) diff --git a/server/planning/tests/output_formatters/json_planning_test.py b/server/planning/tests/output_formatters/json_planning_test.py index 52996069c..9d774de33 100644 --- a/server/planning/tests/output_formatters/json_planning_test.py +++ b/server/planning/tests/output_formatters/json_planning_test.py @@ -319,6 +319,7 @@ def test_matching_product_ids(self): output_item = json.loads(output[1]) self.assertEqual(output_item["products"], [{"code": "prod-type-planning", "name": "planning-only"}]) +<<<<<<< HEAD def test_expand_delivery_uses_ingest_id(self): self.app.data.insert("assignments", self.assignment) self.app.data.insert("delivery", self.delivery) @@ -350,3 +351,44 @@ def test_expand_delivery_uses_ingest_id(self): self.app.data.update("archive", item_id, updates, article) deliveries, _ = formatter._expand_delivery(deepcopy(self.item["coverages"][0])) self.assertEqual(deliveries[0]["item_id"], ingest_id) + + def test_assigned_desk_user(self): + with self.app.app_context(): + item = deepcopy(self.item) + desk_id = ObjectId() + user_id = ObjectId() + + item["coverages"][0]["assigned_to"].update( + desk=desk_id, + user=user_id, + ) + + self.app.data.insert( + "desks", + [{"_id": desk_id, "name": "sports", "email": "sports@example.com"}], + ) + + self.app.data.insert("users", [{"_id": user_id, "display_name": "John Doe", "email": "john@example.com"}]) + + formatter = JsonPlanningFormatter() + with mock.patch.dict(self.app.config, {"PLANNING_JSON_ASSIGNED_INFO_EXTENDED": True}): + output = formatter.format(item, {"name": "Test Subscriber"})[0] + output_item = json.loads(output[1]) + coverage = output_item["coverages"][0] + assert coverage["assigned_user"] == { + "first_name": None, + "last_name": None, + "display_name": "John Doe", + "email": "john@example.com", + } + assert coverage["assigned_desk"] == { + "name": "sports", + "email": "sports@example.com", + } + + # without config + output = formatter.format(item, {"name": "Test Subscriber"})[0] + output_item = json.loads(output[1]) + coverage = output_item["coverages"][0] + assert "email" not in coverage["assigned_user"] + assert "email" not in coverage["assigned_desk"] From 367d470671a6fb171047889965bbf929198aa525 Mon Sep 17 00:00:00 2001 From: MarkLark86 Date: Tue, 9 Jan 2024 09:41:40 +1100 Subject: [PATCH 5/7] [SDBELGA-759] Feature: Sync Event metadata to Planning & Coverages (#1892) * Config: Add new config for Event to Planning sync * ui: Send `embedded_planning` from front-end * api: Improve Planning ContentProfiles module and types * api: Process `embedded_planning` and sync metadata * fix(ui): Failed to get lock for recurring series * fix(ui): Improve EmbeddedCoverageForm UI * fix(ui): Event to Planning translations copies fields multiple times * fix(e2e): Use chrome in CI to run tests * Add behave tests for embedded planning * Add behave tests for multilingual embedded planning * Add CVs to behave test * fix: Coverage Planning updated when processing Assignment details Embedded/Sync wasn't working once above bug was fixed * Add behave tests for event metadata sync --- README.md | 15 + client/api/events.ts | 116 +++--- client/api/locks.ts | 11 +- .../EmbeddedCoverageForm.tsx | 32 +- client/interfaces.ts | 17 + client/utils/strings.tsx | 2 +- .../features/event_embedded_planning.feature | 354 ++++++++++++++++++ .../features/event_sync_to_planning.feature | 282 ++++++++++++++ .../features/steps/fixtures/vocabularies.json | 40 +- server/features/steps/steps.py | 37 ++ server/planning/common.py | 7 +- .../content_profiles/content_profiles_test.py | 37 +- server/planning/content_profiles/utils.py | 80 ++-- server/planning/events/events.py | 76 +++- server/planning/events/events_schema.py | 39 +- .../planning/events/events_sync/__init__.py | 166 ++++++++ server/planning/events/events_sync/common.py | 36 ++ .../events/events_sync/embedded_planning.py | 336 +++++++++++++++++ .../events/events_sync/planning_sync.py | 165 ++++++++ server/planning/planning/planning.py | 17 +- server/planning/types/__init__.py | 46 +++ server/planning/types/content_profiles.py | 27 ++ 22 files changed, 1837 insertions(+), 101 deletions(-) create mode 100644 server/features/event_embedded_planning.feature create mode 100644 server/features/event_sync_to_planning.feature create mode 100644 server/planning/events/events_sync/__init__.py create mode 100644 server/planning/events/events_sync/common.py create mode 100644 server/planning/events/events_sync/embedded_planning.py create mode 100644 server/planning/events/events_sync/planning_sync.py create mode 100644 server/planning/types/__init__.py create mode 100644 server/planning/types/content_profiles.py diff --git a/README.md b/README.md index a5e2ef87b..b356599b9 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,21 @@ Below sections include the config options that can be defined in settings.py. * DEFAULT_CREATE_PLANNING_SERIES_WITH_EVENT_SERIES * Default: False * If true, will default to creating series of Planning items with a recurring series of Events, +* SYNC_EVENT_FIELDS_TO_PLANNING + * Default: "" + * Comma separated list of Planning & Coverage fields to keep in sync with the associated Event + * Supported Fields: + * slugline + * internal_note + * name + * place (list CVs) + * subject (list CVs, exclude items with scheme) + * custom_vocabularies (list CVs, inside subject with scheme) + * anpa_category (list CVs) + * ednote + * language (includes `languages` if multilingual is enabled) + * definition_short (copies to Planning item's `Description Text`) + * priority ### Assignments Config * SLACK_BOT_TOKEN diff --git a/client/api/events.ts b/client/api/events.ts index 266d4cd0b..253cbb8f3 100644 --- a/client/api/events.ts +++ b/client/api/events.ts @@ -1,21 +1,24 @@ import { FILTER_TYPE, IEventItem, - IPlanningAPI, IPlanningItem, + IPlanningAPI, ISearchAPIParams, ISearchParams, ISearchSpikeState, - LOCK_STATE + IPlanningConfig, } from '../interfaces'; +import {appConfig as config} from 'appConfig'; import {IRestApiResponse} from 'superdesk-api'; import {planningApi, superdeskApi} from '../superdeskApi'; import {EVENTS, TEMP_ID_PREFIX} from '../constants'; import {arrayToString, convertCommonParams, cvsToString, searchRaw, searchRawGetAll} from './search'; -import {eventUtils, planningUtils} from '../utils'; +import {eventUtils} from '../utils'; import {eventProfile, eventSearchProfile} from '../selectors/forms'; import * as actions from '../actions'; +const appConfig = config as IPlanningConfig; + function convertEventParams(params: ISearchParams): Partial { return { reference: params.reference, @@ -118,62 +121,89 @@ function getEventSearchProfile() { return eventSearchProfile(planningApi.redux.store.getState()); } -function createOrUpdatePlannings( - event: IEventItem, - items: Array> -): Promise> { - return Promise.all( - items.map( - (updates) => ( - updates._id.startsWith(TEMP_ID_PREFIX) ? - planningApi.planning.createFromEvent(event, updates) : - planningApi.planning.getById(updates._id) - .then((original) => ( - planningApi.planning.update(original, updates) - )) - ) - ) - ) - .then((newOrUpdatedItems) => { - newOrUpdatedItems.forEach(planningUtils.modifyForClient); - - return newOrUpdatedItems; - }); -} - function create(updates: Partial): Promise> { - return superdeskApi.dataApi.create>('events', { + const url = appConfig.planning.default_create_planning_series_with_event_series === true ? + 'events?add_to_series=true' : + 'events'; + + return superdeskApi.dataApi.create>(url, { ...updates, associated_plannings: undefined, + embedded_planning: updates.associated_plannings.map((planning) => ({ + coverages: planning.coverages.map((coverage) => ({ + coverage_id: coverage.coverage_id, + g2_content_type: coverage.planning.g2_content_type, + desk: coverage.assigned_to.desk, + user: coverage.assigned_to.user, + language: coverage.planning.language, + news_coverage_status: coverage.news_coverage_status.qcode, + scheduled: coverage.planning.scheduled, + genre: coverage.planning.genre?.qcode, + slugline: coverage.planning.slugline, + ednote: coverage.planning.ednote, + internal_note: coverage.planning.internal_note, + })), + })), }) .then((response) => { - const events: Array = modifySaveResponseForClient(response); - - return createOrUpdatePlannings(events[0], updates.associated_plannings ?? []) - .then((plannings) => { - // Make sure to update the Redux Store with the latest Planning items - // So that the Editor can set the state with these latest items - planningApi.redux.store.dispatch(actions.planning.api.receivePlannings(plannings)); + const events = modifySaveResponseForClient(response); - return events; - }); + return planningApi.planning.searchGetAll({ + recurrence_id: events[0].recurrence_id, + event_item: events[0].recurrence_id != null ? null : events.map((event) => event._id), + spike_state: 'both', + only_future: false, + }).then((planningItems) => { + // Make sure to update the Redux Store with the latest Planning items + // So that the Editor can set the state with these latest items + planningApi.redux.store.dispatch(actions.planning.api.receivePlannings(planningItems)); + + return events; + }); + }) + .catch((error) => { + console.error(error); + + return Promise.reject(error); }); } function update(original: IEventItem, updates: Partial): Promise> { - return superdeskApi.dataApi.patch('events', original, { + return superdeskApi.dataApi.patch('events', original, { ...updates, associated_plannings: undefined, + embedded_planning: updates.associated_plannings.map((planning) => ({ + planning_id: planning._id.startsWith(TEMP_ID_PREFIX) ? undefined : planning._id, + coverages: planning.coverages.map((coverage) => ({ + coverage_id: coverage.coverage_id, + g2_content_type: coverage.planning.g2_content_type, + desk: coverage.assigned_to.desk, + user: coverage.assigned_to.user, + language: coverage.planning.language, + news_coverage_status: coverage.news_coverage_status.qcode, + scheduled: coverage.planning.scheduled, + genre: coverage.planning.genre?.qcode, + slugline: coverage.planning.slugline, + ednote: coverage.planning.ednote, + internal_note: coverage.planning.internal_note, + })), + })), }) .then((response) => { const events = modifySaveResponseForClient(response); - return createOrUpdatePlannings(events[0], updates.associated_plannings ?? []) - .then((plannings) => { - planningApi.redux.store.dispatch(actions.planning.api.receivePlannings(plannings)); - - return events; - }); + return planningApi.planning.searchGetAll({ + recurrence_id: events[0].recurrence_id, + event_item: events[0].recurrence_id != null ? null : events.map((event) => event._id), + spike_state: 'both', + only_future: false, + }).then((planningItems) => { + // Make sure to update the Redux Store with the latest Planning items + // So that the Editor can set the state with these latest items + planningApi.redux.store.dispatch(actions.planning.api.receivePlannings(planningItems)); + + return events; + }); }); } diff --git a/client/api/locks.ts b/client/api/locks.ts index f217ebff9..aae3f29ff 100644 --- a/client/api/locks.ts +++ b/client/api/locks.ts @@ -234,7 +234,16 @@ function unlockItem(item: T, reloadLocksIfN } } - const lockedItemId = currentLock.item_id; + let lockedItemId: string; + + if (item.type === 'event' && item.recurrence_id === currentLock.item_id) { + lockedItemId = item._id; + } else if (item.type === 'planning' && item.recurrence_id === currentLock.item_id) { + lockedItemId = item.event_item; + } else { + lockedItemId = currentLock.item_id; + } + const resource = getLockResourceName(currentLock.item_type); const endpoint = `${resource}/${lockedItemId}/unlock`; diff --git a/client/components/fields/editor/EventRelatedPlannings/EmbeddedCoverageForm.tsx b/client/components/fields/editor/EventRelatedPlannings/EmbeddedCoverageForm.tsx index 752b3ae61..2d3f34494 100644 --- a/client/components/fields/editor/EventRelatedPlannings/EmbeddedCoverageForm.tsx +++ b/client/components/fields/editor/EventRelatedPlannings/EmbeddedCoverageForm.tsx @@ -171,23 +171,21 @@ export class EmbeddedCoverageFormComponent extends React.PureComponent { - - { - this.onUserChange(null, user); - }} - autoFocus={false} - horizontalSpacing={true} - clearable={true} - /> - + { + this.onUserChange(null, user); + }} + autoFocus={false} + horizontalSpacing={true} + clearable={true} + /> - - - {this.props.coverageProfile.language != null && ( + {this.props.coverageProfile.language?.enabled !== true ? null : ( + + - )} - - + + + )} >; + embedded_planning: Array<{ + planning_id?: IPlanningItem['_id']; + coverages: Array<{ + coverage_id?: IPlanningCoverageItem['coverage_id']; + g2_content_type: ICoveragePlanningDetails['g2_content_type']; + desk: IPlanningAssignedTo['desk']; + user: IPlanningAssignedTo['user']; + language: ICoveragePlanningDetails['language']; + news_coverage_status: IPlanningNewsCoverageStatus['qcode']; + scheduled: ICoveragePlanningDetails['scheduled']; + + genre: ICoveragePlanningDetails['genre']['qcode']; + slugline: ICoveragePlanningDetails['slugline']; + ednote: ICoveragePlanningDetails['ednote']; + internal_note: ICoveragePlanningDetails['internal_note']; + }>; + }>; // Attributes added by API (removed via modifyForClient) // The `_status` field is available when the item comes from a POST/PATCH request diff --git a/client/utils/strings.tsx b/client/utils/strings.tsx index a3134b5e3..5bf408a2b 100644 --- a/client/utils/strings.tsx +++ b/client/utils/strings.tsx @@ -157,7 +157,7 @@ export function convertStringFields 0) { - itemDest.translations = (itemSrc.translations ?? []).concat(translationsDest); + itemDest.translations = (itemDest.translations ?? []).concat(translationsDest); } return itemDest; diff --git a/server/features/event_embedded_planning.feature b/server/features/event_embedded_planning.feature new file mode 100644 index 000000000..875ec9c18 --- /dev/null +++ b/server/features/event_embedded_planning.feature @@ -0,0 +1,354 @@ +Feature: Event Embedded Planning + @auth + @vocabulary + Scenario: Can create and update associated Planning with an Event + # Test creating and Event with a Planning item/Coveage + When we post to "/events" + """ + [{ + "guid": "event1", + "name": "Event1", + "dates": { + "start": "2029-11-21T12:00:00+0000", + "end": "2029-11-21T14:00:00+0000", + "tz": "Australia/Sydney" + }, + "embedded_planning": [{ + "coverages": [{ + "g2_content_type": "text", + "news_coverage_status": "ncostat:int", + "scheduled": "2029-11-21T15:00:00.000Z" + }] + }] + }] + """ + Then we get OK response + And we store "EVENT_ID" with value "#events._id#" to context + When we get "/events" + Then we get list with 1 items + """ + {"_items": [{ + "guid": "event1", + "type": "event", + "original_creator": "#CONTEXT_USER_ID#", + "firstcreated": "__now__", + "versioncreated": "__now__" + }]} + """ + When we get "/events_planning_search?repo=planning&only_future=false&event_item=event1" + Then we get list with 1 items + """ + {"_items": [{ + "_id": "__any_value__", + "original_creator": "#CONTEXT_USER_ID#", + "firstcreated": "__now__", + "versioncreated": "__now__", + "event_item": "event1", + "planning_date": "2029-11-21T12:00:00+0000", + "coverages": [{ + "coverage_id": "__any_value__", + "firstcreated": "__now__", + "versioncreated": "__now__", + "original_creator": "#CONTEXT_USER_ID#", + "version_creator": "#CONTEXT_USER_ID#", + "workflow_status": "draft", + "news_coverage_status": {"qcode": "ncostat:int", "name": "coverage intended", "label": "Planned"}, + "planning": { + "g2_content_type": "text", + "scheduled": "2029-11-21T15:00:00+0000" + } + }] + }]} + """ + And we store "PLAN1" with first item + And we store coverage id in "COVERAGE_ID" from plan 0 coverage 0 + When we get "/events/#EVENT_ID#" + Then we get existing resource + """ + {"planning_ids": ["#PLAN1._id#"]} + """ + + # Test updating an existing Planning item, and add new coverage + When we patch "/events/#EVENT_ID#" + """ + {"embedded_planning": [ + { + "planning_id": "#PLAN1._id#", + "coverages": [ + { + "coverage_id": "#COVERAGE_ID#", + "g2_content_type": "text", + "news_coverage_status": "ncostat:int", + "language": "en", + "scheduled": "2029-11-21T15:00:00.000Z", + "internal_note": "note something here", + "slugline": "test" + }, + { + "g2_content_type": "picture", + "news_coverage_status": "ncostat:onreq", + "language": "en", + "scheduled": "2029-11-21T16:00:00.000Z", + "internal_note": "only if enough demand", + "slugline": "test" + } + ] + } + ]} + """ + Then we get OK response + When we get "/planning/#PLAN1._id#" + Then we get existing resource + """ + {"coverages": [{ + "coverage_id": "#COVERAGE_ID#", + "news_coverage_status": {"qcode": "ncostat:int"}, + "planning": { + "g2_content_type": "text", + "scheduled": "2029-11-21T15:00:00+0000", + "internal_note": "note something here", + "slugline": "test" + } + }, { + "coverage_id": "__any_value__", + "news_coverage_status": {"qcode": "ncostat:onreq"}, + "planning": { + "g2_content_type": "picture", + "scheduled": "2029-11-21T16:00:00+0000", + "internal_note": "only if enough demand" + } + }]} + """ + + # Test removing a coverage + When we patch "/events/#EVENT_ID#" + """ + {"embedded_planning": [ + { + "planning_id": "#PLAN1._id#", + "coverages": [ + { + "g2_content_type": "picture", + "news_coverage_status": "ncostat:onreq", + "language": "en", + "scheduled": "2029-11-21T16:00:00.000Z", + "internal_note": "only if enough demand", + "slugline": "test" + } + ] + } + ]} + """ + Then we get OK response + When we get "/planning/#PLAN1._id#" + Then we get 1 coverages + And we get existing resource + """ + {"coverages": [{ + "coverage_id": "__any_value__", + "news_coverage_status": {"qcode": "ncostat:onreq"}, + "planning": { + "g2_content_type": "picture", + "scheduled": "2029-11-21T16:00:00+0000", + "internal_note": "only if enough demand" + } + }]} + """ + + @auth + @vocabulary + Scenario: Can create multilingual Planning with multilingual Event + Given "planning_types" + """ + [{ + "_id": "event", + "name": "event", + "editor": { + "language": {"enabled": true}, + "name": {"enabled": true}, + "slugline": {"enabled": true}, + "definition_short": {"enabled": true}, + "internal_note": {"enabled": true}, + "ednote": {"enabled": true}, + "priority": {"enabled": true}, + "place": {"enabled": true}, + "subject": {"enabled": true}, + "anpa_category": {"enabled": true} + }, + "schema": { + "language": { + "languages": ["en", "nl"], + "default_language": "en", + "multilingual": true, + "required": true + }, + "name": {"multilingual": true}, + "slugline": {"multilingual": true}, + "definition_short": {"multilingual": true}, + "ednote": {"multilingual": true}, + "internal_note": {"multilingual": true} + } + }, { + "_id": "planing", + "name": "planning", + "editor": { + "language": {"enabled": true}, + "name": {"enabled": true}, + "slugline": {"enabled": true}, + "description_text": {"enabled": true}, + "internal_note": {"enabled": true}, + "ednote": {"enabled": true}, + "priority": {"enabled": true}, + "place": {"enabled": true}, + "subject": {"enabled": true}, + "anpa_category": {"enabled": true} + }, + "schema": { + "language": { + "languages": ["en", "nl"], + "default_language": "en", + "multilingual": true, + "required": true + }, + "name": {"multilingual": true}, + "slugline": {"multilingual": true}, + "description_text": {"multilingual": true}, + "ednote": {"multilingual": true}, + "internal_note": {"multilingual": true} + } + }, { + "_id": "coverage", + "name": "coverage", + "editor": { + "g2_content_type": {"enabled": true}, + "slugline": {"enabled": true}, + "ednote": {"enabled": true}, + "internal_note": {"enabled": true}, + "language": {"enabled": true}, + "priority": {"enabled": true}, + "genre": {"enabled": true} + } + }] + """ + When we post to "/events" + """ + [{ + "guid": "event1", + "name": "name1", + "dates": { + "start": "2029-11-21T12:00:00+0000", + "end": "2029-11-21T14:00:00+0000", + "tz": "Australia/Sydney" + }, + "slugline": "slugline1", + "definition_short": "The description", + "internal_note": "event internal note", + "ednote": "event editorial note", + "language": "en", + "languages": ["en", "nl"], + "priority": 2, + "place": [{ + "name": "NSW", + "qcode": "NSW", + "state": "New South Wales", + "country": "Australia", + "world_region": "Oceania", + "group": "Australia" + }], + "subject":[{"qcode": "17004000", "name": "Statistics"}], + "anpa_category": [{"name": "Overseas Sport", "qcode": "s"}], + "translations": [ + {"field": "name", "language": "en", "value": "name-en"}, + {"field": "name", "language": "nl", "value": "name-nl"}, + {"field": "slugline", "language": "en", "value": "slugline-en"}, + {"field": "slugline", "language": "nl", "value": "slugline-nl"}, + {"field": "definition_short", "language": "en", "value": "description en"}, + {"field": "definition_short", "language": "nl", "value": "description nl"}, + {"field": "ednote", "language": "en", "value": "ednote en"}, + {"field": "ednote", "language": "nl", "value": "ednote nl"}, + {"field": "internal_note", "language": "en", "value": "internal note en"}, + {"field": "internal_note", "language": "nl", "value": "internal note nl"} + ], + "embedded_planning": [{ + "coverages": [{ + "g2_content_type": "text", + "language": "en", + "news_coverage_status": "ncostat:int", + "scheduled": "2029-11-21T15:00:00+0000", + "genre": "Article" + }, { + "g2_content_type": "text", + "language": "nl", + "news_coverage_status": "ncostat:onreq", + "scheduled": "2029-11-21T16:00:00+0000", + "genre": "Sidebar" + }] + }] + }] + """ + Then we get OK response + When we get "/events_planning_search?repo=planning&only_future=false&event_item=event1" + Then we get list with 1 items + """ + {"_items": [{ + "_id": "__any_value__", + "slugline": "slugline1", + "internal_note": "event internal note", + "name": "name1", + "description_text": "The description", + "place": [{ + "name": "NSW", + "qcode": "NSW", + "state": "New South Wales", + "country": "Australia", + "world_region": "Oceania", + "group": "Australia" + }], + "subject":[{"qcode": "17004000", "name": "Statistics"}], + "ednote": "event editorial note", + "language": "en", + "languages": ["en", "nl"], + "priority": 2, + "translations": [ + {"field": "name", "language": "en", "value": "name-en"}, + {"field": "name", "language": "nl", "value": "name-nl"}, + {"field": "slugline", "language": "en", "value": "slugline-en"}, + {"field": "slugline", "language": "nl", "value": "slugline-nl"}, + {"field": "description_text", "language": "en", "value": "description en"}, + {"field": "description_text", "language": "nl", "value": "description nl"}, + {"field": "ednote", "language": "en", "value": "ednote en"}, + {"field": "ednote", "language": "nl", "value": "ednote nl"}, + {"field": "internal_note", "language": "en", "value": "internal note en"}, + {"field": "internal_note", "language": "nl", "value": "internal note nl"} + ], + "coverages": [{ + "coverage_id": "__any_value__", + "workflow_status": "draft", + "news_coverage_status": {"qcode": "ncostat:int", "name": "coverage intended", "label": "Planned"}, + "planning": { + "g2_content_type": "text", + "language": "en", + "scheduled": "2029-11-21T15:00:00+0000", + "ednote": "ednote en", + "internal_note": "internal note en", + "slugline": "slugline-en", + "priority": 2, + "genre": [{"name": "Article (news)", "qcode": "Article"}] + } + }, { + "coverage_id": "__any_value__", + "workflow_status": "draft", + "news_coverage_status": {"qcode": "ncostat:onreq", "name": "coverage upon request", "label": "On request"}, + "planning": { + "g2_content_type": "text", + "language": "nl", + "scheduled": "2029-11-21T16:00:00+0000", + "ednote": "ednote nl", + "internal_note": "internal note nl", + "slugline": "slugline-nl", + "priority": 2, + "genre": [{"name": "Sidebar", "qcode": "Sidebar"}] + } + }] + }]} + """ diff --git a/server/features/event_sync_to_planning.feature b/server/features/event_sync_to_planning.feature new file mode 100644 index 000000000..6c4bf9c55 --- /dev/null +++ b/server/features/event_sync_to_planning.feature @@ -0,0 +1,282 @@ +Feature: Sync Event metadata To Planning + Background: Setup CVs + Given "planning_types" + """ + [{ + "_id": "event", + "name": "event", + "editor": { + "language": {"enabled": true}, + "name": {"enabled": true}, + "slugline": {"enabled": true}, + "ednote": {"enabled": true}, + "anpa_category": {"enabled": true} + }, + "schema": { + "language": { + "languages": ["en", "nl"], + "default_language": "en", + "multilingual": true, + "required": true + }, + "name": {"multilingual": true}, + "slugline": {"multilingual": true} + } + }, { + "_id": "planing", + "name": "planning", + "editor": { + "language": {"enabled": true}, + "name": {"enabled": true}, + "slugline": {"enabled": true}, + "ednote": {"enabled": true}, + "anpa_category": {"enabled": true} + }, + "schema": { + "language": { + "languages": ["en", "nl"], + "default_language": "en", + "multilingual": true, + "required": true + }, + "name": {"multilingual": true}, + "slugline": {"multilingual": true} + } + }, { + "_id": "coverage", + "name": "coverage", + "editor": { + "g2_content_type": {"enabled": true}, + "slugline": {"enabled": true}, + "ednote": {"enabled": true}, + "language": {"enabled": true} + } + }] + """ + + @auth + @vocabulary + Scenario: Sync Event metadata to Planning + Given config update + """ + {"SYNC_EVENT_FIELDS_TO_PLANNING": ["slugline", "name", "anpa_category", "language"]} + """ + + # Create the initial Event & Planning + # No need to check result, as this is covered in ``event_embedded_planning.feature`` + # ``Can create multilingual Planning with multilingual Event`` scenario + When we post to "/events" + """ + [{ + "guid": "event1", + "slugline": "slugline-en", + "name": "name-en", + "ednote": "event editorial note", + "dates": { + "start": "2029-11-21T12:00:00+0000", + "end": "2029-11-21T14:00:00+0000", + "tz": "Australia/Sydney" + }, + "language": "en", + "languages": ["en"], + "anpa_category": [{"name": "Overseas Sport", "qcode": "s"}], + "translations": [ + {"field": "name", "language": "en", "value": "name-en"}, + {"field": "slugline", "language": "en", "value": "slugline-en"} + ], + "embedded_planning": [{ + "coverages": [{ + "g2_content_type": "text", + "language": "en", + "news_coverage_status": "ncostat:int", + "scheduled": "2029-11-21T15:00:00+0000" + }, { + "g2_content_type": "text", + "language": "nl", + "news_coverage_status": "ncostat:onreq", + "scheduled": "2029-11-21T16:00:00+0000" + }] + }] + }] + """ + Then we get OK response + And we store "EVENT_ID" with value "#events._id#" to context + When we get "/events_planning_search?repo=planning&only_future=false&event_item=event1" + Then we get list with 1 items + """ + {"_items": [{ + "slugline": "slugline-en", + "name": "name-en", + "ednote": "event editorial note", + "anpa_category": [{"name": "Overseas Sport", "qcode": "s"}], + "language": "en", + "languages": ["en"], + "translations": [ + {"field": "name", "language": "en", "value": "name-en"}, + {"field": "slugline", "language": "en", "value": "slugline-en"} + ], + "coverages": [{ + "news_coverage_status": {"qcode": "ncostat:int", "name": "coverage intended", "label": "Planned"}, + "planning": { + "g2_content_type": "text", + "language": "en", + "slugline": "slugline-en", + "ednote": "event editorial note" + } + }, { + "news_coverage_status": {"qcode": "ncostat:onreq", "name": "coverage upon request", "label": "On request"}, + "planning": { + "g2_content_type": "text", + "language": "nl", + "slugline": "slugline-en", + "ednote": "event editorial note" + } + }] + }]} + """ + And we store "PLAN1" with first item + And we store coverage id in "COVERAGE1_ID" from plan 0 coverage 0 + And we store coverage id in "COVERAGE2_ID" from plan 0 coverage 1 + # Update the Event's slugline, name, anpa_category and languge fields are synced, and ednote + When we patch "/events/#EVENT_ID#" + """ + { + "slugline": "slugline-en-2", + "name": "name-en-2", + "ednote": "event editorial note 2", + "languages": ["en", "nl"], + "anpa_category": [ + {"name": "Overseas Sport", "qcode": "s"}, + {"name": "International News", "qcode": "i"} + ], + "translations": [ + {"field": "name", "language": "en", "value": "name-en-2"}, + {"field": "name", "language": "nl", "value": "name-nl-1"}, + {"field": "slugline", "language": "en", "value": "slugline-en-2"}, + {"field": "slugline", "language": "nl", "value": "slugline-nl-1"} + ] + } + """ + Then we get OK response + # Test that the slugline, name, anpa_category and languge fields are synced, and ednote is not + When we get "/planning/#PLAN1._id#" + Then we get existing resource + """ + { + "_id": "#PLAN1._id#", + "slugline": "slugline-en-2", + "name": "name-en-2", + "ednote": "event editorial note", + "anpa_category": [ + {"name": "Overseas Sport", "qcode": "s"}, + {"name": "International News", "qcode": "i"} + ], + "language": "en", + "languages": ["en", "nl"], + "translations": [ + {"field": "name", "language": "en", "value": "name-en-2"}, + {"field": "name", "language": "nl", "value": "name-nl-1"}, + {"field": "slugline", "language": "en", "value": "slugline-en-2"}, + {"field": "slugline", "language": "nl", "value": "slugline-nl-1"} + ], + "coverages": [{ + "coverage_id": "#COVERAGE1_ID#", + "news_coverage_status": {"qcode": "ncostat:int", "name": "coverage intended", "label": "Planned"}, + "planning": { + "g2_content_type": "text", + "language": "en", + "slugline": "slugline-en-2", + "ednote": "event editorial note" + } + }, { + "coverage_id": "#COVERAGE2_ID#", + "news_coverage_status": {"qcode": "ncostat:onreq", "name": "coverage upon request", "label": "On request"}, + "planning": { + "g2_content_type": "text", + "language": "nl", + "slugline": "slugline-nl-1", + "ednote": "event editorial note" + } + }] + } + """ + # Update the 1st Coverage so the slugline deviate from the parent Event item + When we patch "/events/#EVENT_ID#" + """ + {"embedded_planning": [{ + "planning_id": "#PLAN1._id#", + "coverages": [{ + "coverage_id": "#COVERAGE1_ID#", + "slugline": "coverage-1-slugline-1" + }, {"coverage_id": "#COVERAGE2_ID#"}] + }]} + """ + Then we get OK response + When we get "/planning/#PLAN1._id#" + Then we get existing resource + """ + { + "_id": "#PLAN1._id#", + "coverages": [{ + "coverage_id": "#COVERAGE1_ID#", + "news_coverage_status": {"qcode": "ncostat:int", "name": "coverage intended", "label": "Planned"}, + "planning": { + "g2_content_type": "text", + "language": "en", + "slugline": "coverage-1-slugline-1", + "ednote": "event editorial note" + } + }, { + "coverage_id": "#COVERAGE2_ID#", + "news_coverage_status": {"qcode": "ncostat:onreq", "name": "coverage upon request", "label": "On request"}, + "planning": { + "g2_content_type": "text", + "language": "nl", + "slugline": "slugline-nl-1", + "ednote": "event editorial note" + } + }] + } + """ + # Now update the Event's slugline + When we patch "/events/#EVENT_ID#" + """ + { + "slugline": "slugline-en-3", + "translations": [ + {"field": "name", "language": "en", "value": "name-en-3"}, + {"field": "name", "language": "nl", "value": "name-nl-2"}, + {"field": "slugline", "language": "en", "value": "slugline-en-3"}, + {"field": "slugline", "language": "nl", "value": "slugline-nl-2"} + ] + } + """ + Then we get OK response + # Now make sure the 1st Coverage's slugline does not change + # as it's value was different than the Event's when this change request was made + When we get "/planning/#PLAN1._id#" + Then we get existing resource + """ + { + "_id": "#PLAN1._id#", + "coverages": [{ + "coverage_id": "#COVERAGE1_ID#", + "news_coverage_status": {"qcode": "ncostat:int", "name": "coverage intended", "label": "Planned"}, + "planning": { + "g2_content_type": "text", + "language": "en", + "slugline": "coverage-1-slugline-1", + "ednote": "event editorial note" + } + }, { + "coverage_id": "#COVERAGE2_ID#", + "news_coverage_status": {"qcode": "ncostat:onreq", "name": "coverage upon request", "label": "On request"}, + "planning": { + "g2_content_type": "text", + "language": "nl", + "slugline": "slugline-nl-2", + "ednote": "event editorial note" + } + }] + } + """ diff --git a/server/features/steps/fixtures/vocabularies.json b/server/features/steps/fixtures/vocabularies.json index bf9b7cec5..ac1ad6b2e 100644 --- a/server/features/steps/fixtures/vocabularies.json +++ b/server/features/steps/fixtures/vocabularies.json @@ -42,5 +42,43 @@ {"is_active": true, "qcode": "ncostat:notint", "name": "coverage not intended", "label": "Not planned"}, {"is_active": true, "qcode": "ncostat:onreq", "name": "coverage upon request", "label": "On request"} ] - } + }, + { + "_id": "genre", + "display_name": "Genre", + "type": "manageable", + "items": [ + {"is_active": true, "name": "Article (news)", "qcode": "Article"}, + {"is_active": true, "name": "Sidebar", "qcode": "Sidebar"}, + {"is_active": true, "name": "Factbox", "qcode": "Factbox"}, + {"is_active": true, "name": "Feature", "qcode": "Feature"}, + {"is_active": true, "name": "Newsfeature", "qcode": "Newsfeature"}, + {"is_active": true, "name": "Backgrounder", "qcode": "Backgrounder"}, + {"is_active": true, "name": "Opinion", "qcode": "Opinion"}, + {"is_active": true, "name": "View (incl parly sketch)", "qcode": "View"}, + {"is_active": true, "name": "Modular", "qcode": "Modular"}, + {"is_active": true, "name": "Broadcast Script", "qcode": "Broadcast Script"}, + {"is_active": true, "name": "Briefs", "qcode": "Briefs"}, + {"is_active": true, "name": "Colour piece", "qcode": "Colour piece"}, + {"is_active": true, "name": "Obituary", "qcode": "Obituary"}, + {"is_active": true, "name": "Analysis", "qcode": "Analysis"}, + {"is_active": true, "name": "Timeline", "qcode": "Timeline"}, + {"is_active": true, "name": "Chronology", "qcode": "Chronology"}, + {"is_active": true, "name": "Interview", "qcode": "Interview"}, + {"is_active": true, "name": "Results (sport)", "qcode": "Results (sport)"}, + {"is_active": true, "name": "Market Open", "qcode": "Market Open"}, + {"is_active": true, "name": "Market Close", "qcode": "Market Close"}, + {"is_active": true, "name": "Market Report", "qcode": "Market Report"}, + {"is_active": true, "name": "Review", "qcode": "Review"}, + {"is_active": true, "name": "Preview", "qcode": "Preview"} + ] + }, + { + "_id": "languages", "display_name": "Languages", "type": "manageable", + "unique_field": "qcode", "service": {"all": 1}, + "items": [ + {"qcode": "en", "name": "English", "is_active": true}, + {"qcode": "nl", "name": "Dutch", "is_active": true} + ] + } ] diff --git a/server/features/steps/steps.py b/server/features/steps/steps.py index 308415a57..80f623565 100644 --- a/server/features/steps/steps.py +++ b/server/features/steps/steps.py @@ -193,6 +193,43 @@ def then_we_store_coverage_id(context, tag, index): set_placeholder(context, tag, coverage_id) +@then('we store coverage id in "{tag}" from plan {planning_index} coverage {coverage_index}') +def then_we_store_planning_coverage_id(context, tag, planning_index, coverage_index): + planning_index = int(planning_index) + coverage_index = int(coverage_index) + response = get_json_data(context.response) or {} + + try: + planning_item = response["_items"][planning_index] + except (KeyError, TypeError): + planning_item = None + assert planning_item is not None, "Planning not found" + + try: + coverage_id = planning_item["coverages"][coverage_index]["coverage_id"] + except (KeyError, TypeError): + coverage_id = None + assert coverage_id is not None, "Coverage ID not found" + + set_placeholder(context, tag, coverage_id) + + +@then("we get {coverage_count} coverages") +def then_we_get_coverages_count(context, coverage_count): + coverage_count = int(coverage_count) + response = get_json_data(context.response) or {} + + try: + actual_coverage_count = len(response["coverages"]) + except (KeyError, TypeError): + assert actual_coverage_count > 0, "No coverages found" + coverage_count = 0 + + assert ( + coverage_count == actual_coverage_count + ), f"Number of coverages {actual_coverage_count} does not match expected {coverage_count}" + + @then('we store scheduled_update id in "{tag}" from scheduled_update {index} of coverage {coverage_index}') def then_we_store_scheduled_update_id_from_assignment_coverage(context, tag, index, coverage_index): index = int(index) diff --git a/server/planning/common.py b/server/planning/common.py index e471d3c7c..819a9f3df 100644 --- a/server/planning/common.py +++ b/server/planning/common.py @@ -8,7 +8,7 @@ # AUTHORS and LICENSE files distributed with this source code, or # at https://www.sourcefabric.org/superdesk/license -from typing import NamedTuple, Dict, Any +from typing import NamedTuple, Dict, Any, Set, Tuple import re import time @@ -244,6 +244,11 @@ def get_config_default_create_planning_series_with_event_series(current_app=None return (current_app or app).config.get("DEFAULT_CREATE_PLANNING_SERIES_WITH_EVENT_SERIES", False) +def get_config_event_fields_to_sync_with_planning(current_app=None) -> Set[str]: + config_value = (current_app or app).config.get("SYNC_EVENT_FIELDS_TO_PLANNING", "") + return set(config_value.split(",") if isinstance(config_value, str) else config_value) + + def remove_lock_information(item): item.update({LOCK_USER: None, LOCK_SESSION: None, LOCK_TIME: None, LOCK_ACTION: None}) diff --git a/server/planning/content_profiles/content_profiles_test.py b/server/planning/content_profiles/content_profiles_test.py index 46cabc207..72b45094d 100644 --- a/server/planning/content_profiles/content_profiles_test.py +++ b/server/planning/content_profiles/content_profiles_test.py @@ -11,7 +11,7 @@ from planning.tests import TestCase -from .utils import get_multilingual_fields +from .utils import get_multilingual_fields, ContentProfileData class ContentProfilesTestCase(TestCase): @@ -80,3 +80,38 @@ def test_get_multilingual_fields(self): self.assertNotIn("slugline", fields) self.assertNotIn("definition_short", fields) self.assertNotIn("definition_long", fields) + + def test_content_profile_data(self): + self.app.data.insert( + "planning_types", + [ + { + "_id": "event", + "name": "event", + "editor": { + "language": {"enabled": True}, + }, + "schema": { + "language": { + "languages": ["en", "de"], + "default_language": "en", + "multilingual": True, + "required": True, + }, + "name": {"multilingual": True}, + "slugline": {"multilingual": True}, + "definition_short": {"multilingual": True}, + "anpa_category": {"required": True}, + }, + } + ], + ) + + data = ContentProfileData("event") + self.assertTrue(data.profile["_id"] == data.profile["name"] == "event") + self.assertTrue(data.is_multilingual) + self.assertEqual(data.multilingual_fields, {"name", "slugline", "definition_short"}) + self.assertIn("name", data.enabled_fields) + self.assertIn("slugline", data.enabled_fields) + self.assertIn("definition_short", data.enabled_fields) + self.assertIn("anpa_category", data.enabled_fields) diff --git a/server/planning/content_profiles/utils.py b/server/planning/content_profiles/utils.py index c753a9df8..5aef4e141 100644 --- a/server/planning/content_profiles/utils.py +++ b/server/planning/content_profiles/utils.py @@ -10,48 +10,82 @@ from typing import Set from superdesk import get_resource_service +from planning.types import ContentProfile -def get_planning_schema(resource: str): +def get_planning_schema(resource: str) -> ContentProfile: return get_resource_service("planning_types").find_one(req=None, name=resource) -def is_field_enabled(field, planning_type): - editor = planning_type.get("editor") or {} - return (editor.get(field) or {}).get("enabled", False) +def is_field_enabled(field: str, profile: ContentProfile) -> bool: + try: + return profile["editor"][field]["enabled"] + except (KeyError, TypeError): + return False -def is_field_editor_3(field: str, planning_type) -> bool: - return ( - is_field_enabled(field, planning_type) - and ((planning_type.get("schema") or {}).get(field) or {}).get("field_type") == "editor_3" - ) +def get_enabled_fields(profile: ContentProfile) -> Set[str]: + return set(field for field in profile["editor"].keys() if is_field_enabled(field, profile)) -def get_multilingual_fields(resource: str) -> Set[str]: - content_type = get_planning_schema(resource) - resource_schema = content_type.get("schema") or {} +def is_field_editor_3(field: str, profile: ContentProfile) -> bool: + try: + return is_field_enabled(field, profile) and profile["schema"][field]["field_type"] == "editor_3" + except (KeyError, TypeError): + return False + +def is_multilingual_enabled(field: str, profile: ContentProfile) -> bool: + try: + return profile["schema"][field]["multilingual"] + except (KeyError, TypeError): + return False + + +def get_multilingual_fields_from_profile(profile: ContentProfile) -> Set[str]: return ( set() - if not (resource_schema.get("language") or {}).get("multilingual") + if not is_multilingual_enabled("language", profile) else set( field_name - for field_name, field_schema in resource_schema.items() + for field_name, field_schema in profile["schema"].items() if ( - is_field_enabled(field_name, content_type) + is_field_enabled(field_name, profile) and field_name != "language" - and (field_schema or {}).get("multilingual") is True + and is_multilingual_enabled(field_name, profile) ) ) ) +def get_multilingual_fields(resource: str) -> Set[str]: + return get_multilingual_fields_from_profile(get_planning_schema(resource)) + + def get_editor3_fields(resource: str) -> Set[str]: - content_type = get_planning_schema(resource) - resource_schema = content_type.get("schema") or {} - return set( - field_name - for field_name, field_schema in resource_schema.items() - if (is_field_enabled(field_name, content_type) and ((field_schema or {})).get("field_type", "") == "editor_3") - ) + profile = get_planning_schema(resource) + return set(field_name for field_name in profile["schema"].keys() if is_field_editor_3(field_name, profile)) + + +class ContentProfileData: + profile: ContentProfile + is_multilingual: bool + multilingual_fields: Set[str] + enabled_fields: Set[str] + + def __init__(self, resource: str): + self.profile = get_planning_schema(resource) + self.enabled_fields = get_enabled_fields(self.profile) + self.is_multilingual = is_multilingual_enabled("language", self.profile) + self.multilingual_fields = get_multilingual_fields_from_profile(self.profile) + + +class AllContentProfileData: + events: ContentProfileData + planning: ContentProfileData + coverages: ContentProfileData + + def __init__(self): + self.events = ContentProfileData("event") + self.planning = ContentProfileData("planning") + self.coverages = ContentProfileData("coverage") diff --git a/server/planning/events/events.py b/server/planning/events/events.py index 4b329a01a..981b1bfb0 100644 --- a/server/planning/events/events.py +++ b/server/planning/events/events.py @@ -10,7 +10,7 @@ """Superdesk Events""" -from typing import Dict, Any, Optional +from typing import Dict, Any, Optional, List, Tuple import superdesk import logging import itertools @@ -40,13 +40,14 @@ from superdesk import get_resource_service from superdesk.errors import SuperdeskApiError from superdesk.metadata.utils import generate_guid -from superdesk.metadata.item import GUID_NEWSML, CONTENT_STATE +from superdesk.metadata.item import GUID_NEWSML from superdesk.notification import push_notification from superdesk.utc import get_date, utcnow from apps.auth import get_user, get_user_id from apps.archive.common import get_auth, update_dates_for from superdesk.users.services import current_user_has_privilege -from .events_base_service import EventsBaseService + +from planning.types import Event, EmbeddedPlanning, EmbeddedCoverageItem from planning.common import ( UPDATE_SINGLE, UPDATE_FUTURE, @@ -66,8 +67,11 @@ set_ingest_version_datetime, is_new_version, update_ingest_on_patch, + TEMP_ID_PREFIX, ) +from .events_base_service import EventsBaseService from .events_schema import events_schema +from .events_sync import sync_event_metadata_with_planning_items logger = logging.getLogger(__name__) @@ -83,6 +87,21 @@ } +def get_events_embedded_planning(event: Event) -> List[EmbeddedPlanning]: + def get_coverage_id(coverage: EmbeddedCoverageItem) -> str: + if not coverage.get("coverage_id"): + coverage["coverage_id"] = TEMP_ID_PREFIX + "-" + generate_guid(type=GUID_NEWSML) + return coverage["coverage_id"] + + return [ + { + "planning_id": planning.get("planning_id"), + "coverages": {get_coverage_id(coverage): coverage for coverage in planning.get("coverages") or []}, + } + for planning in event.pop("embedded_planning", []) + ] + + class EventsService(superdesk.Service): """Service class for the events model.""" @@ -247,6 +266,27 @@ def on_create(self, docs): if generated_events: docs.extend(generated_events) + def create(self, docs: List[Event], **kwargs): + """Saves the list of Events to Mongo & Elastic + + Also extracts out the ``embedded_planning`` before saving the Event(s) + And then uses them to synchronise/process the associated Planning item(s) + """ + + embedded_planning_lists: List[Tuple[Event, List[EmbeddedPlanning]]] = [] + for event in docs: + embedded_planning = get_events_embedded_planning(event) + if len(embedded_planning): + embedded_planning_lists.append((event, embedded_planning)) + + ids = self.backend.create(self.datasource, docs, **kwargs) + + if len(embedded_planning_lists): + for event, embedded_planning in embedded_planning_lists: + sync_event_metadata_with_planning_items(None, event, embedded_planning) + + return ids + def validate_event(self, updates, original=None): """Validate the event @@ -393,8 +433,22 @@ def can_edit(item, user_id): return True, "" def update(self, id, updates, original): + """Updated the Event in Mongo & Elastic + + Also extracts out the ``embedded_planning`` before saving the Event + And then uses them to synchronise/process the associated Planning item(s) + """ + updates.setdefault("versioncreated", utcnow()) + + # Extract the ``embedded_planning`` from the updates + embedded_planning = get_events_embedded_planning(updates) + item = self.backend.update(self.datasource, id, updates, original) + + # Process ``embedded_planning`` field, and sync Event metadata with associated Planning/Coverages + sync_event_metadata_with_planning_items(original, updates, embedded_planning) + return item def on_update(self, updates, original): @@ -877,6 +931,8 @@ def generate_recurring_events(event): # Get the recurrence_id, or generate one if it doesn't exist recurrence_id = event.get("recurrence_id", generate_guid(type=GUID_NEWSML)) + embedded_planning_added = False + # compute the difference between start and end in the original event time_delta = event["dates"]["end"] - event["dates"]["start"] # for all the dates based on the recurring rules: @@ -894,10 +950,18 @@ def generate_recurring_events(event): # Remove fields not required by the new events for key in list(new_event.keys()): - if key.startswith("_"): - new_event.pop(key) - elif key.startswith("lock_"): + if key.startswith("_") or key.startswith("lock_"): new_event.pop(key) + elif key == "embedded_planning": + if not embedded_planning_added: + # If this is the first Event in the series, then keep + # the ``embedded_planning`` field for processing later + embedded_planning_added = True + else: + # Otherwise remove the ``embedded_planning`` from all other Events + # in the series + new_event.pop("embedded_planning") + new_event.pop("pubstatus", None) new_event.pop("reschedule_from", None) diff --git a/server/planning/events/events_schema.py b/server/planning/events/events_schema.py index 731138208..56aa6e70b 100644 --- a/server/planning/events/events_schema.py +++ b/server/planning/events/events_schema.py @@ -9,7 +9,7 @@ # at https://www.sourcefabric.org/superdesk/license from superdesk import Resource -from superdesk.resource import not_analyzed, string_with_analyzer +from superdesk.resource import not_analyzed, not_enabled from superdesk.metadata.item import metadata_schema, ITEM_TYPE from copy import deepcopy @@ -20,6 +20,7 @@ TO_BE_CONFIRMED_FIELD, TO_BE_CONFIRMED_FIELD_SCHEMA, ) +from planning.planning.planning import planning_schema as original_planning_schema event_type = deepcopy(Resource.rel("events", type="string")) event_type["mapping"] = not_analyzed @@ -29,6 +30,9 @@ original_creator_schema = metadata_schema["original_creator"] original_creator_schema.update({"nullable": True}) +planning_schema = deepcopy(original_planning_schema) +planning_schema["event_item"] = {"type": "string"} + events_schema = { # Identifiers "_id": metadata_schema["_id"], @@ -333,4 +337,37 @@ }, }, }, + # This is used from the EmbeddedCoverage form in the Event editor + # This list is NOT stored with the Event + "embedded_planning": { + "type": "list", + "required": False, + "mapping": not_enabled, + "schema": { + "type": "dict", + "schema": { + "planning_id": {"type": "string"}, + "coverages": { + "type": "list", + "schema": { + "type": "dict", + "schema": { + "coverage_id": {"type": "string"}, + "g2_content_type": {"type": "string"}, + "news_coverage_status": {"type": "string"}, + "scheduled": {"type": "datetime"}, + "desk": {"type": "string", "nullable": True}, + "user": {"type": "string", "nullable": True}, + "language": {"type": "string", "nullable": True}, + "genre": {"type": "string", "nullable": True}, + "slugline": {"type": "string", "nullable": True}, + "ednote": {"type": "string", "nullable": True}, + "internal_note": {"type": "string", "nullable": True}, + "priority": {"type": "integer", "nullable": True}, + }, + }, + }, + }, + }, + }, } # end events_schema diff --git a/server/planning/events/events_sync/__init__.py b/server/planning/events/events_sync/__init__.py new file mode 100644 index 000000000..5a220ffe9 --- /dev/null +++ b/server/planning/events/events_sync/__init__.py @@ -0,0 +1,166 @@ +# -*- coding: utf-8; -*- +# +# This file is part of Superdesk. +# +# Copyright 2013, 2014 Sourcefabric z.u. and contributors. +# +# For the full copyright and license information, please see the +# AUTHORS and LICENSE files distributed with this source code, or +# at https://www.sourcefabric.org/superdesk/license + +from typing import Dict, Optional, List +from copy import deepcopy +import pytz + +from eve.utils import str_to_date +from superdesk import get_resource_service + +from planning.types import Event, EmbeddedPlanning, StringFieldTranslation +from planning.common import get_config_event_fields_to_sync_with_planning +from planning.content_profiles.utils import AllContentProfileData + +from .common import VocabsSyncData, SyncItemData, SyncData +from .embedded_planning import ( + create_new_plannings_from_embedded_planning, + get_existing_plannings_from_embedded_planning, +) +from .planning_sync import sync_existing_planning_item + +COVERAGE_SYNC_FIELDS = ["slugline", "internal_note", "ednote", "priority", "language"] + + +def get_translated_fields(translations: List[StringFieldTranslation]) -> Dict[str, Dict[str, str]]: + fields: Dict[str, Dict[str, str]] = {} + for translation in translations: + fields.setdefault(translation["field"], {}) + fields[translation["field"]][translation["language"]] = translation["value"] + return fields + + +def sync_event_metadata_with_planning_items( + original: Optional[Event], updates: Event, embedded_planning: List[EmbeddedPlanning] +): + profiles = AllContentProfileData() + + if original is None: + original = {} + event_updated = deepcopy(original) + event_updated.update(updates) + + if isinstance(event_updated["dates"]["start"], str): + event_updated["dates"]["start"] = str_to_date(event_updated["dates"]["start"]) + if event_updated["dates"]["start"].tzinfo is None: + event_updated["dates"]["start"] = event_updated["dates"]["start"].replace(tzinfo=pytz.utc) + + vocabs_service = get_resource_service("vocabularies") + vocabs = VocabsSyncData( + coverage_states={ + item["qcode"]: item + for item in (vocabs_service.find_one(req=None, _id="newscoveragestatus") or {}).get("items") or [] + }, + genres={ + item["qcode"]: item for item in (vocabs_service.find_one(req=None, _id="genre") or {}).get("items") or [] + }, + ) + + event_sync_data = SyncItemData( + original=original, + updates=updates, + original_translations=get_translated_fields(original.get("translations") or []), + updated_translations=get_translated_fields(updates.get("translations") or []), + ) + event_translations = deepcopy(event_sync_data.updated_translations or event_sync_data.original_translations) + + # Create any new Planning items (and their coverages), based on the ``embedded_planning`` Event field + create_new_plannings_from_embedded_planning(event_updated, event_translations, embedded_planning, profiles, vocabs) + + if not original: + # If this was from the creation of a new Event, then no need to sync metadata with existing items + # as there aren't any yet. + return + + planning_service = get_resource_service("planning") + sync_fields_config = get_config_event_fields_to_sync_with_planning() + sync_fields = set(field for field in sync_fields_config if field in updates) + + if not len(sync_fields): + # There are no fields to sync with the Event + # So only update the Planning items based on the ``embedded_planning`` Event field + for planning_original, planning_updates, update_required in get_existing_plannings_from_embedded_planning( + event_updated, event_translations, embedded_planning, profiles, vocabs + ): + if update_required: + planning_service.patch(planning_original["_id"], planning_updates) + return + + coverage_sync_fields = set(field for field in sync_fields if field in COVERAGE_SYNC_FIELDS) + if ( + profiles.events.is_multilingual + and profiles.planning.is_multilingual + and "language" in sync_fields_config + and "languages" in updates + ): + # If multilingual is enabled for both Event & Planning, then add ``languages`` to the list + # of fields to sync + sync_fields.add("languages") + try: + # And turn off syncing of Coverage language + coverage_sync_fields.remove("language") + except KeyError: + pass + + # Sync all the Planning items that were provided in the ``embedded_planning`` field + processed_planning_ids: List[str] = [] + for planning_original, planning_updates, update_required in get_existing_plannings_from_embedded_planning( + event_updated, event_translations, embedded_planning, profiles, vocabs + ): + translated_fields = get_translated_fields(planning_original.get("translations") or []) + sync_data = SyncData( + event=event_sync_data, + planning=SyncItemData( + original=planning_original, + updates=planning_updates, + original_translations=translated_fields, + updated_translations=deepcopy(translated_fields), + ), + coverage_updates=deepcopy(planning_updates.get("coverages") or planning_original.get("coverages") or []), + update_translations=False, + update_coverages=update_required, + update_planning=update_required, + ) + + sync_existing_planning_item( + sync_data, + sync_fields, + profiles, + coverage_sync_fields, + ) + processed_planning_ids.append(planning_original["_id"]) + if sync_data.update_planning: + planning_service.patch(sync_data.planning.original["_id"], sync_data.planning.updates) + + # Sync all the Planning items that were NOT provided in the ``embedded_planning`` field + where = {"$and": [{"event_item": event_updated.get("_id")}, {"_id": {"$nin": processed_planning_ids}}]} + for item in planning_service.find(where=where): + translated_fields = get_translated_fields(item.get("translations") or []) + sync_data = SyncData( + event=event_sync_data, + planning=SyncItemData( + original=item, + updates={}, + original_translations=translated_fields, + updated_translations=deepcopy(translated_fields), + ), + coverage_updates=deepcopy(item.get("coverages") or []), + update_translations=False, + update_coverages=False, + update_planning=False, + ) + sync_existing_planning_item( + sync_data, + sync_fields, + profiles, + coverage_sync_fields, + ) + if sync_data.update_planning: + planning_service.patch(sync_data.planning.original["_id"], sync_data.planning.updates) diff --git a/server/planning/events/events_sync/common.py b/server/planning/events/events_sync/common.py new file mode 100644 index 000000000..95e6830df --- /dev/null +++ b/server/planning/events/events_sync/common.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8; -*- +# +# This file is part of Superdesk. +# +# Copyright 2013, 2014 Sourcefabric z.u. and contributors. +# +# For the full copyright and license information, please see the +# AUTHORS and LICENSE files distributed with this source code, or +# at https://www.sourcefabric.org/superdesk/license + +from typing import List, Dict, Any +from dataclasses import dataclass + + +@dataclass +class SyncItemData: + original: Dict[str, Any] + updates: Dict[str, Any] + original_translations: Dict[str, Dict[str, str]] + updated_translations: Dict[str, Dict[str, str]] + + +@dataclass +class SyncData: + event: SyncItemData + planning: SyncItemData + coverage_updates: List[Dict[str, Any]] + update_translations: bool + update_coverages: bool + update_planning: bool + + +@dataclass +class VocabsSyncData: + coverage_states: Dict[str, Dict[str, str]] + genres: Dict[str, Dict[str, str]] diff --git a/server/planning/events/events_sync/embedded_planning.py b/server/planning/events/events_sync/embedded_planning.py new file mode 100644 index 000000000..774241bd0 --- /dev/null +++ b/server/planning/events/events_sync/embedded_planning.py @@ -0,0 +1,336 @@ +# -*- coding: utf-8; -*- +# +# This file is part of Superdesk. +# +# Copyright 2013, 2014 Sourcefabric z.u. and contributors. +# +# For the full copyright and license information, please see the +# AUTHORS and LICENSE files distributed with this source code, or +# at https://www.sourcefabric.org/superdesk/license + +from typing import List, Iterator, Tuple, Dict +from copy import deepcopy +import logging + +from superdesk import get_resource_service + +from planning.types import Event, EmbeddedPlanning, EmbeddedCoverageItem, Planning, Coverage, StringFieldTranslation +from planning.content_profiles.utils import AllContentProfileData + +from .common import VocabsSyncData + +logger = logging.getLogger(__name__) + + +def create_new_plannings_from_embedded_planning( + event: Event, + event_translations: Dict[str, Dict[str, str]], + embedded_planning: List[EmbeddedPlanning], + profiles: AllContentProfileData, + vocabs: VocabsSyncData, +): + if not len(embedded_planning): + return + + new_plannings: List[Planning] = [] + planning_fields = set( + field + for field in [ + "slugline", + "internal_note", + "name", + "place", + "subject", + "anpa_category", + "ednote", + "language", + "priority", + ] + if field in profiles.planning.enabled_fields + ) + multilingual_enabled = profiles.events.is_multilingual and profiles.planning.is_multilingual + translations: List[StringFieldTranslation] = [] + if multilingual_enabled and "language" in planning_fields and len(event.get("translations") or []): + planning_fields.add("languages") + + def map_event_to_planning_translation(translation: StringFieldTranslation): + if translation["field"] == "definition_short": + translation["field"] = "description_text" + return translation + + translations = [ + map_event_to_planning_translation(translation) + for translation in event["translations"] + if ( + translation.get("field") is not None + and ( + ( + translation["field"] == "definition_short" + and "description_text" in profiles.planning.enabled_fields + ) + or translation["field"] in profiles.planning.enabled_fields + ) + ) + ] + + for plan in embedded_planning: + if plan.get("planning_id"): + # Skip this item, as it's an existing Planning item + continue + + new_planning: Planning = { + "agendas": [], + "item_class": "plinat:newscoverage", + "state": "draft", + "type": "planning", + "planning_date": event["dates"]["start"], + "event_item": event["_id"], + "coverages": [], + } + + if event.get("recurrence_id"): + new_planning["recurrence_id"] = event["recurrence_id"] + + for field in planning_fields: + new_planning[field] = event.get(field) + + if "description_text" in profiles.planning.enabled_fields: + new_planning["description_text"] = event.get("definition_short") + + if translations: + new_planning["translations"] = translations + + for coverage_id, coverage in (plan.get("coverages") or {}).items(): + new_planning["coverages"].append( + create_new_coverage_from_event_and_planning( + event, event_translations, new_planning, coverage, profiles, vocabs + ) + ) + + new_plannings.append(new_planning) + + if len(new_plannings): + get_resource_service("planning").post(new_plannings) + + +def create_new_coverage_from_event_and_planning( + event: Event, + event_translations: Dict[str, Dict[str, str]], + planning: Planning, + coverage: EmbeddedCoverageItem, + profiles: AllContentProfileData, + vocabs: VocabsSyncData, +) -> Coverage: + try: + news_coverage_status = coverage["news_coverage_status"] + except KeyError: + news_coverage_status = "ncostat:int" + new_coverage: Coverage = { + "original_creator": planning.get("original_creator") or event.get("original_creator"), + "version_creator": ( + planning.get("version_creator") + or event.get("version_creator") + or planning.get("original_creator") + or event.get("original_creator") + ), + "firstcreated": planning.get("firstcreated") or event.get("firstcreated"), + "versioncreated": planning.get("versioncreated") or event.get("versioncreated"), + "news_coverage_status": vocabs.coverage_states.get(news_coverage_status) or {"qcode": news_coverage_status}, + "workflow_status": "draft", + "flags": {"no_content_linking": False}, + "assigned_to": { + "desk": coverage.get("desk"), + "user": coverage.get("user"), + }, + "planning": {}, + } + + if "language" in profiles.coverages.enabled_fields: + # If ``language`` is enabled for Coverages but not defined in ``embedded_planning`` + # then fallback to the language from the Planning item or Event + if coverage.get("language"): + new_coverage["planning"]["language"] = coverage["language"] + elif len(planning.get("languages", [])): + new_coverage["planning"]["language"] = planning["languages"][0] + elif planning.get("language"): + new_coverage["planning"]["language"] = planning["language"] + elif len(event.get("languages", [])): + new_coverage["planning"]["language"] = event["languages"][0] + elif event.get("language"): + new_coverage["planning"]["language"] = event["language"] + + try: + coverage_language = new_coverage["planning"]["language"] + except (KeyError, TypeError): + coverage_language = None + + coverage_planning_fields = set( + field + for field in [ + "ednote", + "g2_content_type", + "scheduled", + "slugline", + "internal_note", + "priority", + ] + if field in profiles.coverages.enabled_fields + ) + for field in coverage_planning_fields: + if coverage.get(field): + # If the value is already provided in the Coverage, then use that + new_coverage["planning"][field] = coverage.get(field) + continue + + new_value = None + if coverage_language is not None: + # If the Coverage has a language defined, then try and get the value + # from the Event's translations array for this field + try: + new_coverage["planning"][field] = event_translations[field][coverage_language] + continue + except (KeyError, TypeError): + pass + + # Otherwise fallback to the Planning or Event value directly + new_coverage["planning"][field] = planning.get(field) or event.get(field) + + if "genre" in profiles.coverages.enabled_fields and coverage.get("genre") is not None: + new_coverage["planning"]["genre"] = [vocabs.genres.get(coverage["genre"]) or {"qcode": coverage["genre"]}] + + return new_coverage + + +def get_existing_plannings_from_embedded_planning( + event: Event, + event_translations: Dict[str, Dict[str, str]], + embedded_planning: List[EmbeddedPlanning], + profiles: AllContentProfileData, + vocabs: VocabsSyncData, +) -> Iterator[Tuple[Planning, Planning, bool]]: + existing_planning_ids: List[str] = [plan["planning_id"] for plan in embedded_planning if plan.get("planning_id")] + + if not len(existing_planning_ids): + return + + existing_plannings: Dict[str, Planning] = { + item["_id"]: item + for item in get_resource_service("planning").get_from_mongo( + req=None, lookup={"_id": {"$in": existing_planning_ids}} + ) + } + + coverage_planning_fields = set( + field + for field in [ + "g2_content_type", + "scheduled", + "language", + "slugline", + "internal_note", + "priority", + "ednote", + ] + if field in profiles.coverages.enabled_fields + ) + for embedded_plan in embedded_planning: + planning_id = embedded_plan.get("planning_id") + if not planning_id: + # This is a new Planning item, which should have already been handled in + # ``create_new_plannings_from_embedded_planning`` + continue + + try: + existing_planning = existing_plannings[planning_id] + except KeyError: + logger.warning(f"Failed to find planning item '{planning_id}' from embedded coverage") + continue + + updated_coverage_ids = [ + coverage["coverage_id"] + for coverage in existing_planning.get("coverages") or [] + if coverage.get("coverage_id") and embedded_plan["coverages"].get(coverage["coverage_id"]) + ] + update_required = len(existing_planning.get("coverages") or []) != len(embedded_plan["coverages"]) + updates = { + "coverages": [ + coverage + for coverage in deepcopy(existing_planning.get("coverages") or []) + if coverage.get("coverage_id") in updated_coverage_ids + ] + } + for existing_coverage in updates["coverages"]: + try: + embedded_coverage: EmbeddedCoverageItem = embedded_plan["coverages"][existing_coverage["coverage_id"]] + except KeyError: + # Coverage not found in Event's EmbeddedCoverages + # We can safely skip this one + continue + + try: + coverage_planning = existing_coverage["planning"] + except KeyError: + coverage_planning = None + + if coverage_planning is not None: + for field in coverage_planning_fields: + try: + if coverage_planning.get(field) != embedded_coverage[field]: # type: ignore + coverage_planning[field] = embedded_coverage[field] # type: ignore + update_required = True + except KeyError: + pass + + try: + if ( + "genre" in profiles.coverages.enabled_fields + and coverage_planning.get("genre") != embedded_coverage["genre"] + ): + coverage_planning["genre"] = [ + vocabs.genres.get(embedded_coverage["genre"]) or {"qcode": embedded_coverage["genre"]} + ] + update_required = True + except KeyError: + pass + + try: + if ( + existing_coverage.get("news_coverage_status", {}).get("qcode") + != embedded_coverage["news_coverage_status"] + ): + existing_coverage["news_coverage_status"] = vocabs.coverage_states.get( + embedded_coverage["news_coverage_status"] + ) or {"qcode": embedded_coverage["news_coverage_status"]} + update_required = True + except KeyError: + pass + + try: + if existing_coverage.get("assigned_to", {}).get("desk") != embedded_coverage["desk"]: + existing_coverage["assigned_to"]["desk"] = embedded_coverage["desk"] + update_required = True + except KeyError: + pass + + try: + if existing_coverage.get("assigned_to", {}).get("user") != embedded_coverage["user"]: + existing_coverage["assigned_to"]["user"] = embedded_coverage["user"] + update_required = True + except KeyError: + pass + + # Create new Coverages from the ``embedded_planning`` Event field + for coverage_id, embedded_coverage in embedded_plan["coverages"].items(): + if coverage_id in updated_coverage_ids: + # This coverage already exists in the Planning item + # No need to create a new one + continue + + updates["coverages"].append( + create_new_coverage_from_event_and_planning( + event, event_translations, existing_planning, embedded_coverage, profiles, vocabs + ) + ) + update_required = True + + yield existing_planning, updates if update_required else {}, update_required diff --git a/server/planning/events/events_sync/planning_sync.py b/server/planning/events/events_sync/planning_sync.py new file mode 100644 index 000000000..1324c02e0 --- /dev/null +++ b/server/planning/events/events_sync/planning_sync.py @@ -0,0 +1,165 @@ +# -*- coding: utf-8; -*- +# +# This file is part of Superdesk. +# +# Copyright 2013, 2014 Sourcefabric z.u. and contributors. +# +# For the full copyright and license information, please see the +# AUTHORS and LICENSE files distributed with this source code, or +# at https://www.sourcefabric.org/superdesk/license + +from typing import List, Set + +from planning.types import StringFieldTranslation +from planning.content_profiles.utils import AllContentProfileData + +from .common import SyncData + + +def get_normalised_field_value(item, field): + value = item.get(field) + if field in ["place", "anpa_category"]: + # list of CV items, return their qcode + return sorted([cv_item.get("qcode") for cv_item in value or []]) + elif field == "subject": + # list of subjects, return those without a scheme set + return sorted([cv_item.get("qcode") for cv_item in value or [] if cv_item.get("scheme") is None]) + elif field == "custom_vocabularies": + # list of subjects, return those WITH a scheme set + return sorted([cv_item.get("qcode") for cv_item in value or [] if cv_item.get("scheme") is not None]) + else: + # This should cater to the plain (or list of string) fields, such as: + # "slugline", "internal_note", "name", "ednote", "definition_short", + # "description_text", "priority", "language", "languages" + return value + + +def _sync_planning_field(sync_data: SyncData, field: str): + original_value_normalised = get_normalised_field_value(sync_data.event.original, field) + updated_value_normalised = get_normalised_field_value(sync_data.event.updates, field) + + if original_value_normalised == updated_value_normalised: + # no changes to the value of this field + return + + planning_value_normalised = get_normalised_field_value( + sync_data.planning.original, "description_text" if field == "definition_short" else field + ) + + if planning_value_normalised != original_value_normalised: + return + + # The Planning field has the same value as the Event field, + # So we can copy the new value from the Event + new_value = sync_data.event.updates.get(field) + if field in ["subject", "custom_vocabularies"]: + sync_data.planning.updates.setdefault("subject", []) + if new_value is not None: + sync_data.planning.updates["subject"] += new_value + else: + sync_data.planning.updates[field] = new_value + sync_data.update_planning = True + + +def _sync_planning_multilingual_field(sync_data: SyncData, field: str, profiles: AllContentProfileData): + if ( + field not in sync_data.event.updated_translations + or field not in profiles.events.multilingual_fields + or field not in profiles.planning.multilingual_fields + ): + return + + for language, updated_value in sync_data.event.updated_translations[field].items(): + try: + original_value = sync_data.event.original_translations[field][language] + except KeyError: + original_value = "" + + try: + planning_value = sync_data.planning.original_translations[field][language] + except KeyError: + planning_value = "" + + if original_value == updated_value or planning_value != original_value: + continue + + sync_data.planning.updated_translations.setdefault(field, {}) + sync_data.planning.updated_translations[field][language] = updated_value + sync_data.update_translations = True + + +def _sync_coverage_field(sync_data: SyncData, field: str, profiles: AllContentProfileData): + field_is_multilingual = ( + field in sync_data.event.updated_translations + and field in profiles.events.multilingual_fields + and field in profiles.planning.multilingual_fields + ) + + for coverage in sync_data.coverage_updates: + if not coverage.get("coverage_id"): + # This is a new Coverage, which it's metadata would have already been synced + # We can safely skip this one + continue + + # All supported fields are under the ``coverage.planning`` dictionary + coverage.setdefault("planning", {}) + try: + coverage_value = coverage["planning"][field] + except KeyError: + coverage_value = "" + + coverage_language = coverage["planning"].get("language") + original_value = sync_data.event.original.get(field) + updated_value = sync_data.event.updates.get(field) + + if field_is_multilingual and coverage_language is not None: + try: + original_value = sync_data.event.original_translations[field][coverage_language] + except KeyError: + pass + + try: + updated_value = sync_data.event.updated_translations[field][coverage_language] + except KeyError: + pass + + if coverage_value != original_value: + continue + + # The Coverage field has the same value as the Event field + # So we can copy the new value from the Event + coverage["planning"][field] = updated_value + sync_data.update_coverages = True + + +def sync_existing_planning_item( + sync_data: SyncData, + sync_fields: Set[str], + profiles: AllContentProfileData, + coverage_sync_fields: Set[str], +): + for field in sync_fields: + _sync_planning_field(sync_data, field) + _sync_planning_multilingual_field(sync_data, field, profiles) + if field in coverage_sync_fields: + _sync_coverage_field(sync_data, field, profiles) + + if sync_data.update_translations: + translations: List[StringFieldTranslation] = [] + for field in sync_data.planning.updated_translations.keys(): + translations.extend( + [ + { + "field": field, + "language": language, + "value": value, + } + for language, value in sync_data.planning.updated_translations[field].items() + ] + ) + sync_data.planning.updates["translations"] = translations + sync_data.update_planning = True + + if sync_data.update_coverages: + sync_data.planning.updates["coverages"] = sync_data.coverage_updates + sync_data.update_planning = True diff --git a/server/planning/planning/planning.py b/server/planning/planning/planning.py index 02e69b80d..dde524c57 100644 --- a/server/planning/planning/planning.py +++ b/server/planning/planning/planning.py @@ -518,6 +518,7 @@ def remove_coverage_entity(self, coverage_entity, original_planning, entity_type if assignment and assignment.get("state") not in [ WORKFLOW_STATE.DRAFT, WORKFLOW_STATE.CANCELLED, + None, ]: raise SuperdeskApiError.badRequestError( "Assignment already exists. {} cannot be deleted.".format(entity_type.capitalize()) @@ -703,6 +704,9 @@ def update_coverages(self, updates, original): self._create_update_assignment(original, updates, coverage, original_coverage) def _set_coverage(self, updates, original=None): + if "coverages" not in updates: + return + if not original: original = {} @@ -803,7 +807,7 @@ def _create_update_assignment( planning_id = planning.get(config.ID_FIELD) doc = deepcopy(original) - doc.update(updates) + doc.update(deepcopy(updates)) assignment_service = get_resource_service("assignments") assigned_to = updates.get("assigned_to") or original.get("assigned_to") new_assignment_id = None @@ -820,22 +824,23 @@ def _create_update_assignment( translations = planning.get("translations") translated_value = {} translated_name = "" - if translations is not None: + doc.setdefault("planning", {}) + if translations is not None and doc["planning"].get("language") is not None: translated_value.update( { entry["field"]: entry["value"] for entry in translations or [] - if entry["language"] == doc.get("planning", {}).get("language") + if entry["language"] == doc["planning"]["language"] } ) translated_name = translated_value.get("name", translated_value.get("headline")) - doc["planning"].update( { key: val for key, val in translated_value.items() if key in ("ednote", "description_text", "headline", "slugline", "authors", "internal_note") + and doc["planning"].get(key) is None } ) @@ -1537,7 +1542,7 @@ def duplicate_xmp_file(self, coverage): "language": metadata_schema["language"], "slugline": metadata_schema["slugline"], "subject": metadata_schema["subject"], - "internal_note": {"type": "string"}, + "internal_note": {"type": "string", "nullable": True}, "workflow_status_reason": {"type": "string", "nullable": True}, "priority": metadata_schema["priority"], }, # end planning dict schema @@ -1582,7 +1587,7 @@ def duplicate_xmp_file(self, coverage): "planning": { "type": "dict", "schema": { - "internal_note": {"type": "string"}, + "internal_note": {"type": "string", "nullable": True}, "contact_info": Resource.rel("contacts", type="string", nullable=True), "scheduled": {"type": "datetime"}, "genre": metadata_schema["genre"], diff --git a/server/planning/types/__init__.py b/server/planning/types/__init__.py new file mode 100644 index 000000000..c0cb4b7c0 --- /dev/null +++ b/server/planning/types/__init__.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8; -*- +# +# This file is part of Superdesk. +# +# Copyright 2023 Sourcefabric z.u. and contributors. +# +# For the full copyright and license information, please see the +# AUTHORS and LICENSE files distributed with this source code, or +# at https://www.sourcefabric.org/superdesk/license + +from typing import TypedDict, Dict, Any +from datetime import datetime + +from .content_profiles import ContentFieldSchema, ContentFieldEditor, ContentProfile # noqa + + +class StringFieldTranslation(TypedDict): + field: str + language: str + value: str + + +class EmbeddedCoverageItem(TypedDict, total=False): + coverage_id: str + g2_content_type: str + desk: str + user: str + language: str + news_coverage_status: str + scheduled: datetime + genre: str + slugline: str + ednote: str + internal_note: str + priority: int + + +class EmbeddedPlanning(TypedDict, total=False): + planning_id: str + coverages: Dict[str, EmbeddedCoverageItem] + + +# TODO: Implement proper types for these next 3 +Event = Dict[str, Any] +Planning = Dict[str, Any] +Coverage = Dict[str, Any] diff --git a/server/planning/types/content_profiles.py b/server/planning/types/content_profiles.py new file mode 100644 index 000000000..244cedab1 --- /dev/null +++ b/server/planning/types/content_profiles.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8; -*- +# +# This file is part of Superdesk. +# +# Copyright 2023 Sourcefabric z.u. and contributors. +# +# For the full copyright and license information, please see the +# AUTHORS and LICENSE files distributed with this source code, or +# at https://www.sourcefabric.org/superdesk/license + +from typing import TypedDict, Dict + + +class ContentFieldSchema(TypedDict, total=False): + multilingual: bool + field_type: str + + +class ContentFieldEditor(TypedDict): + enabled: bool + + +class ContentProfile(TypedDict): + _id: str + name: str + schema: Dict[str, ContentFieldSchema] + editor: Dict[str, ContentFieldEditor] From 1a8d7bc8d5999546e6fbee67fc59014b03b94017 Mon Sep 17 00:00:00 2001 From: Mark Pittaway Date: Tue, 30 Jan 2024 16:11:14 +1100 Subject: [PATCH 6/7] fix unit tests --- server/planning/tests/output_formatters/json_planning_test.py | 1 - 1 file changed, 1 deletion(-) diff --git a/server/planning/tests/output_formatters/json_planning_test.py b/server/planning/tests/output_formatters/json_planning_test.py index 9d774de33..91f1eba98 100644 --- a/server/planning/tests/output_formatters/json_planning_test.py +++ b/server/planning/tests/output_formatters/json_planning_test.py @@ -319,7 +319,6 @@ def test_matching_product_ids(self): output_item = json.loads(output[1]) self.assertEqual(output_item["products"], [{"code": "prod-type-planning", "name": "planning-only"}]) -<<<<<<< HEAD def test_expand_delivery_uses_ingest_id(self): self.app.data.insert("assignments", self.assignment) self.app.data.insert("delivery", self.delivery) From 582acf7e2f8f2bea0fcb34a7a58d7d166671ca1c Mon Sep 17 00:00:00 2001 From: Mark Pittaway Date: Tue, 30 Jan 2024 17:00:51 +1100 Subject: [PATCH 7/7] update cores to use release/2.7 branches --- e2e/server/core-requirements.txt | 2 +- package-lock.json | 533 +++++++++++++++++++------------ package.json | 2 +- server/requirements.txt | 2 +- 4 files changed, 327 insertions(+), 212 deletions(-) diff --git a/e2e/server/core-requirements.txt b/e2e/server/core-requirements.txt index 3ff429e1d..c0e16dbc1 100644 --- a/e2e/server/core-requirements.txt +++ b/e2e/server/core-requirements.txt @@ -1,3 +1,3 @@ gunicorn==19.7.1 honcho==1.0.1 -git+https://github.com/superdesk/superdesk-core.git@develop#egg=superdesk-core \ No newline at end of file +git+https://github.com/superdesk/superdesk-core.git@release/2.7#egg=superdesk-core diff --git a/package-lock.json b/package-lock.json index ebaabb688..d3aa893fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,9 +31,9 @@ } }, "@babel/parser": { - "version": "7.22.10", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.22.10.tgz", - "integrity": "sha512-lNbdGsQb9ekfsnjFGhEiF4hfFqGgfOP3H3d27re3n+CGhNuTSUEQdfWk556sTLNTloczcdM5TYF2LhzmDQKyvQ==", + "version": "7.23.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.9.tgz", + "integrity": "sha512-9tcKgqKbs3xGJ+NtKF2ndOBBLVwPjl1SHxPQkd36r3Dlirw3xWUeGaTbqr7uGZcTaxkVNwc+03SVP7aCdWrTlA==", "dev": true }, "@babel/runtime": { @@ -244,9 +244,9 @@ } }, "@types/hoist-non-react-statics": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz", - "integrity": "sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==", + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.5.tgz", + "integrity": "sha512-SbcrWzkKBw2cdwRTwQAswfpB9g9LJWfjtUeW/jvNwbhC8cpmmNYVePa+ncbUe0rGTQ7G3Ff6mYUN2VMfLVr+Sg==", "dev": true, "requires": { "@types/react": "*", @@ -1716,28 +1716,28 @@ } }, "assert": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/assert/-/assert-1.5.0.tgz", - "integrity": "sha512-EDsgawzwoun2CZkCgtxJbv392v4nbk9XDD06zI+kQYoBM/3RBWLlEyJARDOmhAAosBjWACEkKL6S+lIZtcAubA==", + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/assert/-/assert-1.5.1.tgz", + "integrity": "sha512-zzw1uCAgLbsKwBfFc8CX78DDg+xZeBksSO3vwVIDDN5i94eOrPsSSyiVhmsSABFDM/OcpE2aagCat9dnWQLG1A==", "dev": true, "requires": { - "object-assign": "^4.1.1", - "util": "0.10.3" + "object.assign": "^4.1.4", + "util": "^0.10.4" }, "dependencies": { "inherits": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz", - "integrity": "sha512-8nWq2nLTAwd02jTqJExUYFSD/fKq6VH9Y/oG2accc/kdI0V98Bag8d5a4gi3XHz73rDWa2PvTtvcWYquKqSENA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", "dev": true }, "util": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/util/-/util-0.10.3.tgz", - "integrity": "sha512-5KiHfsmkqacuKjkRkdV7SsfDJ2EGiPsK92s2MhNSY0craxjTdKTtqKsJaCWp4LW33ZZ0OPUv1WO/TFvNQRiQxQ==", + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz", + "integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==", "dev": true, "requires": { - "inherits": "2.0.1" + "inherits": "2.0.3" } } } @@ -2204,17 +2204,39 @@ }, "dependencies": { "deep-equal": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.1.tgz", - "integrity": "sha512-yd9c5AdiqVcR+JjcwUQb9DkhJc8ngNr0MahEBGvDiJw8puWab2yZlh+nkasOnZP+EGTAP6rRp2JzJhJZzvNF8g==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.2.tgz", + "integrity": "sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==", "dev": true, "requires": { - "is-arguments": "^1.0.4", - "is-date-object": "^1.0.1", - "is-regex": "^1.0.4", - "object-is": "^1.0.1", + "is-arguments": "^1.1.1", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "object-is": "^1.1.5", "object-keys": "^1.1.1", - "regexp.prototype.flags": "^1.2.0" + "regexp.prototype.flags": "^1.5.1" + } + }, + "define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "requires": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + } + }, + "regexp.prototype.flags": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz", + "integrity": "sha512-sy6TXMN+hnP/wMy+ISxg3krXx7BAtWVO4UouuCN/ziM9UEne0euamVNafDfvC83bRNr95y0V5iijeDQFUNpvrg==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "set-function-name": "^2.0.0" } } } @@ -2331,20 +2353,20 @@ } }, "browserify-sign": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.2.1.tgz", - "integrity": "sha512-/vrA5fguVAKKAVTNJjgSm1tRQDHUU6DbwO9IROu/0WAzC8PKhucDSh18J0RMvVeHAn5puMd+QHC2erPRNf8lmg==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.2.2.tgz", + "integrity": "sha512-1rudGyeYY42Dk6texmv7c4VcQ0EsvVbLwZkA+AQB7SxvXxmcD93jcHie8bzecJ+ChDlmAm2Qyu0+Ccg5uhZXCg==", "dev": true, "requires": { - "bn.js": "^5.1.1", - "browserify-rsa": "^4.0.1", + "bn.js": "^5.2.1", + "browserify-rsa": "^4.1.0", "create-hash": "^1.2.0", "create-hmac": "^1.1.7", - "elliptic": "^6.5.3", + "elliptic": "^6.5.4", "inherits": "^2.0.4", - "parse-asn1": "^5.1.5", - "readable-stream": "^3.6.0", - "safe-buffer": "^5.2.0" + "parse-asn1": "^5.1.6", + "readable-stream": "^3.6.2", + "safe-buffer": "^5.2.1" }, "dependencies": { "readable-stream": { @@ -2585,9 +2607,9 @@ } }, "caniuse-db": { - "version": "1.0.30001521", - "resolved": "https://registry.npmjs.org/caniuse-db/-/caniuse-db-1.0.30001521.tgz", - "integrity": "sha512-bT9eOOwKYX6jDKXXtAhfiYefkaGArsDgmRQ77tGpdtHply4DcyuirrNhPRlUQQ48IQ2RTU5M1LSCY6m1AnGwBA==", + "version": "1.0.30001581", + "resolved": "https://registry.npmjs.org/caniuse-db/-/caniuse-db-1.0.30001581.tgz", + "integrity": "sha512-EinDmiBt2zluuRUGkW1OonG4O/037ZcIGaCoz0CmFpMGAS2gCNd0DR6+6PUcpjtW9TUJZikhWH5vNTV7r3pFCg==", "dev": true }, "caseless": { @@ -3729,15 +3751,15 @@ "integrity": "sha1-wB3mPvsO7JeYgB1Ax+Da4ltYLIQ=" }, "deep-equal": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.2.tgz", - "integrity": "sha512-xjVyBf0w5vH0I42jdAZzOKVldmPgSulmiyPRywoyq7HXC9qdgo17kxJE+rdnif5Tz6+pIrpJI8dCpMNLIGkUiA==", + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", + "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", "dev": true, "requires": { "array-buffer-byte-length": "^1.0.0", - "call-bind": "^1.0.2", + "call-bind": "^1.0.5", "es-get-iterator": "^1.1.3", - "get-intrinsic": "^1.2.1", + "get-intrinsic": "^1.2.2", "is-arguments": "^1.1.1", "is-array-buffer": "^3.0.2", "is-date-object": "^1.0.5", @@ -3747,33 +3769,51 @@ "object-is": "^1.1.5", "object-keys": "^1.1.1", "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.5.0", + "regexp.prototype.flags": "^1.5.1", "side-channel": "^1.0.4", "which-boxed-primitive": "^1.0.2", "which-collection": "^1.0.1", - "which-typed-array": "^1.1.9" + "which-typed-array": "^1.1.13" }, "dependencies": { + "call-bind": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.5.tgz", + "integrity": "sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==", + "dev": true, + "requires": { + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.1", + "set-function-length": "^1.1.1" + } + }, "define-properties": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.0.tgz", - "integrity": "sha512-xvqAVKGfT1+UAvPwKTVw/njhdQ8ZhXK4lI0bCIuCMrp2up9nPnaDftrLtmpTazqd1o+UY4zgzU+avtMbDP+ldA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", "dev": true, "requires": { + "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, + "function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true + }, "get-intrinsic": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.1.tgz", - "integrity": "sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.2.tgz", + "integrity": "sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==", "dev": true, "requires": { - "function-bind": "^1.1.1", - "has": "^1.0.3", + "function-bind": "^1.1.2", "has-proto": "^1.0.1", - "has-symbols": "^1.0.3" + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" } }, "isarray": { @@ -3783,14 +3823,14 @@ "dev": true }, "regexp.prototype.flags": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.0.tgz", - "integrity": "sha512-0SutC3pNudRKgquxGoRGIz946MZVHqbNfPjBdxeOhBrdgDKlRoXmYLQN9xRbrR09ZXWeGAdPuif7egofn6v5LA==", + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz", + "integrity": "sha512-sy6TXMN+hnP/wMy+ISxg3krXx7BAtWVO4UouuCN/ziM9UEne0euamVNafDfvC83bRNr95y0V5iijeDQFUNpvrg==", "dev": true, "requires": { "call-bind": "^1.0.2", "define-properties": "^1.2.0", - "functions-have-names": "^1.2.3" + "set-function-name": "^2.0.0" } } } @@ -3825,6 +3865,37 @@ "clone": "^1.0.2" } }, + "define-data-property": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.1.tgz", + "integrity": "sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==", + "dev": true, + "requires": { + "get-intrinsic": "^1.2.1", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0" + }, + "dependencies": { + "function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true + }, + "get-intrinsic": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.2.tgz", + "integrity": "sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==", + "dev": true, + "requires": { + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + } + } + } + }, "define-properties": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.4.tgz", @@ -4079,7 +4150,7 @@ } }, "docs-soap": { - "version": "github:tomaskikutis/docs-soap#95dbd7e7d245f4c0329422640cad068e4bcbf9c5", + "version": "github:tomaskikutis/docs-soap#cca5d748d3bdce3537ef512ec1ce1492d0f3983b", "from": "github:tomaskikutis/docs-soap#convert-tables", "dev": true }, @@ -4307,9 +4378,9 @@ "dev": true }, "electron-to-chromium": { - "version": "1.4.494", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.494.tgz", - "integrity": "sha512-KF7wtsFFDu4ws1ZsSOt4pdmO1yWVNWCFtijVYZPUeW4SV7/hy/AESjLn/+qIWgq7mHscNOKAwN5AIM1+YAy+Ww==", + "version": "1.4.650", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.650.tgz", + "integrity": "sha512-sYSQhJCJa4aGA1wYol5cMQgekDBlbVfTRavlGZVr3WZpDdOPcp6a6xUnFfrt8TqZhsBYYbDxJZCjGfHuGupCRQ==", "dev": true }, "elliptic": { @@ -7664,6 +7735,23 @@ "minimalistic-assert": "^1.0.1" } }, + "hasown": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", + "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", + "dev": true, + "requires": { + "function-bind": "^1.1.2" + }, + "dependencies": { + "function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true + } + } + }, "hawk": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/hawk/-/hawk-3.1.3.tgz", @@ -8567,16 +8655,22 @@ "is-typed-array": "^1.1.10" }, "dependencies": { + "function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true + }, "get-intrinsic": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.1.tgz", - "integrity": "sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.2.tgz", + "integrity": "sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==", "dev": true, "requires": { - "function-bind": "^1.1.1", - "has": "^1.0.3", + "function-bind": "^1.1.2", "has-proto": "^1.0.1", - "has-symbols": "^1.0.3" + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" } } } @@ -9149,17 +9243,39 @@ }, "dependencies": { "deep-equal": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.1.tgz", - "integrity": "sha512-yd9c5AdiqVcR+JjcwUQb9DkhJc8ngNr0MahEBGvDiJw8puWab2yZlh+nkasOnZP+EGTAP6rRp2JzJhJZzvNF8g==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.2.tgz", + "integrity": "sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==", "dev": true, "requires": { - "is-arguments": "^1.0.4", - "is-date-object": "^1.0.1", - "is-regex": "^1.0.4", - "object-is": "^1.0.1", + "is-arguments": "^1.1.1", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "object-is": "^1.1.5", "object-keys": "^1.1.1", - "regexp.prototype.flags": "^1.2.0" + "regexp.prototype.flags": "^1.5.1" + } + }, + "define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "requires": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + } + }, + "regexp.prototype.flags": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz", + "integrity": "sha512-sy6TXMN+hnP/wMy+ISxg3krXx7BAtWVO4UouuCN/ziM9UEne0euamVNafDfvC83bRNr95y0V5iijeDQFUNpvrg==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "set-function-name": "^2.0.0" } } } @@ -10210,9 +10326,9 @@ } }, "loglevel": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.8.1.tgz", - "integrity": "sha512-tCRIJM51SHjAayKwC+QAg8hT8vg6z7GSgLJKGvzuPb1Wc+hLzqtuVLxp6/HzSPOozuK+8ErAhy7U/sVzw8Dgfg==", + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.1.tgz", + "integrity": "sha512-hP3I3kCrDIMuRwAwHltphhDM1r8i55H33GgqjXbrisuJhF4kRhW1dNuxsRklp4bXl8DSdLaNLuiL4A/LWRfxvg==", "dev": true }, "lolex": { @@ -14350,6 +14466,59 @@ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", "dev": true }, + "set-function-length": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.0.tgz", + "integrity": "sha512-4DBHDoyHlM1IRPGYcoxexgh67y4ueR53FKV1yyxwFMY7aCqcN/38M1+SwZ/qJQ8iLv7+ck385ot4CcisOAPT9w==", + "dev": true, + "requires": { + "define-data-property": "^1.1.1", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.2", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.1" + }, + "dependencies": { + "function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true + }, + "get-intrinsic": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.2.tgz", + "integrity": "sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==", + "dev": true, + "requires": { + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + } + }, + "has-property-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.1.tgz", + "integrity": "sha512-VsX8eaIewvas0xnvinAe9bw4WfIeODpGYikiWYLH+dma0Jw6KHYqWiWfhQlgOVK8D6PvjubK5Uc4P0iIhIcNVg==", + "dev": true, + "requires": { + "get-intrinsic": "^1.2.2" + } + } + } + }, + "set-function-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.1.tgz", + "integrity": "sha512-tMNCiqYVkXIZgc2Hnoy2IvC/f8ezc5koaRFkCjrpWzGpCd3qbZXPzVy9MAZzK1ch/X0jvSkojys3oqJN0qCmdA==", + "dev": true, + "requires": { + "define-data-property": "^1.0.1", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.0" + } + }, "set-value": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz", @@ -14906,9 +15075,9 @@ } }, "spdx-exceptions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz", - "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.4.0.tgz", + "integrity": "sha512-hcjppoJ68fhxA/cjbN4T8N6uCUejN8yFw69ttpqtBeCbF3u13n7mb31NB9jKwGTTWWnt9IbRA/mf1FprYS8wfw==", "dev": true }, "spdx-expression-parse": { @@ -14922,9 +15091,9 @@ } }, "spdx-license-ids": { - "version": "3.0.13", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.13.tgz", - "integrity": "sha512-XkD+zwiqXHikFZm4AX/7JSCXA98U5Db4AFd5XUg/+9UNtnH75+Z9KxtpYiJZx36mUDVOwH83pl7yvCer6ewM3w==", + "version": "3.0.16", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.16.tgz", + "integrity": "sha512-eWN+LnM3GR6gPu35WxNgbGl8rmY1AEmoMDvL/QD6zYmPWgywxWqJWNdLGT+ke8dKNWrcYgYjPpG5gbTfghP8rw==", "dev": true }, "spdy": { @@ -15030,26 +15199,32 @@ "internal-slot": "^1.0.4" }, "dependencies": { + "function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true + }, "get-intrinsic": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.1.tgz", - "integrity": "sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.2.tgz", + "integrity": "sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==", "dev": true, "requires": { - "function-bind": "^1.1.1", - "has": "^1.0.3", + "function-bind": "^1.1.2", "has-proto": "^1.0.1", - "has-symbols": "^1.0.3" + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" } }, "internal-slot": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.5.tgz", - "integrity": "sha512-Y+R5hJrzs52QCG2laLn4udYVnxsfny9CpOhNhUvk/SSSVyF6T27FzRbF0sroPidSu3X8oEAkOn2K804mjpt6UQ==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.6.tgz", + "integrity": "sha512-Xj6dv+PsbtwyPpEflsejS+oIZxmMlV44zAhG479uYu89MsjcYOhCFnNyKrkJrihbsiasQyY0afoCl/9BLR65bg==", "dev": true, "requires": { - "get-intrinsic": "^1.2.0", - "has": "^1.0.3", + "get-intrinsic": "^1.2.2", + "hasown": "^2.0.0", "side-channel": "^1.0.4" } } @@ -15701,8 +15876,8 @@ } }, "superdesk-core": { - "version": "github:superdesk/superdesk-client-core#67cee3cbf58134a6e21a7693b5ab536b0a3a2217", - "from": "github:superdesk/superdesk-client-core#develop", + "version": "github:superdesk/superdesk-client-core#5ba5106eb292190bb30bb3ae21ef11a0fc4db46e", + "from": "github:superdesk/superdesk-client-core#release/2.7", "dev": true, "requires": { "@metadata/exif": "github:superdesk/exif#431066d", @@ -15806,86 +15981,12 @@ "webpack-dev-server": "2.11.1" }, "dependencies": { - "@types/node": { - "version": "14.18.63", - "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", - "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", - "dev": true - }, "classnames": { "version": "2.2.5", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.5.tgz", "integrity": "sha512-DTt3GhOUDKhh4ONwIJW4lmhyotQmV2LjNlGK/J2hkwUcqcbKkCLAdJPtxQnxnlc7SR3f1CEXCyMmc7WLUsWbNA==", "dev": true }, - "enzyme-adapter-react-16": { - "version": "1.15.7", - "resolved": "https://registry.npmjs.org/enzyme-adapter-react-16/-/enzyme-adapter-react-16-1.15.7.tgz", - "integrity": "sha512-LtjKgvlTc/H7adyQcj+aq0P0H07LDL480WQl1gU512IUyaDo/sbOaNDdZsJXYW2XaoPqrLLE9KbZS+X2z6BASw==", - "dev": true, - "requires": { - "enzyme-adapter-utils": "^1.14.1", - "enzyme-shallow-equal": "^1.0.5", - "has": "^1.0.3", - "object.assign": "^4.1.4", - "object.values": "^1.1.5", - "prop-types": "^15.8.1", - "react-is": "^16.13.1", - "react-test-renderer": "^16.0.0-0", - "semver": "^5.7.0" - }, - "dependencies": { - "prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, - "requires": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - } - } - }, - "enzyme-adapter-utils": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/enzyme-adapter-utils/-/enzyme-adapter-utils-1.14.1.tgz", - "integrity": "sha512-JZgMPF1QOI7IzBj24EZoDpaeG/p8Os7WeBZWTJydpsH7JRStc7jYbHE4CmNQaLqazaGFyLM8ALWA3IIZvxW3PQ==", - "dev": true, - "requires": { - "airbnb-prop-types": "^2.16.0", - "function.prototype.name": "^1.1.5", - "has": "^1.0.3", - "object.assign": "^4.1.4", - "object.fromentries": "^2.0.5", - "prop-types": "^15.8.1", - "semver": "^5.7.1" - }, - "dependencies": { - "prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, - "requires": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - } - } - }, - "enzyme-shallow-equal": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/enzyme-shallow-equal/-/enzyme-shallow-equal-1.0.5.tgz", - "integrity": "sha512-i6cwm7hN630JXenxxJFBKzgLC3hMTafFQXflvzHgPmDhOBhxUWDe8AeRv1qp2/uWJ2Y8z5yLWMzmAfkTOiOCZg==", - "dev": true, - "requires": { - "has": "^1.0.3", - "object-is": "^1.1.5" - } - }, "json5": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/json5/-/json5-0.5.1.tgz", @@ -15937,32 +16038,6 @@ "symbol-observable": "^1.2.0" } }, - "superdesk-ui-framework": { - "version": "3.0.59", - "resolved": "https://registry.npmjs.org/superdesk-ui-framework/-/superdesk-ui-framework-3.0.59.tgz", - "integrity": "sha512-FuXyJNGVE970jlHWm0vD1Cr9QGLEfjONPaPfNSAKQZHW1f//By2VERgPi8TFv1kTdZOlXcOP6vFhnw/OR/Z6Nw==", - "dev": true, - "requires": { - "@material-ui/lab": "^4.0.0-alpha.56", - "@popperjs/core": "^2.4.0", - "@superdesk/primereact": "^5.0.2-12", - "@superdesk/react-resizable-panels": "0.0.39", - "@types/enzyme-adapter-react-16": "^1.0.6", - "@types/node": "^14.10.2", - "chart.js": "^2.9.3", - "date-fns": "2.7.0", - "enzyme": "^3.11.0", - "enzyme-adapter-react-16": "^1.15.7", - "moment": "^2.29.3", - "popper-max-size-modifier": "^0.2.0", - "popper.js": "1.14.4", - "primeicons": "2.0.0", - "react-beautiful-dnd": "^13.0.0", - "react-id-generator": "^3.0.0", - "react-popper": "^2.2.3", - "react-scrollspy": "^3.4.3" - } - }, "uuid": { "version": "8.3.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.1.tgz", @@ -16757,13 +16832,13 @@ "integrity": "sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=" }, "url": { - "version": "0.11.1", - "resolved": "https://registry.npmjs.org/url/-/url-0.11.1.tgz", - "integrity": "sha512-rWS3H04/+mzzJkv0eZ7vEDGiQbgquI1fGfOad6zKvgYQi1SzMmhl7c/DdRGxhaWrVH6z0qWITo8rpnxK/RfEhA==", + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/url/-/url-0.11.3.tgz", + "integrity": "sha512-6hxOLGfZASQK/cijlZnZJTq8OXAkt/3YGfQX45vvMYXpZoo8NdWZcY73K108Jf759lS1Bv/8wXnHDTSz17dSRw==", "dev": true, "requires": { "punycode": "^1.4.1", - "qs": "^6.11.0" + "qs": "^6.11.2" }, "dependencies": { "punycode": { @@ -16771,6 +16846,15 @@ "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", "dev": true + }, + "qs": { + "version": "6.11.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.2.tgz", + "integrity": "sha512-tDNIz22aBzCDxLtVH++VnTfzxlfeK5CbqohpSqpJgj1Wg/cQbStNAz3NuqCs5vV+pjBsK4x4pN9HlVh7rcYRiA==", + "dev": true, + "requires": { + "side-channel": "^1.0.4" + } } } }, @@ -17125,9 +17209,9 @@ } }, "fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, "optional": true }, @@ -17697,16 +17781,47 @@ "dev": true }, "which-typed-array": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.11.tgz", - "integrity": "sha512-qe9UWWpkeG5yzZ0tNYxDmd7vo58HDBc39mZ0xWWpolAGADdFOzkfamWLDxkOWcvHQKVmdTyQdLD4NOfjLWTKew==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.13.tgz", + "integrity": "sha512-P5Nra0qjSncduVPEAr7xhoF5guty49ArDTwzJ/yNuPIbZppyRxFQsRCWrocxIY+CnMVG+qfbU2FmDKyvSGClow==", "dev": true, "requires": { "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", + "call-bind": "^1.0.4", "for-each": "^0.3.3", "gopd": "^1.0.1", "has-tostringtag": "^1.0.0" + }, + "dependencies": { + "call-bind": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.5.tgz", + "integrity": "sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==", + "dev": true, + "requires": { + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.1", + "set-function-length": "^1.1.1" + } + }, + "function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true + }, + "get-intrinsic": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.2.tgz", + "integrity": "sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==", + "dev": true, + "requires": { + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + } + } } }, "wide-align": { diff --git a/package.json b/package.json index e5d8ed798..3324ecfba 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ "simulant": "^0.2.2", "sinon": "^4.5.0", "superdesk-code-style": "1.5.0", - "superdesk-core": "github:superdesk/superdesk-client-core#develop", + "superdesk-core": "github:superdesk/superdesk-client-core#release/2.7", "ts-node": "~7.0.1", "tslint": "5.11.0", "typescript-eslint-parser": "^18.0.0" diff --git a/server/requirements.txt b/server/requirements.txt index a798d6518..7989fe66b 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -19,4 +19,4 @@ pytest-env -e . # Install in editable state so we get feature fixtures --e git+https://github.com/superdesk/superdesk-core.git@develop#egg=superdesk-core +-e git+https://github.com/superdesk/superdesk-core.git@release/2.7#egg=superdesk-core