From 618eccedf37edbf3ba59be85861bfa926bf5ae57 Mon Sep 17 00:00:00 2001 From: Facundo Batista Date: Thu, 5 Oct 2023 22:05:58 -0300 Subject: [PATCH] Add mastodon job publisher (#582) * Draft Mastodon. * Finished mastodon setup. --- .../management/commands/test_mastodon.py | 10 ++++ joboffers/publishers/mastodon/__init__.py | 48 +++++++++++++++ joboffers/publishers/mastodon/template.html | 3 + joboffers/tests/test_joboffer_publisher.py | 20 ++++--- joboffers/tests/test_mastodon_publisher.py | 60 +++++++++++++++++++ pyarweb/settings/base.py | 13 ++-- requirements.txt | 1 + 7 files changed, 143 insertions(+), 12 deletions(-) create mode 100644 joboffers/management/commands/test_mastodon.py create mode 100644 joboffers/publishers/mastodon/__init__.py create mode 100644 joboffers/publishers/mastodon/template.html create mode 100644 joboffers/tests/test_mastodon_publisher.py diff --git a/joboffers/management/commands/test_mastodon.py b/joboffers/management/commands/test_mastodon.py new file mode 100644 index 0000000..f7da012 --- /dev/null +++ b/joboffers/management/commands/test_mastodon.py @@ -0,0 +1,10 @@ +from joboffers.management.commands import TestPublishCommand +from joboffers.publishers.mastodon import MastodonPublisher + + +class Command(TestPublishCommand): + help = 'Test sending a post to Mastodon.' + + def handle(self, *args, **options): + """Post a message to Mastodon.""" + self._handle_publish(options, MastodonPublisher) diff --git a/joboffers/publishers/mastodon/__init__.py b/joboffers/publishers/mastodon/__init__.py new file mode 100644 index 0000000..1e4415d --- /dev/null +++ b/joboffers/publishers/mastodon/__init__.py @@ -0,0 +1,48 @@ +import logging + +from django.conf import settings +from mastodon import Mastodon, errors + +from joboffers.utils import hash_secret +from joboffers.publishers import Publisher + + +def _repr_credentials(): + """Show a string representation of mastodon credentials.""" + # Need to convert to string, in case they are strings or they are not set + credentials_repr = ( + f' MASTODON_AUTH_TOKEN: {hash_secret(settings.MASTODON_AUTH_TOKEN)} ' + f' MASTODON_API_BASE_URL: {hash_secret(settings.MASTODON_API_BASE_URL)} ' + ) + return credentials_repr + + +ERROR_LOG_MESSAGE = ( + 'Falló al querer tootear con las siguientes credenciales (hasheadas): %s - Error: %s' +) + + +class MastodonPublisher(Publisher): + """Mastodon Publisher.""" + + name = 'Mastodon' + + def _push_to_api(self, message: str, title: str, link: str): + """Publish a message to mastodon.""" + mastodon = Mastodon( + access_token=settings.MASTODON_AUTH_TOKEN, + api_base_url=settings.MASTODON_API_BASE_URL, + ) + + try: + mastodon.status_post(message) + except errors.MastodonUnauthorizedError as err: + status = None + logging.error(ERROR_LOG_MESSAGE, _repr_credentials(), err) + except Exception as err: + status = None + logging.error("Unknown error when tooting: %s", repr(err)) + else: + status = 200 + + return status diff --git a/joboffers/publishers/mastodon/template.html b/joboffers/publishers/mastodon/template.html new file mode 100644 index 0000000..62ee88a --- /dev/null +++ b/joboffers/publishers/mastodon/template.html @@ -0,0 +1,3 @@ +Oferta: +{{ job_offer.short_description }} +{{ job_offer.get_full_url }} diff --git a/joboffers/tests/test_joboffer_publisher.py b/joboffers/tests/test_joboffer_publisher.py index d249223..cde0b68 100644 --- a/joboffers/tests/test_joboffer_publisher.py +++ b/joboffers/tests/test_joboffer_publisher.py @@ -10,6 +10,7 @@ from ..publishers.facebook import FacebookPublisher from ..publishers.telegram import TelegramPublisher from ..publishers.twitter import TwitterPublisher +from ..publishers.mastodon import MastodonPublisher from ..models import OfferState from .factories import JobOfferFactory @@ -78,21 +79,24 @@ def test_publisher_publish_error(): @pytest.mark.django_db @patch('joboffers.publishers.publish_offer') def test_publisher_to_all_social_networks_works_ok(publish_offer_function, settings): - """ - Test that publish_to_all_social_networks() uses all the condifured publishers - """ + """Test that publish_to_all_social_networks() uses all the configured publishers.""" joboffer = JobOfferFactory.create(state=OfferState.ACTIVE) settings.SOCIAL_NETWORKS_PUBLISHERS = [ - 'joboffers.publishers.discourse.DiscoursePublisher', - 'joboffers.publishers.facebook.FacebookPublisher', - 'joboffers.publishers.telegram.TelegramPublisher', - 'joboffers.publishers.twitter.TwitterPublisher' + 'joboffers.publishers.discourse.DiscoursePublisher', + 'joboffers.publishers.facebook.FacebookPublisher', + 'joboffers.publishers.telegram.TelegramPublisher', + 'joboffers.publishers.twitter.TwitterPublisher', + 'joboffers.publishers.mastodon.MastodonPublisher', ] publish_to_all_social_networks(joboffer) expected_publishers = [ - DiscoursePublisher, FacebookPublisher, TelegramPublisher, TwitterPublisher + DiscoursePublisher, + FacebookPublisher, + TelegramPublisher, + TwitterPublisher, + MastodonPublisher, ] assert publish_offer_function.called diff --git a/joboffers/tests/test_mastodon_publisher.py b/joboffers/tests/test_mastodon_publisher.py new file mode 100644 index 0000000..26d2f42 --- /dev/null +++ b/joboffers/tests/test_mastodon_publisher.py @@ -0,0 +1,60 @@ +from unittest.mock import patch + +import mastodon + +from ..publishers.mastodon import MastodonPublisher + +DUMMY_MESSAGE = 'message' +DUMMY_TITLE = 'title' +DUMMY_LINK = 'https://example.com' + + +class DummyAPIBad: + def __init__(self, to_raise): + self.to_raise = to_raise + + def status_post(self, *args, **kwargs): + raise self.to_raise + + +class DummyAPIOK: + def status_post(*args, **kwargs): + return + + +@patch('joboffers.publishers.mastodon.Mastodon') +def test_push_to_api_bad_credentials(mock_api, settings, caplog): + """Test exception when the credentials are wrong.""" + mock_api.return_value = DummyAPIBad(mastodon.errors.MastodonUnauthorizedError("bad auth")) + settings.MASTODON_AUTH_TOKEN = "wrong" + settings.MASTODON_API_BASE_URL = "creds" + + status = MastodonPublisher()._push_to_api(DUMMY_MESSAGE, DUMMY_TITLE, DUMMY_LINK) + assert status is None + + expected_error_message = "Falló al querer tootear con las siguientes credenciales (hasheadas)" + assert expected_error_message in caplog.text + + +@patch('joboffers.publishers.mastodon.Mastodon') +def test_push_to_api_generic_error(mock_api, settings, caplog): + """Something went wrong.""" + mock_api.return_value = DummyAPIBad(ValueError("boom")) + settings.MASTODON_AUTH_TOKEN = "good" + settings.MASTODON_API_BASE_URL = "creds" + + status = MastodonPublisher()._push_to_api(DUMMY_MESSAGE, DUMMY_TITLE, DUMMY_LINK) + assert status is None + + expected_error_message = "Unknown error when tooting: ValueError" + assert expected_error_message in caplog.text + + +@patch('joboffers.publishers.mastodon.Mastodon') +def test_push_to_api_ok(mock_api, settings): + mock_api.return_value = DummyAPIOK + settings.MASTODON_AUTH_TOKEN = "good" + settings.MASTODON_API_BASE_URL = "creds" + + status = MastodonPublisher()._push_to_api(DUMMY_MESSAGE, DUMMY_TITLE, DUMMY_LINK) + assert status == 200 diff --git a/pyarweb/settings/base.py b/pyarweb/settings/base.py index fd2a350..dfd344a 100644 --- a/pyarweb/settings/base.py +++ b/pyarweb/settings/base.py @@ -277,6 +277,10 @@ TWITTER_CONSUMER_KEY = os.environ.get('TWITTER_CONSUMER_KEY') TWITTER_CONSUMER_SECRET = os.environ.get('TWITTER_CONSUMER_SECRET') +# Mastodon constants +MASTODON_AUTH_TOKEN = os.environ.get('MASTODON_AUTH_TOKEN') +MASTODON_API_BASE_URL = os.environ.get('MASTODON_API_BASE_URL') + # Discourse constants DISCOURSE_HOST = os.environ.get('DISCOURSE_HOST') DISCOURSE_API_KEY = os.environ.get('DISCOURSE_API_KEY') @@ -284,10 +288,11 @@ DISCOURSE_CATEGORY = os.environ.get('DISCOURSE_CATEGORY') SOCIAL_NETWORKS_PUBLISHERS = [ - 'joboffers.publishers.discourse.DiscoursePublisher', - 'joboffers.publishers.facebook.FacebookPublisher', - 'joboffers.publishers.telegram.TelegramPublisher', - 'joboffers.publishers.twitter.TwitterPublisher' + 'joboffers.publishers.discourse.DiscoursePublisher', + 'joboffers.publishers.facebook.FacebookPublisher', + 'joboffers.publishers.telegram.TelegramPublisher', + 'joboffers.publishers.twitter.TwitterPublisher', + 'joboffers.publishers.mastodon.MastodonPublisher', ] DEFAULT_AUTO_FIELD = "django.db.models.AutoField" diff --git a/requirements.txt b/requirements.txt index 5d9231f..1671cc3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,6 +19,7 @@ django-tagging==0.5.0 django-taggit==1.5.1 django-taggit-autosuggest==0.3.8 lxml==4.9.1 +Mastodon.py==1.8.1 plotly==5.7.0 psycopg2-binary==2.9.1 tweepy==4.5.0