Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions pecha_api/plans/groups/groups_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
)
]


Expand Down Expand Up @@ -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,
Expand Down
12 changes: 8 additions & 4 deletions pecha_api/plans/groups/groups_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)

Expand All @@ -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,
Expand Down Expand Up @@ -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,
):
Expand All @@ -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,
):
Expand Down
62 changes: 61 additions & 1 deletion pecha_api/plans/series/series_repository.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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 []
Expand Down
38 changes: 26 additions & 12 deletions pecha_api/plans/series/series_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -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
Comment on lines +187 to +195

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Unreachable branch when published_only=True

Both production call sites pass published_only=True, so sorted_plans and schedule_plans are computed by identical _get_sorted_active_plans invocations. If sorted_plans is non-empty (the guard at line 184 already passed), schedule_plans will also be non-empty, making the if not schedule_plans return at line 194 dead code on that path. For the published_only=False path the two calls intentionally diverge (total_days from all plans, dates from published only), but for the common published_only=True path you pay the cost of a second filter+sort for no effect. You could short-circuit with schedule_plans = sorted_plans if published_only else _get_sorted_active_plans(plans, published_only=True, language=language, fallback=fallback) to make the intent explicit and avoid the duplicate work.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


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


Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion tests/plans/cms/test_plan_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
123 changes: 123 additions & 0 deletions tests/plans/groups/test_groups_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading