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 django.utils.http import urlencode
|
||||||
|
|
||||||
from .models import User, Language, Text, ReportComment, ReportAdoptionNotice, Log, Timestamp, SearchSubscription, \
|
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, \
|
from .models import Animal, Species, RescueOrganization, AdoptionNotice, Location, Rule, Image, ModerationAction, \
|
||||||
Comment, Report, Announcement, AdoptionNoticeStatus, User, Subscriptions, Notification
|
Comment, Report, Announcement, AdoptionNoticeStatus, User, Subscriptions, Notification
|
||||||
@@ -162,6 +162,9 @@ class LocationAdmin(admin.ModelAdmin):
|
|||||||
ImportantLocationInline,
|
ImportantLocationInline,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@admin.register(SocialMediaPost)
|
||||||
|
class SocialMediaPostAdmin(admin.ModelAdmin):
|
||||||
|
list_filter = ("platform",)
|
||||||
|
|
||||||
admin.site.register(Animal)
|
admin.site.register(Animal)
|
||||||
admin.site.register(Species)
|
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 False
|
||||||
return self.adoptionnoticestatus.is_disabled
|
return self.adoptionnoticestatus.is_disabled
|
||||||
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_closed(self):
|
def is_closed(self):
|
||||||
if not hasattr(self, 'adoptionnoticestatus'):
|
if not hasattr(self, 'adoptionnoticestatus'):
|
||||||
@@ -516,6 +515,14 @@ class AdoptionNotice(models.Model):
|
|||||||
text=text,
|
text=text,
|
||||||
title=notification_title)
|
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):
|
class AdoptionNoticeStatus(models.Model):
|
||||||
"""
|
"""
|
||||||
@@ -1038,3 +1045,24 @@ class SpeciesSpecificURL(models.Model):
|
|||||||
rescue_organization = models.ForeignKey(RescueOrganization, on_delete=models.CASCADE,
|
rescue_organization = models.ForeignKey(RescueOrganization, on_delete=models.CASCADE,
|
||||||
verbose_name=_("Tierschutzorganisation"))
|
verbose_name=_("Tierschutzorganisation"))
|
||||||
url = models.URLField(verbose_name=_("Tierartspezifische URL"))
|
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">
|
<div class="block">
|
||||||
<h1 class="title is-1">{% translate 'Moderationstools' %}</h1>
|
<h1 class="title is-1">{% translate 'Moderationstools' %}</h1>
|
||||||
<div class="block">
|
<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>
|
||||||
<div class="block">
|
<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>
|
||||||
<div class="block">
|
<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>
|
||||||
</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.core.serializers import serialize
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
import json
|
import json
|
||||||
|
import requests
|
||||||
|
|
||||||
from .mail import notify_mods_new_report
|
from .mail import notify_mods_new_report
|
||||||
from notfellchen import settings
|
from notfellchen import settings
|
||||||
@@ -22,11 +23,12 @@ from fellchensammlung import logger
|
|||||||
from .models import AdoptionNotice, Text, Animal, Rule, Image, Report, ModerationAction, \
|
from .models import AdoptionNotice, Text, Animal, Rule, Image, Report, ModerationAction, \
|
||||||
User, Location, AdoptionNoticeStatus, Subscriptions, Notification, RescueOrganization, \
|
User, Location, AdoptionNoticeStatus, Subscriptions, Notification, RescueOrganization, \
|
||||||
Species, Log, Timestamp, TrustLevel, SexChoicesWithAll, SearchSubscription, \
|
Species, Log, Timestamp, TrustLevel, SexChoicesWithAll, SearchSubscription, \
|
||||||
ImportantLocation, SpeciesSpecificURL, NotificationTypeChoices
|
ImportantLocation, SpeciesSpecificURL, NotificationTypeChoices, SocialMediaPost
|
||||||
from .forms import AdoptionNoticeForm, ImageForm, ReportAdoptionNoticeForm, \
|
from .forms import AdoptionNoticeForm, ImageForm, ReportAdoptionNoticeForm, \
|
||||||
CommentForm, ReportCommentForm, AnimalForm, AdoptionNoticeFormAutoAnimal, SpeciesURLForm, RescueOrgInternalComment
|
CommentForm, ReportCommentForm, AnimalForm, AdoptionNoticeFormAutoAnimal, SpeciesURLForm, RescueOrgInternalComment
|
||||||
from .models import Language, Announcement
|
from .models import Language, Announcement
|
||||||
from .tools import i18n
|
from .tools import i18n
|
||||||
|
from .tools.fedi import post_an_to_fedi
|
||||||
from .tools.geo import GeoAPI, zoom_level_for_radius
|
from .tools.geo import GeoAPI, zoom_level_for_radius
|
||||||
from .tools.metrics import gather_metrics_data
|
from .tools.metrics import gather_metrics_data
|
||||||
from .tools.admin import clean_locations, get_unchecked_adoption_notices, deactivate_unchecked_adoption_notices, \
|
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)
|
@user_passes_test(user_is_trust_level_or_above)
|
||||||
def moderation_tools_overview(request):
|
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):
|
def deactivate_an(request, adoption_notice_id):
|
||||||
|
@@ -118,6 +118,12 @@ else:
|
|||||||
EMAIL_USE_TLS = config.getboolean('mail', 'tls', fallback=False)
|
EMAIL_USE_TLS = config.getboolean('mail', 'tls', fallback=False)
|
||||||
EMAIL_USE_SSL = config.getboolean('mail', 'ssl', 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"""
|
"""USER MANAGEMENT"""
|
||||||
AUTH_USER_MODEL = "fellchensammlung.User"
|
AUTH_USER_MODEL = "fellchensammlung.User"
|
||||||
ACCOUNT_ACTIVATION_DAYS = 7 # One-week activation window
|
ACCOUNT_ACTIVATION_DAYS = 7 # One-week activation window
|
||||||
|
Reference in New Issue
Block a user