feat: Add "Post to Fediverse"
This commit is contained in:
		@@ -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)
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										25
									
								
								src/fellchensammlung/migrations/0058_socialmediapost.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								src/fellchensammlung/migrations/0058_socialmediapost.py
									
									
									
									
									
										Normal file
									
								
							@@ -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')),
 | 
			
		||||
            ],
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@@ -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}"
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,14 @@
 | 
			
		||||
{% extends "fellchensammlung/base.html" %}
 | 
			
		||||
{% load i18n %}
 | 
			
		||||
{% load custom_tags %}
 | 
			
		||||
 | 
			
		||||
{% block title %}<title>{% translate "403 Forbidden" %}</title>{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
    <h1 class="title is-1">404 Not Found</h1>
 | 
			
		||||
    <p>
 | 
			
		||||
    {% blocktranslate %}
 | 
			
		||||
    	Diese Seite existiert nicht.
 | 
			
		||||
    {% endblocktranslate %}
 | 
			
		||||
    </p>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
@@ -7,13 +7,52 @@
 | 
			
		||||
    <div class="block">
 | 
			
		||||
        <h1 class="title is-1">{% translate 'Moderationstools' %}</h1>
 | 
			
		||||
        <div class="block">
 | 
			
		||||
            <a class="button is-primary is-fullwidth" href="{% url 'modqueue' %}">{% translate 'Moderationswarteschlange' %}</a>
 | 
			
		||||
            <a class="button is-primary is-fullwidth"
 | 
			
		||||
               href="{% url 'modqueue' %}">{% translate 'Moderationswarteschlange' %}</a>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="block">
 | 
			
		||||
            <a class="button is-primary is-fullwidth" href="{% url 'updatequeue' %}">{% translate 'Up-To-Date Check' %}</a>
 | 
			
		||||
            <a class="button is-primary is-fullwidth"
 | 
			
		||||
               href="{% url 'updatequeue' %}">{% translate 'Up-To-Date Check' %}</a>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="block">
 | 
			
		||||
            <a class="button is-primary is-fullwidth" href="{% url 'organization-check' %}">{% translate 'Organisations Check' %}</a>
 | 
			
		||||
            <a class="button is-primary is-fullwidth"
 | 
			
		||||
               href="{% url 'organization-check' %}">{% translate 'Organisations Check' %}</a>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <div class="block">
 | 
			
		||||
            {% if action_was_posting %}
 | 
			
		||||
                {% if posted_successfully %}
 | 
			
		||||
                    <div class="message is-success">
 | 
			
		||||
                        <div class="message-header">
 | 
			
		||||
                            {% translate 'Vermittlung gepostet' %}
 | 
			
		||||
                        </div>
 | 
			
		||||
                        <div class="message-body">
 | 
			
		||||
                            {% blocktranslate with post_url=post.url %}
 | 
			
		||||
                                Link zum Post: <a href={{ post_url }}>{{ post_url }}</a>
 | 
			
		||||
                            {% endblocktranslate %}
 | 
			
		||||
                        </div>
 | 
			
		||||
 | 
			
		||||
                    </div>
 | 
			
		||||
                {% else %}
 | 
			
		||||
                    <div class="message is-danger">
 | 
			
		||||
                        <div class="message-header">
 | 
			
		||||
                            {% translate 'Vermittlung konnte nicht gepostet werden' %}
 | 
			
		||||
                        </div>
 | 
			
		||||
                        <div class="message-body">
 | 
			
		||||
                            {{ error_message }}
 | 
			
		||||
                        </div>
 | 
			
		||||
 | 
			
		||||
                    </div>
 | 
			
		||||
                {% endif %}
 | 
			
		||||
            {% endif %}
 | 
			
		||||
 | 
			
		||||
            <form class="cell" method="post">
 | 
			
		||||
                {% csrf_token %}
 | 
			
		||||
                <input type="hidden" name="action" value="post_to_fedi">
 | 
			
		||||
                <button class="button is-fullwidth is-warning is-primary" type="submit" id="submit">
 | 
			
		||||
                    <i class="fa-solid fa-bullhorn fa-fw"></i> {% translate "Vermittlung ins Fediverse posten" %}
 | 
			
		||||
                </button>
 | 
			
		||||
            </form>
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										95
									
								
								src/fellchensammlung/tools/fedi.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										95
									
								
								src/fellchensammlung/tools/fedi.py
									
									
									
									
									
										Normal file
									
								
							@@ -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
 | 
			
		||||
@@ -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):
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user