From 41c6ec1f98acefa36989549af2bf4e8155dc6e12 Mon Sep 17 00:00:00 2001 From: MarkLark86 Date: Fri, 20 Sep 2024 18:00:00 +1000 Subject: [PATCH 01/20] [SDBELGA-884] fix: Only copy configured custom CVs from Event to Planning (#2095) * [SDBELGA-884] fix: Only copy configured custom CVs from Event to Planning Also fix bug with validating subject field (removing the `allowed` field for scheme) * fix(e2e): Update actions/upload-artifact * fix subject field name * add tests --- .github/workflows/ci-e2e.yml | 2 +- .../features/event_embedded_planning.feature | 86 +++++++++++++++++-- .../content_profiles/profiles/fields.py | 1 - .../00034_20240920-141321_planning_types.py | 33 +++++++ server/planning/events/events_post.py | 2 +- server/planning/events/events_sync/common.py | 21 +++++ .../events/events_sync/embedded_planning.py | 6 +- .../events/events_sync/planning_sync.py | 5 +- server/planning/types/content_profiles.py | 3 +- server/planning/validate/planning_validate.py | 6 ++ 10 files changed, 149 insertions(+), 16 deletions(-) create mode 100644 server/planning/data_updates/00034_20240920-141321_planning_types.py diff --git a/.github/workflows/ci-e2e.yml b/.github/workflows/ci-e2e.yml index bfbb17250..6bcb6b1b6 100644 --- a/.github/workflows/ci-e2e.yml +++ b/.github/workflows/ci-e2e.yml @@ -47,7 +47,7 @@ jobs: CYPRESS_SCREENSHOTS_FOLDER: /tmp/cypress - name: Upload screenshots if: ${{ failure() }} - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: screenshots-e2e-${{ matrix.e2e }} path: /tmp/cypress/**/*.png diff --git a/server/features/event_embedded_planning.feature b/server/features/event_embedded_planning.feature index 7bff2d291..0b79ec35d 100644 --- a/server/features/event_embedded_planning.feature +++ b/server/features/event_embedded_planning.feature @@ -20,10 +20,7 @@ Feature: Event Embedded Planning "news_coverage_status": "ncostat:int", "scheduled": "2029-11-21T15:00:00.000Z" }] - }], - "subject": [ - {"name": "Test", "qcode": "test", "scheme": "test"} - ] + }] }] """ Then we get OK response @@ -61,10 +58,7 @@ Feature: Event Embedded Planning "g2_content_type": "text", "scheduled": "2029-11-21T15:00:00+0000" } - }], - "subject": [ - {"name": "Test", "qcode": "test", "scheme": "test"} - ] + }] }]} """ And we store "PLAN1" with first item @@ -448,3 +442,79 @@ Feature: Event Embedded Planning Then we store response in "PLAN1" When we create "planning" autosave from context item "PLAN1" Then we get OK response + + @auth + @vocabulary + Scenario: Copies configured custom_vocabularies only + Given "planning_types" + """ + [{ + "_id": "event", + "name": "event", + "editor": { + "subject": {"enabled": true}, + "custom_vocabularies": {"enabled": true} + }, + "schema": { + "custom_vocabularies": { + "required": false, + "type": "list", + "vocabularies": ["keywords", "source"] + } + } + }, { + "_id": "planing", + "name": "planning", + "editor": { + "subject": {"enabled": true}, + "custom_vocabularies": {"enabled": true} + }, + "schema": { + "custom_vocabularies": { + "required": false, + "type": "list", + "vocabularies": ["keywords", "destination"] + } + } + }] + """ + 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", + "subject":[ + {"qcode": "17004000", "name": "Statistics"}, + {"qcode": "sports", "name": "Sports", "scheme": "keywords"}, + {"qcode": "sport_calendar", "name": "Sport Calendar", "scheme": "source"} + ], + "embedded_planning": [{ + "coverages": [{ + "g2_content_type": "text", + "language": "en", + "news_coverage_status": "ncostat:int", + "scheduled": "2029-11-21T15:00:00+0000", + "genre": "Article" + }] + }] + }] + """ + 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", + "subject": [ + {"qcode": "17004000", "name": "Statistics"}, + {"qcode": "sports", "name": "Sports", "scheme": "keywords"} + ] + }]} + """ diff --git a/server/planning/content_profiles/profiles/fields.py b/server/planning/content_profiles/profiles/fields.py index 9c8219022..737b24ebe 100644 --- a/server/planning/content_profiles/profiles/fields.py +++ b/server/planning/content_profiles/profiles/fields.py @@ -120,7 +120,6 @@ def __init__( "type": "string", "required": True, "nullable": True, - "allowed": [], }, "service": {"nullable": True}, "parent": {"nullable": True}, diff --git a/server/planning/data_updates/00034_20240920-141321_planning_types.py b/server/planning/data_updates/00034_20240920-141321_planning_types.py new file mode 100644 index 000000000..3943447b7 --- /dev/null +++ b/server/planning/data_updates/00034_20240920-141321_planning_types.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8; -*- +# This file is part of Superdesk. +# 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 +# +# Author : MarkLark86 +# Creation: 2024-09-20 14:13 + +from superdesk.commands.data_updates import BaseDataUpdate + + +class DataUpdate(BaseDataUpdate): + resource = "planning_types" + + def forwards(self, mongodb_collection, mongodb_database): + for resource_type in ["event", "planning"]: + original_profile = mongodb_collection.find_one({"name": resource_type}) + if not original_profile: + # No need to process this Profile if the defaults are currently used + continue + + try: + schema = original_profile["schema"] + schema["subject"]["schema"]["schema"]["scheme"].pop("allowed", None) + except (KeyError, TypeError): + # ``subject`` or ``allowed`` is not currently set, no need to fix it + continue + + mongodb_collection.update({"name": resource_type}, {"$set": {"schema": schema}}) + + def backwards(self, mongodb_collection, mongodb_database): + pass diff --git a/server/planning/events/events_post.py b/server/planning/events/events_post.py index e4628da28..349b127b4 100644 --- a/server/planning/events/events_post.py +++ b/server/planning/events/events_post.py @@ -237,7 +237,7 @@ def post_related_plannings(self, plannings, new_post_state): try: planning_post_service.post([doc], related_planning=True) except Exception as e: - failed_planning_ids.append({"_id": doc["planning"], "error": e.description}) + failed_planning_ids.append({"_id": doc["planning"], "error": getattr(e, "description", str(e))}) return failed_planning_ids for planning in plannings: if not planning.get("pubstatus") and planning.get("state") in [ diff --git a/server/planning/events/events_sync/common.py b/server/planning/events/events_sync/common.py index 95e6830df..924b6b9b8 100644 --- a/server/planning/events/events_sync/common.py +++ b/server/planning/events/events_sync/common.py @@ -11,6 +11,8 @@ from typing import List, Dict, Any from dataclasses import dataclass +from planning.content_profiles.utils import ContentProfileData + @dataclass class SyncItemData: @@ -34,3 +36,22 @@ class SyncData: class VocabsSyncData: coverage_states: Dict[str, Dict[str, str]] genres: Dict[str, Dict[str, str]] + + +def get_enabled_subjects(item: Dict[str, Any], profile: ContentProfileData) -> List[Dict[str, Any]]: + """Returns the list of subjects (including custom_vocabularies) if they're enabled in Planning profile + + :param item: The source item where the subjects are coming from + :param profile: The Planning ContentProfile to determine enabled fields & vocabularies + :return: A list containing the supported subjects and custom_vocabularies for Planning items + """ + + if not item.get("subject") or not {"subject", "custom_vocabularies"} & profile.enabled_fields: + return [] + + try: + cv_schemes = profile.profile["schema"]["custom_vocabularies"]["vocabularies"] or [] + except (KeyError, TypeError): + cv_schemes = [] + + return [subject for subject in item["subject"] if not subject.get("scheme") or subject.get("scheme") in cv_schemes] diff --git a/server/planning/events/events_sync/embedded_planning.py b/server/planning/events/events_sync/embedded_planning.py index 75f3f1595..571add070 100644 --- a/server/planning/events/events_sync/embedded_planning.py +++ b/server/planning/events/events_sync/embedded_planning.py @@ -17,7 +17,7 @@ from planning.types import Event, EmbeddedPlanning, EmbeddedCoverageItem, Planning, Coverage, StringFieldTranslation from planning.content_profiles.utils import AllContentProfileData -from .common import VocabsSyncData +from .common import VocabsSyncData, get_enabled_subjects logger = logging.getLogger(__name__) @@ -48,8 +48,6 @@ def create_new_plannings_from_embedded_planning( if field in profiles.planning.enabled_fields ) - planning_fields.add("subject") - 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 []): @@ -103,6 +101,8 @@ def map_event_to_planning_translation(translation: StringFieldTranslation): # The Event item contains a value for this field (excluding ``None``), use that new_planning[field] = event.get(field) + new_planning["subject"] = get_enabled_subjects(event, profiles.planning) + if "description_text" in profiles.planning.enabled_fields and event.get("definition_short"): new_planning["description_text"] = event.get("definition_short") diff --git a/server/planning/events/events_sync/planning_sync.py b/server/planning/events/events_sync/planning_sync.py index 1324c02e0..edd9e03bb 100644 --- a/server/planning/events/events_sync/planning_sync.py +++ b/server/planning/events/events_sync/planning_sync.py @@ -13,7 +13,7 @@ from planning.types import StringFieldTranslation from planning.content_profiles.utils import AllContentProfileData -from .common import SyncData +from .common import SyncData, get_enabled_subjects def get_normalised_field_value(item, field): @@ -144,6 +144,9 @@ def sync_existing_planning_item( if field in coverage_sync_fields: _sync_coverage_field(sync_data, field, profiles) + if sync_data.planning.updates.get("subject"): + sync_data.planning.updates["subject"] = get_enabled_subjects(sync_data.planning.updates, profiles.planning) + if sync_data.update_translations: translations: List[StringFieldTranslation] = [] for field in sync_data.planning.updated_translations.keys(): diff --git a/server/planning/types/content_profiles.py b/server/planning/types/content_profiles.py index 0ebadf274..055f4ccdb 100644 --- a/server/planning/types/content_profiles.py +++ b/server/planning/types/content_profiles.py @@ -8,13 +8,14 @@ # AUTHORS and LICENSE files distributed with this source code, or # at https://www.sourcefabric.org/superdesk/license -from typing import TypedDict, Dict +from typing import TypedDict, Dict, List class ContentFieldSchema(TypedDict, total=False): multilingual: bool field_type: str planning_auto_publish: bool # Only available in ``related_plannings`` field + vocabularies: List[str] # Only available in ``custom_vocabularies`` field class ContentFieldEditor(TypedDict): diff --git a/server/planning/validate/planning_validate.py b/server/planning/validate/planning_validate.py index bb536b717..77bd568db 100644 --- a/server/planning/validate/planning_validate.py +++ b/server/planning/validate/planning_validate.py @@ -102,6 +102,12 @@ def _validate_multilingual(self, multilingual, field, value): """ pass + def _validate_vocabularies(self, vocabularies, field, value): + """ + {'type': 'list', 'nullable': True} + """ + pass + class PlanningValidateResource(Resource): endpoint_name = "planning_validator" From ec31062a8098ce3c7022a56c7c98940ceaa23823 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Ja=C5=A1ek?= Date: Thu, 19 Sep 2024 09:17:53 +0200 Subject: [PATCH 02/20] fix error when ingesting cancelled event with assignments (#2093) it was checking session data which failed during ingest STTNHUB-361 --- server/planning/assignments/assignments.py | 2 +- .../commands/populate_planning_types_test.py | 2 +- server/planning/planning/planning.py | 35 +++++++++--------- server/planning/tests/__init__.py | 2 ++ .../planning/tests/ingest_cancelled_test.py | 36 +++++++++++++++++++ .../output_formatters/file_providers_test.py | 2 +- .../output_formatters/json_event_test.py | 2 +- server/planning/utils.py | 9 +++-- 8 files changed, 67 insertions(+), 23 deletions(-) create mode 100644 server/planning/tests/ingest_cancelled_test.py diff --git a/server/planning/assignments/assignments.py b/server/planning/assignments/assignments.py index 76790564a..f285f6f41 100644 --- a/server/planning/assignments/assignments.py +++ b/server/planning/assignments/assignments.py @@ -1195,7 +1195,7 @@ def on_delete(self, doc): """ Validate that we have a lock on the Assignment and it's associated Planning item """ - if doc.get("_to_delete") is True: + if doc.get("_to_delete") is True or not request: # Already marked for delete - no validation needed (could be the background job) return diff --git a/server/planning/commands/populate_planning_types_test.py b/server/planning/commands/populate_planning_types_test.py index 4f5b6d1a4..69185db37 100644 --- a/server/planning/commands/populate_planning_types_test.py +++ b/server/planning/commands/populate_planning_types_test.py @@ -11,7 +11,7 @@ import os import json -from superdesk.tests import TestCase +from planning.tests import TestCase from superdesk import get_resource_service from apps.prepopulate.app_populate import AppPopulateCommand diff --git a/server/planning/planning/planning.py b/server/planning/planning/planning.py index 8712345f6..fac3e8ad2 100644 --- a/server/planning/planning/planning.py +++ b/server/planning/planning/planning.py @@ -15,7 +15,7 @@ import logging from datetime import datetime -from flask import json, current_app as app +from flask import json, current_app as app, request from eve.methods.common import resolve_document_etag import superdesk @@ -1274,23 +1274,24 @@ def delete_assignments_for_coverages(self, coverages, notify=True): if original_assigment: assignment_service.system_update(ObjectId(assign_id), {"_to_delete": True}, original_assigment) - session_id = get_auth().get("_id") - user_id = get_user().get(config.ID_FIELD) - if len(deleted_assignments) > 0: - push_notification( - "assignments:delete", - items=deleted_assignments, - session=session_id, - user=user_id, - ) + if request: + session_id = get_auth().get("_id") + user_id = get_user().get(config.ID_FIELD) + if len(deleted_assignments) > 0: + push_notification( + "assignments:delete", + items=deleted_assignments, + session=session_id, + user=user_id, + ) - if len(failed_assignments) > 0 and notify: - push_notification( - "assignments:delete:fail", - items=failed_assignments, - session=session_id, - user=user_id, - ) + if len(failed_assignments) > 0 and notify: + push_notification( + "assignments:delete:fail", + items=failed_assignments, + session=session_id, + user=user_id, + ) def get_expired_items(self, expiry_datetime, spiked_planning_only=False): """Get the expired items diff --git a/server/planning/tests/__init__.py b/server/planning/tests/__init__.py index e6eabfa68..e4c29c2c5 100644 --- a/server/planning/tests/__init__.py +++ b/server/planning/tests/__init__.py @@ -3,6 +3,8 @@ class TestCase(_TestCase): + test_context = None # avoid using test_request_context + def setUp(self): config = {"INSTALLED_APPS": ["planning"]} update_config(config) diff --git a/server/planning/tests/ingest_cancelled_test.py b/server/planning/tests/ingest_cancelled_test.py new file mode 100644 index 000000000..ecdeffd64 --- /dev/null +++ b/server/planning/tests/ingest_cancelled_test.py @@ -0,0 +1,36 @@ +from flask import request +from planning.tests import TestCase +from planning.common import update_post_item + + +class IngestCancelledTestCase(TestCase): + def test_ingest_cancelled_event(self): + assert not request, request + + assignments = [ + {"planning_item": "p1", "coverage_item": "c1"}, + ] + + self.app.data.insert("assignments", assignments) + planning = { + "_id": "p1", + "name": "planning item", + "type": "planning", + "coverages": [ + { + "coverage_id": "c1", + "planning": {}, + "assigned_to": { + "assignment_id": assignments[0]["_id"], + }, + }, + ], + } + + self.app.data.insert("planning", [planning]) + + with self.app.app_context(): + update_post_item({"pubstatus": "cancelled"}, planning) + + cursor, count = self.app.data.find("assignments", req=None, lookup={}) + assert count == 0 diff --git a/server/planning/tests/output_formatters/file_providers_test.py b/server/planning/tests/output_formatters/file_providers_test.py index 75333a861..be0a7c624 100644 --- a/server/planning/tests/output_formatters/file_providers_test.py +++ b/server/planning/tests/output_formatters/file_providers_test.py @@ -12,7 +12,7 @@ from unittest import mock import hmac -from superdesk.tests import TestCase +from planning.tests import TestCase from superdesk.publish import TransmitterFileEntry from superdesk.publish.transmitters.ftp import FTPPublishService from superdesk.publish.transmitters.http_push import HTTPPushService diff --git a/server/planning/tests/output_formatters/json_event_test.py b/server/planning/tests/output_formatters/json_event_test.py index d71824e89..66a25f26c 100644 --- a/server/planning/tests/output_formatters/json_event_test.py +++ b/server/planning/tests/output_formatters/json_event_test.py @@ -2,7 +2,7 @@ import tempfile from unittest import mock -from superdesk.tests import TestCase +from planning.tests import TestCase from planning.output_formatters.json_event import JsonEventFormatter from planning.events import init_app from eve.methods.common import store_media_files diff --git a/server/planning/utils.py b/server/planning/utils.py index 0d24ac182..477749de1 100644 --- a/server/planning/utils.py +++ b/server/planning/utils.py @@ -4,6 +4,7 @@ from datetime import datetime from flask_babel import lazy_gettext from eve.utils import str_to_date +from superdesk.utc import utc_to_local import arrow from flask import current_app as app import pytz @@ -67,14 +68,18 @@ def parse_date(datetime: Union[str, datetime]) -> datetime: return datetime +def local_date(datetime: datetime, tz: pytz.BaseTzInfo) -> datetime: + return tz.normalize(parse_date(datetime).replace(tzinfo=pytz.utc).astimezone(tz)) + + def time_short(datetime: datetime, tz: pytz.BaseTzInfo): if datetime: - return parse_date(datetime).astimezone(tz).strftime(app.config.get("TIME_FORMAT_SHORT", "%H:%M")) + return local_date(datetime, tz).strftime(app.config.get("TIME_FORMAT_SHORT", "%H:%M")) def date_short(datetime: datetime, tz: pytz.BaseTzInfo): if datetime: - return parse_date(datetime).astimezone(tz).strftime(app.config.get("DATE_FORMAT_SHORT", "%d/%m/%Y")) + return local_date(datetime, tz).strftime(app.config.get("DATE_FORMAT_SHORT", "%d/%m/%Y")) def get_event_formatted_dates(event: Dict[str, Any]) -> str: From 71c99ed7b6337c847a6e558767571fc0b25941b9 Mon Sep 17 00:00:00 2001 From: Petr Jasek Date: Tue, 1 Oct 2024 13:41:00 +0200 Subject: [PATCH 03/20] release 2.8.0 --- package-lock.json | 10 +++++----- package.json | 2 +- server/planning/__init__.py | 2 +- setup.py | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4625ee66a..6da591985 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "superdesk-planning", - "version": "2.8.0-dev", + "version": "2.8.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -12207,7 +12207,7 @@ "sass-loader": "6.0.6", "shortid": "2.2.8", "style-loader": "0.20.2", - "superdesk-ui-framework": "^3.1.17", + "superdesk-ui-framework": "^3.1.21", "ts-loader": "3.5.0", "typescript": "4.9.5", "uuid": "8.3.1", @@ -12297,9 +12297,9 @@ } }, "superdesk-ui-framework": { - "version": "3.1.16", - "resolved": "https://registry.npmjs.org/superdesk-ui-framework/-/superdesk-ui-framework-3.1.16.tgz", - "integrity": "sha512-6v2/DtCmENh+n4pfq0W2cVg9Hrrs/7wKNIZnzzKDFwblEbquklwCbC9Rju1e0fnkxxcNZxXOHCo6PqoUe4fDLA==", + "version": "3.1.21", + "resolved": "https://registry.npmjs.org/superdesk-ui-framework/-/superdesk-ui-framework-3.1.21.tgz", + "integrity": "sha512-+o9MwlATifWK+f+8Lq61GOjgQ5sFsIqj//6lk3DEN2QvDsnm7B7ZQGJGK+74lNe/X/HMUQzaxLc1vPbw7GjUfg==", "dev": true, "requires": { "@popperjs/core": "^2.4.0", diff --git a/package.json b/package.json index e9b07ddaf..2de0b52e8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "superdesk-planning", - "version": "2.8.0-dev", + "version": "2.8.0", "license": "AGPL-3.0", "description": "", "repository": { diff --git a/server/planning/__init__.py b/server/planning/__init__.py index 58f960ad9..a2d90054c 100644 --- a/server/planning/__init__.py +++ b/server/planning/__init__.py @@ -78,7 +78,7 @@ from planning.planning_locks import init_app as init_planning_locks_app from planning.search.planning_autocomplete import init_app as init_planning_autocomplete_app -__version__ = "2.8.0-dev" +__version__ = "2.8.0" _SERVER_PATH = os.path.dirname(os.path.realpath(__file__)) diff --git a/setup.py b/setup.py index eb27bb628..138d690fa 100644 --- a/setup.py +++ b/setup.py @@ -15,7 +15,7 @@ setup( name="superdesk-planning", - version="2.8.0-dev0", + version="2.8.0", description=DESCRIPTION, long_description=DESCRIPTION, package_dir={"": "server"}, From dde027f41a6686a3b46a4ac9072505f372c54190 Mon Sep 17 00:00:00 2001 From: Konstantin Markov Date: Thu, 3 Oct 2024 09:55:00 +0300 Subject: [PATCH 04/20] Fix tree select item rendering (#2102) (#2103) --- .../fields/editor/CustomVocabularies.tsx | 63 +++++++++++-------- 1 file changed, 36 insertions(+), 27 deletions(-) diff --git a/client/components/fields/editor/CustomVocabularies.tsx b/client/components/fields/editor/CustomVocabularies.tsx index e33c5bc1e..467c0eea1 100644 --- a/client/components/fields/editor/CustomVocabularies.tsx +++ b/client/components/fields/editor/CustomVocabularies.tsx @@ -5,8 +5,9 @@ import {ISubject, IVocabulary} from 'superdesk-api'; import {superdeskApi} from '../../../superdeskApi'; import {IEditorFieldProps, IProfileSchemaTypeList} from '../../../interfaces'; import {Row} from '../../UI/Form'; -import {getVocabularyItemNameFromString} from '../../../utils/vocabularies'; -import {EditorFieldTreeSelect} from '../editor/base/treeSelect'; +import {getVocabularyItemFieldTranslated} from '../../../utils/vocabularies'; +import {arrayToTree} from 'superdesk-core/scripts/core/helpers/tree'; +import {TreeSelect} from 'superdesk-ui-framework/react'; interface IProps extends IEditorFieldProps { schema?: IProfileSchemaTypeList; @@ -33,6 +34,8 @@ class CustomVocabulariesComponent extends React.PureComponent { required, testId, language, + disabled, + invalid, } = this.props; const customVocabularies = vocabularies.filter((cv) => @@ -41,7 +44,7 @@ class CustomVocabulariesComponent extends React.PureComponent { return customVocabularies.map((cv) => { const cvFieldName = `custom_vocabularies.${cv._id}`; - const parentField = cv.schema_field || 'subject'; + const itemFieldName = cv.schema_field ?? 'subject'; return ( { id={`form-row-${cvFieldName}`} data-test-id={testId?.length ? `${testId}.${cv._id}` : cv._id} > - values.filter((value) => cv._id == null || value?.scheme === cv._id)} - item={item} - field={parentField} - label={gettext(cv.display_name)} - required={required || schema?.required} - allowMultiple={true} + cv.items.map((item: ISubject) => ({value: {...item, scheme: cv._id}}))} - getId={(item: ISubject) => item.qcode} - getLabel={(item: ISubject) => ( - getVocabularyItemNameFromString( - item.qcode, - cv.items, - 'qcode', - 'name', - language - ) + kind="synchronous" + allowMultiple={true} + value={item.subject.filter((x) => x.scheme === cv._id)} + label={gettext(cv.display_name)} + required={required ?? schema?.required} + getOptions={() => arrayToTree( + cv.items.map((cvItem) => ({ + ...cvItem, + scheme: cv._id, + })) as Array, + ({qcode}) => qcode.toString(), + ({parent}) => parent?.toString(), + ).result} + getLabel={(item) => getVocabularyItemFieldTranslated( + item, + 'name', + language, )} - onChange={(field, value) => { - const otherCvValues = item[parentField] ?? []; + getId={(item) => item.qcode} + invalid={errors?.length > 0 || invalid} + error={showErrors ? errors[itemFieldName] : undefined} + readOnly={disabled} + disabled={disabled} + onChange={(vals) => { + const restOfItems = (item.subject ?? []).filter((x) => x.scheme !== cv._id); - const newValues = value.concat( - otherCvValues.filter((value) => value?.scheme != cv._id) + onChange( + 'subject', + [...restOfItems, ...vals], ); - - onChange(field, newValues); }} - errors={errors} + tabindex={0} + zIndex={1051} /> ); From 0c6ef8ba0abbfde8330c76dd1b7bb58cf32ca4c4 Mon Sep 17 00:00:00 2001 From: Konstantin Markov Date: Thu, 3 Oct 2024 17:29:27 +0300 Subject: [PATCH 05/20] Fix default fallback (#2104) (#2106) --- client/components/fields/editor/CustomVocabularies.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/components/fields/editor/CustomVocabularies.tsx b/client/components/fields/editor/CustomVocabularies.tsx index 467c0eea1..927dae8b8 100644 --- a/client/components/fields/editor/CustomVocabularies.tsx +++ b/client/components/fields/editor/CustomVocabularies.tsx @@ -56,7 +56,7 @@ class CustomVocabulariesComponent extends React.PureComponent { sortable={true} kind="synchronous" allowMultiple={true} - value={item.subject.filter((x) => x.scheme === cv._id)} + value={(item.subject ?? []).filter((x) => x.scheme === cv._id)} label={gettext(cv.display_name)} required={required ?? schema?.required} getOptions={() => arrayToTree( From c877e510d788c75ce213d7b5f86a6c39be2aba65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Ja=C5=A1ek?= Date: Wed, 9 Oct 2024 10:12:44 +0200 Subject: [PATCH 06/20] fix events location elastic mapping (#2109) * fix events location elastic mapping disable location translations indexing to limit the number of indexed fields. * fix black --- server/planning/events/events_schema.py | 6 ++++-- server/requirements.txt | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/server/planning/events/events_schema.py b/server/planning/events/events_schema.py index d0c7034d9..88f547840 100644 --- a/server/planning/events/events_schema.py +++ b/server/planning/events/events_schema.py @@ -201,13 +201,15 @@ "location": { "type": "list", "mapping": { + "type": "object", + "dynamic": False, "properties": { "qcode": not_analyzed, "name": {"type": "string"}, - "address": {"type": "object"}, + "address": {"type": "object", "dynamic": True}, "geo": {"type": "string"}, "location": {"type": "geo_point"}, - } + }, }, "nullable": True, }, diff --git a/server/requirements.txt b/server/requirements.txt index 085a5941c..fbc7fd95a 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -16,6 +16,7 @@ httmock==1.4.0 responses pytest pytest-env +black~=23.0 -e . # Install in editable state so we get feature fixtures From 0cd2a63095ea64df483562904f3309a6d247ffe8 Mon Sep 17 00:00:00 2001 From: Ketan <73937490+devketanpro@users.noreply.github.com> Date: Wed, 16 Oct 2024 13:48:57 +0530 Subject: [PATCH 07/20] Fix: Planning items created via an event are not always scheduled, but are being kept in draft mode [SDBELGA-879] (#2113) * fix backend validation issue on non enabled fields * remove frontend chnages and implement backend * update behave tests --- server/features/planning_validate.feature | 5 + server/planning/events/events_tests.py | 91 +++++++++++++++++++ server/planning/validate/planning_validate.py | 15 +-- 3 files changed, 105 insertions(+), 6 deletions(-) diff --git a/server/features/planning_validate.feature b/server/features/planning_validate.feature index 01d9d91fc..0e5935482 100644 --- a/server/features/planning_validate.feature +++ b/server/features/planning_validate.feature @@ -30,6 +30,11 @@ Feature: Planning Validate } }, { "_id": "planning", "name": "planning", + "editor":{ + "place": { + "enabled":true + } + }, "schema": { "slugline": { "type": "string", diff --git a/server/planning/events/events_tests.py b/server/planning/events/events_tests.py index 1fb210264..d04fe0d8f 100644 --- a/server/planning/events/events_tests.py +++ b/server/planning/events/events_tests.py @@ -607,3 +607,94 @@ def test_new_planning_is_published_when_adding_to_published_event(self): planning_item = planning_service.find_one(req=None, _id=planning_id) self.assertIsNotNone(planning_item) self.assertEqual(planning_item["pubstatus"], POST_STATE.USABLE) + + def test_related_planning_item_fields_validation_on_post(self): + with self.app.app_context(): + events_service = get_resource_service("events") + planning_service = get_resource_service("planning") + event = { + "type": "event", + "_id": "1234", + "occur_status": { + "qcode": "eocstat:eos5", + "name": "Planned, occurs certainly", + "label": "Planned, occurs certainly", + }, + "dates": { + "start": datetime(2099, 11, 21, 11, 00, 00, tzinfo=pytz.UTC), + "end": datetime(2099, 11, 21, 12, 00, 00, tzinfo=pytz.UTC), + "tz": "Asia/Calcutta", + }, + "calendars": [], + "state": "draft", + "language": "en", + "languages": ["en"], + "place": [], + "_time_to_be_confirmed": False, + "name": "Demo ", + "update_method": "single", + } + event_id = events_service.post([event]) + planning = { + "planning_date": datetime(2099, 11, 21, 12, 00, 00, tzinfo=pytz.UTC), + "name": "Demo 1", + "place": [], + "language": "en", + "type": "planning", + "slugline": "slug", + "agendas": [], + "languages": ["en"], + "event_item": event_id[0], + "coverages": [ + { + "coverage_id": "urn:newsmle264a179-5b1a-4b52-b73b-332660848cae", + "planning": { + "scheduled": datetime(2099, 11, 21, 12, 00, 00, tzinfo=pytz.UTC), + "g2_content_type": "text", + "language": "en", + "genre": "None", + }, + "news_coverage_status": { + "qcode": "ncostat:int", + "name": "coverage intended", + "label": "Planned", + }, + "workflow_status": "draft", + "assigned_to": {}, + "firstcreated": datetime(2099, 11, 21, 12, 00, 00, tzinfo=pytz.UTC), + } + ], + } + planning_id = planning_service.post([planning]) + self.app.data.insert( + "planning_types", + [ + { + "_id": "event", + "name": "event", + "editor": { + "related_plannings": {"enabled": True}, + }, + "schema": { + "related_plannings": {"planning_auto_publish": True}, + }, + }, + { + "_id": "planning", + "name": "planning", + "editor": {"subject": {"enabled": False}}, + "schema": {"subject": {"required": True}}, + }, + ], + ) + get_resource_service("events_post").post( + [{"event": event_id[0], "pubstatus": "usable", "update_method": "single", "failed_planning_ids": []}] + ) + + event_item = events_service.find_one(req=None, _id=event_id[0]) + self.assertEqual(len([event_item]), 1) + self.assertEqual(event_item.get("state"), "scheduled") + + planning_item = planning_service.find_one(req=None, _id=planning_id[0]) + self.assertEqual(len([planning_item]), 1) + self.assertEqual(planning_item.get("state"), "scheduled") diff --git a/server/planning/validate/planning_validate.py b/server/planning/validate/planning_validate.py index 77bd568db..a27120a9d 100644 --- a/server/planning/validate/planning_validate.py +++ b/server/planning/validate/planning_validate.py @@ -17,6 +17,7 @@ from apps.validate.validate import SchemaValidator as Validator from copy import deepcopy +from planning.content_profiles.utils import get_enabled_fields REQUIRED_ERROR = "{} is a required field" @@ -134,14 +135,16 @@ def _get_validator(self, doc): return get_resource_service("planning_types").find_one(req=None, name=doc[ITEM_TYPE]) def _get_validator_schema(self, validator, validate_on_post): - """Get schema for given validator. + """Get schema for a given validator, excluding fields with None values, + and only include fields that are in enabled_fields.""" - And make sure there is no `None` value which would raise an exception. - """ + enabled_fields = get_enabled_fields(validator) return { - field: get_validator_schema(schema) - for field, schema in validator["schema"].items() - if schema and schema.get("validate_on_post", False) == validate_on_post + field: get_validator_schema(field_schema) + for field, field_schema in validator["schema"].items() + if field in enabled_fields + and field_schema + and field_schema.get("validate_on_post", False) == validate_on_post } def _validate(self, doc): From a66755897532b3462371cd28e7542c3403d98767 Mon Sep 17 00:00:00 2001 From: Petr Jasek Date: Fri, 25 Oct 2024 14:11:13 +0200 Subject: [PATCH 08/20] release 2.8.1 --- package-lock.json | 2 +- package.json | 2 +- server/planning/__init__.py | 2 +- setup.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6da591985..376f41476 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "superdesk-planning", - "version": "2.8.0", + "version": "2.8.1", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 2de0b52e8..68a0220ed 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "superdesk-planning", - "version": "2.8.0", + "version": "2.8.1", "license": "AGPL-3.0", "description": "", "repository": { diff --git a/server/planning/__init__.py b/server/planning/__init__.py index a2d90054c..68e9491be 100644 --- a/server/planning/__init__.py +++ b/server/planning/__init__.py @@ -78,7 +78,7 @@ from planning.planning_locks import init_app as init_planning_locks_app from planning.search.planning_autocomplete import init_app as init_planning_autocomplete_app -__version__ = "2.8.0" +__version__ = "2.8.1" _SERVER_PATH = os.path.dirname(os.path.realpath(__file__)) diff --git a/setup.py b/setup.py index 138d690fa..cb45b1788 100644 --- a/setup.py +++ b/setup.py @@ -15,7 +15,7 @@ setup( name="superdesk-planning", - version="2.8.0", + version="2.8.1", description=DESCRIPTION, long_description=DESCRIPTION, package_dir={"": "server"}, From f844f747c9650b339f2f2181f53ee05afdc8c516 Mon Sep 17 00:00:00 2001 From: Tomas Kikutis Date: Tue, 29 Oct 2024 09:25:18 +0100 Subject: [PATCH 09/20] @superdesk/common was used, but not installed --- package-lock.json | 13 +++---------- package.json | 1 + 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index 376f41476..b8876212d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -147,7 +147,6 @@ "version": "0.0.17", "resolved": "https://registry.npmjs.org/@superdesk/common/-/common-0.0.17.tgz", "integrity": "sha512-DeycOche2WLJkH4k2JdG8IR5Oi3K3MRTrxXtDqlxLvp/lU9eqbp0glHuthnTnUPEfaUo6bsye6wmGKC6M0DGEQ==", - "dev": true, "requires": { "date-fns": "2.7.0", "lodash": "4.17.19", @@ -159,20 +158,17 @@ "date-fns": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.7.0.tgz", - "integrity": "sha512-wxYp2PGoUDN5ZEACc61aOtYFvSsJUylIvCjpjDOqM1UDaKIIuMJ9fAnMYFHV3TQaDpfTVxhwNK/GiCaHKuemTA==", - "dev": true + "integrity": "sha512-wxYp2PGoUDN5ZEACc61aOtYFvSsJUylIvCjpjDOqM1UDaKIIuMJ9fAnMYFHV3TQaDpfTVxhwNK/GiCaHKuemTA==" }, "lodash": { "version": "4.17.19", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.19.tgz", - "integrity": "sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ==", - "dev": true + "integrity": "sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ==" }, "react-sortable-hoc": { "version": "1.11.0", "resolved": "https://registry.npmjs.org/react-sortable-hoc/-/react-sortable-hoc-1.11.0.tgz", "integrity": "sha512-v1CDCvdfoR3zLGNp6qsBa4J1BWMEVH25+UKxF/RvQRh+mrB+emqtVHMgZ+WreUiKJoEaiwYoScaueIKhMVBHUg==", - "dev": true, "requires": { "@babel/runtime": "^7.2.0", "invariant": "^2.2.4", @@ -9813,7 +9809,6 @@ "version": "16.9.0", "resolved": "https://registry.npmjs.org/react/-/react-16.9.0.tgz", "integrity": "sha512-+7LQnFBwkiw+BobzOF6N//BdoNw0ouwmSJTEm9cglOOmsg/TMiFHZLe2sEoN5M7LgJTj9oHH0gxklfnQe66S1w==", - "dev": true, "requires": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", @@ -9877,7 +9872,6 @@ "version": "16.9.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.9.0.tgz", "integrity": "sha512-YFT2rxO9hM70ewk9jq0y6sQk8cL02xm4+IzYBz75CQGlClQQ1Bxq0nhHF6OtSbit+AIahujJgb/CPRibFkMNJQ==", - "dev": true, "requires": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", @@ -10739,7 +10733,6 @@ "version": "0.15.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.15.0.tgz", "integrity": "sha512-xAefmSfN6jqAa7Kuq7LIJY0bwAPG3xlCj0HMEBQk1lxYiDKZscY2xJ5U/61ZTrYbmNQbXa+gc7czPkVo11tnCg==", - "dev": true, "requires": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1" @@ -12109,7 +12102,7 @@ } }, "superdesk-core": { - "version": "github:superdesk/superdesk-client-core#61d411e5074fd6736693e67dcdf5b503d91198a8", + "version": "github:superdesk/superdesk-client-core#c1b648853877b8072918efb65b45bdd04f031775", "from": "github:superdesk/superdesk-client-core#develop", "dev": true, "requires": { diff --git a/package.json b/package.json index 68a0220ed..9b0e20bd7 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ }, "author": "Edouard Richard", "dependencies": { + "@superdesk/common": "0.0.17", "dompurify": "^1.0.11", "moment": "^2.30.1", "moment-timezone": "^0.5.45", From 4914e295ed1f49a52e7b695f6b4ea835026f7372 Mon Sep 17 00:00:00 2001 From: Konstantin Markov Date: Mon, 4 Nov 2024 12:09:31 +0200 Subject: [PATCH 10/20] Show reassign on auto add to workflow, types cleanup (#2121) --- .../Coverages/CoverageArrayInput.tsx | 16 ++-- .../CoverageEditor/CoverageFormHeader.tsx | 71 +++++++++------ .../Coverages/CoverageEditor/index.tsx | 2 +- client/components/UI/Button.tsx | 89 ++++++++----------- .../components/UI/Form/InputArray/index.tsx | 28 +++++- client/components/UI/List/Row.tsx | 1 - client/utils/index.ts | 4 +- 7 files changed, 115 insertions(+), 96 deletions(-) diff --git a/client/components/Coverages/CoverageArrayInput.tsx b/client/components/Coverages/CoverageArrayInput.tsx index 82cf285a0..cac903e8e 100644 --- a/client/components/Coverages/CoverageArrayInput.tsx +++ b/client/components/Coverages/CoverageArrayInput.tsx @@ -4,6 +4,7 @@ import {isEmpty} from 'lodash'; import { EDITOR_TYPE, + IAssignmentItem, IAssignmentPriority, ICoverageFormProfile, ICoverageProvider, ICoverageScheduledUpdate, IEventItem, IFile, @@ -22,6 +23,7 @@ import * as selectors from '../../selectors'; import {InputArray} from '../UI/Form'; import {CoverageEditor} from './CoverageEditor'; import {CoverageAddButton} from './CoverageAddButton'; +import * as actions from '../../actions'; interface IProps { @@ -78,12 +80,7 @@ interface IProps { scheduledUpdate?: ICoverageScheduledUpdate, scheduledUpdateIndex?: number ): void; - onRemoveAssignment( - coverage: IPlanningCoverageItem, - index: number, - scheduledUpdate?: ICoverageScheduledUpdate, - scheduledUpdateIndex?: number - ): void; + onRemoveAssignment(assignemnt: IAssignmentItem): Promise; uploadFiles(files: Array>): Promise>; notifyValidationErrors(errors: Array): void; } @@ -106,6 +103,10 @@ const mapStateToProps = (state) => ({ defaultDesk: selectors.general.defaultDesk(state), }); +const mapDispatchToProps = (dispatch) => ({ + onRemoveAssignment: (assignment) => dispatch(actions.assignments.ui.showRemoveAssignmentModal(assignment)), +}); + class CoverageArrayInputComponent extends React.Component { constructor(props) { super(props); @@ -214,6 +215,7 @@ class CoverageArrayInputComponent extends React.Component { onChange={onChange} addButtonText={addButtonText} addButtonComponent={CoverageAddButton} + onRemoveAssignment={this.props.onRemoveAssignment} addButtonProps={{ contentTypes, defaultDesk, @@ -260,4 +262,4 @@ class CoverageArrayInputComponent extends React.Component { } } -export const CoverageArrayInput = connect(mapStateToProps)(CoverageArrayInputComponent); +export const CoverageArrayInput = connect(mapStateToProps, mapDispatchToProps)(CoverageArrayInputComponent); diff --git a/client/components/Coverages/CoverageEditor/CoverageFormHeader.tsx b/client/components/Coverages/CoverageEditor/CoverageFormHeader.tsx index 26c534fb3..11b971be9 100644 --- a/client/components/Coverages/CoverageEditor/CoverageFormHeader.tsx +++ b/client/components/Coverages/CoverageEditor/CoverageFormHeader.tsx @@ -2,15 +2,18 @@ import React from 'react'; import {connect} from 'react-redux'; import {get} from 'lodash'; -import {IPlanningCoverageItem, ICoverageScheduledUpdate} from '../../../interfaces'; +import {IPlanningCoverageItem, ICoverageScheduledUpdate, ILockedItems} from '../../../interfaces'; import {IArticle, IDesk, IUser} from 'superdesk-api'; -import {getCreator, getItemInArrayById, gettext, planningUtils, onEventCapture} from '../../../utils'; +import {getCreator, getItemInArrayById, gettext, onEventCapture} from '../../../utils'; import {Item, Border, Column, Row as ListRow} from '../../UI/List'; -import {Button} from '../../UI'; import {UserAvatar} from '../../../components/UserAvatar'; import {StateLabel} from '../../StateLabel'; import * as actions from '../../../actions'; +import {ASSIGNMENTS} from '../../../constants/assignments'; +import * as selectors from '../../../selectors'; +import {planningUtils} from '../../../utils'; +import {Button} from 'superdesk-ui-framework/react'; interface IProps { field: string; @@ -21,7 +24,7 @@ interface IProps { addNewsItemToPlanning?: IArticle; onChange(field: string, value: any): void; onFocus?(): void; - onRemoveAssignment?(): void; + onRemoveAssignment?(): Promise; setCoverageDefaultDesk(coverage: IPlanningCoverageItem | ICoverageScheduledUpdate): void; showEditCoverageAssignmentModal(props: { field: string; @@ -32,6 +35,7 @@ interface IProps { onChange(field: string, value: any): void; setCoverageDefaultDesk(coverage: IPlanningCoverageItem | ICoverageScheduledUpdate): void; }): void; + lockedItems: ILockedItems; } const mapDispatchToProps = (dispatch) => ({ @@ -40,6 +44,10 @@ const mapDispatchToProps = (dispatch) => ({ ), }); +const mapStateToProps = (state) => ({ + lockedItems: selectors.locks.getLockedItems(state), +}); + export class CoverageFormHeaderComponent extends React.PureComponent { constructor(props) { super(props); @@ -69,15 +77,24 @@ export class CoverageFormHeaderComponent extends React.PureComponent { addNewsItemToPlanning, onRemoveAssignment, readOnly, + lockedItems, } = this.props; const userAssigned = getCreator(value, 'assigned_to.user', users); - const deskAssigned = getItemInArrayById(desks, get(value, 'assigned_to.desk')); - const coverageProvider = get(value, 'assigned_to.coverage_provider'); - const assignmentState = get(value, 'assigned_to.state'); - const cancelled = get(value, 'workflow_status') === 'cancelled'; - const canEditAssignment = planningUtils.isCoverageDraft(value) || - (!!addNewsItemToPlanning && !get(value, 'coverage_id') && !get(value, 'scheduled_update_id')); + const deskAssigned = getItemInArrayById(desks, value.assigned_to?.desk); + const coverageProvider = value.assigned_to?.coverage_provider; + const assignmentState = value.assigned_to?.state; + const cancelled = value.workflow_status === ASSIGNMENTS.WORKFLOW_STATE.CANCELLED; + + /* + Check if: + 1. This view is rendered from AddToPlanning action + 2. There's an already scheduled update for the coverage + */ + const isAssignmentLocked = lockedItems?.assignment + && value.assigned_to?.assignment_id in lockedItems.assignment; + const canEditAssignment = addNewsItemToPlanning == null && !isAssignmentLocked + && !((value as ICoverageScheduledUpdate).scheduled_update_id); if (!deskAssigned && (!userAssigned || !coverageProvider)) { return ( @@ -102,11 +119,9 @@ export class CoverageFormHeaderComponent extends React.PureComponent {