From 1b3887f5ed50321ed02dd161b8f97ae47f0ca0cd Mon Sep 17 00:00:00 2001 From: Helmy Giacoman Date: Wed, 11 Dec 2024 15:15:08 +0100 Subject: [PATCH] Adjust `EventsAsyncService` and fix tests SDESK-7442 --- server/planning/events/events.py | 22 +- server/planning/events/events_service.py | 85 ++- .../planning/events/events_sync/__init__.py | 10 +- server/planning/events/events_tests.py | 722 +++++++++--------- server/planning/types/event.py | 3 +- server/planning/utils.py | 43 +- 6 files changed, 448 insertions(+), 437 deletions(-) diff --git a/server/planning/events/events.py b/server/planning/events/events.py index bb2bd876f..9aea36777 100644 --- a/server/planning/events/events.py +++ b/server/planning/events/events.py @@ -49,9 +49,10 @@ from apps.auth import get_user, get_user_id from apps.archive.common import get_auth, update_dates_for -from planning.types import Event, PlanningRelatedEventLink, PLANNING_RELATED_EVENT_LINK_TYPE +from planning.types import EmbeddedCoverageItem, Event, PlanningRelatedEventLink, PLANNING_RELATED_EVENT_LINK_TYPE from planning.types.event import EmbeddedPlanning from planning.common import ( + TEMP_ID_PREFIX, UPDATE_SINGLE, UPDATE_FUTURE, get_max_recurrent_events, @@ -79,7 +80,6 @@ from .events_base_service import EventsBaseService from .events_schema import events_schema from .events_sync import sync_event_metadata_with_planning_items -from .events_utils import get_events_embedded_planning logger = logging.getLogger(__name__) @@ -95,6 +95,23 @@ } +# TODO-ASYNC: this method was migrated to events_utils and it uses pydantic models instead +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 [ + EmbeddedPlanning( + planning_id=planning.get("planning_id"), + update_method=planning.get("update_method") or "single", + coverages={get_coverage_id(coverage): coverage for coverage in planning.get("coverages") or []}, + ) + for planning in event.pop("embedded_planning", []) + ] + + def get_subject_str(subject: Dict[str, str]) -> str: return ":".join( [ @@ -199,6 +216,7 @@ def set_ingest_provider_sequence(item, provider): def on_create(self, docs): # events generated by recurring rules generated_events = [] + for event in docs: # generates an unique id if "guid" not in event: diff --git a/server/planning/events/events_service.py b/server/planning/events/events_service.py index 4de0e8758..3c17abb62 100644 --- a/server/planning/events/events_service.py +++ b/server/planning/events/events_service.py @@ -108,6 +108,30 @@ async def get_expired_items( # Yield the results for iteration by the callee yield items + def _extract_embedded_planning( + self, docs: list[EventResourceModel] + ) -> list[tuple[EventResourceModel, list[EmbeddedPlanning]]]: + """ + Extracts out the ``embedded_planning`` of a given event Event + """ + + embedded_planning_lists: list[tuple[EventResourceModel, list[EmbeddedPlanning]]] = [] + for event in docs: + embedded_planning = get_events_embedded_planning(event) + if len(embedded_planning): + embedded_planning_lists.append((event, embedded_planning)) + + return embedded_planning_lists + + def _synchronise_associated_plannings( + self, embedded_planning_list: list[tuple[EventResourceModel, list[EmbeddedPlanning]]] + ): + """ + Synchronise/process the given associated Planning item(s) + """ + for event, embedded_planning in embedded_planning_list: + sync_event_metadata_with_planning_items(None, event.to_dict(), embedded_planning) + async def create(self, docs: list[EventResourceModel]): """ Extracts out the ``embedded_planning`` before saving the Event(s) @@ -115,36 +139,34 @@ async def create(self, docs: list[EventResourceModel]): """ docs = await self._convert_dicts_to_model(docs) - print(docs) + + embedded_planning_list = self._extract_embedded_planning(docs) + await self.prepare_events_data(docs) ids = await super().create(docs) - embedded_planning_lists: list[tuple[EventResourceModel, list[EmbeddedPlanning]]] = [] + self._synchronise_associated_plannings(embedded_planning_list) - for event in docs: - embedded_planning = get_events_embedded_planning(event) - if len(embedded_planning): - embedded_planning_lists.append((event.to_dict(), embedded_planning)) + return ids - if len(embedded_planning_lists): - for event, embedded_planning in embedded_planning_lists: - sync_event_metadata_with_planning_items(None, event, embedded_planning) + async def prepare_events_data(self, docs: list[EventResourceModel]) -> None: + """ + Prepares basic attributes of events before creation. This method generates recurring events + if applicable, sets up planning schedules, and links events to planning items. - return ids + Args: + events (list[EventResourceModel]): A list of event models to prepare. - async def on_create(self, docs: list[EventResourceModel]) -> None: - # events generated by recurring rules + Returns: + None: Modifies the input list in-place. + """ generated_events = [] for event in docs: - # generates an unique id if not event.guid: event.guid = generate_guid(type=GUID_NEWSML) event.id = event.guid if not event.language: - try: - event.language = event.languages[0] - except IndexError: - event.language = get_app_config("DEFAULT_LANGUAGE") + event.language = event.languages[0] if len(event.languages) > 0 else get_app_config("DEFAULT_LANGUAGE") # TODO-ASYNC: consider moving this into base service later event.original_creator = ObjectId(get_user_id()) or None @@ -163,7 +185,6 @@ async def on_create(self, docs: list[EventResourceModel]) -> None: event.planning_schedule = self._create_planning_schedule(event) original_planning_item = event.planning_item - # validate event self.validate_event(event) # If _created_externally is true, generate_recurring_events is restricted. @@ -171,6 +192,9 @@ async def on_create(self, docs: list[EventResourceModel]) -> None: recurring_events = self._generate_recurring_events(event) generated_events.extend(recurring_events) + # remove the event that contains the recurring rule. We don't need it anymore + docs.remove(event) + # Set the current Event to the first Event in the new series # This will make sure the ID of the Event can be used when # using 'event' from here on, such as when linking to a Planning item @@ -186,13 +210,11 @@ async def on_create(self, docs: list[EventResourceModel]) -> None: if original_planning_item: await self._link_to_planning(event) - del event["_planning_item"] + event.planning_item = None if generated_events: docs.extend(generated_events) - await super().on_create(docs) - async def on_created(self, docs: list[EventResourceModel]): """Send WebSocket Notifications for created Events @@ -248,7 +270,6 @@ async def on_update(self, updates: dict[str, Any], original: EventResourceModel) return update_method = updates.pop("update_method", UpdateMethods.SINGLE) - user = get_user() user_id = user.get(ID_FIELD) if user else None @@ -263,7 +284,7 @@ async def on_update(self, updates: dict[str, Any], original: EventResourceModel) raise SuperdeskApiError.forbiddenError("The item was locked by another user") # If only the `recurring_rule` was provided, then fill in the rest from the original - # This can happen, for example, when converting a single Event to a series of Recurring Events + # this can happen, for example, when converting a single Event to a series of Recurring Events if list(updates.get("dates") or {}) == ["recurring_rule"]: new_dates = original.to_dict()["dates"] new_dates.update(updates["dates"]) @@ -470,10 +491,7 @@ async def _update_single_event(self, updates: dict[str, Any], original: EventRes get_resource_service("events_post").validate_item(merged.to_dict()) # Determine if we're to convert this single event to a recurring of events - if ( - original.lock_action == "convert_recurring" - and updates.get("dates", {}).get("recurring_rule", None) is not None - ): + if original.lock_action == "convert_recurring" and updates.get("dates", {}).get("recurring_rule") is not None: generated_events = await self._convert_to_recurring_events(updates, original) # if the original event was "posted" then post all the generated events @@ -612,7 +630,10 @@ def mark_event_complete(self, updates: dict[str, Any], event: EventResourceModel ) async def _convert_to_recurring_events(self, updates: dict[str, Any], original: EventResourceModel): - """Convert a single event to a series of recurring events""" + """ + Convert a single event to a series of recurring events and stores them into database. + This also triggers the `signals.events_created` signal. + """ self._validate_convert_to_recurring(updates, original) updates["recurrence_id"] = original.id @@ -652,8 +673,12 @@ async def _convert_to_recurring_events(self, updates: dict[str, Any], original: updates["_planning_schedule"] = [x.to_dict() for x in self._create_planning_schedule(updated_event)] remove_lock_information(item=updates) - # Create the new events and generate their history - await self.create(generated_events) + # create the new events + embedded_planning_list = self._extract_embedded_planning(generated_events) + await super().create(generated_events) + self._synchronise_associated_plannings(embedded_planning_list) + + # signal's listener will generate these events' history await signals.events_created.send(generated_events) return generated_events diff --git a/server/planning/events/events_sync/__init__.py b/server/planning/events/events_sync/__init__.py index 362099f92..320660c9d 100644 --- a/server/planning/events/events_sync/__init__.py +++ b/server/planning/events/events_sync/__init__.py @@ -15,7 +15,7 @@ from superdesk import get_resource_service -from planning.utils import str_to_date +from planning.utils import parse_date from planning.utils import get_related_planning_for_events from planning.content_profiles.utils import AllContentProfileData from planning.common import get_config_event_fields_to_sync_with_planning @@ -43,13 +43,9 @@ def get_translated_fields(translations: List[StringFieldTranslation]) -> Dict[st # TODO-ASYNC: use resource models instead of typed dicts def sync_event_metadata_with_planning_items( original: Optional[Event], - updates: Event | EventResourceModel, + updates: Event, embedded_planning: list[EmbeddedPlanningDict] | list[EmbeddedPlanningModel], ): - # TODO-ASYNC: remove these checks after this is migrated - if isinstance(updates, EventResourceModel): - updates = cast(Event, updates.to_dict()) - embedded_planning = [ cast(EmbeddedPlanningDict, obj.to_dict()) if isinstance(obj, EmbeddedPlanningModel) else obj for obj in embedded_planning @@ -63,7 +59,7 @@ def sync_event_metadata_with_planning_items( event_updated.update(updates) if isinstance(event_updated["dates"]["start"], str): - event_updated["dates"]["start"] = str_to_date(event_updated["dates"]["start"]) + event_updated["dates"]["start"] = parse_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) diff --git a/server/planning/events/events_tests.py b/server/planning/events/events_tests.py index 619a43333..9b3ec5909 100644 --- a/server/planning/events/events_tests.py +++ b/server/planning/events/events_tests.py @@ -8,6 +8,8 @@ # AUTHORS and LICENSE files distributed with this source code, or # at https://www.sourcefabric.org/superdesk/license +from typing import Any + import pytz from pytest import mark from copy import deepcopy @@ -24,9 +26,16 @@ from planning.events.events import generate_recurring_dates from planning.types import PlanningRelatedEventLink from planning.events import EventsAsyncService +from planning.events.events_utils import get_recurring_timeline + + +class EventsBaseTestCase(TestCase): + async def asyncSetUp(self): + await super().asyncSetUp() + self.events_service = EventsAsyncService() -class EventTestCase(TestCase): +class EventTestCase(EventsBaseTestCase): def test_recurring_dates_generation(self): # Every other thurdsay and friday afternoon on January 2016 self.assertEquals( @@ -120,59 +129,55 @@ def test_recurring_dates_generation(self): ) async def test_get_recurring_timeline(self): - async with self.app.app_context(): - generated_events = generate_recurring_events(10) - self.app.data.insert("events", generated_events) + generated_events = generate_recurring_events(10) + self.app.data.insert("events", generated_events) - service = get_resource_service("events") - selected = service.find_one(req=None, name="Event 5") - self.assertEquals("Event 5", selected["name"]) + selected = await self.events_service.find_one_raw(name="Event 5") + self.assertEquals("Event 5", selected["name"]) - (historic, past, future) = service.get_recurring_timeline(selected) + (historic, past, future) = await get_recurring_timeline(selected) - self.assertEquals(2, len(historic)) - self.assertEquals(3, len(past)) - self.assertEquals(4, len(future)) + self.assertEquals(2, len(historic)) + self.assertEquals(3, len(past)) + self.assertEquals(4, len(future)) - expected_time = generated_events[0]["dates"]["start"] - for e in historic: - self.assertEquals(e["dates"]["start"], expected_time) - expected_time += timedelta(days=1) - - for e in past: - self.assertEquals(e["dates"]["start"], expected_time) - expected_time += timedelta(days=1) + expected_time = generated_events[0]["dates"]["start"] + for e in historic: + self.assertEquals(e["dates"]["start"], expected_time) + expected_time += timedelta(days=1) - self.assertEquals(selected["dates"]["start"], expected_time) + for e in past: + self.assertEquals(e["dates"]["start"], expected_time) expected_time += timedelta(days=1) - for e in future: - self.assertEquals(e["dates"]["start"], expected_time) - expected_time += timedelta(days=1) + self.assertEquals(selected["dates"]["start"], expected_time) + expected_time += timedelta(days=1) + + for e in future: + self.assertEquals(e["dates"]["start"], expected_time) + expected_time += timedelta(days=1) async def test_create_cancelled_event(self): - async with self.app.app_context(): - service = get_resource_service("events") - service.post_in_mongo( - [ - { - "guid": "test", - "name": "Test Event", - "pubstatus": "cancelled", - "dates": { - "start": datetime.now(), - "end": datetime.now() + timedelta(days=1), - }, - } - ] - ) + await self.events_service.create( + [ + { + "guid": "test", + "name": "Test Event", + "pubstatus": "cancelled", + "dates": { + "start": datetime.now(), + "end": datetime.now() + timedelta(days=1), + }, + } + ] + ) - event = service.find_one(req=None, guid="test") - assert event is not None - assert event["pubstatus"] == "cancelled" + event = await self.events_service.find_one(guid="test") + assert event is not None + assert event.pubstatus == "cancelled" -class EventLocationFormatAddress(TestCase): +class EventLocationFormatAddress(EventsBaseTestCase): def test_format_address(self): location = { "address": { @@ -223,7 +228,11 @@ def test_format_address(self): self.assertEqual(location["formatted_address"], "") -class EventPlanningSchedule(TestCase): +class EventPlanningSchedule(EventsBaseTestCase): + async def _get_all_events_raw(self) -> list[dict[str, Any]]: + events_cursor = await self.events_service.find({}) + return await events_cursor.to_list_raw() + def assertPlanningSchedule(self, events, event_count): self.assertEqual(len(events), event_count) for evt in events: @@ -233,7 +242,6 @@ def assertPlanningSchedule(self, events, event_count): ) async def test_planning_schedule_for_recurring_event(self): - service = get_resource_service("events") event = { "name": "Friday Club", "dates": { @@ -249,12 +257,11 @@ async def test_planning_schedule_for_recurring_event(self): }, } - service.post([event]) - events = list(service.get(req=None, lookup=None)) + await self.events_service.create([event]) + events = await self._get_all_events_raw() self.assertPlanningSchedule(events, 3) async def test_planning_schedule_reschedule_event(self): - service = get_resource_service("events") event = { "name": "Friday Club", "dates": { @@ -271,8 +278,8 @@ async def test_planning_schedule_reschedule_event(self): } # create recurring events - service.post([event]) - events = list(service.get(req=None, lookup=None)) + await self.events_service.create([event]) + events = await self._get_all_events_raw() self.assertPlanningSchedule(events, 3) # reschedule recurring event before posting @@ -289,7 +296,7 @@ async def test_planning_schedule_reschedule_event(self): res = reschedule.patch(events[0].get("_id"), {"dates": schedule}) self.assertEqual(res.get("dates").get("start"), schedule["start"]) - events = list(service.get(req=None, lookup=None)) + events = await self._get_all_events_raw() self.assertPlanningSchedule(events, 3) # post recurring events @@ -311,10 +318,10 @@ async def test_planning_schedule_reschedule_event(self): schedule["end"] = datetime(2099, 11, 21, 12, 00, 00, tzinfo=pytz.UTC) + timedelta(days=3) res = reschedule.patch(events[0].get("_id"), {"dates": schedule}) - rescheduled_event = service.find_one(req=None, _id=events[0].get("_id")) + rescheduled_event = await self.events_service.find_by_id_raw(events[0].get("_id")) self.assertNotEqual(rescheduled_event.get("dates").get("start"), schedule["start"]) - events = list(service.get(req=None, lookup=None)) + events = await self._get_all_events_raw() self.assertPlanningSchedule(events, 4) # reset mocked function @@ -322,99 +329,94 @@ async def test_planning_schedule_reschedule_event(self): reschedule.REQUIRE_LOCK = True async def test_planning_schedule_update_time(self): - async with self.app.app_context(): - service = get_resource_service("events") - event = { - "name": "Friday Club", - "dates": { - "start": datetime(2099, 11, 21, 12, 00, 00, tzinfo=pytz.UTC), - "end": datetime(2099, 11, 21, 14, 00, 00, tzinfo=pytz.UTC), - "tz": "Australia/Sydney", - "recurring_rule": { - "frequency": "DAILY", - "interval": 1, - "count": 3, - "end_repeat_mode": "count", - }, + event = { + "name": "Friday Club", + "dates": { + "start": datetime(2099, 11, 21, 12, 00, 00, tzinfo=pytz.UTC), + "end": datetime(2099, 11, 21, 14, 00, 00, tzinfo=pytz.UTC), + "tz": "Australia/Sydney", + "recurring_rule": { + "frequency": "DAILY", + "interval": 1, + "count": 3, + "end_repeat_mode": "count", }, - } + }, + } - service.post([event]) - events = list(service.get(req=None, lookup=None)) - self.assertPlanningSchedule(events, 3) + await self.events_service.create([event]) + events = await self._get_all_events_raw() + self.assertPlanningSchedule(events, 3) - schedule = deepcopy(events[0].get("dates")) - schedule["start"] = datetime(2099, 11, 21, 12, 00, 00, tzinfo=pytz.UTC) + timedelta(hours=2) - schedule["end"] = datetime(2099, 11, 21, 14, 00, 00, tzinfo=pytz.UTC) + timedelta(hours=2) + schedule = deepcopy(events[0].get("dates")) + schedule["start"] = datetime(2099, 11, 21, 12, 00, 00, tzinfo=pytz.UTC) + timedelta(hours=2) + schedule["end"] = datetime(2099, 11, 21, 14, 00, 00, tzinfo=pytz.UTC) + timedelta(hours=2) - update_time = get_resource_service("events_update_time") - update_time.REQUIRE_LOCK = False - # mocking function - is_original_event_func = update_time.is_original_event - update_time.is_original_event = Mock(return_value=False) + update_time = get_resource_service("events_update_time") + update_time.REQUIRE_LOCK = False + # mocking function + is_original_event_func = update_time.is_original_event + update_time.is_original_event = Mock(return_value=False) - res = update_time.patch(events[0].get("_id"), {"dates": schedule, "update_method": "all"}) - self.assertEqual(res.get("dates").get("start"), schedule["start"]) + res = update_time.patch(events[0].get("_id"), {"dates": schedule, "update_method": "all"}) + self.assertEqual(res.get("dates").get("start"), schedule["start"]) - events = list(service.get(req=None, lookup=None)) - self.assertPlanningSchedule(events, 3) + events = await self._get_all_events_raw() + self.assertPlanningSchedule(events, 3) - schedule = deepcopy(events[1].get("dates")) - schedule["start"] = datetime(2099, 11, 21, 20, 00, 00, tzinfo=pytz.UTC) + timedelta(hours=2) - schedule["end"] = datetime(2099, 11, 21, 21, 00, 00, tzinfo=pytz.UTC) + timedelta(hours=2) + schedule = deepcopy(events[1].get("dates")) + schedule["start"] = datetime(2099, 11, 21, 20, 00, 00, tzinfo=pytz.UTC) + timedelta(hours=2) + schedule["end"] = datetime(2099, 11, 21, 21, 00, 00, tzinfo=pytz.UTC) + timedelta(hours=2) - res = update_time.patch(events[0].get("_id"), {"dates": schedule, "update_method": "single"}) - self.assertEqual(res.get("dates").get("start"), schedule["start"]) + res = update_time.patch(events[0].get("_id"), {"dates": schedule, "update_method": "single"}) + self.assertEqual(res.get("dates").get("start"), schedule["start"]) - events = list(service.get(req=None, lookup=None)) - self.assertPlanningSchedule(events, 3) + events = await self._get_all_events_raw() + self.assertPlanningSchedule(events, 3) - # reset mocked function - update_time.is_original_event = is_original_event_func - update_time.REQUIRE_LOCK = True + # reset mocked function + update_time.is_original_event = is_original_event_func + update_time.REQUIRE_LOCK = True async def test_planning_schedule_update_repetitions(self): - async with self.app.app_context(): - service = get_resource_service("events") - event = { - "name": "Friday Club", - "dates": { - "start": datetime(2099, 11, 21, 12, 00, 00, tzinfo=pytz.UTC), - "end": datetime(2099, 11, 21, 14, 00, 00, tzinfo=pytz.UTC), - "tz": "Australia/Sydney", - "recurring_rule": { - "frequency": "DAILY", - "interval": 1, - "count": 3, - "end_repeat_mode": "count", - }, + event = { + "name": "Friday Club", + "dates": { + "start": datetime(2099, 11, 21, 12, 00, 00, tzinfo=pytz.UTC), + "end": datetime(2099, 11, 21, 14, 00, 00, tzinfo=pytz.UTC), + "tz": "Australia/Sydney", + "recurring_rule": { + "frequency": "DAILY", + "interval": 1, + "count": 3, + "end_repeat_mode": "count", }, - } + }, + } - service.post([event]) - events = list(service.get_from_mongo(req=None, lookup=None)) - self.assertPlanningSchedule(events, 3) + ids = await self.events_service.create([event]) + events = await self._get_all_events_raw() + self.assertPlanningSchedule(events, 3) - schedule = deepcopy(events[0].get("dates")) - schedule["recurring_rule"]["count"] = 5 + schedule = deepcopy(event["dates"]) + schedule["recurring_rule"]["count"] = 5 - update_repetitions = get_resource_service("events_update_repetitions") - update_repetitions.REQUIRE_LOCK = False - # mocking function - is_original_event_func = update_repetitions.is_original_event - update_repetitions.is_original_event = Mock(return_value=False) - update_repetitions.patch(events[0].get("_id"), {"dates": schedule}) + update_repetitions = get_resource_service("events_update_repetitions") + update_repetitions.REQUIRE_LOCK = False + # mocking function + is_original_event_func = update_repetitions.is_original_event + update_repetitions.is_original_event = Mock(return_value=False) + update_repetitions.patch(events[0].get("_id"), {"dates": schedule}) - events = list(service.get_from_mongo(req=None, lookup=None)) - self.assertPlanningSchedule(events, 5) + events = await self._get_all_events_raw() + self.assertPlanningSchedule(events, 5) - # reset mocked function - update_repetitions.is_original_event = is_original_event_func - update_repetitions.REQUIRE_LOCK = True + # reset mocked function + update_repetitions.is_original_event = is_original_event_func + update_repetitions.REQUIRE_LOCK = True @patch("planning.events.events.get_user") async def test_planning_schedule_convert_to_recurring(self, get_user_mock): - service = EventsAsyncService() get_user_mock.return_value = {"_id": "None"} event = { "name": "Friday Club", @@ -425,9 +427,8 @@ async def test_planning_schedule_convert_to_recurring(self, get_user_mock): }, } - await service.create([event]) - events_cursor = await service.find({}) - events = await events_cursor.to_list_raw() + await self.events_service.create([event]) + events = await self._get_all_events_raw() self.assertPlanningSchedule(events, 1) # TODO-ASYNC: adjust when `LockService` is async as it uses `get_resource_service` dynamically @@ -445,9 +446,8 @@ async def test_planning_schedule_convert_to_recurring(self, get_user_mock): "end_repeat_mode": "count", } - await service.update(events[0].get("_id"), {"dates": schedule}) - events_cursor = await service.find({}) - events = await events_cursor.to_list_raw() + await self.events_service.update(events[0].get("_id"), {"dates": schedule}) + events = await self._get_all_events_raw() self.assertPlanningSchedule(events, 3) @@ -470,252 +470,246 @@ def generate_recurring_events(num_events): return events -class EventsRelatedPlanningAutoPublish(TestCase): +class EventsRelatedPlanningAutoPublish(EventsBaseTestCase): async def test_planning_item_is_published_with_events(self): - async with self.app.app_context(): - events_service = get_resource_service("events") - planning_service = get_resource_service("planning") - event = { - "type": "event", - "_id": "123", - "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"], - "user": "12234553", - "related_events": [PlanningRelatedEventLink(_id=event_id[0], link_type="primary")], - "coverages": [ - { - "coverage_id": "urn:newsml:localhost:5000:2023-09-08T17:40:56.290922:e264a179-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]) - schema = { - "language": { - "languages": ["en", "de"], - "default_language": "en", - "multilingual": True, - "required": True, - }, - "name": {"multilingual": True}, - "slugline": {"multilingual": True}, - "definition_short": {"multilingual": True}, - "related_plannings": {"planning_auto_publish": True}, - } - self.app.data.insert( - "planning_types", - [ - { - "_id": "event", - "name": "event", - "editor": { - "language": {"enabled": True}, - "related_plannings": {"enabled": True}, - }, - "schema": schema, - } - ], - ) - now = utcnow() - get_resource_service("events_post").post( - [{"event": event_id[0], "pubstatus": "usable", "update_method": "single", "failed_planning_ids": []}] - ) + planning_service = get_resource_service("planning") + event = { + "type": "event", + "_id": "123", + "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 = await self.events_service.create([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"], + "user": "12234553", + "related_events": [PlanningRelatedEventLink(_id=event_id[0], link_type="primary")], + "coverages": [ + { + "coverage_id": "urn:newsml:localhost:5000:2023-09-08T17:40:56.290922:e264a179-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]) + schema = { + "language": { + "languages": ["en", "de"], + "default_language": "en", + "multilingual": True, + "required": True, + }, + "name": {"multilingual": True}, + "slugline": {"multilingual": True}, + "definition_short": {"multilingual": True}, + "related_plannings": {"planning_auto_publish": True}, + } + self.app.data.insert( + "planning_types", + [ + { + "_id": "event", + "name": "event", + "editor": { + "language": {"enabled": True}, + "related_plannings": {"enabled": True}, + }, + "schema": schema, + } + ], + ) + now = utcnow() + 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") + event_item = await self.events_service.find_by_id_raw(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") - assert now <= planning_item.get("versionposted") < now + timedelta(seconds=5) + 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") + assert now <= planning_item.get("versionposted") < now + timedelta(seconds=5) async def test_new_planning_is_published_when_adding_to_published_event(self): - events_service = get_resource_service("events") planning_service = get_resource_service("planning") - async with self.app.app_context(): - self.app.data.insert( - "planning_types", - [ - { - "_id": "event", - "name": "event", - "editor": {"related_plannings": {"enabled": True}}, - "schema": {"related_plannings": {"planning_auto_publish": True}}, - } - ], - ) - event_id = events_service.post( - [ - { - "type": "event", - "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": "Australia/Sydney", - }, - "state": "draft", - "name": "Demo", - } - ] - )[0] - get_resource_service("events_post").post( - [{"event": event_id, "pubstatus": "usable", "update_method": "single", "failed_planning_ids": []}] - ) - planning_id = planning_service.post( - [ - { - "planning_date": datetime(2099, 11, 21, 12, 00, 00, tzinfo=pytz.UTC), - "name": "Demo 1", - "type": "planning", - "related_events": [PlanningRelatedEventLink(_id=event_id, link_type="primary")], - } - ] - )[0] - - event_item = events_service.find_one(req=None, _id=event_id) - self.assertIsNotNone(event_item) - self.assertEqual(event_item["pubstatus"], POST_STATE.USABLE) - - planning_item = planning_service.find_one(req=None, _id=planning_id) - self.assertIsNotNone(planning_item) - self.assertEqual(planning_item["pubstatus"], POST_STATE.USABLE) + self.app.data.insert( + "planning_types", + [ + { + "_id": "event", + "name": "event", + "editor": {"related_plannings": {"enabled": True}}, + "schema": {"related_plannings": {"planning_auto_publish": True}}, + } + ], + ) + event_id = await self.events_service.create( + [ + { + "type": "event", + "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": "Australia/Sydney", + }, + "state": "draft", + "name": "Demo", + } + ] + ) + get_resource_service("events_post").post( + [{"event": event_id[0], "pubstatus": "usable", "update_method": "single", "failed_planning_ids": []}] + ) + planning_id = planning_service.post( + [ + { + "planning_date": datetime(2099, 11, 21, 12, 00, 00, tzinfo=pytz.UTC), + "name": "Demo 1", + "type": "planning", + "related_events": [PlanningRelatedEventLink(_id=event_id, link_type="primary")], + } + ] + )[0] + + event_item = await self.events_service.find_by_id_raw(event_id) + self.assertIsNotNone(event_item) + self.assertEqual(event_item["pubstatus"], POST_STATE.USABLE) + + planning_item = planning_service.find_one(req=None, _id=planning_id) + self.assertIsNotNone(planning_item) + self.assertEqual(planning_item["pubstatus"], POST_STATE.USABLE) # TODO-ASYNC: figure out @mark.skip(reason="Fails with an async unrelated error") async def test_related_planning_item_fields_validation_on_post(self): - async 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}, - }, + 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 = await self.events_service.create([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", }, - { - "_id": "planning", - "name": "planning", - "editor": {"subject": {"enabled": False}}, - "schema": {"subject": {"required": True}}, + "news_coverage_status": { + "qcode": "ncostat:int", + "name": "coverage intended", + "label": "Planned", }, - ], - ) - get_resource_service("events_post").post( - [{"event": event_id[0], "pubstatus": "usable", "update_method": "single", "failed_planning_ids": []}] - ) + "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") + event_item = await self.events_service.find_by_id_raw(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") + 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/types/event.py b/server/planning/types/event.py index 0ba29f672..11ee04c95 100644 --- a/server/planning/types/event.py +++ b/server/planning/types/event.py @@ -9,6 +9,7 @@ from superdesk.utc import utcnow from superdesk.core.resources import fields, dataclass, Dataclass from superdesk.core.resources.validators import validate_data_relation_async +from superdesk.utils import merge_dicts_deep from .base import BasePlanningModel from .event_dates import EventDates, OccurStatus @@ -290,5 +291,5 @@ def clone_with(self, updates: dict[str, Any]) -> Self: """ cloned_data = deepcopy(self.to_dict()) - cloned_data.update(updates) + cloned_data = dict(merge_dicts_deep(cloned_data, updates)) return self.from_dict(cloned_data) diff --git a/server/planning/utils.py b/server/planning/utils.py index ce8d6e374..b920d2198 100644 --- a/server/planning/utils.py +++ b/server/planning/utils.py @@ -8,28 +8,28 @@ # AUTHORS and LICENSE files distributed with this source code, or # at https://www.sourcefabric.org/superdesk/license -from typing import Type, Union, List, Dict, Any, TypedDict, Optional +import arrow +import pytz import logging from datetime import datetime from bson.objectid import ObjectId from bson.errors import InvalidId from quart_babel import lazy_gettext -from eve.utils import str_to_date, ParsedRequest -import arrow -import pytz +from eve.utils import ParsedRequest +from werkzeug.exceptions import BadRequest +from typing import Type, Union, List, Dict, Any, TypedDict, Optional +from superdesk import get_resource_service +from superdesk.json_utils import cast_item +from superdesk.core.utils import str_to_date +from superdesk.resource_fields import ID_FIELD from superdesk.core import json, get_app_config -from superdesk.core.utils import str_to_date as superdesk_str_to_date from superdesk.core.resources.service import AsyncResourceService -from superdesk.resource_fields import ID_FIELD + from planning import types -from superdesk import get_resource_service -from superdesk.json_utils import cast_item from planning.types import EventResourceModel, PlanningResourceModel, AssignmentResourceModel, BasePlanningModel - from planning.types import Event, Planning, PLANNING_RELATED_EVENT_LINK_TYPE, PlanningRelatedEventLink -from werkzeug.exceptions import BadRequest logger = logging.getLogger(__name__) @@ -251,26 +251,3 @@ def update_event_item_with_translations_value(event_item: Dict[str, Any], langua updated_event_item[translation["field"]] = translation["value"] return updated_event_item - - -def str_to_date(date_string: str, fallback_format: str = "%Y-%m-%dT%H:%M:%S+00:00") -> datetime | None: - """ - Converts a date string to a datetime object using a default format from settings, - otherwise it uses a fallback format in case of an error. - - :param date_string: The date string to convert. - :param fallback_format: The fallback format. Default: "%Y-%m-%dT%H:%M:%S+00:00" - :return: A datetime object or None - """ - try: - return superdesk_str_to_date(date_string) - except ValueError: - try: - return datetime.strptime(date_string, fallback_format) - except ValueError: - pass - - default_format = get_app_config("DATE_FORMAT") - raise ValueError( - f"Date string '{date_string}' doesn't match any of the formats (`{default_format}` or `{fallback_format}`)." - )