Skip to content
1 change: 1 addition & 0 deletions migrations/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
from pecha_api.events.event_metadata_model import EventMetadata
from pecha_api.mantra.mantra_metadata_model import MantraMetadata
from pecha_api.traditions.tradition_models import Tradition, TraditionMetadata, UserTradition
from pecha_api.region_restrictions.region_restriction_models import ChinaRestrictedItem

# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
Expand Down
111 changes: 111 additions & 0 deletions migrations/versions/g2h3i4j5k6l7_add_china_restricted_items_table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
"""add china_restricted_items table for China timezone content exclusions

Revision ID: g2h3i4j5k6l7
Revises: f1a2b3c4d5e7
Create Date: 2026-07-05 12:00:00.000000

Items listed in this table are hidden from clients whose X-Timezone header
matches a Chinese IANA timezone (see pecha_api/assets/chinese_timezone.json).
"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql

from migrations.idempotency import index_exists, table_exists

revision: str = "g2h3i4j5k6l7"
down_revision: Union[str, None] = "f1a2b3c4d5e7"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None

china_restricted_item_type = postgresql.ENUM(
"MANTRA",
"ACCUMULATOR",
"GROUP_ACCUMULATOR",
"PLAN",
"SERIES",
"GROUP",
"RECITATION",
"RECITATION_COLLECTION",
name="china_restricted_item_type",
create_type=False,
)


def upgrade() -> None:
op.execute(
"""
DO $$ BEGIN
CREATE TYPE china_restricted_item_type AS ENUM (
'MANTRA',
'ACCUMULATOR',
'GROUP_ACCUMULATOR',
'PLAN',
'SERIES',
'GROUP',
'RECITATION',
'RECITATION_COLLECTION'
);
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
"""
)

if not table_exists("china_restricted_items"):
op.create_table(
"china_restricted_items",
sa.Column(
"id",
sa.UUID(),
nullable=False,
server_default=sa.text("gen_random_uuid()"),
),
sa.Column("item_type", china_restricted_item_type, nullable=False),
sa.Column("item_id", sa.UUID(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=True,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"item_type",
"item_id",
name="uq_china_restricted_items_type_id",
),
)

if not index_exists("china_restricted_items", "idx_china_restricted_items_item_type"):
op.create_index(
"idx_china_restricted_items_item_type",
"china_restricted_items",
["item_type"],
unique=False,
)
if not index_exists("china_restricted_items", "idx_china_restricted_items_item_id"):
op.create_index(
"idx_china_restricted_items_item_id",
"china_restricted_items",
["item_id"],
unique=False,
)


def downgrade() -> None:
if index_exists("china_restricted_items", "idx_china_restricted_items_item_id"):
op.drop_index("idx_china_restricted_items_item_id", table_name="china_restricted_items")
if index_exists("china_restricted_items", "idx_china_restricted_items_item_type"):
op.drop_index("idx_china_restricted_items_item_type", table_name="china_restricted_items")
if table_exists("china_restricted_items"):
op.drop_table("china_restricted_items")
op.execute("DROP TYPE IF EXISTS china_restricted_item_type")
33 changes: 23 additions & 10 deletions pecha_api/accumulator/accumulator_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,28 +208,41 @@ def add_history_row(db: Session, accumulator_id: UUID, user_id: UUID, count: int
)


def get_user_total_counted_by_parent(
db: Session,
user_id: UUID,
parent_id: UUID,
) -> int:
"""Sum of all history counts across every accumulator the user has had
for this preset, including soft-deleted instances."""
total = (
db.query(func.sum(AccumulatorHistory.count))
.join(Accumulator, AccumulatorHistory.accumulator_id == Accumulator.id)
.filter(
Accumulator.user_id == user_id,
Accumulator.parent_id == parent_id,
AccumulatorHistory.user_id == user_id,
)
.scalar()
)
return total or 0


def get_accumulator_with_history(
db: Session,
user_id: UUID,
parent_id: UUID,
) -> Optional[Tuple[Accumulator, int, List[AccumulatorHistory]]]:
"""Fetch the user's active accumulator created from a given preset
(parent_id), along with their total counted and ordered session rows.
(parent_id), along with their lifetime total counted for that preset and
ordered session rows for the active accumulator.
Returns None if the user has no accumulator for that preset."""
accumulator = get_user_accumulator_by_parent(db, user_id, parent_id)
if not accumulator:
return None

accumulator_id = accumulator.id

total_counted = (
db.query(func.sum(AccumulatorHistory.count))
.filter(
AccumulatorHistory.accumulator_id == accumulator_id,
AccumulatorHistory.user_id == user_id
)
.scalar()
) or 0
total_counted = get_user_total_counted_by_parent(db, user_id, parent_id)

sessions = (
db.query(AccumulatorHistory)
Expand Down
15 changes: 13 additions & 2 deletions pecha_api/accumulator/accumulator_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@
from .accumulator_metadata_model import AccumulatorMetadata
from .accumulator_history_model import AccumulatorHistory
from .accumulator_enums import AccumulatorType
from pecha_api.region_restrictions.region_restriction_enums import RestrictedItemType
from pecha_api.region_restrictions.region_restriction_service import (
filter_items_for_timezone,
)
from .response_message import (
NOT_FOUND,
FORBIDDEN,
Expand Down Expand Up @@ -329,15 +333,22 @@ def get_all_accumulators_service(
limit: int = 20,
language: Optional[str] = None,
search: Optional[str] = None,
timezone_name: Optional[str] = None,
) -> PublicAccumulatorsResponse:
with SessionLocal() as db:
accumulators, total = get_all_accumulators(db, skip, limit, search=search)
mantra_ids = [a.mantra_id for a in accumulators if a.mantra_id is not None]
visible_accumulators = filter_items_for_timezone(
accumulators,
timezone_name=timezone_name,
item_type=RestrictedItemType.ACCUMULATOR,
id_of=lambda accumulator: accumulator.id,
)
mantra_ids = [a.mantra_id for a in visible_accumulators if a.mantra_id is not None]
mantras_by_id = get_mantras_by_ids(db, mantra_ids)
return PublicAccumulatorsResponse(
accumulators=[
convert_accumulator_to_public_dto(a, mantras_by_id, language)
for a in accumulators
for a in visible_accumulators
],
total=total,
skip=skip,
Expand Down
14 changes: 12 additions & 2 deletions pecha_api/accumulator/accumulator_views.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, Header, Query
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from typing import Annotated, Optional
from uuid import UUID
Expand Down Expand Up @@ -49,8 +49,18 @@ async def get_all_preset_accumulators(
Optional[str],
Query(description="Filter presets by mantra text, title, or pronunciation (case-insensitive, any language)"),
] = None,
x_timezone: Annotated[
Optional[str],
Header(alias="X-Timezone", description="IANA timezone (e.g. Asia/Shanghai). Restricted accumulators are hidden for Chinese timezones."),
] = None,
):
return get_all_accumulators_service(skip=skip, limit=limit, language=language, search=search)
return get_all_accumulators_service(
skip=skip,
limit=limit,
language=language,
search=search,
timezone_name=x_timezone,
)


@accumulator_router.get("/user", response_model=AccumulatorsResponse)
Expand Down
2 changes: 2 additions & 0 deletions pecha_api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
from pecha_api.events import events_router, cms_events_router
from pecha_api.traditions import tradition_views
from pecha_api.plans.admin.admin_views import cms_admin_router
from pecha_api.region_restrictions.region_restriction_views import cms_china_restrictions_router
from pecha_api.plans.transfers.transfer_views import (
cms_transfers_router,
group_transfers_router,
Expand Down Expand Up @@ -103,6 +104,7 @@
api.include_router(author_groups_views.cms_groups_router)
api.include_router(cms_notification_views.cms_notifications_router)
api.include_router(cms_admin_router)
api.include_router(cms_china_restrictions_router)
api.include_router(cms_transfers_router)
api.include_router(group_transfers_router)
api.include_router(plan_transfers_router)
Expand Down
79 changes: 62 additions & 17 deletions pecha_api/daily_log/daily_log_cache_service.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import asyncio
from datetime import date, datetime, timedelta, timezone
from datetime import date, datetime, timezone
from typing import Optional
from uuid import UUID

from pecha_api import config
from pecha_api.cache.cache_enums import CacheType
from pecha_api.cache.cache_repository import delete_cache, exists_in_cache, get_cache_data, set_cache
from pecha_api.daily_log.daily_log_response_models import UserStatsResponse
from pecha_api.timezone_utils import get_day_bounds_in_timezone
from pecha_api.utils import Utils

_DAILY_LOG_CACHE_VALUE = "logged"
Expand All @@ -17,54 +18,98 @@ def _build_daily_log_cache_key(user_id: UUID, log_date: date) -> str:
return Utils.generate_hash_key(payload)


def _seconds_until_end_of_utc_day() -> int:
def _timezone_cache_suffix(timezone_name: Optional[str]) -> str:
if not timezone_name or not timezone_name.strip():
return "UTC"
return timezone_name.strip()


def _seconds_until_end_of_day_in_timezone(timezone_name: Optional[str]) -> int:
now = datetime.now(timezone.utc)
tomorrow = (now + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
return max(int((tomorrow - now).total_seconds()), 1)
_, day_end = get_day_bounds_in_timezone(timezone_name, at=now)
now_in_timezone = now.astimezone(day_end.tzinfo)
return max(int((day_end - now_in_timezone).total_seconds()), 1)


async def is_user_logged_today_in_cache(user_id: UUID, log_date: date) -> bool:
cache_key = _build_daily_log_cache_key(user_id=user_id, log_date=log_date)
return await exists_in_cache(cache_key)
Comment on lines 34 to 36

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P0 timezone_name passed to a function that doesn't accept it

is_user_logged_today_in_cache accepts only user_id and log_date, but record_daily_log_if_needed in daily_log_service.py (lines 59-63) calls it with timezone_name=timezone_name. Python raises TypeError: is_user_logged_today_in_cache() got an unexpected keyword argument 'timezone_name' at call time regardless of the value — so every request to /daily-log/streak and /daily-log/stats introduced in this PR will 500. The function signature needs a timezone_name: Optional[str] = None parameter (even if not used for the cache key) to match how all callers now invoke it.



async def set_user_daily_log_cache(user_id: UUID, log_date: date) -> None:
async def set_user_daily_log_cache(
user_id: UUID,
log_date: date,
timezone_name: Optional[str] = None,
) -> None:
cache_key = _build_daily_log_cache_key(user_id=user_id, log_date=log_date)
await set_cache(
hash_key=cache_key,
value=_DAILY_LOG_CACHE_VALUE,
cache_time_out=_seconds_until_end_of_utc_day(),
cache_time_out=_seconds_until_end_of_day_in_timezone(timezone_name),
)


def _build_user_stats_cache_key(user_id: UUID) -> str:
payload = [str(user_id), CacheType.USER_STATS.value]
def _build_user_stats_cache_key(
user_id: UUID,
timezone_name: Optional[str] = None,
) -> str:
payload = [
str(user_id),
CacheType.USER_STATS.value,
_timezone_cache_suffix(timezone_name),
]
return Utils.generate_hash_key(payload)


async def get_user_stats_cache(user_id: UUID) -> Optional[UserStatsResponse]:
cache_key = _build_user_stats_cache_key(user_id=user_id)
async def get_user_stats_cache(
user_id: UUID,
timezone_name: Optional[str] = None,
) -> Optional[UserStatsResponse]:
cache_key = _build_user_stats_cache_key(
user_id=user_id,
timezone_name=timezone_name,
)
cache_data = await get_cache_data(hash_key=cache_key)
if cache_data and isinstance(cache_data, dict):
return UserStatsResponse(**cache_data)
return None


async def set_user_stats_cache(user_id: UUID, data: UserStatsResponse) -> None:
cache_key = _build_user_stats_cache_key(user_id=user_id)
async def set_user_stats_cache(
user_id: UUID,
data: UserStatsResponse,
timezone_name: Optional[str] = None,
) -> None:
cache_key = _build_user_stats_cache_key(
user_id=user_id,
timezone_name=timezone_name,
)
cache_time_out = config.get_int("CACHE_USER_STATS_TIMEOUT")
await set_cache(hash_key=cache_key, value=data, cache_time_out=cache_time_out)


async def invalidate_user_stats_cache(user_id: UUID) -> None:
cache_key = _build_user_stats_cache_key(user_id=user_id)
async def invalidate_user_stats_cache(
user_id: UUID,
timezone_name: Optional[str] = None,
) -> None:
cache_key = _build_user_stats_cache_key(
user_id=user_id,
timezone_name=timezone_name,
)
await delete_cache(hash_key=cache_key)


def schedule_invalidate_user_stats_cache(user_id: UUID) -> None:
def schedule_invalidate_user_stats_cache(
user_id: UUID,
timezone_name: Optional[str] = None,
) -> None:
"""Invalidate stats cache from sync callers without blocking."""
try:
loop = asyncio.get_running_loop()
loop.create_task(invalidate_user_stats_cache(user_id))
loop.create_task(
invalidate_user_stats_cache(user_id=user_id, timezone_name=timezone_name)
)
except RuntimeError:
asyncio.run(invalidate_user_stats_cache(user_id))
asyncio.run(
invalidate_user_stats_cache(user_id=user_id, timezone_name=timezone_name)
)
Loading
Loading