From e1f00148983689c9238af74c297b783110c41305 Mon Sep 17 00:00:00 2001 From: moanos Date: Sun, 20 Jul 2025 07:07:33 +0200 Subject: [PATCH] feat: Add "Post to Fediverse" --- src/fellchensammlung/admin.py | 5 +- .../migrations/0058_socialmediapost.py | 25 +++++ src/fellchensammlung/models.py | 30 +++++- .../fellchensammlung/errors/404.html | 14 +++ .../fellchensammlung/mod-tool-overview.html | 45 ++++++++- src/fellchensammlung/tools/fedi.py | 95 +++++++++++++++++++ src/fellchensammlung/views.py | 30 +++++- src/notfellchen/settings.py | 6 ++ 8 files changed, 243 insertions(+), 7 deletions(-) create mode 100644 src/fellchensammlung/migrations/0058_socialmediapost.py create mode 100644 src/fellchensammlung/templates/fellchensammlung/errors/404.html create mode 100644 src/fellchensammlung/tools/fedi.py diff --git a/src/fellchensammlung/admin.py b/src/fellchensammlung/admin.py index c77ec07..e91eb7e 100644 --- a/src/fellchensammlung/admin.py +++ b/src/fellchensammlung/admin.py @@ -8,7 +8,7 @@ from django.urls import reverse from django.utils.http import urlencode from .models import User, Language, Text, ReportComment, ReportAdoptionNotice, Log, Timestamp, SearchSubscription, \ - SpeciesSpecificURL, ImportantLocation + SpeciesSpecificURL, ImportantLocation, SocialMediaPost from .models import Animal, Species, RescueOrganization, AdoptionNotice, Location, Rule, Image, ModerationAction, \ Comment, Report, Announcement, AdoptionNoticeStatus, User, Subscriptions, Notification @@ -162,6 +162,9 @@ class LocationAdmin(admin.ModelAdmin): ImportantLocationInline, ] +@admin.register(SocialMediaPost) +class SocialMediaPostAdmin(admin.ModelAdmin): + list_filter = ("platform",) admin.site.register(Animal) admin.site.register(Species) diff --git a/src/fellchensammlung/migrations/0058_socialmediapost.py b/src/fellchensammlung/migrations/0058_socialmediapost.py new file mode 100644 index 0000000..f893812 --- /dev/null +++ b/src/fellchensammlung/migrations/0058_socialmediapost.py @@ -0,0 +1,25 @@ +# Generated by Django 5.2.1 on 2025-07-19 17:48 + +import django.db.models.deletion +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('fellchensammlung', '0057_delete_speciesspecialization'), + ] + + operations = [ + migrations.CreateModel( + name='SocialMediaPost', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateField(default=django.utils.timezone.now, verbose_name='Erstellt am')), + ('platform', models.CharField(choices=[('fediverse', 'Fediverse')], max_length=255, verbose_name='Social Media Platform')), + ('url', models.URLField(verbose_name='URL')), + ('adoption_notice', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='fellchensammlung.adoptionnotice', verbose_name='Vermittlung')), + ], + ), + ] diff --git a/src/fellchensammlung/models.py b/src/fellchensammlung/models.py index ed836d6..cdead1f 100644 --- a/src/fellchensammlung/models.py +++ b/src/fellchensammlung/models.py @@ -475,7 +475,6 @@ class AdoptionNotice(models.Model): return False return self.adoptionnoticestatus.is_disabled - @property def is_closed(self): if not hasattr(self, 'adoptionnoticestatus'): @@ -516,6 +515,14 @@ class AdoptionNotice(models.Model): text=text, title=notification_title) + def last_posted(self, platform=None): + if platform is None: + last_post = SocialMediaPost.objects.filter(adoption_notice=self).order_by('-created_at').first() + else: + last_post = SocialMediaPost.objects.filter(adoption_notice=self, platform=platform).order_by( + '-created_at').first() + return last_post.created_at + class AdoptionNoticeStatus(models.Model): """ @@ -1038,3 +1045,24 @@ class SpeciesSpecificURL(models.Model): rescue_organization = models.ForeignKey(RescueOrganization, on_delete=models.CASCADE, verbose_name=_("Tierschutzorganisation")) url = models.URLField(verbose_name=_("Tierartspezifische URL")) + + +class PlatformChoices(models.TextChoices): + FEDIVERSE = "fediverse", _("Fediverse") + + +class SocialMediaPost(models.Model): + created_at = models.DateField(verbose_name=_('Erstellt am'), default=timezone.now) + platform = models.CharField(max_length=255, verbose_name=_("Social Media Platform"), + choices=PlatformChoices.choices) + adoption_notice = models.ForeignKey(AdoptionNotice, on_delete=models.CASCADE, verbose_name=_('Vermittlung')) + url = models.URLField(verbose_name=_("URL")) + + @staticmethod + def get_an_to_post(): + adoption_notices_without_post = AdoptionNotice.objects.filter(socialmediapost__isnull=True, + adoptionnoticestatus__major_status=AdoptionNoticeStatus.ACTIVE) + return adoption_notices_without_post.first() + + def __str__(self): + return f"{self.platform} - {self.adoption_notice}" diff --git a/src/fellchensammlung/templates/fellchensammlung/errors/404.html b/src/fellchensammlung/templates/fellchensammlung/errors/404.html new file mode 100644 index 0000000..b6c4b87 --- /dev/null +++ b/src/fellchensammlung/templates/fellchensammlung/errors/404.html @@ -0,0 +1,14 @@ +{% extends "fellchensammlung/base.html" %} +{% load i18n %} +{% load custom_tags %} + +{% block title %}{% translate "403 Forbidden" %}{% endblock %} + +{% block content %} +

