diff --git a/pyproject.toml b/pyproject.toml index 62261ecb6..2c8a95eef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,4 +18,6 @@ exclude = ''' [tool.pytest.ini_options] testpaths = ["server/planning", "server/tests/prod_api"] -python_files = "*_test.py *_tests.py test_*.py tests_*.py tests.py test.py" \ No newline at end of file +python_files = "*_test.py *_tests.py test_*.py tests_*.py tests.py test.py" +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" \ No newline at end of file diff --git a/server/planning/events/events.py b/server/planning/events/events.py index ccb9ea908..bb2bd876f 100644 --- a/server/planning/events/events.py +++ b/server/planning/events/events.py @@ -862,13 +862,12 @@ def generate_recurring_dates( start, frequency, interval=1, - endRepeatMode="count", until=None, byday=None, count=5, tz=None, date_only=False, - _created_externally=False, + **_, ): """ diff --git a/server/planning/events/events_history.py b/server/planning/events/events_history.py index 93af4ae25..c1f44d0dc 100644 --- a/server/planning/events/events_history.py +++ b/server/planning/events/events_history.py @@ -11,6 +11,8 @@ from copy import deepcopy import logging +from planning.types.event import EventResourceModel + from superdesk.resource_fields import ID_FIELD from superdesk import Resource from planning.utils import get_related_planning_for_events @@ -37,6 +39,9 @@ def on_item_created(self, items, operation=None): created_from_planning = [] regular_events = [] for item in items: + if isinstance(item, EventResourceModel): + item = item.to_dict() + planning_items = get_related_planning_for_events([item[ID_FIELD]], "primary") if len(planning_items) > 0: item["created_from_planning"] = planning_items[0].get("_id") diff --git a/server/planning/events/events_service.py b/server/planning/events/events_service.py index 745168200..4de0e8758 100644 --- a/server/planning/events/events_service.py +++ b/server/planning/events/events_service.py @@ -115,6 +115,7 @@ async def create(self, docs: list[EventResourceModel]): """ docs = await self._convert_dicts_to_model(docs) + print(docs) ids = await super().create(docs) embedded_planning_lists: list[tuple[EventResourceModel, list[EmbeddedPlanning]]] = [] @@ -463,7 +464,7 @@ async def _update_single_event(self, updates: dict[str, Any], original: EventRes """ if post_required(updates, original.to_dict()): - merged: EventResourceModel = original.model_copy(updates, deep=True) + merged: EventResourceModel = original.clone_with(updates) # TODO-ASYNC: replace when `event_post` is async get_resource_service("events_post").validate_item(merged.to_dict()) @@ -616,7 +617,7 @@ async def _convert_to_recurring_events(self, updates: dict[str, Any], original: self._validate_convert_to_recurring(updates, original) updates["recurrence_id"] = original.id - merged: EventResourceModel = original.model_copy(updates, deep=True) + merged = original.clone_with(updates) # Generated new events will be "draft" merged.state = WorkflowState.DRAFT @@ -753,7 +754,7 @@ def _generate_recurring_events( recurring_event_updates[field] = None # let's finally clone the original event & update it with recurring event data - new_event = event.model_copy(update=recurring_event_updates, deep=True) + new_event = event.clone_with(recurring_event_updates) # reset embedded_planning to all Events but the first one, as this auto-generates # associated Planning item with Coverages to the event diff --git a/server/planning/events/events_sync/__init__.py b/server/planning/events/events_sync/__init__.py index d2b405d95..362099f92 100644 --- a/server/planning/events/events_sync/__init__.py +++ b/server/planning/events/events_sync/__init__.py @@ -8,18 +8,18 @@ # AUTHORS and LICENSE files distributed with this source code, or # at https://www.sourcefabric.org/superdesk/license -from typing import Dict, Optional, List, cast -from copy import deepcopy import pytz -from eve.utils import str_to_date +from copy import deepcopy +from typing import Dict, Optional, List, cast from superdesk import get_resource_service -from planning.types import Event, EmbeddedPlanningDict, StringFieldTranslation -from planning.common import get_config_event_fields_to_sync_with_planning -from planning.content_profiles.utils import AllContentProfileData +from planning.utils import str_to_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 +from planning.types import Event, EmbeddedPlanningDict, StringFieldTranslation from planning.types.event import EmbeddedPlanning as EmbeddedPlanningModel, EventResourceModel from .common import VocabsSyncData, SyncItemData, SyncData @@ -64,6 +64,7 @@ def sync_event_metadata_with_planning_items( if isinstance(event_updated["dates"]["start"], str): event_updated["dates"]["start"] = str_to_date(event_updated["dates"]["start"]) + if event_updated["dates"]["start"].tzinfo is None: event_updated["dates"]["start"] = event_updated["dates"]["start"].replace(tzinfo=pytz.utc) diff --git a/server/planning/events/events_tests.py b/server/planning/events/events_tests.py index 3c172bdf4..619a43333 100644 --- a/server/planning/events/events_tests.py +++ b/server/planning/events/events_tests.py @@ -8,22 +8,22 @@ # AUTHORS and LICENSE files distributed with this source code, or # at https://www.sourcefabric.org/superdesk/license -from datetime import datetime, timedelta +import pytz +from pytest import mark from copy import deepcopy - from bson import ObjectId -from pytest import mark -import pytz from mock import Mock, patch +from datetime import datetime, timedelta -from superdesk import get_resource_service from superdesk.utc import utcnow +from superdesk import get_resource_service from planning.tests import TestCase from planning.common import format_address, POST_STATE from planning.item_lock import LockService from planning.events.events import generate_recurring_dates from planning.types import PlanningRelatedEventLink +from planning.events import EventsAsyncService class EventTestCase(TestCase): @@ -37,7 +37,7 @@ def test_recurring_dates_generation(self): byday="TH FR", interval=2, until=datetime(2016, 2, 1), - endRepeatMode="until", + end_repeat_mode="until", ) ), [ @@ -56,7 +56,7 @@ def test_recurring_dates_generation(self): frequency="WEEKLY", byday="MO TU WE TH FR", count=2, - endRepeatMode="count", + end_repeat_mode="count", ) ), [ @@ -80,7 +80,7 @@ def test_recurring_dates_generation(self): frequency="YEARLY", interval=4, count=4, - endRepeatMode="count", + end_repeat_mode="count", ) ), [ @@ -94,7 +94,7 @@ def test_recurring_dates_generation(self): my_birthdays = generate_recurring_dates( start=datetime(1989, 12, 13), frequency="YEARLY", - endRepeatMode="count", + end_repeat_mode="count", count=200, ) self.assertTrue(datetime(1989, 12, 13) in my_birthdays) @@ -108,7 +108,7 @@ def test_recurring_dates_generation(self): frequency="WEEKLY", byday="FR", count=3, - endRepeatMode="count", + end_repeat_mode="count", tz=pytz.timezone("Europe/Berlin"), ) ), @@ -233,95 +233,93 @@ def assertPlanningSchedule(self, events, event_count): ) async def test_planning_schedule_for_recurring_event(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, - "endRepeatMode": "count", - }, + 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", }, - } + }, + } - service.post([event]) - events = list(service.get(req=None, lookup=None)) - self.assertPlanningSchedule(events, 3) + service.post([event]) + events = list(service.get(req=None, lookup=None)) + self.assertPlanningSchedule(events, 3) async def test_planning_schedule_reschedule_event(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, - "endRepeatMode": "count", - }, + 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", }, - } + }, + } - # create recurring events - service.post([event]) - events = list(service.get(req=None, lookup=None)) - self.assertPlanningSchedule(events, 3) + # create recurring events + service.post([event]) + events = list(service.get(req=None, lookup=None)) + self.assertPlanningSchedule(events, 3) - # reschedule recurring event before posting - schedule = deepcopy(events[0].get("dates")) - schedule["start"] = datetime(2099, 11, 21, 12, 00, 00, tzinfo=pytz.UTC) + timedelta(days=5) - schedule["end"] = datetime(2099, 11, 21, 12, 00, 00, tzinfo=pytz.UTC) + timedelta(days=5) + # reschedule recurring event before posting + schedule = deepcopy(events[0].get("dates")) + schedule["start"] = datetime(2099, 11, 21, 12, 00, 00, tzinfo=pytz.UTC) + timedelta(days=5) + schedule["end"] = datetime(2099, 11, 21, 12, 00, 00, tzinfo=pytz.UTC) + timedelta(days=5) - reschedule = get_resource_service("events_reschedule") - reschedule.REQUIRE_LOCK = False - # mocking function - is_original_event_func = reschedule.is_original_event - reschedule.is_original_event = Mock(return_value=False) + reschedule = get_resource_service("events_reschedule") + reschedule.REQUIRE_LOCK = False + # mocking function + is_original_event_func = reschedule.is_original_event + reschedule.is_original_event = Mock(return_value=False) - res = reschedule.patch(events[0].get("_id"), {"dates": schedule}) - self.assertEqual(res.get("dates").get("start"), schedule["start"]) + 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)) - self.assertPlanningSchedule(events, 3) + events = list(service.get(req=None, lookup=None)) + self.assertPlanningSchedule(events, 3) - # post recurring events - get_resource_service("events_post").post( - [ - { - "event": events[0].get("_id"), - "etag": events[0].get("etag"), - "pubstatus": "usable", - "update_method": "all", - "failed_planning_ids": [], - } - ] - ) + # post recurring events + get_resource_service("events_post").post( + [ + { + "event": events[0].get("_id"), + "etag": events[0].get("etag"), + "pubstatus": "usable", + "update_method": "all", + "failed_planning_ids": [], + } + ] + ) - # reschedule posted recurring event - schedule = deepcopy(events[0].get("dates")) - schedule["start"] = datetime(2099, 11, 21, 12, 00, 00, tzinfo=pytz.UTC) + timedelta(days=3) - schedule["end"] = datetime(2099, 11, 21, 12, 00, 00, tzinfo=pytz.UTC) + timedelta(days=3) + # reschedule posted recurring event + schedule = deepcopy(events[0].get("dates")) + schedule["start"] = datetime(2099, 11, 21, 12, 00, 00, tzinfo=pytz.UTC) + timedelta(days=3) + 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")) - self.assertNotEqual(rescheduled_event.get("dates").get("start"), schedule["start"]) + res = reschedule.patch(events[0].get("_id"), {"dates": schedule}) + rescheduled_event = service.find_one(req=None, _id=events[0].get("_id")) + self.assertNotEqual(rescheduled_event.get("dates").get("start"), schedule["start"]) - events = list(service.get(req=None, lookup=None)) - self.assertPlanningSchedule(events, 4) + events = list(service.get(req=None, lookup=None)) + self.assertPlanningSchedule(events, 4) - # reset mocked function - reschedule.is_original_event = is_original_event_func - reschedule.REQUIRE_LOCK = True + # reset mocked function + reschedule.is_original_event = is_original_event_func + reschedule.REQUIRE_LOCK = True async def test_planning_schedule_update_time(self): async with self.app.app_context(): @@ -336,7 +334,7 @@ async def test_planning_schedule_update_time(self): "frequency": "DAILY", "interval": 1, "count": 3, - "endRepeatMode": "count", + "end_repeat_mode": "count", }, }, } @@ -388,7 +386,7 @@ async def test_planning_schedule_update_repetitions(self): "frequency": "DAILY", "interval": 1, "count": 3, - "endRepeatMode": "count", + "end_repeat_mode": "count", }, }, } @@ -416,37 +414,41 @@ async def test_planning_schedule_update_repetitions(self): @patch("planning.events.events.get_user") async def test_planning_schedule_convert_to_recurring(self, get_user_mock): - async with self.app.app_context(): - service = get_resource_service("events") - get_user_mock.return_value = {"_id": "None"} - 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", - }, - } + service = EventsAsyncService() + get_user_mock.return_value = {"_id": "None"} + 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", + }, + } - service.post([event]) - events = list(service.get_from_mongo(req=None, lookup=None)) - self.assertPlanningSchedule(events, 1) - lock_service = LockService(self.app) - locked_event = lock_service.lock(events[0], None, ObjectId(), "convert_recurring", "events") - self.assertEqual(locked_event.get("lock_action"), "convert_recurring") - schedule = deepcopy(events[0].get("dates")) - schedule["start"] = datetime(2099, 11, 21, 12, 00, 00, tzinfo=pytz.UTC) - schedule["end"] = datetime(2099, 11, 21, 14, 00, 00, tzinfo=pytz.UTC) - schedule["recurring_rule"] = { - "frequency": "DAILY", - "interval": 1, - "count": 3, - "endRepeatMode": "count", - } + await service.create([event]) + events_cursor = await service.find({}) + events = await events_cursor.to_list_raw() + self.assertPlanningSchedule(events, 1) + + # TODO-ASYNC: adjust when `LockService` is async as it uses `get_resource_service` dynamically + lock_service = LockService(self.app) + locked_event = lock_service.lock(events[0], None, ObjectId(), "convert_recurring", "events") + self.assertEqual(locked_event.get("lock_action"), "convert_recurring") + + schedule = deepcopy(events[0].get("dates")) + schedule["start"] = datetime(2099, 11, 21, 12, 00, 00, tzinfo=pytz.UTC) + schedule["end"] = datetime(2099, 11, 21, 14, 00, 00, tzinfo=pytz.UTC) + schedule["recurring_rule"] = { + "frequency": "DAILY", + "interval": 1, + "count": 3, + "end_repeat_mode": "count", + } - service.patch(events[0].get("_id"), {"_id": events[0].get("_id"), "dates": schedule}) - events = list(service.get(req=None, lookup=None)) - self.assertPlanningSchedule(events, 3) + await service.update(events[0].get("_id"), {"dates": schedule}) + events_cursor = await service.find({}) + events = await events_cursor.to_list_raw() + self.assertPlanningSchedule(events, 3) def generate_recurring_events(num_events): diff --git a/server/planning/events/events_utils.py b/server/planning/events/events_utils.py index ad7d9a036..1a7b1c529 100644 --- a/server/planning/events/events_utils.py +++ b/server/planning/events/events_utils.py @@ -46,6 +46,7 @@ def generate_recurring_dates( count: int = 5, tz: pytz.BaseTzInfo | None = None, date_only: bool = False, + **_, ) -> Generator[datetime | date, None, None]: """ diff --git a/server/planning/tests/__init__.py b/server/planning/tests/__init__.py index f9d056f4d..e2261fb76 100644 --- a/server/planning/tests/__init__.py +++ b/server/planning/tests/__init__.py @@ -17,3 +17,15 @@ def setup_test_user(self): user = {"_id": ObjectId()} self.app.data.insert("users", [user]) g.user = user + + async def setUp(self): + """ + Set up the test case by entering the application's asynchronous context. + This ensures all tests run within the same app context, avoiding repetitive + boilerplate and allowing automatic resource cleanup, even if a test fails. + + Using `enterAsyncContext` ensures the app context (`self.app.app_context()`) + is properly exited after each test. + """ + + self.ctx = await self.enterAsyncContext(self.app.app_context()) diff --git a/server/planning/types/event.py b/server/planning/types/event.py index ded74ed7c..0ba29f672 100644 --- a/server/planning/types/event.py +++ b/server/planning/types/event.py @@ -1,3 +1,5 @@ +from copy import deepcopy +from typing_extensions import Self from pydantic import Field from datetime import datetime from typing import Annotated, Any @@ -272,3 +274,21 @@ class EventResourceModel(BasePlanningModel, LockFieldsMixin): related_items: list[RelatedItem] = Field(default_factory=list) failed_planned_ids: list[str] = Field(default_factory=list) + + def clone_with(self, updates: dict[str, Any]) -> Self: + """ + Deeply clones the instance and applies updates with proper validation. + + Addresses limitations of Pydantic's `model_copy`, which doesn't handle + nested data classes or validate updates the given updates. + + Args: + updates (dict[str, Any]): Attributes to update in the cloned instance. + + Returns: + Self: A new instance with the applied updates. + """ + + cloned_data = deepcopy(self.to_dict()) + cloned_data.update(updates) + return self.from_dict(cloned_data) diff --git a/server/planning/types/event_dates.py b/server/planning/types/event_dates.py index a68267363..e300ef833 100644 --- a/server/planning/types/event_dates.py +++ b/server/planning/types/event_dates.py @@ -14,7 +14,7 @@ class RecurringRule(Dataclass): frequency: str | None = None interval: int | None = None - end_repeat_mode: RepeatModeType | None = Field(default=None, alias="endRepeatMode") + end_repeat_mode: RepeatModeType | None = Field(default=None, validation_alias="endRepeatMode") until: datetime | None = None count: int | None = None bymonth: str | None = None diff --git a/server/planning/utils.py b/server/planning/utils.py index 3b6d6dd89..ce8d6e374 100644 --- a/server/planning/utils.py +++ b/server/planning/utils.py @@ -20,6 +20,7 @@ import pytz 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 @@ -250,3 +251,26 @@ 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}`)." + )