diff --git a/pecha_api/plans/groups/groups_service.py b/pecha_api/plans/groups/groups_service.py index 2e40288b..84f13f11 100644 --- a/pecha_api/plans/groups/groups_service.py +++ b/pecha_api/plans/groups/groups_service.py @@ -170,7 +170,16 @@ def _metadata_to_dtos(metadata_entries, language: Optional[str] = None) -> List[ description_long=_optional_metadata_str(getattr(item, "description_long", None)), language=_language_value(item.language), ) - for item in sorted(metadata_entries, key=lambda value: value.language) + for item in sorted( + metadata_entries, + key=lambda value: ( + 0 + if language + and _language_value(value.language).upper() == language.upper() + else 1, + value.language, + ), + ) ] @@ -698,7 +707,6 @@ def list_public_groups( skip=skip, limit=limit, search=search, - language=language, tag_id=tag_id, exclude_group_ids=exclude_group_ids, is_public=True, diff --git a/pecha_api/plans/groups/groups_views.py b/pecha_api/plans/groups/groups_views.py index 1351d893..02642418 100644 --- a/pecha_api/plans/groups/groups_views.py +++ b/pecha_api/plans/groups/groups_views.py @@ -58,6 +58,10 @@ optional_oauth2_scheme = HTTPBearer(auto_error=False) cms_groups_router = APIRouter(prefix="/cms/author/groups", tags=["CMS Author Groups"]) +_LANGUAGE_QUERY_DESCRIPTION = ( + "Render group metadata in this language; falls back to English (en) per group when missing. " + "All groups are returned regardless of available metadata languages." +) public_groups_router = APIRouter(prefix="/author/groups", tags=["Author Groups"]) user_groups_router = APIRouter( prefix="/users/me/following/author/groups", @@ -316,7 +320,7 @@ def delete_group_member_by_id( @public_groups_router.get("/{group_id}", status_code=status.HTTP_200_OK, response_model=PublicAuthorGroupDetailDTO) def get_public_group( group_id: UUID, - language: Annotated[Optional[str], Query(description="Filter group metadata by language (e.g. 'en', 'bo', 'zh')")] = None, + language: Annotated[Optional[str], Query(description=_LANGUAGE_QUERY_DESCRIPTION)] = None, ): return get_author_group_detail(group_id=group_id, require_public=True, language=language) @@ -327,7 +331,7 @@ def get_public_groups( Optional[HTTPAuthorizationCredentials], Depends(optional_oauth2_scheme) ] = None, search: Annotated[Optional[str], Query()] = None, - language: Annotated[Optional[str], Query()] = None, + language: Annotated[Optional[str], Query(description=_LANGUAGE_QUERY_DESCRIPTION)] = None, tag_id: Annotated[Optional[UUID], Query()] = None, group_type: Annotated[ AuthorGroupType, @@ -391,7 +395,7 @@ def delete_join_group( def get_my_followed_groups( authentication_credential: Annotated[HTTPAuthorizationCredentials, Depends(oauth2_scheme)], group_id: Annotated[Optional[UUID], Query(description="Return this group if the user is following it")] = None, - language: Annotated[Optional[str], Query(description="Filter group metadata by language (e.g. 'en', 'bo', 'zh')")] = None, + language: Annotated[Optional[str], Query(description=_LANGUAGE_QUERY_DESCRIPTION)] = None, skip: Annotated[int, Query(ge=0)] = 0, limit: Annotated[int, Query(ge=1, le=100)] = 20, ): @@ -417,7 +421,7 @@ def get_my_followed_groups( def get_my_joined_groups( authentication_credential: Annotated[HTTPAuthorizationCredentials, Depends(oauth2_scheme)], group_id: Annotated[Optional[UUID], Query(description="Return this group if the user has joined it")] = None, - language: Annotated[Optional[str], Query(description="Filter group metadata by language (e.g. 'en', 'bo', 'zh')")] = None, + language: Annotated[Optional[str], Query(description=_LANGUAGE_QUERY_DESCRIPTION)] = None, skip: Annotated[int, Query(ge=0)] = 0, limit: Annotated[int, Query(ge=1, le=100)] = 20, ): diff --git a/pecha_api/plans/series/series_repository.py b/pecha_api/plans/series/series_repository.py index 576907ca..1f63fb8a 100644 --- a/pecha_api/plans/series/series_repository.py +++ b/pecha_api/plans/series/series_repository.py @@ -1,5 +1,5 @@ from datetime import datetime, timezone -from typing import Dict, List, Optional, Sequence, Tuple +from typing import Dict, List, NamedTuple, Optional, Sequence, Tuple from uuid import UUID from sqlalchemy import String, cast, desc, asc, or_, exists, select, func @@ -9,11 +9,22 @@ from pecha_api.plans.series.series_model import Series from pecha_api.plans.series.series_metadata_model import SeriesMetadata from pecha_api.plans.plans_models import Plan +from pecha_api.plans.items.plan_items_models import PlanItem from pecha_api.plans.users.plan_users_models import UserSeriesEnrollment _REFERENCE_START_DATE_UNSET = object() +class SeriesPlanScheduleRow(NamedTuple): + series_id: UUID + status: object + language: object + display_order: Optional[int] + start_date: Optional[datetime] + deleted_at: Optional[datetime] + total_days: int + + def _series_active_plans_count_subquery(published_only: bool = False): conditions = [Plan.series_id == Series.id, Plan.deleted_at.is_(None)] if published_only: @@ -108,6 +119,55 @@ def get_series_with_plans_by_ids(db: Session, series_ids: List[UUID]) -> List[Se ) +def get_series_plan_schedule_by_series_ids( + db: Session, + series_ids: Sequence[UUID], +) -> Dict[UUID, List[SeriesPlanScheduleRow]]: + """Lightweight plan fields + item counts for series list schedule calculation.""" + if not series_ids: + return {} + total_days_label = func.count(func.distinct(PlanItem.id)).label("total_days") + rows = ( + db.query( + Plan.series_id, + Plan.status, + Plan.language, + Plan.display_order, + Plan.start_date, + Plan.deleted_at, + total_days_label, + ) + .outerjoin(PlanItem, PlanItem.plan_id == Plan.id) + .filter( + Plan.series_id.in_(series_ids), + Plan.deleted_at.is_(None), + ) + .group_by(Plan.id) + .all() + ) + plans_by_series_id: Dict[UUID, List[SeriesPlanScheduleRow]] = {} + for ( + series_id, + plan_status, + language, + display_order, + start_date, + deleted_at, + total_days, + ) in rows: + schedule_row = SeriesPlanScheduleRow( + series_id=series_id, + status=plan_status, + language=language, + display_order=display_order, + start_date=start_date, + deleted_at=deleted_at, + total_days=int(total_days or 0), + ) + plans_by_series_id.setdefault(series_id, []).append(schedule_row) + return plans_by_series_id + + def get_plans_by_ids(db: Session, plan_ids: List[UUID]) -> List[Plan]: if not plan_ids: return [] diff --git a/pecha_api/plans/series/series_service.py b/pecha_api/plans/series/series_service.py index 8e33df5d..592ab34e 100644 --- a/pecha_api/plans/series/series_service.py +++ b/pecha_api/plans/series/series_service.py @@ -30,7 +30,7 @@ update_series_featured, soft_delete_series_with_plan_detach, get_random_featured_published_series, - get_series_with_plans_by_ids, + get_series_plan_schedule_by_series_ids, ) from pecha_api.plans.series.series_response_models import ( CreateSeriesRequest, @@ -164,7 +164,9 @@ def _plan_to_dto(plan, group_id: Optional[UUID] = None) -> SeriesPlanDTO: def _plan_total_days(plan) -> int: - return len(plan.items) if hasattr(plan, "items") and plan.items else 0 + if hasattr(plan, "items"): + return len(plan.items) if plan.items else 0 + return int(getattr(plan, "total_days", 0) or 0) def _series_schedule_from_plans( @@ -182,16 +184,30 @@ def _series_schedule_from_plans( if not sorted_plans: return None, None, 0 + schedule_plans = _get_sorted_active_plans( + plans, + published_only=True, + language=language, + fallback=fallback, + ) series_total_days = sum(_plan_total_days(plan) for plan in sorted_plans) - first_plan = sorted_plans[0] - if not first_plan.start_date: + if not schedule_plans: + return None, None, series_total_days + + first_published = schedule_plans[0] + last_published = schedule_plans[-1] + if not first_published.start_date: return None, None, series_total_days - start_date = first_plan.start_date - if series_total_days <= 0: - return start_date, start_date, series_total_days + start_date = first_published.start_date + if not last_published.start_date: + return start_date, None, series_total_days - end_date = start_date + timedelta(days=series_total_days - 1) + last_plan_days = _plan_total_days(last_published) + if last_plan_days <= 0: + end_date = last_published.start_date + else: + end_date = last_published.start_date + timedelta(days=last_plan_days - 1) return start_date, end_date, series_total_days @@ -390,11 +406,10 @@ def get_filtered_series( series_rows=[row for row, _, _ in rows], language=language, ) - series_with_plans = get_series_with_plans_by_ids( + plans_by_series_id = get_series_plan_schedule_by_series_ids( db=db_session, series_ids=[row.id for row, _, _ in rows], ) - plans_by_series_id = {series.id: series.plans for series in series_with_plans} series_dtos: List[SeriesListItemDTO] = [] for row, plan_count, enrolled_count in rows: @@ -447,11 +462,10 @@ def get_random_featured_series( series_rows=[row for row, _, _ in rows], language=language, ) - series_with_plans = get_series_with_plans_by_ids( + plans_by_series_id = get_series_plan_schedule_by_series_ids( db=db_session, series_ids=[row.id for row, _, _ in rows], ) - plans_by_series_id = {series.id: series.plans for series in series_with_plans} series_dtos: List[SeriesListItemDTO] = [] for row, plan_count, enrolled_count in rows: diff --git a/tests/plans/cms/test_plan_service.py b/tests/plans/cms/test_plan_service.py index 48792bca..01ac2f99 100644 --- a/tests/plans/cms/test_plan_service.py +++ b/tests/plans/cms/test_plan_service.py @@ -345,7 +345,9 @@ def test_create_new_plan_with_series_id(): created_plan_model = mock_save_plan.call_args.kwargs["plan"] assert created_plan_model.series_id == series_id assert created_plan_model.display_order == 2 - mock_get_series.assert_called_once_with(db=db_session, series_id=series_id) + assert mock_get_series.call_count == 2 + for call in mock_get_series.call_args_list: + assert call.kwargs == {"db": db_session, "series_id": series_id} def test_create_new_plan_with_series_id_auto_display_order(): diff --git a/tests/plans/groups/test_groups_service.py b/tests/plans/groups/test_groups_service.py index 4f62577c..c5bac19b 100644 --- a/tests/plans/groups/test_groups_service.py +++ b/tests/plans/groups/test_groups_service.py @@ -467,6 +467,129 @@ def test_list_public_groups_with_invalid_token_does_not_exclude_joined_groups(): assert mock_paginated.call_args.kwargs["exclude_group_ids"] is None +def test_list_public_groups_does_not_filter_by_language_and_falls_back_metadata(): + group = _make_group(group_type=AuthorGroupType.COMMUNITY) + meta_en = MagicMock() + meta_en.id = uuid4() + meta_en.title = "English Community" + meta_en.description = "EN desc" + meta_en.sub_title = None + meta_en.description_long = None + meta_en.language = "EN" + group.metadata_entries = [meta_en] + + with patch("pecha_api.plans.groups.groups_service.SessionLocal") as mock_session, patch( + "pecha_api.plans.groups.groups_service.get_groups_paginated", + return_value=([group], 1), + ) as mock_paginated, patch( + "pecha_api.plans.groups.groups_service.get_followers_count_map", + return_value={group.id: 0}, + ), patch( + "pecha_api.plans.groups.groups_service.get_joiners_count_map", + return_value={group.id: 0}, + ): + _session_local_context(mock_session) + result = list_public_groups( + skip=0, + limit=10, + language="bo", + group_type=AuthorGroupType.COMMUNITY, + ) + + assert "language" not in mock_paginated.call_args.kwargs + assert result.total == 1 + assert result.groups[0].metadata.title == "English Community" + assert result.groups[0].metadata.language == "EN" + + +def test_list_public_groups_returns_page_type_with_language_fallback(): + group = _make_group(group_type=AuthorGroupType.PAGE) + meta_en = MagicMock() + meta_en.id = uuid4() + meta_en.title = "English Author Group" + meta_en.description = "EN desc" + meta_en.sub_title = None + meta_en.description_long = None + meta_en.language = "EN" + group.metadata_entries = [meta_en] + + with patch("pecha_api.plans.groups.groups_service.SessionLocal") as mock_session, patch( + "pecha_api.plans.groups.groups_service.get_groups_paginated", + return_value=([group], 1), + ) as mock_paginated, patch( + "pecha_api.plans.groups.groups_service.get_followers_count_map", + return_value={group.id: 2}, + ), patch( + "pecha_api.plans.groups.groups_service.get_joiners_count_map", + return_value={group.id: 0}, + ): + _session_local_context(mock_session) + result = list_public_groups( + skip=0, + limit=10, + language="bo", + group_type=AuthorGroupType.PAGE, + ) + + assert "language" not in mock_paginated.call_args.kwargs + assert mock_paginated.call_args.kwargs["group_type"] == AuthorGroupType.PAGE + assert result.groups[0].metadata.title == "English Author Group" + assert result.groups[0].metadata.language == "EN" + + +def test_list_public_groups_mixed_metadata_uses_selected_language_then_en_fallback(): + group_with_bo = _make_group(group_type=AuthorGroupType.COMMUNITY) + meta_en = MagicMock() + meta_en.id = uuid4() + meta_en.title = "English Community" + meta_en.description = None + meta_en.sub_title = None + meta_en.description_long = None + meta_en.language = "EN" + meta_bo = MagicMock() + meta_bo.id = uuid4() + meta_bo.title = "Tibetan Community" + meta_bo.description = None + meta_bo.sub_title = None + meta_bo.description_long = None + meta_bo.language = "BO" + group_with_bo.metadata_entries = [meta_en, meta_bo] + + group_en_only = _make_group(group_type=AuthorGroupType.COMMUNITY) + meta_en_only = MagicMock() + meta_en_only.id = uuid4() + meta_en_only.title = "English Only Community" + meta_en_only.description = None + meta_en_only.sub_title = None + meta_en_only.description_long = None + meta_en_only.language = "EN" + group_en_only.metadata_entries = [meta_en_only] + + with patch("pecha_api.plans.groups.groups_service.SessionLocal") as mock_session, patch( + "pecha_api.plans.groups.groups_service.get_groups_paginated", + return_value=([group_with_bo, group_en_only], 2), + ), patch( + "pecha_api.plans.groups.groups_service.get_followers_count_map", + return_value={group_with_bo.id: 0, group_en_only.id: 0}, + ), patch( + "pecha_api.plans.groups.groups_service.get_joiners_count_map", + return_value={group_with_bo.id: 0, group_en_only.id: 0}, + ): + _session_local_context(mock_session) + result = list_public_groups( + skip=0, + limit=10, + language="bo", + group_type=AuthorGroupType.COMMUNITY, + ) + + assert result.total == 2 + assert result.groups[0].metadata.title == "Tibetan Community" + assert result.groups[0].metadata.language == "BO" + assert result.groups[1].metadata.title == "English Only Community" + assert result.groups[1].metadata.language == "EN" + + def _make_series_with_metadata(): meta_en = MagicMock() meta_en.id = uuid4() diff --git a/tests/plans/series/test_series_repository.py b/tests/plans/series/test_series_repository.py index 384442c0..621b08e3 100644 --- a/tests/plans/series/test_series_repository.py +++ b/tests/plans/series/test_series_repository.py @@ -12,6 +12,7 @@ get_series_by_id, get_series_paginated, get_random_featured_published_series, + get_series_plan_schedule_by_series_ids, save_series_with_plans, clone_series_with_plans, clone_series_plans_for_language, @@ -839,4 +840,43 @@ def test_clone_series_plans_for_language_returns_empty_when_target_exists(): assert result == [] assert len(_added_of_type(db, Plan)) == 0 - db.commit.assert_not_called() \ No newline at end of file + db.commit.assert_not_called() + + +def test_get_series_plan_schedule_by_series_ids_returns_empty_for_no_ids(): + db = _make_session_mock() + + result = get_series_plan_schedule_by_series_ids(db=db, series_ids=[]) + + assert result == {} + db.query.assert_not_called() + + +def test_get_series_plan_schedule_by_series_ids_groups_rows_by_series(): + db = _make_session_mock() + series_id = uuid.uuid4() + query_mock = MagicMock() + outerjoin_mock = MagicMock() + filter_mock = MagicMock() + group_by_mock = MagicMock() + query_mock.outerjoin.return_value = outerjoin_mock + outerjoin_mock.filter.return_value = filter_mock + filter_mock.group_by.return_value = group_by_mock + group_by_mock.all.return_value = [ + ( + series_id, + PlanStatus.PUBLISHED, + "EN", + 0, + None, + None, + 3, + ) + ] + db.query.return_value = query_mock + + result = get_series_plan_schedule_by_series_ids(db=db, series_ids=[series_id]) + + assert len(result[series_id]) == 1 + assert result[series_id][0].total_days == 3 + assert result[series_id][0].status == PlanStatus.PUBLISHED \ No newline at end of file diff --git a/tests/plans/series/test_series_service.py b/tests/plans/series/test_series_service.py index 0b03c7f4..a9944a12 100644 --- a/tests/plans/series/test_series_service.py +++ b/tests/plans/series/test_series_service.py @@ -15,6 +15,8 @@ _build_plan_order_pairs, _series_schedule_from_plans, _plan_total_days, + _active_plan_ids, + _group_summary_for_series, create_new_series, get_filtered_series, get_random_featured_series, @@ -83,8 +85,8 @@ def test_get_filtered_series_maps_rows_to_response(): "pecha_api.plans.series.series_service.get_series_paginated", return_value=([(row, 3, 0)], 1), ) as mock_repo, patch( - "pecha_api.plans.series.series_service.get_series_with_plans_by_ids", - return_value=[], + "pecha_api.plans.series.series_service.get_series_plan_schedule_by_series_ids", + return_value={}, ): _session_local_context(mock_session_local) @@ -1886,6 +1888,25 @@ def _featured_series_plan( return plan +def _schedule_rows_for_series(series_id, *plans): + from pecha_api.plans.series.series_repository import SeriesPlanScheduleRow + + return { + series_id: [ + SeriesPlanScheduleRow( + series_id=series_id, + status=plan.status, + language=plan.language, + display_order=plan.display_order, + start_date=plan.start_date, + deleted_at=plan.deleted_at, + total_days=len(plan.items or []), + ) + for plan in plans + ] + } + + def test_get_random_featured_series_includes_schedule_from_first_plan(): series_start = datetime(2026, 6, 1, tzinfo=timezone.utc) row = MagicMock() @@ -1907,15 +1928,13 @@ def test_get_random_featured_series_includes_schedule_from_first_plan(): start_date=datetime(2026, 6, 10, tzinfo=timezone.utc), item_count=2, ) - series_with_plans = MagicMock() - series_with_plans.id = row.id - series_with_plans.plans = [second_plan, first_plan] + series_with_plans = _schedule_rows_for_series(row.id, second_plan, first_plan) with patch("pecha_api.plans.series.series_service.SessionLocal") as mock_session_local, \ patch("pecha_api.plans.series.series_service.get_random_featured_published_series", return_value=([(row, 2, 0)], 1)), \ - patch("pecha_api.plans.series.series_service.get_series_with_plans_by_ids", - return_value=[series_with_plans]), \ + patch("pecha_api.plans.series.series_service.get_series_plan_schedule_by_series_ids", + return_value=series_with_plans), \ patch("pecha_api.plans.series.series_service._group_summaries_for_series_rows", return_value={}): _session_local_context(mock_session_local) @@ -1924,7 +1943,7 @@ def test_get_random_featured_series_includes_schedule_from_first_plan(): assert result.series[0].start_date == series_start assert result.series[0].total_days == 5 - assert result.series[0].end_date == series_start + timedelta(days=4) + assert result.series[0].end_date == datetime(2026, 6, 11, tzinfo=timezone.utc) def test_get_random_featured_series_omits_schedule_when_first_plan_has_no_start_date(): @@ -1943,15 +1962,13 @@ def test_get_random_featured_series_omits_schedule_when_first_plan_has_no_start_ start_date=datetime(2026, 6, 10, tzinfo=timezone.utc), item_count=3, ) - series_with_plans = MagicMock() - series_with_plans.id = row.id - series_with_plans.plans = [first_plan, second_plan] + series_with_plans = _schedule_rows_for_series(row.id, first_plan, second_plan) with patch("pecha_api.plans.series.series_service.SessionLocal") as mock_session_local, \ patch("pecha_api.plans.series.series_service.get_random_featured_published_series", return_value=([(row, 2, 0)], 1)), \ - patch("pecha_api.plans.series.series_service.get_series_with_plans_by_ids", - return_value=[series_with_plans]), \ + patch("pecha_api.plans.series.series_service.get_series_plan_schedule_by_series_ids", + return_value=series_with_plans), \ patch("pecha_api.plans.series.series_service._group_summaries_for_series_rows", return_value={}): _session_local_context(mock_session_local) @@ -1963,6 +1980,33 @@ def test_get_random_featured_series_omits_schedule_when_first_plan_has_no_start_ assert result.series[0].total_days == 5 +def test_series_schedule_from_plans_uses_total_days_on_schedule_rows(): + from pecha_api.plans.series.series_repository import SeriesPlanScheduleRow + + series_id = uuid.uuid4() + series_start = datetime(2026, 7, 1, tzinfo=timezone.utc) + schedule_rows = [ + SeriesPlanScheduleRow( + series_id=series_id, + status=PlanStatus.PUBLISHED, + language=LanguageCode.EN, + display_order=0, + start_date=series_start, + deleted_at=None, + total_days=4, + ) + ] + + start_date, end_date, total_days = _series_schedule_from_plans( + schedule_rows, + published_only=True, + ) + + assert start_date == series_start + assert end_date == series_start + timedelta(days=3) + assert total_days == 4 + + def test_series_schedule_from_plans_returns_empty_when_no_plans(): start_date, end_date, total_days = _series_schedule_from_plans([], published_only=True) @@ -2004,6 +2048,52 @@ def test_series_schedule_from_plans_excludes_unpublished_plans(): assert total_days == 0 +def test_series_schedule_from_plans_end_date_from_last_published_plan(): + series_start = datetime(2026, 7, 1, tzinfo=timezone.utc) + first_plan = _featured_series_plan( + display_order=0, + start_date=series_start, + item_count=3, + ) + last_plan = _featured_series_plan( + display_order=1, + start_date=datetime(2026, 7, 10, tzinfo=timezone.utc), + item_count=2, + ) + + start_date, end_date, total_days = _series_schedule_from_plans( + [first_plan, last_plan], + published_only=True, + ) + + assert start_date == series_start + assert total_days == 5 + assert end_date == datetime(2026, 7, 11, tzinfo=timezone.utc) + + +def test_series_schedule_from_plans_uses_first_published_plan_when_earlier_plans_are_drafts(): + draft_plan = _featured_series_plan( + display_order=0, + start_date=datetime(2026, 7, 1, tzinfo=timezone.utc), + item_count=5, + status=PlanStatus.DRAFT, + ) + published_plan = _featured_series_plan( + display_order=1, + start_date=datetime(2026, 7, 15, tzinfo=timezone.utc), + item_count=2, + ) + + start_date, end_date, total_days = _series_schedule_from_plans( + [draft_plan, published_plan], + published_only=True, + ) + + assert start_date == datetime(2026, 7, 15, tzinfo=timezone.utc) + assert end_date == datetime(2026, 7, 16, tzinfo=timezone.utc) + assert total_days == 2 + + def test_series_schedule_from_plans_filters_by_language(): series_start = datetime(2026, 7, 1, tzinfo=timezone.utc) en_plan = _featured_series_plan( @@ -2060,15 +2150,13 @@ def test_get_random_featured_series_schedule_respects_language_filter(): item_count=2, language=LanguageCode.BO, ) - series_with_plans = MagicMock() - series_with_plans.id = row.id - series_with_plans.plans = [en_plan, bo_plan] + series_with_plans = _schedule_rows_for_series(row.id, en_plan, bo_plan) with patch("pecha_api.plans.series.series_service.SessionLocal") as mock_session_local, \ patch("pecha_api.plans.series.series_service.get_random_featured_published_series", return_value=([(row, 2, 0)], 1)), \ - patch("pecha_api.plans.series.series_service.get_series_with_plans_by_ids", - return_value=[series_with_plans]), \ + patch("pecha_api.plans.series.series_service.get_series_plan_schedule_by_series_ids", + return_value=series_with_plans), \ patch("pecha_api.plans.series.series_service._group_summaries_for_series_rows", return_value={}): _session_local_context(mock_session_local) @@ -3135,3 +3223,262 @@ def test_clone_series_plans_for_language_rejects_when_target_has_plans(): assert exc.value.status_code == status.HTTP_400_BAD_REQUEST mock_clone.assert_not_called() + + +def test_series_schedule_from_plans_returns_total_days_without_published_schedule(): + draft_plan = _featured_series_plan( + display_order=0, + start_date=datetime(2026, 7, 1, tzinfo=timezone.utc), + item_count=4, + status=PlanStatus.DRAFT, + ) + + start_date, end_date, total_days = _series_schedule_from_plans( + [draft_plan], + published_only=False, + ) + + assert start_date is None + assert end_date is None + assert total_days == 4 + + +def test_series_schedule_from_plans_returns_start_without_end_when_last_plan_has_no_start_date(): + series_start = datetime(2026, 7, 1, tzinfo=timezone.utc) + first_plan = _featured_series_plan( + display_order=0, + start_date=series_start, + item_count=2, + ) + last_plan = _featured_series_plan( + display_order=1, + start_date=None, + item_count=3, + ) + + start_date, end_date, total_days = _series_schedule_from_plans( + [first_plan, last_plan], + published_only=True, + ) + + assert start_date == series_start + assert end_date is None + assert total_days == 5 + + +def test_active_plan_ids_excludes_soft_deleted_plans(): + active_plan = MagicMock() + active_plan.id = uuid.uuid4() + active_plan.deleted_at = None + deleted_plan = MagicMock() + deleted_plan.id = uuid.uuid4() + deleted_plan.deleted_at = datetime.now(timezone.utc) + + series = MagicMock() + series.plans = [active_plan, deleted_plan] + + assert _active_plan_ids(series) == [active_plan.id] + + +def test_group_summary_for_series_returns_none_without_group_id(): + series = MagicMock() + series.group_id = None + + assert _group_summary_for_series(MagicMock(), series) is None + + +def test_get_cms_filtered_series_returns_empty_when_author_has_no_groups(): + author_id = uuid.uuid4() + mock_author = _make_mock_author(author_id, is_admin=False) + + with patch("pecha_api.plans.series.series_service.SessionLocal") as mock_session_local, \ + patch("pecha_api.plans.series.series_service.validate_cms_author_details", return_value=mock_author), \ + patch("pecha_api.plans.series.series_service.get_author_group_ids", return_value=[]), \ + patch("pecha_api.plans.series.series_service.get_series_paginated") as mock_repo: + _session_local_context(mock_session_local) + + result = get_cms_filtered_series(token="dummy", search=None, skip=0, limit=10) + + mock_repo.assert_not_called() + assert result.series == [] + assert result.total == 0 + assert result.skip == 0 + assert result.limit == 10 + + +def test_clone_series_integrity_error_raises_400(): + parent_id = uuid.uuid4() + target_group_id = uuid.uuid4() + request = CreateSeriesRequest( + group_id=target_group_id, + parent_series_id=parent_id, + ) + parent = MagicMock() + parent.id = parent_id + parent.group_id = FIXTURE_GROUP_ID + mock_author = MagicMock() + mock_author.id = uuid.uuid4() + mock_author.email = "cloner@example.com" + orig = Exception("constraint violation") + + with patch("pecha_api.plans.series.series_service.SessionLocal") as mock_session_local, patch( + "pecha_api.plans.series.series_service.get_series_for_clone", + return_value=parent, + ), patch( + "pecha_api.plans.series.series_service.require_can_read_group_content", + ), patch( + "pecha_api.plans.series.series_service.require_can_create_content", + ), patch( + "pecha_api.plans.series.series_service.clone_series_with_plans", + side_effect=IntegrityError("statement", {}, orig), + ), patch( + "pecha_api.plans.series.series_service.validate_cms_author_details", + return_value=mock_author, + ): + _session_local_context(mock_session_local) + + with pytest.raises(HTTPException) as exc: + create_new_series(token="dummy", create_series_request=request) + + assert exc.value.status_code == status.HTTP_400_BAD_REQUEST + assert "Database integrity error" in exc.value.detail + + +def test_clone_series_plans_for_language_raises_404_when_series_missing(): + series_id = uuid.uuid4() + mock_author = _make_mock_author(uuid.uuid4(), is_admin=False) + + with patch("pecha_api.plans.series.series_service.SessionLocal") as mock_session_local, patch( + "pecha_api.plans.series.series_service.validate_cms_author_details", + return_value=mock_author, + ), patch( + "pecha_api.plans.series.series_service.get_series_by_id", + return_value=None, + ): + _session_local_context(mock_session_local) + + with pytest.raises(HTTPException) as exc: + clone_series_plans_for_language( + token="dummy", + series_id=series_id, + clone_request=CloneSeriesPlansRequest( + source_language=LanguageCode.EN, + target_language=LanguageCode.BO, + ), + ) + + assert exc.value.status_code == status.HTTP_404_NOT_FOUND + + +def test_clone_series_plans_for_language_raises_400_when_no_source_plans(): + series_id = uuid.uuid4() + row = MagicMock() + row.id = series_id + row.group_id = FIXTURE_GROUP_ID + row.status = PlanStatus.DRAFT + row.plans = [_make_series_plan(LanguageCode.BO)] + + mock_author = _make_mock_author(uuid.uuid4(), is_admin=False) + + with patch("pecha_api.plans.series.series_service.SessionLocal") as mock_session_local, patch( + "pecha_api.plans.series.series_service.validate_cms_author_details", + return_value=mock_author, + ), patch( + "pecha_api.plans.series.series_service.get_series_by_id", + return_value=row, + ), patch( + "pecha_api.plans.series.series_service.require_can_edit_content", + ), patch( + "pecha_api.plans.series.series_service.clone_series_language_plans", + ) as mock_clone: + _session_local_context(mock_session_local) + + with pytest.raises(HTTPException) as exc: + clone_series_plans_for_language( + token="dummy", + series_id=series_id, + clone_request=CloneSeriesPlansRequest( + source_language=LanguageCode.EN, + target_language=LanguageCode.BO, + ), + ) + + assert exc.value.status_code == status.HTTP_400_BAD_REQUEST + assert "No plans found in language" in exc.value.detail + mock_clone.assert_not_called() + + +def test_clone_series_plans_for_language_raises_400_when_clone_returns_empty(): + series_id = uuid.uuid4() + row = MagicMock() + row.id = series_id + row.group_id = FIXTURE_GROUP_ID + row.status = PlanStatus.DRAFT + row.plans = [_make_series_plan(LanguageCode.EN)] + + mock_author = _make_mock_author(uuid.uuid4(), is_admin=False) + + with patch("pecha_api.plans.series.series_service.SessionLocal") as mock_session_local, patch( + "pecha_api.plans.series.series_service.validate_cms_author_details", + return_value=mock_author, + ), patch( + "pecha_api.plans.series.series_service.get_series_by_id", + return_value=row, + ), patch( + "pecha_api.plans.series.series_service.require_can_edit_content", + ), patch( + "pecha_api.plans.series.series_service.clone_series_language_plans", + return_value=[], + ): + _session_local_context(mock_session_local) + + with pytest.raises(HTTPException) as exc: + clone_series_plans_for_language( + token="dummy", + series_id=series_id, + clone_request=CloneSeriesPlansRequest( + source_language=LanguageCode.EN, + target_language=LanguageCode.BO, + ), + ) + + assert exc.value.status_code == status.HTTP_400_BAD_REQUEST + assert "Could not clone plans" in exc.value.detail + + +def test_clone_series_plans_for_language_integrity_error_raises_400(): + series_id = uuid.uuid4() + row = MagicMock() + row.id = series_id + row.group_id = FIXTURE_GROUP_ID + row.status = PlanStatus.DRAFT + row.plans = [_make_series_plan(LanguageCode.EN)] + mock_author = _make_mock_author(uuid.uuid4(), is_admin=False) + orig = Exception("constraint violation") + + with patch("pecha_api.plans.series.series_service.SessionLocal") as mock_session_local, patch( + "pecha_api.plans.series.series_service.validate_cms_author_details", + return_value=mock_author, + ), patch( + "pecha_api.plans.series.series_service.get_series_by_id", + return_value=row, + ), patch( + "pecha_api.plans.series.series_service.require_can_edit_content", + ), patch( + "pecha_api.plans.series.series_service.clone_series_language_plans", + side_effect=IntegrityError("statement", {}, orig), + ): + _session_local_context(mock_session_local) + + with pytest.raises(HTTPException) as exc: + clone_series_plans_for_language( + token="dummy", + series_id=series_id, + clone_request=CloneSeriesPlansRequest( + source_language=LanguageCode.EN, + target_language=LanguageCode.BO, + ), + ) + + assert exc.value.status_code == status.HTTP_400_BAD_REQUEST + assert "Database integrity error" in exc.value.detail diff --git a/tests/plans/series/test_series_views.py b/tests/plans/series/test_series_views.py index 901b9066..ef2b1270 100644 --- a/tests/plans/series/test_series_views.py +++ b/tests/plans/series/test_series_views.py @@ -838,4 +838,30 @@ def test_update_series_featured_forbidden(): headers={"Authorization": "Bearer dummy"}, ) - assert response.status_code == status.HTTP_403_FORBIDDEN \ No newline at end of file + assert response.status_code == status.HTTP_403_FORBIDDEN + + +def test_clone_series_plans_success(sample_series_dto): + series_id = uuid.uuid4() + payload = { + "source_language": "EN", + "target_language": "BO", + } + + with patch( + "pecha_api.plans.series.series_view.clone_series_plans_for_language", + return_value=sample_series_dto, + ) as mock_clone: + response = client.post( + f"/cms/series/{series_id}/clone-plans", + json=payload, + headers={"Authorization": "Bearer dummy"}, + ) + + assert response.status_code == status.HTTP_200_OK + mock_clone.assert_called_once_with( + token="dummy", + series_id=series_id, + clone_request=mock_clone.call_args.kwargs["clone_request"], + ) + assert response.json()["id"] == str(sample_series_dto.id) \ No newline at end of file