From fb19145e8c0f2311c9168010f986b9bfe9ab0b1b Mon Sep 17 00:00:00 2001 From: Artsiom Siamashka Date: Mon, 30 Mar 2026 12:23:13 +0200 Subject: [PATCH] Store posting results to a mongodb --- .env.example | 6 + pyproject.toml | 1 + .../adapters/publish_store/__init__.py | 5 + .../adapters/publish_store/mongodb.py | 22 ++++ src/content_automation/controller.py | 48 +++++++- src/content_automation/factories.py | 22 +++- src/content_automation/interfaces.py | 15 ++- src/content_automation/main.py | 12 +- src/content_automation/models.py | 16 +++ src/content_automation/settings.py | 7 ++ tests/test_controller.py | 59 +++++++++ tests/test_publish_store.py | 115 ++++++++++++++++++ tests/test_settings.py | 12 ++ 13 files changed, 331 insertions(+), 9 deletions(-) create mode 100644 src/content_automation/adapters/publish_store/__init__.py create mode 100644 src/content_automation/adapters/publish_store/mongodb.py create mode 100644 src/content_automation/models.py create mode 100644 tests/test_publish_store.py diff --git a/.env.example b/.env.example index 6c3242d..3de8fbf 100644 --- a/.env.example +++ b/.env.example @@ -33,3 +33,9 @@ CONTENT_AUTOMATION_STORAGE__S3__REGION_NAME= CONTENT_AUTOMATION_STORAGE__S3__ENDPOINT_URL= CONTENT_AUTOMATION_STORAGE__S3__PUBLIC_URL_BASE= CONTENT_AUTOMATION_STORAGE__S3__URL_EXPIRATION_SECONDS=3600 + +# MongoDB publish persistence +CONTENT_AUTOMATION_MONGODB__ENABLED=false +CONTENT_AUTOMATION_MONGODB__CONNECTION_URI= +CONTENT_AUTOMATION_MONGODB__DATABASE_NAME=content_automation +CONTENT_AUTOMATION_MONGODB__COLLECTION_NAME=published_content diff --git a/pyproject.toml b/pyproject.toml index 61ad368..44c4d29 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ dependencies = [ "boto3>=1.37.17", "google-api-python-client>=2.190.0", "google-auth-oauthlib>=1.2.4", + "pymongo>=4.15.2", "pydantic>=2.12.5", "pydantic-settings>=2.11.0", "pytest>=8.3.5", diff --git a/src/content_automation/adapters/publish_store/__init__.py b/src/content_automation/adapters/publish_store/__init__.py new file mode 100644 index 0000000..35a9240 --- /dev/null +++ b/src/content_automation/adapters/publish_store/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from content_automation.adapters.publish_store.mongodb import MongoPublishedContentStore + +__all__ = ["MongoPublishedContentStore"] diff --git a/src/content_automation/adapters/publish_store/mongodb.py b/src/content_automation/adapters/publish_store/mongodb.py new file mode 100644 index 0000000..8aa6c66 --- /dev/null +++ b/src/content_automation/adapters/publish_store/mongodb.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from pymongo import MongoClient + +from content_automation.interfaces import PublishedContentStore +from content_automation.models import PublishedContentRecord + + +class MongoPublishedContentStore(PublishedContentStore): + """MongoDB-backed persistence for publish operation records.""" + + def __init__( + self, + connection_uri: str, + database_name: str, + collection_name: str, + ) -> None: + self._client = MongoClient(connection_uri) + self._collection = self._client[database_name][collection_name] + + def save(self, record: PublishedContentRecord) -> None: + self._collection.insert_one(record.model_dump(mode="json")) diff --git a/src/content_automation/controller.py b/src/content_automation/controller.py index 2161c38..463cc5f 100644 --- a/src/content_automation/controller.py +++ b/src/content_automation/controller.py @@ -1,7 +1,11 @@ from __future__ import annotations +from collections.abc import Mapping +from typing import Any + from content_automation.adapters.storage.base import StorageAdapterBase -from content_automation.interfaces import SocialNetworkAdapter +from content_automation.interfaces import PublishedContentStore, SocialNetworkAdapter +from content_automation.models import PublishedContentRecord from content_automation.settings import AppSettings @@ -13,22 +17,60 @@ class PublishController: settings: AppSettings, storage: StorageAdapterBase, social_adapters: dict[str, SocialNetworkAdapter], + published_content_store: PublishedContentStore | None = None, ) -> None: self._settings = settings self._storage = storage self._social_adapters = social_adapters + self._published_content_store = published_content_store - def publish(self, relative_path: str, caption: str) -> dict[str, str]: + def publish(self, relative_path: str, caption: str) -> dict[str, Any]: if not self._storage.exists(relative_path): raise FileNotFoundError( f"Media file is not available in storage: {relative_path}" ) media_url = self._storage.get_public_url(relative_path) - result: dict[str, str] = {} + result: dict[str, Any] = {} for network in self._settings.target_social_networks: adapter = self._social_adapters.get(network) if adapter is None: raise ValueError(f"No adapter configured for network: {network}") result[network] = adapter.post_media(media_url=media_url, caption=caption) + + if self._published_content_store is not None: + record = PublishedContentRecord( + relative_path=relative_path, + media_url=media_url, + caption=caption, + platform_responses=self._serialize_platform_responses(result), + ) + self._published_content_store.save(record) + return result + + def _serialize_platform_responses( + self, responses: Mapping[str, Any] + ) -> dict[str, Any]: + serialized: dict[str, Any] = {} + for network, response in responses.items(): + if network == "youtube": + serialized[network] = self._extract_youtube_video_id(response) + continue + serialized[network] = response + return serialized + + @staticmethod + def _extract_youtube_video_id(response: Any) -> str: + if isinstance(response, str): + return response + + if isinstance(response, Mapping): + video_id = response.get("id") + if isinstance(video_id, str) and video_id: + return video_id + + raise ValueError( + "Unsupported YouTube publish response: expected a string ID " + "or payload containing an 'id' field." + ) diff --git a/src/content_automation/factories.py b/src/content_automation/factories.py index b43bc9b..c0dd320 100644 --- a/src/content_automation/factories.py +++ b/src/content_automation/factories.py @@ -1,11 +1,12 @@ from __future__ import annotations +from content_automation.adapters.publish_store.mongodb import MongoPublishedContentStore from content_automation.adapters.social.instagram import InstagramAdapter from content_automation.adapters.social.youtube import YouTubeAdapter from content_automation.adapters.storage.base import StorageAdapterBase from content_automation.adapters.storage.local import LocalFilesystemStorageAdapter from content_automation.adapters.storage.s3 import S3StorageAdapter -from content_automation.interfaces import SocialNetworkAdapter +from content_automation.interfaces import PublishedContentStore, SocialNetworkAdapter from content_automation.settings import AppSettings @@ -49,3 +50,22 @@ def build_social_adapters(settings: AppSettings) -> dict[str, SocialNetworkAdapt expiry=settings.youtube.expiry or None, ), } + + +def build_published_content_store( + settings: AppSettings, +) -> PublishedContentStore | None: + if not settings.mongodb.enabled: + return None + + if not settings.mongodb.connection_uri: + raise ValueError( + "CONTENT_AUTOMATION_MONGODB__CONNECTION_URI must be set when " + "MongoDB persistence is enabled." + ) + + return MongoPublishedContentStore( + connection_uri=settings.mongodb.connection_uri, + database_name=settings.mongodb.database_name, + collection_name=settings.mongodb.collection_name, + ) diff --git a/src/content_automation/interfaces.py b/src/content_automation/interfaces.py index a723322..e79c54f 100644 --- a/src/content_automation/interfaces.py +++ b/src/content_automation/interfaces.py @@ -1,6 +1,8 @@ from __future__ import annotations -from typing import Protocol +from typing import Any, Protocol + +from content_automation.models import PublishedContentRecord class SocialNetworkAdapter(Protocol): @@ -8,5 +10,12 @@ class SocialNetworkAdapter(Protocol): name: str - def post_media(self, media_url: str, caption: str) -> str: - """Publish media and return an external post identifier.""" + def post_media(self, media_url: str, caption: str) -> Any: + """Publish media and return a provider response payload.""" + + +class PublishedContentStore(Protocol): + """Contract for persisting publish operation metadata.""" + + def save(self, record: PublishedContentRecord) -> None: + """Persist a published content record.""" diff --git a/src/content_automation/main.py b/src/content_automation/main.py index 09281bd..fca530f 100644 --- a/src/content_automation/main.py +++ b/src/content_automation/main.py @@ -3,7 +3,11 @@ from __future__ import annotations import argparse from content_automation.controller import PublishController -from content_automation.factories import build_social_adapters, build_storage_adapter +from content_automation.factories import ( + build_published_content_store, + build_social_adapters, + build_storage_adapter, +) from content_automation.settings import AppSettings @@ -23,8 +27,12 @@ def main() -> None: settings = AppSettings() storage = build_storage_adapter(settings) adapters = build_social_adapters(settings) + published_content_store = build_published_content_store(settings) controller = PublishController( - settings=settings, storage=storage, social_adapters=adapters + settings=settings, + storage=storage, + social_adapters=adapters, + published_content_store=published_content_store, ) publish_results = controller.publish( relative_path=args.relative_path, caption=args.caption diff --git a/src/content_automation/models.py b/src/content_automation/models.py new file mode 100644 index 0000000..582f7ec --- /dev/null +++ b/src/content_automation/models.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any + +from pydantic import BaseModel, Field + + +class PublishedContentRecord(BaseModel): + """Stored metadata for a publish operation across social networks.""" + + relative_path: str + media_url: str + caption: str + platform_responses: dict[str, Any] + published_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) diff --git a/src/content_automation/settings.py b/src/content_automation/settings.py index 8afc684..fd7b616 100644 --- a/src/content_automation/settings.py +++ b/src/content_automation/settings.py @@ -48,9 +48,16 @@ class AppSettings(BaseSettings): local: LocalSettings = Field(default_factory=LocalSettings) s3: S3Settings = Field(default_factory=S3Settings) + class MongoDbSettings(BaseModel): + enabled: bool = False + connection_uri: str = "" + database_name: str = "content_automation" + collection_name: str = "published_content" + target_social_networks: list[str] = Field( default_factory=lambda: ["instagram", "youtube"] ) instagram: InstagramSettings = Field(default_factory=InstagramSettings) youtube: YoutubeSettings = Field(default_factory=YoutubeSettings) storage: StorageSettings = Field(default_factory=StorageSettings) + mongodb: MongoDbSettings = Field(default_factory=MongoDbSettings) diff --git a/tests/test_controller.py b/tests/test_controller.py index 339e04c..06198e6 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -4,6 +4,7 @@ import pytest from content_automation.adapters.storage.base import StorageAdapterBase from content_automation.controller import PublishController +from content_automation.models import PublishedContentRecord from content_automation.settings import AppSettings @@ -29,6 +30,23 @@ class FakeAdapter: return f"{self.name}-post-id" +class FakeAdapterWithResponsePayload: + def __init__(self, payload: object) -> None: + self.name = "adapter-with-payload" + self._payload = payload + + def post_media(self, media_url: str, caption: str) -> object: + return self._payload + + +class FakePublishedContentStore: + def __init__(self) -> None: + self.records: list[PublishedContentRecord] = [] + + def save(self, record: PublishedContentRecord) -> None: + self.records.append(record) + + def test_controller_publishes_to_all_configured_networks() -> None: settings = AppSettings.model_validate( {"target_social_networks": ["instagram", "youtube"]} @@ -47,6 +65,47 @@ def test_controller_publishes_to_all_configured_networks() -> None: assert result == {"instagram": "instagram-post-id", "youtube": "youtube-post-id"} +def test_controller_stores_published_content_with_youtube_id_only() -> None: + settings = AppSettings.model_validate( + {"target_social_networks": ["instagram", "youtube"]} + ) + published_content_store = FakePublishedContentStore() + controller = PublishController( + settings=settings, + storage=FakeStorage(exists_result=True), + social_adapters={ + "instagram": FakeAdapterWithResponsePayload( + {"id": "ig-123", "status": "ok"} + ), + "youtube": FakeAdapterWithResponsePayload( + { + "id": "yt-456", + "kind": "youtube#video", + "snippet": {"title": "hello"}, + } + ), + }, + published_content_store=published_content_store, + ) + + result = controller.publish(relative_path="video.mp4", caption="hello") + + assert result["youtube"] == { + "id": "yt-456", + "kind": "youtube#video", + "snippet": {"title": "hello"}, + } + assert len(published_content_store.records) == 1 + saved_record = published_content_store.records[0] + assert saved_record.relative_path == "video.mp4" + assert saved_record.caption == "hello" + assert saved_record.platform_responses["instagram"] == { + "id": "ig-123", + "status": "ok", + } + assert saved_record.platform_responses["youtube"] == "yt-456" + + def test_controller_raises_when_file_missing() -> None: settings = AppSettings.model_validate({"target_social_networks": ["youtube"]}) controller = PublishController( diff --git a/tests/test_publish_store.py b/tests/test_publish_store.py new file mode 100644 index 0000000..228b23b --- /dev/null +++ b/tests/test_publish_store.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import pytest + +from content_automation.adapters.publish_store import MongoPublishedContentStore +from content_automation.factories import build_published_content_store +from content_automation.models import PublishedContentRecord +from content_automation.settings import AppSettings + + +class FakeCollection: + def __init__(self) -> None: + self.documents: list[dict[str, object]] = [] + + def insert_one(self, document: dict[str, object]) -> None: + self.documents.append(document) + + +class FakeDatabase: + def __init__(self, collection: FakeCollection) -> None: + self._collection = collection + + def __getitem__(self, collection_name: str) -> FakeCollection: + return self._collection + + +class FakeMongoClient: + def __init__(self, connection_uri: str) -> None: + self.connection_uri = connection_uri + self.collection = FakeCollection() + + def __getitem__(self, database_name: str) -> FakeDatabase: + return FakeDatabase(self.collection) + + +def test_mongo_published_content_store_saves_record(monkeypatch) -> None: + fake_client = FakeMongoClient("mongodb://example:27017") + + monkeypatch.setattr( + "content_automation.adapters.publish_store.mongodb.MongoClient", + lambda connection_uri: fake_client, + ) + + store = MongoPublishedContentStore( + connection_uri="mongodb://example:27017", + database_name="content_automation", + collection_name="published_content", + ) + + store.save( + PublishedContentRecord( + relative_path="media/video.mp4", + media_url="https://cdn.example.com/media/video.mp4", + caption="caption", + platform_responses={ + "instagram": {"id": "ig-1"}, + "youtube": "yt-1", + }, + ) + ) + + assert len(fake_client.collection.documents) == 1 + assert fake_client.collection.documents[0]["relative_path"] == "media/video.mp4" + assert fake_client.collection.documents[0]["platform_responses"] == { + "instagram": {"id": "ig-1"}, + "youtube": "yt-1", + } + + +def test_build_published_content_store_returns_none_when_disabled() -> None: + settings = AppSettings.model_validate({"mongodb": {"enabled": False}}) + + store = build_published_content_store(settings) + + assert store is None + + +def test_build_published_content_store_requires_connection_uri() -> None: + settings = AppSettings.model_validate({"mongodb": {"enabled": True}}) + + with pytest.raises(ValueError): + build_published_content_store(settings) + + +def test_build_published_content_store_returns_mongo_store(monkeypatch) -> None: + class FakeMongoStore: + def __init__( + self, + connection_uri: str, + database_name: str, + collection_name: str, + ) -> None: + self.connection_uri = connection_uri + self.database_name = database_name + self.collection_name = collection_name + + monkeypatch.setattr("content_automation.factories.MongoPublishedContentStore", FakeMongoStore) + + settings = AppSettings.model_validate( + { + "mongodb": { + "enabled": True, + "connection_uri": "mongodb://localhost:27017", + "database_name": "content_automation", + "collection_name": "published_content", + } + } + ) + + store = build_published_content_store(settings) + + assert isinstance(store, FakeMongoStore) + assert store.connection_uri == "mongodb://localhost:27017" + assert store.database_name == "content_automation" + assert store.collection_name == "published_content" diff --git a/tests/test_settings.py b/tests/test_settings.py index 73f0f9c..8dfb327 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -9,6 +9,15 @@ def test_settings_parse_nested_env(monkeypatch) -> None: monkeypatch.setenv("CONTENT_AUTOMATION_YOUTUBE__USE_RESUMABLE_UPLOAD", "true") monkeypatch.setenv("CONTENT_AUTOMATION_STORAGE__BACKEND", "s3") monkeypatch.setenv("CONTENT_AUTOMATION_STORAGE__S3__BUCKET_NAME", "bucket-a") + monkeypatch.setenv("CONTENT_AUTOMATION_MONGODB__ENABLED", "true") + monkeypatch.setenv( + "CONTENT_AUTOMATION_MONGODB__CONNECTION_URI", + "mongodb://localhost:27017", + ) + monkeypatch.setenv( + "CONTENT_AUTOMATION_MONGODB__DATABASE_NAME", + "content-automation", + ) settings = AppSettings() @@ -17,3 +26,6 @@ def test_settings_parse_nested_env(monkeypatch) -> None: assert settings.youtube.use_resumable_upload is True assert settings.storage.backend == "s3" assert settings.storage.s3.bucket_name == "bucket-a" + assert settings.mongodb.enabled is True + assert settings.mongodb.connection_uri == "mongodb://localhost:27017" + assert settings.mongodb.database_name == "content-automation"