feat: Add "Post to Fediverse"

This commit is contained in:
2025-07-20 07:07:33 +02:00
parent 05b3a470f3
commit e1f0014898
8 changed files with 243 additions and 7 deletions

View File

@@ -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)

View 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')),
],
),
]

View File

@@ -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}"

View File

@@ -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 %}

View File

@@ -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>

View 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

View File

@@ -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):

View File

@@ -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