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 %}
+
+
+
+ {% blocktranslate with post_url=post.url %}
+ Link zum Post:
{{ post_url }}
+ {% endblocktranslate %}
+
+
+
+ {% else %}
+
+
+
+ {{ error_message }}
+
+
+
+ {% endif %}
+ {% endif %}
+
+
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