diff --git a/README.md b/README.md index 5667606e5..65bf5a2c7 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,9 @@ Below sections include the config options that can be defined in settings.py. * PLANNING_DEFAULT_COVERAGE_STATUS_ON_INGEST: * Default: 'ncostat:int' - Coverage Planned * The default CV qcode for populating planning.coverage[x].news_coverage_status on ingest +* 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, ### Assignments Config * SLACK_BOT_TOKEN diff --git a/client/api/planning.ts b/client/api/planning.ts index 845539c55..1a6dfffa8 100644 --- a/client/api/planning.ts +++ b/client/api/planning.ts @@ -9,7 +9,10 @@ import { ISearchAPIParams, ISearchParams, ISearchSpikeState, + IPlanningConfig, } from '../interfaces'; +import {appConfig as config} from 'appConfig'; + import {arrayToString, convertCommonParams, searchRaw, searchRawGetAll, cvsToString} from './search'; import {planningApi, superdeskApi} from '../superdeskApi'; import {IRestApiResponse} from 'superdesk-api'; @@ -20,6 +23,8 @@ import {PLANNING} from '../constants'; import * as selectors from '../selectors'; import * as actions from '../actions'; +const appConfig = config as IPlanningConfig; + function convertPlanningParams(params: ISearchParams): Partial { return { agendas: arrayToString(params.agendas), @@ -140,14 +145,11 @@ function getPlanningSearchProfile() { return planningSearchProfile(planningApi.redux.store.getState()); } -function create(updates: Partial): Promise { - // If the Planning item has coverages, then we need to create the Planning first - // before saving the coverages - // As Assignments are created and require a Planning ID - return !updates.coverages?.length ? - superdeskApi.dataApi.create('planning', updates) : - superdeskApi.dataApi.create('planning', {...updates, coverages: []}) - .then((item) => update(item, updates)); +function create(updates: Partial, addToSeries?: boolean): Promise { + return superdeskApi.dataApi.create( + addToSeries === true ? 'planning?add_to_series=true' : 'planning', + updates + ); } function update(original: IPlanningItem, updates: Partial): Promise { @@ -159,20 +161,23 @@ function update(original: IPlanningItem, updates: Partial): Promi } function createFromEvent(event: IEventItem, updates: Partial): Promise { - return create(planningUtils.modifyForServer({ - slugline: event.slugline, - planning_date: event._sortDate ?? event.dates.start, - internal_note: event.internal_note, - name: event.name, - place: event.place, - subject: event.subject, - anpa_category: event.anpa_category, - description_text: event.definition_short, - ednote: event.ednote, - language: event.language, - ...updates, - event_item: event._id, - })); + return create( + planningUtils.modifyForServer({ + slugline: event.slugline, + planning_date: event._sortDate ?? event.dates.start, + internal_note: event.internal_note, + name: event.name, + place: event.place, + subject: event.subject, + anpa_category: event.anpa_category, + description_text: event.definition_short, + ednote: event.ednote, + language: event.language, + ...updates, + event_item: event._id, + }), + appConfig.planning.default_create_planning_series_with_event_series === true, + ); } function setDefaultValues( diff --git a/client/interfaces.ts b/client/interfaces.ts index b84ad5acf..f2a30f006 100644 --- a/client/interfaces.ts +++ b/client/interfaces.ts @@ -293,6 +293,7 @@ export interface IPlanningConfig extends ISuperdeskGlobalConfig { timeformat?: string; allowed_coverage_link_types?: Array; autosave_timeout?: number; + default_create_planning_series_with_event_series?: boolean; }; } diff --git a/server/features/recurring_event_and_planning.feature b/server/features/recurring_event_and_planning.feature new file mode 100644 index 000000000..ce0bb49f7 --- /dev/null +++ b/server/features/recurring_event_and_planning.feature @@ -0,0 +1,159 @@ +Feature: Recurring Events & Planning + Background: Initial setup + When we post to "/events" + """ + [{ + "name": "Daily Club", + "dates": { + "start": "2024-11-21T12:00:00.000Z", + "end": "2024-11-21T14:00:00.000Z", + "tz": "Australia/Sydney", + "recurring_rule": { + "frequency": "DAILY", + "interval": 1, + "count": 3, + "endRepeatMode": "count" + } + } + }] + """ + Then we get OK response + Then we store "EVENT1" with first item + Then we store "EVENT2" with 2 item + Then we store "EVENT3" with 3 item + When we get "/events" + Then we get list with 3 items + """ + {"_items": [{ + "_id": "#EVENT1._id#", + "recurrence_id": "#EVENT1.recurrence_id#", + "name": "Daily Club", + "dates": {"start": "2024-11-21T12:00:00+0000", "end": "2024-11-21T14:00:00+0000"} + }, { + "_id": "#EVENT2._id#", + "recurrence_id": "#EVENT1.recurrence_id#", + "name": "Daily Club", + "dates": {"start": "2024-11-22T12:00:00+0000", "end": "2024-11-22T14:00:00+0000"} + }, { + "_id": "#EVENT3._id#", + "recurrence_id": "#EVENT1.recurrence_id#", + "name": "Daily Club", + "dates": {"start": "2024-11-23T12:00:00+0000", "end": "2024-11-23T14:00:00+0000"} + }]} + """ + + @auth + Scenario: Creates single plan for event series by default + When we post to "/planning" + """ + [{ + "headline": "test headline", + "event_item": "#EVENT1._id#", + "planning_date": "2024-11-21T12:00:00.000Z", + "coverages": [{ + "workflow_status": "draft", + "news_coverage_status": {"qcode": "ncostat:int"}, + "planning": { + "headline": "test headline", + "slugline": "test slugline", + "g2_content_type": "text", + "scheduled": "2024-11-21T15:00:00.000Z" + } + }, { + "workflow_status": "draft", + "news_coverage_status": {"qcode": "ncostat:int"}, + "planning": { + "headline": "test headline", + "slugline": "test slugline", + "g2_content_type": "picture", + "scheduled": "2024-11-21T16:00:00.000Z" + } + }] + }] + """ + Then we get OK response + When we get "/planning" + Then we get list with 1 items + """ + {"_items": [{ + "guid": "__any_value__", + "type": "planning", + "headline": "test headline", + "planning_date": "2024-11-21T12:00:00+0000", + "event_item": "#EVENT1._id#", + "recurrence_id": "#EVENT1.recurrence_id#", + "coverages": [ + {"planning": {"g2_content_type": "text", "scheduled": "2024-11-21T15:00:00+0000"}}, + {"planning": {"g2_content_type": "picture", "scheduled": "2024-11-21T16:00:00+0000"}} + ] + }]} + """ + + @auth + Scenario: Create planning for each event in the series + When we post to "/planning?add_to_series=true" + """ + [{ + "headline": "test headline", + "event_item": "#EVENT1._id#", + "planning_date": "2024-11-21T12:00:00.000Z", + "coverages": [{ + "workflow_status": "draft", + "news_coverage_status": {"qcode": "ncostat:int"}, + "planning": { + "headline": "test headline", + "slugline": "test slugline", + "g2_content_type": "text", + "scheduled": "2024-11-21T15:00:00.000Z" + } + }, { + "workflow_status": "draft", + "news_coverage_status": {"qcode": "ncostat:int"}, + "planning": { + "headline": "test headline", + "slugline": "test slugline", + "g2_content_type": "picture", + "scheduled": "2024-11-21T16:00:00.000Z" + } + }] + }] + """ + Then we get OK response + When we get "/planning" + Then we get list with 3 items + """ + {"_items": [{ + "guid": "__any_value__", + "type": "planning", + "headline": "test headline", + "planning_date": "2024-11-21T12:00:00+0000", + "event_item": "#EVENT1._id#", + "recurrence_id": "#EVENT1.recurrence_id#", + "coverages": [ + {"planning": {"g2_content_type": "text", "scheduled": "2024-11-21T15:00:00+0000"}}, + {"planning": {"g2_content_type": "picture", "scheduled": "2024-11-21T16:00:00+0000"}} + ] + }, { + "guid": "__any_value__", + "type": "planning", + "headline": "test headline", + "planning_date": "2024-11-22T12:00:00+0000", + "event_item": "#EVENT2._id#", + "recurrence_id": "#EVENT1.recurrence_id#", + "coverages": [ + {"planning": {"g2_content_type": "text", "scheduled": "2024-11-22T15:00:00+0000"}}, + {"planning": {"g2_content_type": "picture", "scheduled": "2024-11-22T16:00:00+0000"}} + ] + }, { + "guid": "__any_value__", + "type": "planning", + "headline": "test headline", + "planning_date": "2024-11-23T12:00:00+0000", + "event_item": "#EVENT3._id#", + "recurrence_id": "#EVENT1.recurrence_id#", + "coverages": [ + {"planning": {"g2_content_type": "text", "scheduled": "2024-11-23T15:00:00+0000"}}, + {"planning": {"g2_content_type": "picture", "scheduled": "2024-11-23T16:00:00+0000"}} + ] + }]} + """ diff --git a/server/planning/__init__.py b/server/planning/__init__.py index fc2fbd647..a282a905a 100644 --- a/server/planning/__init__.py +++ b/server/planning/__init__.py @@ -36,6 +36,7 @@ get_planning_use_xmp_for_pic_slugline, get_planning_allowed_coverage_link_types, get_planning_auto_close_popup_editor, + get_config_default_create_planning_series_with_event_series, ) from apps.common.components.utils import register_component from .item_lock import LockService @@ -235,6 +236,9 @@ def init_app(app): app.client_config.setdefault("planning", {}) app.client_config["planning"]["allowed_coverage_link_types"] = get_planning_allowed_coverage_link_types(app) + app.client_config["planning"][ + "default_create_planning_series_with_event_series" + ] = get_config_default_create_planning_series_with_event_series(app) # Set up Celery task options if not app.config.get("CELERY_TASK_ROUTES"): diff --git a/server/planning/common.py b/server/planning/common.py index 6b8f662ef..e471d3c7c 100644 --- a/server/planning/common.py +++ b/server/planning/common.py @@ -240,6 +240,10 @@ def get_notify_self_on_assignment(current_app=None): return (current_app or app).config.get("PLANNING_SEND_NOTIFICATION_FOR_SELF_ASSIGNMENT", False) +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 remove_lock_information(item): item.update({LOCK_USER: None, LOCK_SESSION: None, LOCK_TIME: None, LOCK_ACTION: None}) diff --git a/server/planning/planning/planning.py b/server/planning/planning/planning.py index e3cfb50c9..0ac13af90 100644 --- a/server/planning/planning/planning.py +++ b/server/planning/planning/planning.py @@ -9,22 +9,28 @@ # at https://www.sourcefabric.org/superdesk/license """Superdesk Planning""" +from typing import Dict, Any, Optional, List from bson import ObjectId - -import superdesk +from copy import deepcopy import logging -from flask import json, current_app as app +from datetime import datetime, timedelta + +from flask import json, current_app as app, request from eve.methods.common import resolve_document_etag + +import superdesk from superdesk.errors import SuperdeskApiError from planning.errors import AssignmentApiError + from superdesk.metadata.utils import generate_guid, item_url from superdesk.metadata.item import GUID_NEWSML, metadata_schema, ITEM_TYPE, CONTENT_STATE from superdesk import get_resource_service from superdesk.resource import not_analyzed, string_with_analyzer from superdesk.users.services import current_user_has_privilege from superdesk.notification import push_notification +from superdesk.default_settings import strtobool + from apps.archive.common import get_user, get_auth, update_dates_for -from copy import deepcopy from eve.utils import config, ParsedRequest, date_to_str from planning.common import ( WORKFLOW_STATE_SCHEMA, @@ -55,7 +61,6 @@ from itertools import chain from planning.planning_notifications import PlanningNotifications from superdesk.utc import utc_to_local -from datetime import datetime from planning.content_profiles.utils import is_field_enabled from superdesk import Resource from lxml import etree @@ -126,6 +131,8 @@ def find_one(self, req, **lookup): def on_create(self, docs): """Set default metadata.""" planning_type = get_resource_service("planning_types").find_one(req=None, name="planning") + history_service = get_resource_service("planning_history") + generated_planning_items = [] for doc in docs: if "guid" not in doc: doc["guid"] = generate_guid(type=GUID_NEWSML) @@ -140,14 +147,24 @@ def on_create(self, docs): self.validate_planning(doc) set_original_creator(doc) - self._set_planning_event_info(doc, planning_type) + event = self._set_planning_event_info(doc, planning_type) self._set_coverage(doc) self.set_planning_schedule(doc) # set timestamps update_dates_for(doc) - if doc["state"] == "ingested": - get_resource_service("planning_history").on_item_created([doc]) + is_ingested = doc["state"] == "ingested" + if is_ingested: + history_service.on_item_created([doc]) + + if event 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) + generated_planning_items.extend(new_plans) + + if len(generated_planning_items): + docs.extend(generated_planning_items) def on_created(self, docs): session_id = get_auth().get("_id") @@ -293,25 +310,74 @@ def validate_planning(self, updates, original=None): if next_schedule and next_schedule["planning"]["scheduled"] > scheduled_update["planning"]["scheduled"]: raise SuperdeskApiError(message="Scheduled updates of a coverage must be after the previous update") - def _set_planning_event_info(self, doc, planning_type): + def _set_planning_event_info(self, doc, planning_type) -> Optional[Dict[str, Any]]: """Set the planning event date :param dict doc: planning document :param dict planning_types: planning type """ event_id = doc.get("event_item") - event = {} - if event_id: - event = get_resource_service("events").find_one(req=None, _id=event_id) - if event: - if event.get("recurrence_id"): - doc["recurrence_id"] = event.get("recurrence_id") - # populate headline using name - if event.get("name") and is_field_enabled("headline", planning_type): - doc.setdefault("headline", event["name"]) - - if event.get(TO_BE_CONFIRMED_FIELD): - doc[TO_BE_CONFIRMED_FIELD] = True + + if not event_id: + return None + + event = get_resource_service("events").find_one(req=None, _id=event_id) + + if not event: + plan_id = doc.get("_id") + logger.warning(f"Failed to find linked event {event_id} for planning {plan_id}") + return None + + if event.get("recurrence_id"): + doc["recurrence_id"] = event.get("recurrence_id") + + # populate headline using name + if event.get("name") and is_field_enabled("headline", planning_type): + doc.setdefault("headline", event["name"]) + + if event.get(TO_BE_CONFIRMED_FIELD): + doc[TO_BE_CONFIRMED_FIELD] = True + + return event + + def _add_planning_to_event_series(self, plan, event) -> List[Dict[str, Any]]: + recurrence_id = event.get("recurrence_id") + if not recurrence_id: + # Not a series of Events, can safely return + return [] + + planning_date_relative = plan["planning_date"] - event["dates"]["start"] + items = [] + for series_entry in get_resource_service("events").find(where={"recurrence_id": recurrence_id}): + if series_entry["_id"] == event["_id"]: + # This is the Event that was provided + # We assume a Planning item was already created for this Event + continue + + new_plan = deepcopy(plan) + + # Set the Planning & Event IDs for the new item + new_plan["guid"] = new_plan["_id"] = generate_guid(type=GUID_NEWSML) + new_plan["event_item"] = series_entry["_id"] + new_plan["recurrence_id"] = recurrence_id + + # Set the Planning date/time relative to the Event start date/time + new_plan["planning_date"] = series_entry["dates"]["start"] + planning_date_relative + for coverage in new_plan.get("coverages") or []: + # Remove the Coverage and Assignment IDs (as these will be created for us in ``self._set_coverage``) + coverage.pop("coverage_id", None) + (coverage.get("assigned_to") or {}).pop("assignment_id", None) + + # Set the scheduled date/time relative to the Event start date/time + coverage_date_relative = coverage["planning"]["scheduled"] - event["dates"]["start"] + coverage["planning"]["scheduled"] = series_entry["dates"]["start"] + coverage_date_relative + + self._set_coverage(new_plan) + self.set_planning_schedule(new_plan) + + items.append(new_plan) + + return items def _get_added_removed_agendas(self, updates, original): updated_agendas = [str(a) for a in (updates.get("agendas") or [])] @@ -1103,7 +1169,9 @@ def delete_assignments_for_coverages(self, coverages, notify=True): deleted_assignments = [] assignment_service = get_resource_service("assignments") for coverage in coverages: - assign_id = coverage["assigned_to"]["assignment_id"] + assign_id = coverage["assigned_to"].get("assignment_id") + if not assign_id: + continue assign_planning = coverage.get("planning") try: assignment_service.delete_action(lookup={"_id": assign_id})