diff --git a/README.md b/README.md index 65bf5a2c7..dd5456ff6 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/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/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 0ac13af90..e7a0b0186 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) @@ -512,6 +512,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()) @@ -687,6 +688,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 = {} @@ -787,7 +791,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 @@ -804,22 +808,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 } ) @@ -1521,7 +1526,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 @@ -1566,7 +1571,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]