404 Not Found

+

+ {% blocktranslate %} + Diese Seite existiert nicht. + {% endblocktranslate %} +

+{% endblock %} \ No newline at end of file diff --git a/src/fellchensammlung/templates/fellchensammlung/mod-tool-overview.html b/src/fellchensammlung/templates/fellchensammlung/mod-tool-overview.html index 12a2f54..7b4271f 100644 --- a/src/fellchensammlung/templates/fellchensammlung/mod-tool-overview.html +++ b/src/fellchensammlung/templates/fellchensammlung/mod-tool-overview.html @@ -7,13 +7,52 @@

{% translate 'Moderationstools' %}

+ +
+ {% if action_was_posting %} + {% if posted_successfully %} +
+
+ {% translate 'Vermittlung gepostet' %} +
+
+ {% blocktranslate with post_url=post.url %} + Link zum Post: {{ post_url }} + {% endblocktranslate %} +
+ +
+ {% else %} +
+
+ {% translate 'Vermittlung konnte nicht gepostet werden' %} +
+
+ {{ error_message }} +
+ +
+ {% endif %} + {% endif %} + +
+ {% csrf_token %} + + +
diff --git a/src/fellchensammlung/tools/fedi.py b/src/fellchensammlung/tools/fedi.py new file mode 100644 index 0000000..4e9ba08 --- /dev/null +++ b/src/fellchensammlung/tools/fedi.py @@ -0,0 +1,95 @@ +import logging +import requests + +from fellchensammlung.models import SocialMediaPost, PlatformChoices +from notfellchen import settings + + +class FediClient: + def __init__(self, access_token, api_base_url): + """ + :param access_token: Your server API access token. + :param api_base_url: The base URL of the Fediverse instance (e.g., 'https://gay-pirate-assassins.de'). + """ + self.access_token = access_token + self.api_base_url = api_base_url.rstrip('/') + self.headers = { + 'Authorization': f'Bearer {self.access_token}', + } + + def upload_media(self, image_path, alt_text): + """ + Uploads media (image) to the server and returns the media ID. + :param image_path: Path to the image file to upload. + :param alt_text: Description (alt text) for the image. + :return: The media ID of the uploaded image. + """ + + media_endpoint = f'{self.api_base_url}/api/v2/media' + + with open(image_path, 'rb') as image_file: + files = { + 'file': image_file, + 'description': (None, alt_text) + } + response = requests.post(media_endpoint, headers=self.headers, files=files) + + # Raise exception if upload fails + response.raise_for_status() + + # Parse and return the media ID from the response + media_id = response.json().get('id') + return media_id + + def post_status(self, status, media_ids=None): + """ + Posts a status to Mastodon with optional media. + :param status: The text of the status to post. + :param media_ids: A list of media IDs to attach to the status (optional). + :return: The response from the Mastodon API. + """ + status_endpoint = f'{self.api_base_url}/api/v1/statuses' + + payload = { + 'status': status, + 'media_ids[]': media_ids if media_ids else [] + } + response = requests.post(status_endpoint, headers=self.headers, data=payload) + + # Raise exception if posting fails + response.raise_for_status() + + return response.json() + + def post_status_with_images(self, status, images): + """ + Uploads one or more image, then posts a status with that images and alt text. + :param status: The text of the status. + :param image_paths: The paths to the image file. + :param alt_text: The alt text for the image. + :return: The response from the Mastodon API. + """ + media_ids = [] + for image in images: + # Upload the image and get the media ID + media_ids = self.upload_media(f"{settings.MEDIA_ROOT}/{image.image}", image.alt_text) + + # Post the status with the uploaded image's media ID + return self.post_status(status, media_ids=media_ids) + + +def post_an_to_fedi(adoption_notice): + client = FediClient(settings.fediverse_access_token, settings.fediverse_api_base_url) + + status_text = adoption_notice.name + images = adoption_notice.get_photos() + + if images is not None: + response = client.post_status_with_images(status_text, images) + else: + response = client.post_status(status_text) + logging.info(response) + post = SocialMediaPost.objects.create(adoption_notice=adoption_notice, + platform=PlatformChoices.FEDIVERSE, + url=response['url'], ) + return post diff --git a/src/fellchensammlung/views.py b/src/fellchensammlung/views.py index da5b146..2b3ccf5 100644 --- a/src/fellchensammlung/views.py +++ b/src/fellchensammlung/views.py @@ -14,6 +14,7 @@ from django.contrib.auth.decorators import user_passes_test from django.core.serializers import serialize from django.utils.translation import gettext_lazy as _ import json +import requests from .mail import notify_mods_new_report from notfellchen import settings @@ -22,11 +23,12 @@ from fellchensammlung import logger from .models import AdoptionNotice, Text, Animal, Rule, Image, Report, ModerationAction, \ User, Location, AdoptionNoticeStatus, Subscriptions, Notification, RescueOrganization, \ Species, Log, Timestamp, TrustLevel, SexChoicesWithAll, SearchSubscription, \ - ImportantLocation, SpeciesSpecificURL, NotificationTypeChoices + ImportantLocation, SpeciesSpecificURL, NotificationTypeChoices, SocialMediaPost from .forms import AdoptionNoticeForm, ImageForm, ReportAdoptionNoticeForm, \ CommentForm, ReportCommentForm, AnimalForm, AdoptionNoticeFormAutoAnimal, SpeciesURLForm, RescueOrgInternalComment from .models import Language, Announcement from .tools import i18n +from .tools.fedi import post_an_to_fedi from .tools.geo import GeoAPI, zoom_level_for_radius from .tools.metrics import gather_metrics_data from .tools.admin import clean_locations, get_unchecked_adoption_notices, deactivate_unchecked_adoption_notices, \ @@ -864,7 +866,31 @@ def rescue_organization_check_dq(request): @user_passes_test(user_is_trust_level_or_above) def moderation_tools_overview(request): - return render(request, 'fellchensammlung/mod-tool-overview.html') + context = None + if request.method == "POST": + action = request.POST.get("action") + if action == "post_to_fedi": + adoption_notice = SocialMediaPost.get_an_to_post() + if adoption_notice is not None: + try: + post = post_an_to_fedi(adoption_notice) + context = {"action_was_posting": True, "post": post, "posted_successfully": True} + except requests.exceptions.ConnectionError as e: + logging.error(f"Could not post fediverse post: {e}") + context = {"action_was_posting": True, + "posted_successfully": False, + "error_message": _("Verbindungsfehler. Vermittlung wurde nicht gepostet")} + except requests.exceptions.HTTPError as e: + logging.error(f"Could not post fediverse post: {e}") + context = {"action_was_posting": True, + "posted_successfully": False, + "error_message": _("Fehler beim Posten. Vermittlung wurde nicht gepostet. Das kann " + "z.B. an falschen Zugangsdaten liegen. Kontaktieren einen Admin.")} + else: + context = {"action_was_posting": True, + "posted_successfully": False, + "error_message": _("Keine Vermittlung zum Posten gefunden.")} + return render(request, 'fellchensammlung/mod-tool-overview.html', context=context) def deactivate_an(request, adoption_notice_id): diff --git a/src/notfellchen/settings.py b/src/notfellchen/settings.py index df3d12e..afabd08 100644 --- a/src/notfellchen/settings.py +++ b/src/notfellchen/settings.py @@ -118,6 +118,12 @@ else: EMAIL_USE_TLS = config.getboolean('mail', 'tls', fallback=False) EMAIL_USE_SSL = config.getboolean('mail', 'ssl', fallback=False) +""" Fediverse """ +fediverse_enabled = config.get('fediverse', 'enabled', fallback=False) +if fediverse_enabled: + fediverse_api_base_url = config.get('fediverse', 'api_base_url') + fediverse_access_token = config.get('fediverse', 'access_token') + """USER MANAGEMENT""" AUTH_USER_MODEL = "fellchensammlung.User" ACCOUNT_ACTIVATION_DAYS = 7 # One-week activation window