diff --git a/config/settings.py b/config/settings.py index 559a5fad..15f3f312 100644 --- a/config/settings.py +++ b/config/settings.py @@ -22,6 +22,8 @@ SITE_URL = os.environ.get("SITE_URL", "http://localhost:8000") USER_MANAGEMENT = os.environ.get("USER_MANAGEMENT") == "1" DEMO = os.environ.get("DEMO") == "1" +EXTERNAL_API_ENABLED = os.environ.get("EXTERNAL_API_ENABLED") == "1" +EXTERNAL_API_TOKEN = os.environ.get("EXTERNAL_API_TOKEN") # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent # Read version from pyproject.toml diff --git a/config/urls.py b/config/urls.py index 2ce5b507..49693030 100644 --- a/config/urls.py +++ b/config/urls.py @@ -21,6 +21,7 @@ from django.conf.urls.static import static from django.views.generic.base import RedirectView from core.admin import core_admin_site +from core.api import external_api favicon_view = RedirectView.as_view(url="/static/favicon.ico", permanent=True) @@ -33,6 +34,7 @@ class AccessUser: urlpatterns = [ path("favicon.ico", favicon_view), + path("api/v1/", external_api.urls), # path("log/", log, name="log"), path("rss/", include("core.urls")), path("", core_admin_site.urls), diff --git a/core/api.py b/core/api.py new file mode 100644 index 00000000..f6606c73 --- /dev/null +++ b/core/api.py @@ -0,0 +1,789 @@ +import hmac +import logging +from functools import partial +from typing import Any + +from django.conf import settings +from django.contrib.contenttypes.models import ContentType +from django.core.cache import cache +from django.db import IntegrityError, transaction +from django.db.models import Prefetch +from django.utils import timezone +from ninja import NinjaAPI, Schema +from ninja.errors import HttpError +from pydantic import AnyHttpUrl, Field, TypeAdapter, field_validator + +from core.cache import cache_rss, cache_tag +from core.management.commands.feed_updater import update_single_feed +from core.models import ( + DeepLAgent, + Feed, + LibreTranslateAgent, + OpenAIAgent, + Tag, + TestAgent, +) +from core.tasks.task_manager import task_manager + +logger = logging.getLogger(__name__) + + +class ExternalBearerAuth: + def __call__(self, request): + if not settings.EXTERNAL_API_ENABLED: + raise HttpError(404, "Not found.") + + configured_token = settings.EXTERNAL_API_TOKEN + if not configured_token: + raise HttpError(503, "External API token is not configured.") + + authorization = request.headers.get("Authorization", "") + scheme, _, token = authorization.partition(" ") + if scheme.lower() != "bearer" or not token: + raise HttpError(401, "Unauthorized.") + + if not hmac.compare_digest(token, configured_token): + raise HttpError(401, "Unauthorized.") + + return token + + +external_api = NinjaAPI( + auth=ExternalBearerAuth(), + docs_url=None, + openapi_url=None, + urls_namespace="external_api", +) + + +MAX_SUPPORTED_UPDATE_FREQUENCY = 10080 +FEED_NAME_MAX_LENGTH = Feed._meta.get_field("name").max_length +TAG_NAME_MAX_LENGTH = Tag._meta.get_field("name").max_length +FEED_CACHE_VARIANTS = ( + ("o", "xml"), + ("o", "json"), + ("t", "xml"), + ("t", "json"), +) +ORIGINAL_FEED_CACHE_VARIANTS = tuple( + variant for variant in FEED_CACHE_VARIANTS if variant[0] == "o" +) +TAG_CACHE_VARIANTS = ( + ("o", "xml"), + ("t", "xml"), + ("t", "json"), +) +ORIGINAL_TAG_CACHE_VARIANTS = tuple( + variant for variant in TAG_CACHE_VARIANTS if variant[0] == "o" +) +VALID_TRANSLATOR_MODELS = ( + OpenAIAgent, + DeepLAgent, + LibreTranslateAgent, + TestAgent, +) + + +def _allowed_target_languages() -> set[str]: + return {language for language, _label in settings.TRANSLATION_LANGUAGES} + + +def _allowed_translation_display_values() -> set[int]: + return { + choice_value + for choice_value, _label in Feed.TRANSLATION_DISPLAY_CHOICES + } + + +def _normalize_non_blank_name(value: str | None) -> str | None: + if value is None: + return None + + normalized_value = value.strip() + if not normalized_value: + raise ValueError("name must not be blank.") + + return normalized_value + + +def _parse_translator_option( + value: str | None, +) -> tuple[int | None, int | None]: + if value is None: + return None, None + + normalized_value = value.strip() + if not normalized_value: + raise ValueError("translator_option must not be blank.") + + try: + content_type_part, object_id_part = normalized_value.split(":", 1) + content_type_id = int(content_type_part) + object_id = int(object_id_part) + except ValueError as exc: + raise ValueError( + "translator_option must be ':'." + ) from exc + + content_type = ContentType.objects.filter(id=content_type_id).first() + model_class = content_type.model_class() if content_type else None + if model_class not in VALID_TRANSLATOR_MODELS: + raise ValueError("translator_option must reference a valid translator agent.") + + if not model_class.objects.filter(id=object_id, valid=True).exists(): + raise ValueError("translator_option must reference a valid translator agent.") + + return content_type_id, object_id + + +def _normalize_feed_payload(payload: dict[str, Any]) -> dict[str, Any]: + normalized_payload = dict(payload) + if "translator_option" in normalized_payload: + content_type_id, object_id = _parse_translator_option( + normalized_payload.pop("translator_option") + ) + normalized_payload["translator_content_type_id"] = content_type_id + normalized_payload["translator_object_id"] = object_id + return normalized_payload + + +def _feed_queryset(): + return ( + Feed.objects.exclude(author="RSSBox Digest") + .prefetch_related(Prefetch("tags", queryset=Tag.objects.order_by("id"))) + ) + + +def _feed_cache_keys(feed_slug: str) -> list[str]: + return [ + f"cache_rss_{feed_slug}_{feed_type}_{format_type}" + for feed_type, format_type in FEED_CACHE_VARIANTS + ] + + +def _original_feed_cache_keys(feed_slug: str) -> list[str]: + return [ + f"cache_rss_{feed_slug}_{feed_type}_{format_type}" + for feed_type, format_type in ORIGINAL_FEED_CACHE_VARIANTS + ] + + +def _tag_cache_keys(tag_slug: str) -> list[str]: + return [ + f"cache_tag_{tag_slug}_{feed_type}_{format_type}" + for feed_type, format_type in TAG_CACHE_VARIANTS + ] + + +def _original_tag_cache_keys(tag_slug: str) -> list[str]: + return [ + f"cache_tag_{tag_slug}_{feed_type}_{format_type}" + for feed_type, format_type in ORIGINAL_TAG_CACHE_VARIANTS + ] + + +def _invalidate_feed_caches(feed_slug: str | None) -> None: + if not feed_slug: + return + _delete_cache_keys(_feed_cache_keys(feed_slug)) + + +def _invalidate_original_feed_caches(feed_slug: str | None) -> None: + if not feed_slug: + return + _delete_cache_keys(_original_feed_cache_keys(feed_slug)) + + +def _invalidate_tag_caches(tag_slugs: set[str] | list[str]) -> None: + cache_keys: list[str] = [] + for tag_slug in sorted(set(tag_slugs)): + if not tag_slug: + continue + cache_keys.extend(_tag_cache_keys(tag_slug)) + _delete_cache_keys(cache_keys) + + +def _invalidate_original_tag_caches(tag_slugs: set[str] | list[str]) -> None: + cache_keys: list[str] = [] + for tag_slug in sorted(set(tag_slugs)): + if not tag_slug: + continue + cache_keys.extend(_original_tag_cache_keys(tag_slug)) + _delete_cache_keys(cache_keys) + + +def _delete_cache_keys(cache_keys: list[str]) -> None: + if not cache_keys: + return + try: + cache.delete_many(cache_keys) + except Exception as exc: + logger.warning("Failed to invalidate external API cache keys: %s", exc) + + +def _reset_feed_revalidation_state( + feed: Feed, + *, + original_output_changed: bool, + translated_output_changed: bool, + translated_output_requires_reprocessing: bool, +) -> None: + if original_output_changed: + feed.last_fetch = None + feed.etag = None + feed.fetch_status = None + if translated_output_requires_reprocessing: + feed.last_translate = None + feed.translation_status = None + elif translated_output_changed and feed.translation_status is not None: + feed.last_translate = timezone.now() + + +def _clear_feed_source_metadata(feed: Feed) -> None: + feed.subtitle = None + feed.link = None + feed.author = None + feed.language = None + feed.pubdate = None + feed.updated = None + + +def _validate_processing_requirements(feed: Feed, payload: dict[str, Any]) -> None: + translate_title_enabled = payload.get("translate_title", feed.translate_title) + translate_content_enabled = payload.get( + "translate_content", + feed.translate_content, + ) + summary_enabled = payload.get("summary", feed.summary) + + if ( + "translator_content_type_id" in payload + or "translator_object_id" in payload + ): + has_translator = bool( + payload.get("translator_content_type_id") + and payload.get("translator_object_id") + ) + else: + has_translator = bool(feed.translator) + + has_summarizer = ( + bool(payload.get("summarizer_id")) + if "summarizer_id" in payload + else bool(feed.summarizer_id) + ) + + if (translate_title_enabled or translate_content_enabled) and not has_translator: + raise HttpError( + 422, + "translator must already be configured before enabling translation.", + ) + + if summary_enabled and not has_summarizer: + raise HttpError( + 422, + "summarizer must already be configured before enabling summary.", + ) + + +def _translated_output_in_progress(feed: Feed) -> bool: + return feed.translation_status is None and ( + feed.translate_title or feed.translate_content or feed.summary + ) + + +def _warm_feed_caches(feed: Feed) -> None: + for feed_type, format_type in FEED_CACHE_VARIANTS: + cache_rss(feed.slug, feed_type=feed_type, format=format_type) + + +def _warm_tag_caches(feed: Feed) -> None: + for tag in feed.tags.all(): + for feed_type, format_type in TAG_CACHE_VARIANTS: + cache_tag(tag.slug, feed_type=feed_type, format=format_type) + + +class TagSchema(Schema): + id: int + name: str + slug: str + + +class FeedListItemSchema(Schema): + id: int + name: str | None + feed_url: str + slug: str | None + target_language: str + update_frequency: int + max_posts: int + fetch_article: bool + translate_title: bool + translate_content: bool + summary: bool + translation_display: int + fetch_status: bool | None + translation_status: bool | None + last_fetch: Any = None + last_translate: Any = None + tags: list[TagSchema] + + +class FeedDetailSchema(FeedListItemSchema): + subtitle: str | None + link: str | None + author: str | None + language: str | None + pubdate: Any = None + updated: Any = None + + +class FeedCreateSchema(Schema): + feed_url: str + name: str | None = Field(default=None, max_length=FEED_NAME_MAX_LENGTH) + target_language: str = None + update_frequency: int = Field(default=None, ge=1) + max_posts: int = Field(default=None, ge=1) + fetch_article: bool = None + translate_title: bool = None + translate_content: bool = None + summary: bool = None + translation_display: int = None + translator_option: str | None = None + summarizer_id: int | None = Field(default=None, ge=1) + + @field_validator("feed_url") + @classmethod + def validate_feed_url(cls, value: str) -> str: + TypeAdapter(AnyHttpUrl).validate_python(value) + return value + + @field_validator("target_language") + @classmethod + def validate_target_language(cls, value: str | None) -> str | None: + if value is not None and value not in _allowed_target_languages(): + raise ValueError("Unsupported target_language.") + return value + + @field_validator("translation_display") + @classmethod + def validate_translation_display(cls, value: int | None) -> int | None: + if value is not None and value not in _allowed_translation_display_values(): + raise ValueError("Unsupported translation_display.") + return value + + @field_validator("update_frequency") + @classmethod + def validate_update_frequency(cls, value: int | None) -> int | None: + if value is not None and value > MAX_SUPPORTED_UPDATE_FREQUENCY: + raise ValueError( + f"update_frequency must be <= {MAX_SUPPORTED_UPDATE_FREQUENCY}." + ) + return value + + @field_validator("translator_option") + @classmethod + def validate_translator_option(cls, value: str | None) -> str | None: + _parse_translator_option(value) + return value + + @field_validator("summarizer_id") + @classmethod + def validate_summarizer_id(cls, value: int | None) -> int | None: + if value is not None and not OpenAIAgent.objects.filter( + id=value, + valid=True, + ).exists(): + raise ValueError("summarizer_id must reference a valid OpenAI agent.") + return value + + +class FeedUpdateSchema(FeedCreateSchema): + feed_url: str = None + + +class TagCreateSchema(Schema): + name: str = Field(max_length=TAG_NAME_MAX_LENGTH) + + @field_validator("name") + @classmethod + def validate_name(cls, value: str) -> str: + return _normalize_non_blank_name(value) + + +class TagUpdateSchema(Schema): + name: str = Field(default=None, max_length=TAG_NAME_MAX_LENGTH) + + @field_validator("name") + @classmethod + def validate_name(cls, value: str | None) -> str | None: + return _normalize_non_blank_name(value) + + +class FeedTagSetSchema(Schema): + tag_ids: list[int] + + +class ActionStatusSchema(Schema): + status: str + detail: str + + +def _serialize_tag(tag: Tag) -> dict[str, Any]: + return { + "id": tag.id, + "name": tag.name, + "slug": tag.slug, + } + + +def _serialize_feed(feed: Feed, *, detail: bool = False) -> dict[str, Any]: + payload = { + "id": feed.id, + "name": feed.name, + "feed_url": feed.feed_url, + "slug": feed.slug, + "target_language": feed.target_language, + "update_frequency": feed.update_frequency, + "max_posts": feed.max_posts, + "fetch_article": feed.fetch_article, + "translate_title": feed.translate_title, + "translate_content": feed.translate_content, + "summary": feed.summary, + "translation_display": feed.translation_display, + "fetch_status": feed.fetch_status, + "translation_status": feed.translation_status, + "last_fetch": feed.last_fetch, + "last_translate": feed.last_translate, + "tags": [_serialize_tag(tag) for tag in feed.tags.all()], + } + if detail: + payload.update( + { + "subtitle": feed.subtitle, + "link": feed.link, + "author": feed.author, + "language": feed.language, + "pubdate": feed.pubdate, + "updated": feed.updated, + } + ) + return payload + + +def _translated_cache_must_be_invalidated( + feed: Feed, + payload: dict[str, Any], +) -> bool: + normalized_payload = _normalize_feed_payload(payload) + + feed_url_changed = ( + "feed_url" in normalized_payload + and normalized_payload["feed_url"] != feed.feed_url + ) + target_language_changed = ( + "target_language" in normalized_payload + and normalized_payload["target_language"] != feed.target_language + ) + fetch_article_changed = ( + "fetch_article" in normalized_payload + and normalized_payload["fetch_article"] != feed.fetch_article + ) + translator_changed = ( + ( + "translator_content_type_id" in normalized_payload + or "translator_object_id" in normalized_payload + ) + and ( + normalized_payload.get("translator_content_type_id") + != feed.translator_content_type_id + or normalized_payload.get("translator_object_id") + != feed.translator_object_id + ) + ) + summarizer_changed = ( + "summarizer_id" in normalized_payload + and normalized_payload["summarizer_id"] != feed.summarizer_id + ) + translate_title_disabled = ( + "translate_title" in normalized_payload + and normalized_payload["translate_title"] is False + and feed.translate_title + ) + translate_content_disabled = ( + "translate_content" in normalized_payload + and normalized_payload["translate_content"] is False + and feed.translate_content + ) + summary_disabled = ( + "summary" in normalized_payload + and normalized_payload["summary"] is False + and feed.summary + ) + + return any( + ( + feed_url_changed, + target_language_changed and ( + feed.translate_title or feed.translate_content or feed.summary + ), + translator_changed and (feed.translate_title or feed.translate_content), + summarizer_changed and feed.summary, + translate_title_disabled, + translate_content_disabled, + summary_disabled, + fetch_article_changed and feed.translate_content, + ) + ) + + +def _get_feed_or_404(feed_id: int) -> Feed: + try: + return _feed_queryset().get(id=feed_id) + except Feed.DoesNotExist as exc: + raise HttpError(404, "Feed not found.") from exc + + +def _get_tag_or_404(tag_id: int) -> Tag: + try: + return Tag.objects.get(id=tag_id) + except Tag.DoesNotExist as exc: + raise HttpError(404, "Tag not found.") from exc + + +def _apply_feed_changes(feed: Feed, payload: dict[str, Any]) -> Feed: + payload = _normalize_feed_payload(payload) + _validate_processing_requirements(feed, payload) + translate_content_enabled = payload.get( + "translate_content", + feed.translate_content, + ) + feed_url_changed = "feed_url" in payload and payload["feed_url"] != feed.feed_url + target_language_changed = ( + "target_language" in payload + and payload["target_language"] != feed.target_language + ) + fetch_article_changed = ( + "fetch_article" in payload and payload["fetch_article"] != feed.fetch_article + ) + translator_changed = ( + ( + "translator_content_type_id" in payload + or "translator_object_id" in payload + ) + and ( + payload.get("translator_content_type_id") + != feed.translator_content_type_id + or payload.get("translator_object_id") != feed.translator_object_id + ) + ) + summarizer_changed = ( + "summarizer_id" in payload and payload["summarizer_id"] != feed.summarizer_id + ) + translate_title_disabled = ( + "translate_title" in payload + and payload["translate_title"] is False + and feed.translate_title + ) + translate_content_disabled = ( + "translate_content" in payload + and payload["translate_content"] is False + and feed.translate_content + ) + summary_disabled = ( + "summary" in payload and payload["summary"] is False and feed.summary + ) + cleared_entry_fields: dict[str, Any] = {} + if target_language_changed or translate_title_disabled or translator_changed: + cleared_entry_fields["translated_title"] = None + if ( + target_language_changed + or translate_content_disabled + or translator_changed + or (fetch_article_changed and translate_content_enabled) + ): + cleared_entry_fields["translated_content"] = None + if target_language_changed or summary_disabled or summarizer_changed: + cleared_entry_fields["ai_summary"] = None + original_output_changed = any( + field_name in payload for field_name in ("feed_url", "name", "max_posts") + ) + translated_output_requires_reprocessing = feed_url_changed or bool( + cleared_entry_fields + ) + translated_output_changed = ( + original_output_changed + or "translation_display" in payload + or translated_output_requires_reprocessing + ) + + for field_name, field_value in payload.items(): + setattr(feed, field_name, field_value) + + try: + with transaction.atomic(): + _reset_feed_revalidation_state( + feed, + original_output_changed=original_output_changed, + translated_output_changed=translated_output_changed, + translated_output_requires_reprocessing=translated_output_requires_reprocessing, + ) + if feed_url_changed: + _clear_feed_source_metadata(feed) + feed.save() + + if feed_url_changed: + feed.entries.all().delete() + elif cleared_entry_fields: + feed.entries.update(**cleared_entry_fields) + except IntegrityError as exc: + if _is_duplicate_feed_integrity_error(exc): + raise HttpError( + 409, "Feed with this feed_url and target_language already exists." + ) from exc + raise + + return _feed_queryset().get(id=feed.id) + + +def _is_duplicate_feed_integrity_error(exc: IntegrityError) -> bool: + error_message = str(exc) + error_message_lower = error_message.lower() + return "unique_feed_lang" in error_message_lower or ( + "feed_url" in error_message_lower + and "target_language" in error_message_lower + and ( + "unique" in error_message_lower or "duplicate" in error_message_lower + ) + ) + + +def _submit_refresh(feed_id: int, feed_slug: str) -> None: + task_manager.submit_task( + f"update_feed_{feed_slug}", + _refresh_feed_and_cache, + feed_id, + ) + + +def _refresh_feed_and_cache(feed_id: int) -> None: + try: + feed = _feed_queryset().get(id=feed_id) + except Feed.DoesNotExist: + return + + update_single_feed(feed) + try: + refreshed_feed = _feed_queryset().get(id=feed_id) + except Feed.DoesNotExist: + return + _warm_feed_caches(refreshed_feed) + _warm_tag_caches(refreshed_feed) + + +@external_api.get("/feeds", response=list[FeedListItemSchema]) +def list_feeds(request): + feeds = _feed_queryset().order_by("id") + return [_serialize_feed(feed) for feed in feeds] + + +@external_api.get("/feeds/{feed_id}", response=FeedDetailSchema) +def get_feed(request, feed_id: int): + feed = _get_feed_or_404(feed_id) + return _serialize_feed(feed, detail=True) + + +@external_api.post("/feeds", response={201: FeedDetailSchema}) +def create_feed(request, payload: FeedCreateSchema): + feed = Feed() + feed = _apply_feed_changes(feed, payload.model_dump(exclude_unset=True)) + return 201, _serialize_feed(feed, detail=True) + + +@external_api.patch("/feeds/{feed_id}", response=FeedDetailSchema) +def update_feed(request, feed_id: int, payload: FeedUpdateSchema): + feed = _get_feed_or_404(feed_id) + tag_slugs = list(feed.tags.values_list("slug", flat=True)) + payload_data = payload.model_dump(exclude_unset=True) + must_invalidate_translated_cache = _translated_cache_must_be_invalidated( + feed, + payload_data, + ) + feed = _apply_feed_changes(feed, payload_data) + if _translated_output_in_progress(feed) and not must_invalidate_translated_cache: + _invalidate_original_feed_caches(feed.slug) + _invalidate_original_tag_caches(tag_slugs) + else: + _invalidate_feed_caches(feed.slug) + _invalidate_tag_caches(tag_slugs) + return _serialize_feed(feed, detail=True) + + +@external_api.delete("/feeds/{feed_id}", response={204: None}) +def delete_feed(request, feed_id: int): + feed = _get_feed_or_404(feed_id) + feed_slug = feed.slug + tag_slugs = list(feed.tags.values_list("slug", flat=True)) + feed.delete() + _invalidate_feed_caches(feed_slug) + _invalidate_tag_caches(tag_slugs) + return 204, None + + +@external_api.post("/feeds/{feed_id}/refresh", response={202: ActionStatusSchema}) +def refresh_feed(request, feed_id: int): + with transaction.atomic(): + feed = _get_feed_or_404(feed_id) + feed.fetch_status = None + feed.translation_status = None + feed.save() + transaction.on_commit(partial(_submit_refresh, feed.id, feed.slug)) + + return 202, { + "status": "queued", + "detail": f"Refresh queued for feed {feed.id}.", + } + + +@external_api.get("/tags", response=list[TagSchema]) +def list_tags(request): + return [_serialize_tag(tag) for tag in Tag.objects.order_by("id")] + + +@external_api.post("/tags", response={201: TagSchema}) +def create_tag(request, payload: TagCreateSchema): + tag = Tag.objects.create(name=payload.name) + return 201, _serialize_tag(tag) + + +@external_api.patch("/tags/{tag_id}", response=TagSchema) +def update_tag(request, tag_id: int, payload: TagUpdateSchema): + tag = _get_tag_or_404(tag_id) + changes = payload.model_dump(exclude_unset=True) + for field_name, field_value in changes.items(): + setattr(tag, field_name, field_value) + tag.save() + return _serialize_tag(tag) + + +@external_api.delete("/tags/{tag_id}", response={204: None}) +def delete_tag(request, tag_id: int): + tag = _get_tag_or_404(tag_id) + tag.delete() + return 204, None + + +@external_api.post("/feeds/{feed_id}/tags", response=FeedDetailSchema) +def set_feed_tags(request, feed_id: int, payload: FeedTagSetSchema): + feed = _get_feed_or_404(feed_id) + previous_tag_slugs = set(feed.tags.values_list("slug", flat=True)) + tags = list(Tag.objects.filter(id__in=payload.tag_ids).order_by("id")) + found_tag_ids = {tag.id for tag in tags} + missing_tag_ids = sorted(set(payload.tag_ids) - found_tag_ids) + if missing_tag_ids: + raise HttpError(404, f"Tag ids not found: {missing_tag_ids}") + + feed.tags.set(tags) + current_tag_slugs = {tag.slug for tag in tags} + _invalidate_tag_caches(previous_tag_slugs | current_tag_slugs) + return _serialize_feed(_get_feed_or_404(feed_id), detail=True) diff --git a/core/tests/test_dev_server.py b/core/tests/test_dev_server.py new file mode 100644 index 00000000..75113f48 --- /dev/null +++ b/core/tests/test_dev_server.py @@ -0,0 +1,18 @@ +from unittest.mock import patch + +from django.test import SimpleTestCase + +from scripts.dev_server import start_development_server + + +class DevServerScriptTests(SimpleTestCase): + @patch("scripts.dev_server.print") + @patch("scripts.dev_server.subprocess.run") + @patch("scripts.dev_server.sys.executable", "C:\\venv\\Scripts\\python.exe") + def test_start_development_server_uses_current_python(self, mock_run, mock_print): + start_development_server() + + mock_run.assert_called_once_with( + ["C:\\venv\\Scripts\\python.exe", "manage.py", "runserver"], + check=True, + ) diff --git a/core/tests/test_external_api.py b/core/tests/test_external_api.py new file mode 100644 index 00000000..f12e54fe --- /dev/null +++ b/core/tests/test_external_api.py @@ -0,0 +1,1312 @@ +import json +from datetime import timedelta +from unittest.mock import patch + +from django.contrib.contenttypes.models import ContentType +from django.core.cache import cache +from django.test import TestCase, override_settings +from django.utils import timezone +from django.utils.http import http_date + +from core.management.commands.feed_updater import update_single_feed +from core.models import Digest, Feed, OpenAIAgent, Tag + + +class ExternalAPIBaseTestCase(TestCase): + FEED_CACHE_VARIANTS = ( + ("o", "xml"), + ("o", "json"), + ("t", "xml"), + ("t", "json"), + ) + TAG_CACHE_VARIANTS = ( + ("o", "xml"), + ("t", "xml"), + ("t", "json"), + ) + + def setUp(self): + self.feed = Feed.objects.create( + name="Primary Feed", + feed_url="https://example.com/feed.xml", + ) + self.tag = Tag.objects.create(name="Tech") + self.feed.tags.add(self.tag) + + def auth_headers(self, token: str = "secret-token") -> dict[str, str]: + return {"HTTP_AUTHORIZATION": f"Bearer {token}"} + + def feed_cache_keys(self, feed: Feed | None = None) -> list[str]: + feed = feed or self.feed + return [ + f"cache_rss_{feed.slug}_{feed_type}_{format_type}" + for feed_type, format_type in self.FEED_CACHE_VARIANTS + ] + + def tag_cache_keys(self, tag: Tag | None = None) -> list[str]: + tag = tag or self.tag + return [ + f"cache_tag_{tag.slug}_{feed_type}_{format_type}" + for feed_type, format_type in self.TAG_CACHE_VARIANTS + ] + + def create_digest_backing_feed(self) -> Feed: + agent = OpenAIAgent.objects.create(name="Digest Agent", api_key="test-key") + digest = Digest.objects.create(name="Daily Digest", summarizer=agent) + digest.tags.add(self.tag) + return digest.get_digest_feed() + + def create_valid_openai_agent(self, name: str = "External API Agent") -> OpenAIAgent: + return OpenAIAgent.objects.create( + name=name, + api_key="test-key", + valid=True, + ) + + def translator_option(self, agent: OpenAIAgent) -> str: + content_type = ContentType.objects.get_for_model(agent.__class__) + return f"{content_type.id}:{agent.id}" + + +class ExternalAPIDisabledTests(ExternalAPIBaseTestCase): + @override_settings(EXTERNAL_API_ENABLED=False, EXTERNAL_API_TOKEN="secret-token") + def test_api_is_disabled_by_default(self): + response = self.client.get("/api/v1/feeds") + + self.assertEqual(response.status_code, 404) + + +class ExternalAPIMisconfiguredTokenTests(ExternalAPIBaseTestCase): + @override_settings(EXTERNAL_API_ENABLED=True, EXTERNAL_API_TOKEN=None) + def test_enabled_api_without_token_returns_503(self): + response = self.client.get("/api/v1/feeds", **self.auth_headers()) + + self.assertEqual(response.status_code, 503) + self.assertEqual(response.json()["detail"], "External API token is not configured.") + + +@override_settings(EXTERNAL_API_ENABLED=True, EXTERNAL_API_TOKEN="secret-token") +class ExternalAPIAuthTests(ExternalAPIBaseTestCase): + def test_missing_token_returns_401(self): + response = self.client.get("/api/v1/feeds") + + self.assertEqual(response.status_code, 401) + self.assertEqual(response.json()["detail"], "Unauthorized.") + + def test_wrong_token_returns_401(self): + response = self.client.get("/api/v1/feeds", **self.auth_headers("wrong-token")) + + self.assertEqual(response.status_code, 401) + self.assertEqual(response.json()["detail"], "Unauthorized.") + + def test_valid_token_returns_success(self): + response = self.client.get("/api/v1/feeds", **self.auth_headers()) + + self.assertEqual(response.status_code, 200) + + +@override_settings(EXTERNAL_API_ENABLED=True, EXTERNAL_API_TOKEN="secret-token") +class ExternalAPIFeedTests(ExternalAPIBaseTestCase): + def test_feed_create_rejects_invalid_feed_url(self): + response = self.client.post( + "/api/v1/feeds", + data=json.dumps({"feed_url": "not-a-url"}), + content_type="application/json", + **self.auth_headers(), + ) + + self.assertEqual(response.status_code, 422) + self.assertEqual( + response.json()["detail"][0]["loc"], + ["body", "payload", "feed_url"], + ) + self.assertFalse(Feed.objects.filter(feed_url="not-a-url").exists()) + + def test_feed_create_rejects_name_above_model_max_length(self): + response = self.client.post( + "/api/v1/feeds", + data=json.dumps( + { + "feed_url": "https://example.com/too-long-name.xml", + "name": "x" * 300, + } + ), + content_type="application/json", + **self.auth_headers(), + ) + + self.assertEqual(response.status_code, 422) + self.assertEqual( + response.json()["detail"][0]["loc"], + ["body", "payload", "name"], + ) + self.assertFalse( + Feed.objects.filter(feed_url="https://example.com/too-long-name.xml").exists() + ) + + def test_feed_update_rejects_name_above_model_max_length(self): + response = self.client.patch( + f"/api/v1/feeds/{self.feed.id}", + data=json.dumps({"name": "x" * 300}), + content_type="application/json", + **self.auth_headers(), + ) + + self.assertEqual(response.status_code, 422) + self.assertEqual( + response.json()["detail"][0]["loc"], + ["body", "payload", "name"], + ) + self.feed.refresh_from_db() + self.assertEqual(self.feed.name, "Primary Feed") + + def test_list_and_detail_only_expose_safe_fields(self): + list_response = self.client.get("/api/v1/feeds", **self.auth_headers()) + detail_response = self.client.get( + f"/api/v1/feeds/{self.feed.id}", + **self.auth_headers(), + ) + + self.assertEqual(list_response.status_code, 200) + self.assertEqual(detail_response.status_code, 200) + + list_payload = list_response.json() + detail_payload = detail_response.json() + + self.assertEqual([item["id"] for item in list_payload], [self.feed.id]) + self.assertEqual(detail_payload["id"], self.feed.id) + self.assertEqual(detail_payload["tags"][0]["id"], self.tag.id) + + for payload in [list_payload[0], detail_payload]: + self.assertNotIn("log", payload) + self.assertNotIn("etag", payload) + self.assertNotIn("total_tokens", payload) + self.assertNotIn("total_characters", payload) + self.assertNotIn("translator_object_id", payload) + self.assertNotIn("entries", payload) + + def test_list_feeds_excludes_digest_backing_feeds(self): + digest_feed = self.create_digest_backing_feed() + + response = self.client.get("/api/v1/feeds", **self.auth_headers()) + + self.assertEqual(response.status_code, 200) + self.assertEqual([item["id"] for item in response.json()], [self.feed.id]) + self.assertNotIn(digest_feed.id, [item["id"] for item in response.json()]) + + def test_feed_detail_returns_404_for_digest_backing_feed(self): + digest_feed = self.create_digest_backing_feed() + + response = self.client.get( + f"/api/v1/feeds/{digest_feed.id}", + **self.auth_headers(), + ) + + self.assertEqual(response.status_code, 404) + + def test_list_feeds_avoids_n_plus_one_tag_queries(self): + for index in range(3): + feed = Feed.objects.create(feed_url=f"https://example.com/{index}.xml") + for tag_index in range(2): + feed.tags.add(Tag.objects.create(name=f"Tag {index}-{tag_index}")) + + with self.assertNumQueries(2): + response = self.client.get("/api/v1/feeds", **self.auth_headers()) + + self.assertEqual(response.status_code, 200) + + def test_feed_crud(self): + create_response = self.client.post( + "/api/v1/feeds", + data=json.dumps( + { + "feed_url": "https://example.com/new-feed.xml", + "name": "Created Feed", + "update_frequency": 7, + "max_posts": 15, + "fetch_article": True, + "translation_display": 1, + } + ), + content_type="application/json", + **self.auth_headers(), + ) + + self.assertEqual(create_response.status_code, 201) + created_payload = create_response.json() + created_id = created_payload["id"] + self.assertEqual(created_payload["name"], "Created Feed") + self.assertEqual(created_payload["update_frequency"], 15) + self.assertEqual(created_payload["max_posts"], 15) + self.assertTrue(created_payload["fetch_article"]) + self.assertFalse(created_payload["translate_title"]) + self.assertEqual(created_payload["translation_display"], 1) + + patch_response = self.client.patch( + f"/api/v1/feeds/{created_id}", + data=json.dumps( + { + "name": "Updated Feed", + "fetch_article": False, + } + ), + content_type="application/json", + **self.auth_headers(), + ) + + self.assertEqual(patch_response.status_code, 200) + updated_payload = patch_response.json() + self.assertEqual(updated_payload["name"], "Updated Feed") + self.assertFalse(updated_payload["summary"]) + self.assertFalse(updated_payload["fetch_article"]) + + delete_response = self.client.delete( + f"/api/v1/feeds/{created_id}", + **self.auth_headers(), + ) + + self.assertEqual(delete_response.status_code, 204) + self.assertFalse(Feed.objects.filter(id=created_id).exists()) + + def test_duplicate_feed_create_returns_409(self): + response = self.client.post( + "/api/v1/feeds", + data=json.dumps({"feed_url": self.feed.feed_url}), + content_type="application/json", + **self.auth_headers(), + ) + + self.assertEqual(response.status_code, 409) + + def test_duplicate_feed_update_returns_409(self): + other_feed = Feed.objects.create( + feed_url="https://example.com/second-feed.xml", + name="Second Feed", + ) + + response = self.client.patch( + f"/api/v1/feeds/{other_feed.id}", + data=json.dumps({"feed_url": self.feed.feed_url}), + content_type="application/json", + **self.auth_headers(), + ) + + self.assertEqual(response.status_code, 409) + + def test_feed_create_rejects_update_frequency_above_weekly(self): + response = self.client.post( + "/api/v1/feeds", + data=json.dumps( + { + "feed_url": "https://example.com/too-frequent.xml", + "update_frequency": 20000, + } + ), + content_type="application/json", + **self.auth_headers(), + ) + + self.assertEqual(response.status_code, 422) + self.assertEqual( + response.json()["detail"][0]["loc"], + ["body", "payload", "update_frequency"], + ) + self.assertFalse( + Feed.objects.filter(feed_url="https://example.com/too-frequent.xml").exists() + ) + + def test_duplicate_root_feed_url_create_returns_409(self): + Feed.objects.create(feed_url="https://example.com", name="Root Feed") + + response = self.client.post( + "/api/v1/feeds", + data=json.dumps({"feed_url": "https://example.com"}), + content_type="application/json", + **self.auth_headers(), + ) + + self.assertEqual(response.status_code, 409) + + def test_feed_create_preserves_root_feed_url_string(self): + response = self.client.post( + "/api/v1/feeds", + data=json.dumps({"feed_url": "https://example.net"}), + content_type="application/json", + **self.auth_headers(), + ) + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.json()["feed_url"], "https://example.net") + self.assertTrue(Feed.objects.filter(feed_url="https://example.net").exists()) + + def test_feed_create_rejects_null_target_language(self): + response = self.client.post( + "/api/v1/feeds", + data=json.dumps( + { + "feed_url": "https://example.com/null-target-language.xml", + "target_language": None, + } + ), + content_type="application/json", + **self.auth_headers(), + ) + + self.assertEqual(response.status_code, 422) + self.assertEqual( + response.json()["detail"][0]["loc"], + ["body", "payload", "target_language"], + ) + self.assertFalse( + Feed.objects.filter( + feed_url="https://example.com/null-target-language.xml" + ).exists() + ) + + def test_feed_update_rejects_null_fetch_article(self): + response = self.client.patch( + f"/api/v1/feeds/{self.feed.id}", + data=json.dumps({"fetch_article": None}), + content_type="application/json", + **self.auth_headers(), + ) + + self.assertEqual(response.status_code, 422) + self.assertEqual( + response.json()["detail"][0]["loc"], + ["body", "payload", "fetch_article"], + ) + + self.feed.refresh_from_db() + self.assertFalse(self.feed.fetch_article) + + def test_feed_create_rejects_translation_or_summary_flags_without_required_engines( + self, + ): + unsupported_cases = [ + ("translate_title", "translator"), + ("translate_content", "translator"), + ("summary", "summarizer"), + ] + + for field_name, engine_name in unsupported_cases: + response = self.client.post( + "/api/v1/feeds", + data=json.dumps( + { + "feed_url": f"https://example.com/{field_name}.xml", + field_name: True, + } + ), + content_type="application/json", + **self.auth_headers(), + ) + + self.assertEqual(response.status_code, 422) + self.assertIn(engine_name, response.json()["detail"]) + + def test_feed_create_accepts_ai_flags_when_agent_configuration_is_provided(self): + agent = self.create_valid_openai_agent() + + response = self.client.post( + "/api/v1/feeds", + data=json.dumps( + { + "feed_url": "https://example.com/ai-enabled.xml", + "translate_title": True, + "translate_content": True, + "summary": True, + "translator_option": self.translator_option(agent), + "summarizer_id": agent.id, + } + ), + content_type="application/json", + **self.auth_headers(), + ) + + self.assertEqual(response.status_code, 201) + created_feed = Feed.objects.get(feed_url="https://example.com/ai-enabled.xml") + self.assertTrue(created_feed.translate_title) + self.assertTrue(created_feed.translate_content) + self.assertTrue(created_feed.summary) + self.assertEqual(created_feed.translator_object_id, agent.id) + self.assertEqual(created_feed.summarizer_id, agent.id) + + def test_feed_update_rejects_translation_or_summary_flags_without_required_engines( + self, + ): + unsupported_cases = [ + ("translate_title", "translator"), + ("translate_content", "translator"), + ("summary", "summarizer"), + ] + + for field_name, engine_name in unsupported_cases: + response = self.client.patch( + f"/api/v1/feeds/{self.feed.id}", + data=json.dumps({field_name: True}), + content_type="application/json", + **self.auth_headers(), + ) + + self.assertEqual(response.status_code, 422) + self.assertIn(engine_name, response.json()["detail"]) + + def test_feed_update_rejects_clearing_translator_while_translation_remains_enabled( + self, + ): + agent = self.create_valid_openai_agent() + content_type = ContentType.objects.get_for_model(agent.__class__) + self.feed.translate_title = True + self.feed.translator_content_type = content_type + self.feed.translator_object_id = agent.id + self.feed.save() + + response = self.client.patch( + f"/api/v1/feeds/{self.feed.id}", + data=json.dumps({"translator_option": None}), + content_type="application/json", + **self.auth_headers(), + ) + + self.assertEqual(response.status_code, 422) + self.assertIn("translator", response.json()["detail"]) + self.feed.refresh_from_db() + self.assertEqual(self.feed.translator_content_type_id, content_type.id) + self.assertEqual(self.feed.translator_object_id, agent.id) + + def test_feed_update_rejects_clearing_summarizer_while_summary_remains_enabled( + self, + ): + agent = self.create_valid_openai_agent() + self.feed.summary = True + self.feed.summarizer = agent + self.feed.save() + + response = self.client.patch( + f"/api/v1/feeds/{self.feed.id}", + data=json.dumps({"summarizer_id": None}), + content_type="application/json", + **self.auth_headers(), + ) + + self.assertEqual(response.status_code, 422) + self.assertIn("summarizer", response.json()["detail"]) + self.feed.refresh_from_db() + self.assertEqual(self.feed.summarizer_id, agent.id) + + def test_feed_update_changing_feed_url_clears_existing_entries(self): + self.feed.entries.create( + original_title="Old Entry", + link="https://example.com/old-entry", + ) + + response = self.client.patch( + f"/api/v1/feeds/{self.feed.id}", + data=json.dumps({"feed_url": "https://example.com/new-feed.xml"}), + content_type="application/json", + **self.auth_headers(), + ) + + self.assertEqual(response.status_code, 200) + self.feed.refresh_from_db() + self.assertEqual(self.feed.feed_url, "https://example.com/new-feed.xml") + self.assertEqual(self.feed.entries.count(), 0) + + def test_feed_update_changing_feed_url_clears_stale_metadata_fields(self): + self.feed.subtitle = "Old subtitle" + self.feed.link = "https://example.com/old-home" + self.feed.author = "Old author" + self.feed.language = "en" + self.feed.pubdate = timezone.now().replace(microsecond=0) + self.feed.updated = timezone.now().replace(microsecond=0) + self.feed.save() + + response = self.client.patch( + f"/api/v1/feeds/{self.feed.id}", + data=json.dumps({"feed_url": "https://example.com/new-feed.xml"}), + content_type="application/json", + **self.auth_headers(), + ) + + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertIsNone(payload["subtitle"]) + self.assertIsNone(payload["link"]) + self.assertIsNone(payload["author"]) + self.assertIsNone(payload["language"]) + self.assertIsNone(payload["pubdate"]) + self.assertIsNone(payload["updated"]) + + self.feed.refresh_from_db() + self.assertIsNone(self.feed.subtitle) + self.assertIsNone(self.feed.link) + self.assertIsNone(self.feed.author) + self.assertIsNone(self.feed.language) + self.assertIsNone(self.feed.pubdate) + self.assertIsNone(self.feed.updated) + + def test_feed_update_changing_feed_url_resets_original_conditional_get_metadata(self): + previous_fetch = timezone.now().replace(microsecond=0) - timedelta(minutes=5) + self.feed.last_fetch = previous_fetch + self.feed.last_translate = previous_fetch + self.feed.etag = "stale-etag" + self.feed.entries.create( + original_title="Old Entry", + link="https://example.com/old-entry", + ) + self.feed.save() + + response = self.client.patch( + f"/api/v1/feeds/{self.feed.id}", + data=json.dumps({"feed_url": "https://example.com/new-feed.xml"}), + content_type="application/json", + **self.auth_headers(), + ) + + self.assertEqual(response.status_code, 200) + self.feed.refresh_from_db() + self.assertIsNone(self.feed.last_fetch) + self.assertIsNone(self.feed.last_translate) + self.assertIsNone(self.feed.etag) + with ( + patch("core.views.cache.get", return_value=None), + patch("core.views.cache_rss", return_value=""), + ): + conditional_response = self.client.get( + f"/rss/proxy/{self.feed.slug}", + HTTP_IF_NONE_MATCH="stale-etag", + ) + + self.assertEqual(conditional_response.status_code, 200) + + def test_feed_update_changing_target_language_clears_translation_fields(self): + entry = self.feed.entries.create( + original_title="Old Entry", + link="https://example.com/old-entry", + translated_title="旧标题", + translated_content="旧内容", + ai_summary="旧摘要", + ) + + response = self.client.patch( + f"/api/v1/feeds/{self.feed.id}", + data=json.dumps({"target_language": "English"}), + content_type="application/json", + **self.auth_headers(), + ) + + self.assertEqual(response.status_code, 200) + entry.refresh_from_db() + self.assertIsNone(entry.translated_title) + self.assertIsNone(entry.translated_content) + self.assertIsNone(entry.ai_summary) + + def test_feed_update_changing_translator_clears_existing_translations(self): + old_agent = self.create_valid_openai_agent("Old Translator") + new_agent = self.create_valid_openai_agent("New Translator") + content_type = ContentType.objects.get_for_model(old_agent.__class__) + previous_translate = timezone.now().replace(microsecond=0) - timedelta(minutes=5) + self.feed.translate_title = True + self.feed.translate_content = True + self.feed.translation_status = True + self.feed.last_translate = previous_translate + self.feed.translator_content_type = content_type + self.feed.translator_object_id = old_agent.id + self.feed.save() + entry = self.feed.entries.create( + original_title="Old Entry", + link="https://example.com/old-entry", + translated_title="旧标题", + translated_content="旧内容", + ) + + response = self.client.patch( + f"/api/v1/feeds/{self.feed.id}", + data=json.dumps( + {"translator_option": self.translator_option(new_agent)} + ), + content_type="application/json", + **self.auth_headers(), + ) + + self.assertEqual(response.status_code, 200) + entry.refresh_from_db() + self.feed.refresh_from_db() + self.assertIsNone(entry.translated_title) + self.assertIsNone(entry.translated_content) + self.assertIsNone(self.feed.last_translate) + self.assertIsNone(self.feed.translation_status) + self.assertEqual(self.feed.translator_object_id, new_agent.id) + + def test_feed_update_changing_summarizer_clears_existing_summaries(self): + old_agent = self.create_valid_openai_agent("Old Summarizer") + new_agent = self.create_valid_openai_agent("New Summarizer") + previous_translate = timezone.now().replace(microsecond=0) - timedelta(minutes=5) + self.feed.summary = True + self.feed.translation_status = True + self.feed.last_translate = previous_translate + self.feed.summarizer = old_agent + self.feed.save() + entry = self.feed.entries.create( + original_title="Old Entry", + link="https://example.com/old-entry", + ai_summary="旧摘要", + ) + + response = self.client.patch( + f"/api/v1/feeds/{self.feed.id}", + data=json.dumps({"summarizer_id": new_agent.id}), + content_type="application/json", + **self.auth_headers(), + ) + + self.assertEqual(response.status_code, 200) + entry.refresh_from_db() + self.feed.refresh_from_db() + self.assertIsNone(entry.ai_summary) + self.assertIsNone(self.feed.last_translate) + self.assertIsNone(self.feed.translation_status) + self.assertEqual(self.feed.summarizer_id, new_agent.id) + + def test_feed_update_changing_fetch_article_clears_existing_content_translations(self): + agent = self.create_valid_openai_agent("Translator") + content_type = ContentType.objects.get_for_model(agent.__class__) + previous_translate = timezone.now().replace(microsecond=0) - timedelta(minutes=5) + self.feed.translate_title = True + self.feed.translate_content = True + self.feed.translation_status = True + self.feed.last_translate = previous_translate + self.feed.translator_content_type = content_type + self.feed.translator_object_id = agent.id + self.feed.save() + entry = self.feed.entries.create( + original_title="Old Entry", + link="https://example.com/old-entry", + translated_title="旧标题", + translated_content="旧内容", + ) + + response = self.client.patch( + f"/api/v1/feeds/{self.feed.id}", + data=json.dumps({"fetch_article": True}), + content_type="application/json", + **self.auth_headers(), + ) + + self.assertEqual(response.status_code, 200) + entry.refresh_from_db() + self.feed.refresh_from_db() + self.assertEqual(entry.translated_title, "旧标题") + self.assertIsNone(entry.translated_content) + self.assertTrue(self.feed.fetch_article) + self.assertIsNone(self.feed.last_translate) + self.assertIsNone(self.feed.translation_status) + + def test_feed_update_disabling_translated_outputs_resets_translated_conditional_get_metadata( + self, + ): + previous_translate = ( + timezone.now().replace(microsecond=0) - timedelta(minutes=5) + ) + self.feed.translate_title = True + self.feed.translation_status = True + self.feed.last_translate = previous_translate + self.feed.save() + self.feed.entries.create( + original_title="Old Entry", + link="https://example.com/old-entry", + translated_title="旧标题", + ) + + response = self.client.patch( + f"/api/v1/feeds/{self.feed.id}", + data=json.dumps({"translate_title": False}), + content_type="application/json", + **self.auth_headers(), + ) + + self.assertEqual(response.status_code, 200) + self.feed.refresh_from_db() + self.assertIsNone(self.feed.last_translate) + with ( + patch("core.views.cache.get", return_value=None), + patch("core.views.cache_rss", return_value=""), + ): + conditional_response = self.client.get( + f"/rss/{self.feed.slug}", + HTTP_IF_MODIFIED_SINCE=http_date(previous_translate.timestamp()), + ) + + self.assertEqual(conditional_response.status_code, 200) + + def test_feed_update_disabling_translated_outputs_clears_existing_fields(self): + self.feed.translate_title = True + self.feed.translate_content = True + self.feed.summary = True + self.feed.save() + entry = self.feed.entries.create( + original_title="Old Entry", + link="https://example.com/old-entry", + translated_title="旧标题", + translated_content="旧内容", + ai_summary="旧摘要", + ) + + response = self.client.patch( + f"/api/v1/feeds/{self.feed.id}", + data=json.dumps( + { + "translate_title": False, + "translate_content": False, + "summary": False, + } + ), + content_type="application/json", + **self.auth_headers(), + ) + + self.assertEqual(response.status_code, 200) + entry.refresh_from_db() + self.assertIsNone(entry.translated_title) + self.assertIsNone(entry.translated_content) + self.assertIsNone(entry.ai_summary) + + def test_feed_patch_keeps_translated_feed_renderable_for_name_changes(self): + agent = self.create_valid_openai_agent() + content_type = ContentType.objects.get_for_model(agent.__class__) + self.feed.translate_title = True + self.feed.translation_status = True + self.feed.last_translate = timezone.now().replace(microsecond=0) + self.feed.translator_content_type = content_type + self.feed.translator_object_id = agent.id + self.feed.save() + self.feed.entries.create( + original_title="Old Title", + translated_title="新标题", + link="https://example.com/old-entry", + ) + + response = self.client.patch( + f"/api/v1/feeds/{self.feed.id}", + data=json.dumps({"name": "Renamed Feed"}), + content_type="application/json", + **self.auth_headers(), + ) + + self.assertEqual(response.status_code, 200) + rss_response = self.client.get(f"/rss/{self.feed.slug}") + self.assertEqual(rss_response.status_code, 200) + + rss_content = b"".join(rss_response.streaming_content).decode() + self.assertIn("Renamed Feed", rss_content) + self.assertIn("新标题", rss_content) + self.assertNotIn("No feed data available", rss_content) + + def test_feed_patch_preserves_existing_translated_cache_while_translation_in_progress( + self, + ): + agent = self.create_valid_openai_agent() + content_type = ContentType.objects.get_for_model(agent.__class__) + self.feed.translate_title = True + self.feed.translation_status = None + self.feed.translator_content_type = content_type + self.feed.translator_object_id = agent.id + self.feed.save() + + translated_cache_key = f"cache_rss_{self.feed.slug}_t_xml" + cache.set(translated_cache_key, "cached-translated", 60) + + response = self.client.patch( + f"/api/v1/feeds/{self.feed.id}", + data=json.dumps({"name": "Renamed Feed"}), + content_type="application/json", + **self.auth_headers(), + ) + + self.assertEqual(response.status_code, 200) + rss_response = self.client.get(f"/rss/{self.feed.slug}") + self.assertEqual(rss_response.status_code, 200) + + rss_content = b"".join(rss_response.streaming_content).decode() + self.assertIn("cached-translated", rss_content) + self.assertNotIn("No feed data available", rss_content) + + def test_feed_patch_preserves_existing_translated_cache_when_patch_enables_translation( + self, + ): + agent = self.create_valid_openai_agent() + translated_cache_key = f"cache_rss_{self.feed.slug}_t_xml" + cache.set( + translated_cache_key, + "cached-before-enable", + 60, + ) + + response = self.client.patch( + f"/api/v1/feeds/{self.feed.id}", + data=json.dumps( + { + "translate_title": True, + "translator_option": self.translator_option(agent), + } + ), + content_type="application/json", + **self.auth_headers(), + ) + + self.assertEqual(response.status_code, 200) + rss_response = self.client.get(f"/rss/{self.feed.slug}") + self.assertEqual(rss_response.status_code, 200) + + rss_content = b"".join(rss_response.streaming_content).decode() + self.assertIn("cached-before-enable", rss_content) + self.assertNotIn("No feed data available", rss_content) + + def test_feed_patch_clears_stale_translated_cache_when_feed_url_changes(self): + agent = self.create_valid_openai_agent("Translator") + content_type = ContentType.objects.get_for_model(agent.__class__) + self.feed.translate_title = True + self.feed.translation_status = True + self.feed.translator_content_type = content_type + self.feed.translator_object_id = agent.id + self.feed.save() + self.feed.entries.create( + original_title="Old Title", + translated_title="旧标题", + link="https://example.com/old-entry", + ) + + translated_cache_key = f"cache_rss_{self.feed.slug}_t_xml" + cache.set( + translated_cache_key, + "stale-feed-url-cache", + 60, + ) + + response = self.client.patch( + f"/api/v1/feeds/{self.feed.id}", + data=json.dumps({"feed_url": "https://example.com/new-feed.xml"}), + content_type="application/json", + **self.auth_headers(), + ) + + self.assertEqual(response.status_code, 200) + self.assertIsNone(cache.get(translated_cache_key)) + + def test_feed_patch_clears_stale_translated_cache_when_translator_changes(self): + old_agent = self.create_valid_openai_agent("Old Translator") + new_agent = self.create_valid_openai_agent("New Translator") + content_type = ContentType.objects.get_for_model(old_agent.__class__) + self.feed.translate_title = True + self.feed.translation_status = True + self.feed.translator_content_type = content_type + self.feed.translator_object_id = old_agent.id + self.feed.save() + self.feed.entries.create( + original_title="Old Title", + translated_title="旧标题", + link="https://example.com/old-entry", + ) + + translated_cache_key = f"cache_rss_{self.feed.slug}_t_xml" + cache.set( + translated_cache_key, + "stale-translator-cache", + 60, + ) + + response = self.client.patch( + f"/api/v1/feeds/{self.feed.id}", + data=json.dumps( + {"translator_option": self.translator_option(new_agent)} + ), + content_type="application/json", + **self.auth_headers(), + ) + + self.assertEqual(response.status_code, 200) + self.assertIsNone(cache.get(translated_cache_key)) + + def test_feed_patch_preserves_existing_tag_cache_while_translation_in_progress( + self, + ): + agent = self.create_valid_openai_agent() + content_type = ContentType.objects.get_for_model(agent.__class__) + stable_tag = Tag.objects.create(name="stabletag") + self.feed.tags.add(stable_tag) + self.feed.translate_title = True + self.feed.translation_status = None + self.feed.translator_content_type = content_type + self.feed.translator_object_id = agent.id + self.feed.save() + self.feed.entries.create( + original_title="Old Title", + link="https://example.com/old-entry", + ) + + translated_tag_cache_key = f"cache_tag_{stable_tag.slug}_t_xml" + cache.set( + translated_tag_cache_key, + "cached-tag-translated", + 60, + ) + + response = self.client.patch( + f"/api/v1/feeds/{self.feed.id}", + data=json.dumps({"name": "Renamed Feed"}), + content_type="application/json", + **self.auth_headers(), + ) + + self.assertEqual(response.status_code, 200) + rss_response = self.client.get(f"/rss/tag/{stable_tag.slug}") + self.assertEqual(rss_response.status_code, 200) + + rss_content = b"".join(rss_response.streaming_content).decode() + self.assertIn("cached-tag-translated", rss_content) + self.assertNotIn("No feed data available", rss_content) + + def test_feed_patch_clears_cached_feed_output(self): + for cache_key in self.feed_cache_keys(): + cache.set(cache_key, f"stale:{cache_key}", 60) + + response = self.client.patch( + f"/api/v1/feeds/{self.feed.id}", + data=json.dumps({"max_posts": 5}), + content_type="application/json", + **self.auth_headers(), + ) + + self.assertEqual(response.status_code, 200) + for cache_key in self.feed_cache_keys(): + self.assertIsNone(cache.get(cache_key)) + + def test_feed_delete_clears_cached_feed_output(self): + for cache_key in self.feed_cache_keys(): + cache.set(cache_key, f"stale:{cache_key}", 60) + + response = self.client.delete( + f"/api/v1/feeds/{self.feed.id}", + **self.auth_headers(), + ) + + self.assertEqual(response.status_code, 204) + for cache_key in self.feed_cache_keys(): + self.assertIsNone(cache.get(cache_key)) + + def test_feed_patch_succeeds_even_if_cache_invalidation_fails(self): + self.client.raise_request_exception = False + try: + with patch( + "core.api.cache.delete_many", + side_effect=RuntimeError("redis unavailable"), + ): + response = self.client.patch( + f"/api/v1/feeds/{self.feed.id}", + data=json.dumps({"name": "Updated Feed"}), + content_type="application/json", + **self.auth_headers(), + ) + finally: + self.client.raise_request_exception = True + + self.assertEqual(response.status_code, 200) + self.feed.refresh_from_db() + self.assertEqual(self.feed.name, "Updated Feed") + + def test_feed_delete_succeeds_even_if_cache_invalidation_fails(self): + self.client.raise_request_exception = False + try: + with patch( + "core.api.cache.delete_many", + side_effect=RuntimeError("redis unavailable"), + ): + response = self.client.delete( + f"/api/v1/feeds/{self.feed.id}", + **self.auth_headers(), + ) + finally: + self.client.raise_request_exception = True + + self.assertEqual(response.status_code, 204) + self.assertFalse(Feed.objects.filter(id=self.feed.id).exists()) + + def test_refresh_endpoint_queues_async_update_and_warms_caches(self): + with ( + patch("core.api.update_single_feed") as mock_update_single_feed, + patch("core.api.cache_rss", create=True) as mock_cache_rss, + patch("core.api.cache_tag", create=True) as mock_cache_tag, + patch("core.api.task_manager.submit_task") as mock_submit_task, + ): + mock_submit_task.side_effect = ( + lambda task_name, task_fn, *args: task_fn(*args) + ) + with self.captureOnCommitCallbacks(execute=True): + response = self.client.post( + f"/api/v1/feeds/{self.feed.id}/refresh", + data=json.dumps({}), + content_type="application/json", + **self.auth_headers(), + ) + + self.assertEqual(response.status_code, 202) + payload = response.json() + self.assertEqual(payload["status"], "queued") + mock_submit_task.assert_called_once() + args = mock_submit_task.call_args.args + self.assertEqual(args[0], f"update_feed_{self.feed.slug}") + self.assertEqual(args[2], self.feed.id) + mock_update_single_feed.assert_called_once() + mock_cache_rss.assert_has_calls( + [ + ((self.feed.slug,), {"feed_type": "o", "format": "xml"}), + ((self.feed.slug,), {"feed_type": "o", "format": "json"}), + ((self.feed.slug,), {"feed_type": "t", "format": "xml"}), + ((self.feed.slug,), {"feed_type": "t", "format": "json"}), + ] + ) + mock_cache_tag.assert_has_calls( + [ + ((self.tag.slug,), {"feed_type": "o", "format": "xml"}), + ((self.tag.slug,), {"feed_type": "t", "format": "xml"}), + ((self.tag.slug,), {"feed_type": "t", "format": "json"}), + ] + ) + + def test_refresh_callback_does_not_recreate_deleted_feed(self): + with ( + patch("core.api.cache_rss"), + patch("core.api.cache_tag"), + patch("core.api.update_single_feed") as mock_update_single_feed, + patch("core.api.task_manager.submit_task") as mock_submit_task, + ): + mock_update_single_feed.side_effect = ( + lambda feed: feed.save() if isinstance(feed, Feed) else None + ) + mock_submit_task.side_effect = ( + lambda task_name, task_fn, *args: task_fn(*args) + ) + with self.captureOnCommitCallbacks(execute=False) as callbacks: + refresh_response = self.client.post( + f"/api/v1/feeds/{self.feed.id}/refresh", + data=json.dumps({}), + content_type="application/json", + **self.auth_headers(), + ) + + self.assertEqual(refresh_response.status_code, 202) + delete_response = self.client.delete( + f"/api/v1/feeds/{self.feed.id}", + **self.auth_headers(), + ) + self.assertEqual(delete_response.status_code, 204) + + for callback in callbacks: + callback() + + self.assertFalse(Feed.objects.filter(id=self.feed.id).exists()) + + +@override_settings(EXTERNAL_API_ENABLED=True, EXTERNAL_API_TOKEN="secret-token") +class ExternalAPITagTests(ExternalAPIBaseTestCase): + def test_tag_crud(self): + list_response = self.client.get("/api/v1/tags", **self.auth_headers()) + self.assertEqual(list_response.status_code, 200) + self.assertEqual([item["id"] for item in list_response.json()], [self.tag.id]) + + create_response = self.client.post( + "/api/v1/tags", + data=json.dumps({"name": "Science"}), + content_type="application/json", + **self.auth_headers(), + ) + + self.assertEqual(create_response.status_code, 201) + created_tag_id = create_response.json()["id"] + + patch_response = self.client.patch( + f"/api/v1/tags/{created_tag_id}", + data=json.dumps({"name": "Science Daily"}), + content_type="application/json", + **self.auth_headers(), + ) + + self.assertEqual(patch_response.status_code, 200) + self.assertEqual(patch_response.json()["name"], "Science Daily") + + delete_response = self.client.delete( + f"/api/v1/tags/{created_tag_id}", + **self.auth_headers(), + ) + + self.assertEqual(delete_response.status_code, 204) + self.assertFalse(Tag.objects.filter(id=created_tag_id).exists()) + + def test_tag_update_rejects_null_name(self): + response = self.client.patch( + f"/api/v1/tags/{self.tag.id}", + data=json.dumps({"name": None}), + content_type="application/json", + **self.auth_headers(), + ) + + self.assertEqual(response.status_code, 422) + self.assertEqual( + response.json()["detail"][0]["loc"], + ["body", "payload", "name"], + ) + + self.tag.refresh_from_db() + self.assertEqual(self.tag.name, "Tech") + + def test_tag_create_rejects_name_above_model_max_length(self): + response = self.client.post( + "/api/v1/tags", + data=json.dumps({"name": "y" * 300}), + content_type="application/json", + **self.auth_headers(), + ) + + self.assertEqual(response.status_code, 422) + self.assertEqual( + response.json()["detail"][0]["loc"], + ["body", "payload", "name"], + ) + self.assertFalse(Tag.objects.filter(name="y" * 300).exists()) + + def test_tag_create_rejects_blank_name(self): + response = self.client.post( + "/api/v1/tags", + data=json.dumps({"name": " "}), + content_type="application/json", + **self.auth_headers(), + ) + + self.assertEqual(response.status_code, 422) + self.assertEqual( + response.json()["detail"][0]["loc"], + ["body", "payload", "name"], + ) + self.assertFalse(Tag.objects.filter(name="").exists()) + + def test_tag_update_rejects_name_above_model_max_length(self): + response = self.client.patch( + f"/api/v1/tags/{self.tag.id}", + data=json.dumps({"name": "y" * 300}), + content_type="application/json", + **self.auth_headers(), + ) + + self.assertEqual(response.status_code, 422) + self.assertEqual( + response.json()["detail"][0]["loc"], + ["body", "payload", "name"], + ) + + self.tag.refresh_from_db() + self.assertEqual(self.tag.name, "Tech") + + def test_tag_update_rejects_blank_name(self): + response = self.client.patch( + f"/api/v1/tags/{self.tag.id}", + data=json.dumps({"name": " "}), + content_type="application/json", + **self.auth_headers(), + ) + + self.assertEqual(response.status_code, 422) + self.assertEqual( + response.json()["detail"][0]["loc"], + ["body", "payload", "name"], + ) + + self.tag.refresh_from_db() + self.assertEqual(self.tag.name, "Tech") + + def test_feed_tag_assignment_replaces_and_clears(self): + second_tag = Tag.objects.create(name="News") + + replace_response = self.client.post( + f"/api/v1/feeds/{self.feed.id}/tags", + data=json.dumps({"tag_ids": [second_tag.id]}), + content_type="application/json", + **self.auth_headers(), + ) + + self.assertEqual(replace_response.status_code, 200) + self.feed.refresh_from_db() + self.assertEqual( + list(self.feed.tags.values_list("id", flat=True).order_by("id")), + [second_tag.id], + ) + self.assertEqual( + [tag["id"] for tag in replace_response.json()["tags"]], + [second_tag.id], + ) + + clear_response = self.client.post( + f"/api/v1/feeds/{self.feed.id}/tags", + data=json.dumps({"tag_ids": []}), + content_type="application/json", + **self.auth_headers(), + ) + + self.assertEqual(clear_response.status_code, 200) + self.feed.refresh_from_db() + self.assertEqual(self.feed.tags.count(), 0) + self.assertEqual(clear_response.json()["tags"], []) + + def test_feed_tag_assignment_clears_related_tag_caches(self): + second_tag = Tag.objects.create(name="News") + for cache_key in self.tag_cache_keys(): + cache.set(cache_key, f"stale:{cache_key}", 60) + for cache_key in self.tag_cache_keys(second_tag): + cache.set(cache_key, f"stale:{cache_key}", 60) + + response = self.client.post( + f"/api/v1/feeds/{self.feed.id}/tags", + data=json.dumps({"tag_ids": [second_tag.id]}), + content_type="application/json", + **self.auth_headers(), + ) + + self.assertEqual(response.status_code, 200) + for cache_key in self.tag_cache_keys(): + self.assertIsNone(cache.get(cache_key)) + for cache_key in self.tag_cache_keys(second_tag): + self.assertIsNone(cache.get(cache_key)) + + def test_feed_tag_assignment_succeeds_even_if_cache_invalidation_fails(self): + second_tag = Tag.objects.create(name="News") + self.client.raise_request_exception = False + try: + with patch( + "core.api.cache.delete_many", + side_effect=RuntimeError("redis unavailable"), + ): + response = self.client.post( + f"/api/v1/feeds/{self.feed.id}/tags", + data=json.dumps({"tag_ids": [second_tag.id]}), + content_type="application/json", + **self.auth_headers(), + ) + finally: + self.client.raise_request_exception = True + + self.assertEqual(response.status_code, 200) + self.feed.refresh_from_db() + self.assertEqual( + list(self.feed.tags.values_list("id", flat=True).order_by("id")), + [second_tag.id], + ) + + def test_feed_tag_assignment_rejects_unknown_tag_ids(self): + response = self.client.post( + f"/api/v1/feeds/{self.feed.id}/tags", + data=json.dumps({"tag_ids": [999999]}), + content_type="application/json", + **self.auth_headers(), + ) + + self.assertEqual(response.status_code, 404) diff --git a/docs/docs/external-api.en.md b/docs/docs/external-api.en.md new file mode 100644 index 00000000..85d2e608 --- /dev/null +++ b/docs/docs/external-api.en.md @@ -0,0 +1,164 @@ +--- +title: External API +summary: Enablement, authentication, and MVP scope for the RSSBox integration API +--- + +# External API + +RSSBox includes a small management API for third-party integrations. It is disabled by default and must be enabled explicitly. + +## Enable The API + +Set these environment variables: + +```bash +EXTERNAL_API_ENABLED=1 +EXTERNAL_API_TOKEN=replace-with-a-long-random-token +``` + +When enabled, the API base path is `/api/v1/`. + +If `EXTERNAL_API_ENABLED` is unset or not `1`, all API endpoints return `404`. + +If the API is enabled but `EXTERNAL_API_TOKEN` is missing, all API endpoints return `503`. + +## Authentication + +Use a Bearer token: + +```http +Authorization: Bearer +``` + +Missing or invalid tokens return `401`. + +## MVP Endpoints + +- `GET /api/v1/feeds` +- `GET /api/v1/feeds/{id}` +- `POST /api/v1/feeds` +- `PATCH /api/v1/feeds/{id}` +- `DELETE /api/v1/feeds/{id}` +- `POST /api/v1/feeds/{id}/refresh` +- `GET /api/v1/tags` +- `POST /api/v1/tags` +- `PATCH /api/v1/tags/{id}` +- `DELETE /api/v1/tags/{id}` +- `POST /api/v1/feeds/{id}/tags` + +## Exposed Feed Fields + +The API only exposes minimal safe fields: + +- `id` +- `name` +- `feed_url` +- `slug` +- `target_language` +- `update_frequency` +- `max_posts` +- `fetch_article` +- `translate_title` +- `translate_content` +- `summary` +- `translation_display` +- `fetch_status` +- `translation_status` +- `last_fetch` +- `last_translate` +- `tags` + +Internal logs, etags, token counters, agent configuration, and other internal-only fields are not exposed. + +For write operations only, you may also provide: + +- `translator_option`: `":"`, used to configure the feed's translator +- `summarizer_id`: an existing valid `OpenAIAgent` id used for summaries + +These configuration fields are write-only and are not returned in API responses. This MVP still does not include an agent listing API, so integrations must obtain those ids from an existing admin or database workflow. + +## Curl Examples + +List feeds: + +```bash +curl -H "Authorization: Bearer $EXTERNAL_API_TOKEN" \ + http://localhost:8000/api/v1/feeds +``` + +Create a feed: + +```bash +curl -X POST \ + -H "Authorization: Bearer $EXTERNAL_API_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "feed_url": "https://example.com/rss.xml", + "name": "Example Feed", + "update_frequency": 30, + "translate_title": true, + "translator_option": "12:34" + }' \ + http://localhost:8000/api/v1/feeds +``` + +Patch a feed: + +```bash +curl -X PATCH \ + -H "Authorization: Bearer $EXTERNAL_API_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"name": "Renamed Feed", "summary": true, "summarizer_id": 56}' \ + http://localhost:8000/api/v1/feeds/1 +``` + +Queue a refresh: + +```bash +curl -X POST \ + -H "Authorization: Bearer $EXTERNAL_API_TOKEN" \ + http://localhost:8000/api/v1/feeds/1/refresh +``` + +Replace a feed's tag set: + +```bash +curl -X POST \ + -H "Authorization: Bearer $EXTERNAL_API_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"tag_ids": [1, 2]}' \ + http://localhost:8000/api/v1/feeds/1/tags +``` + +Create a tag: + +```bash +curl -X POST \ + -H "Authorization: Bearer $EXTERNAL_API_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"name": "Tech"}' \ + http://localhost:8000/api/v1/tags +``` + +## Local Verification Notes + +- The refresh endpoint reuses the existing RSSBox update pipeline. +- In `DEBUG=0` or other production-like settings, Django cache is configured to use Redis via `REDIS_URL`. +- After the main refresh work succeeds, RSSBox tries to warm feed and tag caches. If Redis is unavailable, you will see log messages such as `Failed to cache RSS ...` or `Failed to cache tag ...`. +- Those cache errors do not roll back the core fetch, translation, summary, or database updates. The main refresh work finishes first. +- What remains incomplete is cache warm-up. If Redis stays unavailable, cache-backed RSS/tag output endpoints may fail until Redis is reachable again. +- For local API verification, either use `uv run dev` / `DEBUG=1` so Django falls back to its local in-process cache, or run Redis and set `REDIS_URL` explicitly. + +## MVP Limitations + +- Feed `create` and `patch` only save configuration. They do not fetch, translate, or summarize content automatically. +- The `refresh` endpoint only queues asynchronous work. A `202` response means accepted, not completed. +- There is no pagination, filter management API, agent management API, digest API, or task status API in this MVP. +- AI weekly and digest APIs are explicitly out of scope. + +## Explicitly Out Of Scope + +- AI weekly or digest API +- Unrelated refactors +- Frontend or admin redesign +- Large architecture changes diff --git a/docs/docs/external-api.md b/docs/docs/external-api.md new file mode 100644 index 00000000..f72126ad --- /dev/null +++ b/docs/docs/external-api.md @@ -0,0 +1,164 @@ +--- +title: 外部 API +summary: RSSBox 第三方集成管理 API 的启用方式、认证方式和 MVP 范围 +--- + +# 外部 API + +RSSBox 提供了一个面向第三方集成的小型管理 API。该 API 默认关闭,需要显式启用。 + +## 启用 API + +设置以下环境变量: + +```bash +EXTERNAL_API_ENABLED=1 +EXTERNAL_API_TOKEN=replace-with-a-long-random-token +``` + +启用后,API 基础路径为 `/api/v1/`。 + +如果 `EXTERNAL_API_ENABLED` 未设置或不等于 `1`,所有 API 端点都会返回 `404`。 + +如果 API 已启用但未设置 `EXTERNAL_API_TOKEN`,所有 API 端点都会返回 `503`。 + +## 认证 + +使用 Bearer Token: + +```http +Authorization: Bearer +``` + +缺少 token 或 token 无效时返回 `401`。 + +## MVP 端点 + +- `GET /api/v1/feeds` +- `GET /api/v1/feeds/{id}` +- `POST /api/v1/feeds` +- `PATCH /api/v1/feeds/{id}` +- `DELETE /api/v1/feeds/{id}` +- `POST /api/v1/feeds/{id}/refresh` +- `GET /api/v1/tags` +- `POST /api/v1/tags` +- `PATCH /api/v1/tags/{id}` +- `DELETE /api/v1/tags/{id}` +- `POST /api/v1/feeds/{id}/tags` + +## Feed 暴露字段 + +API 仅暴露最小且安全的字段: + +- `id` +- `name` +- `feed_url` +- `slug` +- `target_language` +- `update_frequency` +- `max_posts` +- `fetch_article` +- `translate_title` +- `translate_content` +- `summary` +- `translation_display` +- `fetch_status` +- `translation_status` +- `last_fetch` +- `last_translate` +- `tags` + +不会暴露内部日志、etag、token 统计、Agent 配置或其他仅供内部使用的字段。 + +仅在写操作中,你还可以额外提交: + +- `translator_option`:`":"`,用于配置 feed 的翻译器 +- `summarizer_id`:一个已存在且有效的 `OpenAIAgent` id,用于生成摘要 + +这些配置字段是只写的,不会出现在 API 响应里。当前 MVP 仍然不提供 agent 列表 API,因此这些 id 需要通过现有的管理后台或数据库流程获取。 + +## Curl 示例 + +列出 feeds: + +```bash +curl -H "Authorization: Bearer $EXTERNAL_API_TOKEN" \ + http://localhost:8000/api/v1/feeds +``` + +创建 feed: + +```bash +curl -X POST \ + -H "Authorization: Bearer $EXTERNAL_API_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "feed_url": "https://example.com/rss.xml", + "name": "Example Feed", + "update_frequency": 30, + "translate_title": true, + "translator_option": "12:34" + }' \ + http://localhost:8000/api/v1/feeds +``` + +更新 feed: + +```bash +curl -X PATCH \ + -H "Authorization: Bearer $EXTERNAL_API_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"name": "Renamed Feed", "summary": true, "summarizer_id": 56}' \ + http://localhost:8000/api/v1/feeds/1 +``` + +触发刷新排队: + +```bash +curl -X POST \ + -H "Authorization: Bearer $EXTERNAL_API_TOKEN" \ + http://localhost:8000/api/v1/feeds/1/refresh +``` + +替换 feed 的 tag 集合: + +```bash +curl -X POST \ + -H "Authorization: Bearer $EXTERNAL_API_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"tag_ids": [1, 2]}' \ + http://localhost:8000/api/v1/feeds/1/tags +``` + +创建 tag: + +```bash +curl -X POST \ + -H "Authorization: Bearer $EXTERNAL_API_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"name": "Tech"}' \ + http://localhost:8000/api/v1/tags +``` + +## 本地验证说明 + +- 刷新流程复用了 RSSBox 现有的更新管线。 +- 在 `DEBUG=0` 或其他接近生产环境的配置下,Django 缓存会通过 `REDIS_URL` 使用 Redis。 +- 主刷新流程成功后,RSSBox 会继续尝试预热 feed 和 tag 缓存。如果 Redis 不可用,你会在日志中看到 `Failed to cache RSS ...` 或 `Failed to cache tag ...` 之类的报错。 +- 这些缓存错误不会回滚核心的抓取、翻译、摘要或数据库更新。刷新任务的主要工作会先完成。 +- 真正未完成的是缓存预热。如果 Redis 持续不可用,依赖缓存的 RSS/tag 输出端点可能会失败,直到 Redis 恢复可用。 +- 本地验证 API 时,要么使用 `uv run dev` / `DEBUG=1` 让 Django 使用本地进程内缓存,要么启动 Redis 并显式设置 `REDIS_URL`。 + +## MVP 限制 + +- Feed 的 `create` 和 `patch` 只会保存配置,不会自动抓取、翻译或生成摘要。 +- `refresh` 端点只负责将异步任务加入队列。返回 `202` 仅表示已接受,不表示刷新已完成。 +- 这个 MVP 不包含分页、过滤器管理 API、Agent 管理 API、Digest API 或任务状态 API。 +- AI 周报和摘要 API 明确不在当前范围内。 + +## 明确不包含 + +- AI weekly 或 digest API +- 无关重构 +- 前端或管理后台重设计 +- 大规模架构调整 diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index dd0bef89..0703ff2b 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -36,6 +36,7 @@ plugins: 一键部署: One Click Docker安装: Docker 使用说明: Guide + 外部 API: External API 翻译服务: Translate Services 环境变量配置: Environment Variables 常见问题: FAQ @@ -50,6 +51,7 @@ nav: - Docker安装: install/docker.md - 一键部署: install/one-click.md - 使用说明: guide.md + - 外部 API: external-api.md - 翻译服务: translator.md - 环境变量配置: config.md - 常见问题: faq.md diff --git a/pyproject.toml b/pyproject.toml index dcb45cc1..d29a8d88 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ dependencies = [ "httpx>=0.28.1", "django-autoslug>=1.9.9", "coverage>=7.10.6", + "django-ninja>=1.6.0", ] [tool.uv] diff --git a/scripts/dev_server.py b/scripts/dev_server.py index 9c71aad1..fbf4c55d 100644 --- a/scripts/dev_server.py +++ b/scripts/dev_server.py @@ -26,7 +26,7 @@ def start_development_server(): """启动开发服务器""" print("🌐 Start DEV Server...") try: - subprocess.run(["python", "manage.py", "runserver"], check=True) + subprocess.run([sys.executable, "manage.py", "runserver"], check=True) except KeyboardInterrupt: print("\n🛑 Server Stopped by User") except subprocess.CalledProcessError as e: diff --git a/uv.lock b/uv.lock index fd31ab7f..48be9ab5 100644 --- a/uv.lock +++ b/uv.lock @@ -349,6 +349,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e4/36/1b86079a489f181e33bd83600127e7b3b73c66cd609ac672e58de93ed9a9/django_encrypted_model_fields-0.6.5-py3-none-any.whl", hash = "sha256:b21bbdd8ae2e1a0ea37a5049b3ba46e6e63bf287ad241219a058fac1070796cc", size = 8934, upload-time = "2022-02-24T22:52:13.955Z" }, ] +[[package]] +name = "django-ninja" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8d/28/e2b28ea02da0cd408f035f81c20b8c9f7c7661c773779a508827ab8d55b1/django_ninja-1.6.0.tar.gz", hash = "sha256:dd84230931f511503a251ac090200c65fd0be25a16ba2b9140073b814decc201", size = 3684883, upload-time = "2026-03-12T08:45:00.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/ff/51e518a434f1af18932d4fe52a4c46985b7c15a75e394aeed5ed87ff6f98/django_ninja-1.6.0-py3-none-any.whl", hash = "sha256:44c6e3f5f1b929cf51f645004715b36326ea32fddf1a94026c4917de8d230135", size = 2374822, upload-time = "2026-03-12T08:44:58.345Z" }, +] + [[package]] name = "django-tagulous" version = "2.1.1" @@ -1150,7 +1163,7 @@ wheels = [ [[package]] name = "rssbox" -version = "2025.12.13" +version = "2026.2.21" source = { editable = "." } dependencies = [ { name = "bs4" }, @@ -1161,6 +1174,7 @@ dependencies = [ { name = "django-autoslug" }, { name = "django-debug-toolbar" }, { name = "django-encrypted-model-fields" }, + { name = "django-ninja" }, { name = "django-tagulous" }, { name = "fake-useragent" }, { name = "feed2json" }, @@ -1191,6 +1205,7 @@ requires-dist = [ { name = "django-autoslug", specifier = ">=1.9.9" }, { name = "django-debug-toolbar", specifier = ">=5.2.0" }, { name = "django-encrypted-model-fields", specifier = ">=0.6.5" }, + { name = "django-ninja", specifier = ">=1.6.0" }, { name = "django-tagulous", specifier = ">=2.1.1" }, { name = "fake-useragent", specifier = ">=2.2.0" }, { name = "feed2json", specifier = ">=2024.4.29" },