Compare commits

...

41 Commits

Author SHA1 Message Date
ef824d6b82 feat: Download templates when acessing page 2026-02-07 21:40:46 +01:00
153dc8c048 feat: Add overview page for adoption notice social media actions 2026-02-07 21:36:22 +01:00
00e32e0f7c refactor: Abstract post to fedi component 2026-02-07 21:35:51 +01:00
75789f4247 feat: Add instagram story photo generator 2026-02-07 20:19:34 +01:00
96221a1232 fix: also exclude ongoing communication from check statistics 2026-02-07 20:19:05 +01:00
c230020fc8 fix: Translate Allow material usage 2026-01-20 22:32:53 +01:00
e5c822dbb4 feat: exclude orgs that denied usage from check 2026-01-20 22:27:52 +01:00
9457b19e83 feat: Make date of birt optional, fixes #30 2026-01-20 22:15:16 +01:00
0e6a9c7c7d fix: adjust a bit of styling in darkmode 2026-01-20 21:17:06 +01:00
c324394949 feat: add species specific url to api 2026-01-20 21:03:08 +01:00
dfa44ea0c6 feat: add script to export shelters with websites for #29 2026-01-20 21:02:40 +01:00
c309ea9ed4 feat: add meerschweinchen 2026-01-06 18:54:36 +01:00
f3f7131912 feat: add rescue org create and change form 2026-01-06 17:15:52 +01:00
9a3cbffa42 feat: Add basic name based search for rescue orgs 2025-11-30 09:41:33 +01:00
f2bad5f171 feat: buttons span whole footer 2025-11-30 08:37:38 +01:00
515d9453d1 fix: use correct db value for migration 2025-11-29 13:17:42 +01:00
d7797ab02e feat: Add nicer display for location and adoption notice 2025-11-29 11:53:44 +01:00
ad3511c086 feat: Flatten status and use django form 2025-11-29 11:27:30 +01:00
f9f35f9104 feat: Add exclude shelters with different purpose 2025-11-26 23:41:03 +01:00
c4da3318c2 feat: Add history tracking 2025-11-16 18:49:01 +01:00
c529153373 feat: Add mod notes and user activation and deavtivation 2025-11-16 18:07:13 +01:00
8d0e4c62f7 feat: Hide user action buttons 2025-11-16 17:23:20 +01:00
4ab546bd8e feat: add cover for images and adoption process if inactive 2025-11-16 16:17:19 +01:00
cab9010aff feat: make status of disabled ANs more clear 2025-11-16 13:40:38 +01:00
97df7e3ed6 fix: remove caching 2025-11-12 20:30:16 +01:00
e64cc4bd5f Merge branch 'develop'
# Conflicts:
#	src/locale/en/LC_MESSAGES/django.po
2025-11-09 17:55:11 +01:00
a498971d66 feat: create directory in one go, don't use cache dir and use more standard entrypoint.sh 2025-11-09 17:36:48 +01:00
527ab07b6f trans: fis various 2025-11-08 00:33:35 +01:00
e2e236d650 feat: add button to download all logs 2025-11-08 00:00:54 +01:00
c4100a9ade trans: fix various 2025-11-07 18:17:53 +01:00
9511b46af0 feat: deactivate cache in dev config 2025-11-07 18:13:30 +01:00
5b906a7708 feat: add clean method 2025-11-07 18:12:53 +01:00
d68e836b57 trans: add various english translations 2025-11-07 18:12:37 +01:00
fe77f1da8d trans: add many translations 2025-11-07 17:08:13 +01:00
78b71690c0 feat: Allow toggling ongoing communication status 2025-11-07 14:02:22 +01:00
3b9ee95abc feat: Add log export functionality 2025-11-07 14:01:51 +01:00
b4e50364de fix: dependency 2025-11-03 22:22:22 +01:00
b014b3b227 fix: quotes 2025-11-03 22:22:12 +01:00
99bfe460ee fix: remove debug statement 2025-11-03 18:24:45 +01:00
d4c7caa42d fix: allow empty internal IP 2025-11-03 18:24:20 +01:00
32c8fc88cf feat: add debug command that prints settings 2025-11-03 18:22:11 +01:00
50 changed files with 4760 additions and 1054 deletions

View File

@@ -9,15 +9,14 @@ RUN apt install gettext -y
RUN apt install libpq-dev gcc -y RUN apt install libpq-dev gcc -y
COPY . /app COPY . /app
WORKDIR /app WORKDIR /app
RUN mkdir /app/data RUN mkdir /app/data/static -p
RUN mkdir /app/data/static
RUN mkdir /app/data/media RUN mkdir /app/data/media
RUN pip install -e . # Without the -e the library static folder will not be copied by collectstatic! RUN pip install --no-cache-dir -e . # Without the -e the library static folder will not be copied by collectstatic!
RUN nf collectstatic --noinput RUN nf collectstatic --noinput
RUN nf compilemessages --ignore venv RUN nf compilemessages --ignore venv
COPY docker/notfellchen.bash /bin/notfellchen COPY docker/entrypoint.sh /bin/notfellchen
EXPOSE 7345 EXPOSE 7345
CMD ["notfellchen"] CMD ["notfellchen"]

View File

@@ -0,0 +1,19 @@
Tierschutzorganisation hinzufügen
=================================
Notfellchen führt eine Liste von Tierheime und anderer Tierschutzorganisationen. Die meisten Tierheime wurden von
OpenStreetMap importiert.
Ausnahmen stellen nicht-ortsgebunden Organisationen wie die Rattenhilfe Süd dar. Sie existieren nicht auf der Karte und
können dort auch nicht sinnvoll verzeichnet werden, daher werden sie nur in Notfellchen geführt.
.. warning::
Generell ist es besser eine Tierschutzorganisation in OpenStreetMap anzulegen, anstatt bei Notfellchen. Das
ermöglichtes anderen die Daten ebenfalls zu nutzen und sie werden ggf. durch die OpenStreetMap Community aktuell gehalten.
Hier erklären wir aber trotzdem wie Tierheime direkt in Notfellchen hinzugefügt werden können, damit es schneller geht
diese Anzulegen und ihnen Vermittlungen zuzuweisen.
Organisationen in Notfellchen hinzufügen
----------------------------------------

View File

@@ -9,6 +9,7 @@ host=localhost
secret=CHANGE-ME secret=CHANGE-ME
debug=True debug=True
internal_ips=["127.0.0.1"] internal_ips=["127.0.0.1"]
cache=False
[database] [database]
backend=sqlite3 backend=sqlite3

View File

@@ -40,6 +40,9 @@ dependencies = [
"django-widget-tweaks", "django-widget-tweaks",
"django-super-deduper", "django-super-deduper",
"django-allauth[mfa]", "django-allauth[mfa]",
"django_debug_toolbar",
"django-admin-extra-buttons",
"django-simple-history"
] ]
dynamic = ["version", "readme"] dynamic = ["version", "readme"]
@@ -49,13 +52,16 @@ develop = [
"pytest", "pytest",
"coverage", "coverage",
"model_bakery", "model_bakery",
"debug_toolbar",
] ]
docs = [ docs = [
"sphinx", "sphinx",
"sphinx-rtd-theme", "sphinx-rtd-theme",
"sphinx-autobuild" "sphinx-autobuild"
] ]
shelter-upload = [
"osm2geojson",
"tqdm"
]
[project.urls] [project.urls]
homepage = "https://notfellchen.org" homepage = "https://notfellchen.org"

View File

@@ -0,0 +1,66 @@
import argparse
import csv
import os
import requests
OUTPUT_FILE = "export.csv"
def parse_args():
"""Parse command-line arguments."""
parser = argparse.ArgumentParser(
description="Download animal shelter data from the Overpass API to the Notfellchen API.")
parser.add_argument("--api-token", type=str, help="API token for authentication.")
parser.add_argument("--instance", type=str, help="API instance URL.")
parser.add_argument("--output-file", type=str, help="Path output file.")
return parser.parse_args()
def get_config():
"""Get configuration from environment variables or command-line arguments."""
args = parse_args()
api_token = args.api_token or os.getenv("NOTFELLCHEN_API_TOKEN")
instance = args.instance or os.getenv("NOTFELLCHEN_INSTANCE")
output_file = args.output_file or OUTPUT_FILE
return api_token, instance, output_file
def rat_specific_url_or_none(org):
try:
urls = org["species_specific_urls"]
for url in urls:
# 1 is the key for rats
if url["species"] == 1:
return url["url"]
# Return none if no url for this species is found
return None
except KeyError:
return None
def main():
api_token, instance, output_file = get_config()
# Set headers and endpoint
endpoint = f"{instance}/api/organizations/"
h = {'Authorization': f'Token {api_token}', "content-type": "application/json"}
rescue_orgs_result = requests.get(endpoint, headers=h)
with open(output_file, 'w') as csvfile:
fieldnames = ['id', 'name', 'website', 'rat_specific_website']
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
writer.writeheader()
for org in rescue_orgs_result.json():
writer.writerow({'id': org["id"],
'name': org["name"],
'website': org["website"],
"rat_specific_website": rat_specific_url_or_none(org)})
if __name__ == "__main__":
main()

View File

@@ -133,6 +133,19 @@ def get_center_coordinates(geometry):
raise ValueError(f"Unsupported geometry type: {geom_type}") raise ValueError(f"Unsupported geometry type: {geom_type}")
def is_not_for_adoption(tierheim):
"""
Returns true if the shelter is not for the purpose of adoption
"""
if tierheim.purpose_adoption == "no":
return True
elif (tierheim.purpose_adoption is None and
(tierheim.purpose_sanctuary == "yes" or tierheim.purpose_release == "yes")):
return True
else:
return False
# TODO: take note of new get_overpass_result function which does the bulk of the new overpass query work # TODO: take note of new get_overpass_result function which does the bulk of the new overpass query work
def get_overpass_result(area, data_file): def get_overpass_result(area, data_file):
"""Build the Overpass query for fetching animal shelters in the specified area.""" """Build the Overpass query for fetching animal shelters in the specified area."""
@@ -225,7 +238,13 @@ def main():
description=get_or_none(tierheim, "opening_hours"), description=get_or_none(tierheim, "opening_hours"),
external_object_identifier=tierheim["id"], external_object_identifier=tierheim["id"],
EXTERNAL_SOURCE_IDENTIFIER="OSM", EXTERNAL_SOURCE_IDENTIFIER="OSM",
purpose_adoption=get_or_none(tierheim, "animal_shelter:adoption"),
purpose_sanctuary=get_or_none(tierheim, "animal_shelter:sanctuary"),
purpose_release=get_or_none(tierheim, "animal_shelter:release"),
) )
# Skip the shelter if it is not for adopting animals
if is_not_for_adoption(th_data):
continue
# Define here for later # Define here for later
optional_data = ["email", "phone_number", "website", "description", "fediverse_profile", "facebook", optional_data = ["email", "phone_number", "website", "description", "fediverse_profile", "facebook",
@@ -250,7 +269,8 @@ def main():
result = requests.patch(endpoint, json=org_patch_data, headers=h) result = requests.patch(endpoint, json=org_patch_data, headers=h)
if result.status_code != 200: if result.status_code != 200:
logging.warning(f"Updating {tierheim['properties']['name']} failed:{result.status_code} {result.json()}") logging.warning(
f"Updating {tierheim['properties']['name']} failed:{result.status_code} {result.json()}")
continue continue
# Rescue organization does not exist # Rescue organization does not exist
else: else:
@@ -268,7 +288,8 @@ def main():
if result.status_code != 201: if result.status_code != 201:
print(f"{idx} {tierheim["properties"]["name"]}:{result.status_code} {result.json()}") print(f"{idx} {tierheim["properties"]["name"]}:{result.status_code} {result.json()}")
print(f"Upload finished. Inserted {stats['num_inserted_orgs']} new orgs and updated {stats['num_updated_orgs']} orgs.") print(
f"Upload finished. Inserted {stats['num_inserted_orgs']} new orgs and updated {stats['num_updated_orgs']} orgs.")
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -7,6 +7,9 @@ from django.utils.html import format_html
from django.urls import reverse from django.urls import reverse
from django.utils.http import urlencode from django.utils.http import urlencode
from admin_extra_buttons.api import ExtraButtonsMixin, button, link
from simple_history.admin import SimpleHistoryAdmin
from .models import Language, Text, ReportComment, ReportAdoptionNotice, Log, Timestamp, SearchSubscription, \ from .models import Language, Text, ReportComment, ReportAdoptionNotice, Log, Timestamp, SearchSubscription, \
SpeciesSpecificURL, ImportantLocation, SocialMediaPost SpeciesSpecificURL, ImportantLocation, SocialMediaPost
@@ -17,12 +20,34 @@ from django.utils.translation import gettext_lazy as _
from .tools.model_helpers import AdoptionNoticeStatusChoices from .tools.model_helpers import AdoptionNoticeStatusChoices
def export_to_csv_generic(model, queryset):
meta = model._meta
field_names = [field.name for field in meta.fields]
response = HttpResponse(content_type='text/csv')
response['Content-Disposition'] = 'attachment; filename={}.csv'.format(meta)
writer = csv.writer(response)
writer.writerow(field_names)
for obj in queryset:
row = writer.writerow([getattr(obj, field) for field in field_names])
return response
@admin.register(AdoptionNotice) @admin.register(AdoptionNotice)
class AdoptionNoticeAdmin(admin.ModelAdmin): class AdoptionNoticeAdmin(admin.ModelAdmin):
search_fields = ("name__icontains", "description__icontains") search_fields = ("name__icontains", "description__icontains", "location__icontains")
list_filter = ("owner",) list_display = ["name", "adoption_notice_status", "owner", "organization", "last_checked_hr"]
list_filter = ("adoption_notice_status", "owner")
actions = ("activate",) actions = ("activate",)
# This admin display is only needed to get a translated label of this property
# If not present the column would show up as "last checked hr"
@admin.display(description=_("zuletzt überprüft"))
def last_checked_hr(self, obj):
return obj.last_checked_hr
def activate(self, request, queryset): def activate(self, request, queryset):
for obj in queryset: for obj in queryset:
obj.adoption_notice_status = AdoptionNoticeStatusChoices.Active.SEARCHING obj.adoption_notice_status = AdoptionNoticeStatusChoices.Active.SEARCHING
@@ -33,7 +58,7 @@ class AdoptionNoticeAdmin(admin.ModelAdmin):
# Re-register UserAdmin # Re-register UserAdmin
@admin.register(User) @admin.register(User)
class UserAdmin(admin.ModelAdmin): class UserAdmin(SimpleHistoryAdmin):
search_fields = ("usernamname__icontains", "first_name__icontains", "last_name__icontains", "email__icontains") search_fields = ("usernamname__icontains", "first_name__icontains", "last_name__icontains", "email__icontains")
list_display = ("username", "email", "trust_level", "is_active", "view_adoption_notices") list_display = ("username", "email", "trust_level", "is_active", "view_adoption_notices")
list_filter = ("is_active", "trust_level",) list_filter = ("is_active", "trust_level",)
@@ -49,17 +74,7 @@ class UserAdmin(admin.ModelAdmin):
return format_html('<a href="{}">{} Adoption Notices</a>', url, count) return format_html('<a href="{}">{} Adoption Notices</a>', url, count)
def export_as_csv(self, request, queryset): def export_as_csv(self, request, queryset):
meta = self.model._meta response = export_to_csv_generic(self.model, queryset)
field_names = [field.name for field in meta.fields]
response = HttpResponse(content_type='text/csv')
response['Content-Disposition'] = 'attachment; filename={}.csv'.format(meta)
writer = csv.writer(response)
writer.writerow(field_names)
for obj in queryset:
row = writer.writerow([getattr(obj, field) for field in field_names])
return response return response
export_as_csv.short_description = _("Ausgewählte User exportieren") export_as_csv.short_description = _("Ausgewählte User exportieren")
@@ -71,7 +86,7 @@ def _reported_content_link(obj):
@admin.register(ReportComment) @admin.register(ReportComment)
class ReportCommentAdmin(admin.ModelAdmin): class ReportCommentAdmin(SimpleHistoryAdmin):
list_display = ["user_comment", "reported_content_link"] list_display = ["user_comment", "reported_content_link"]
date_hierarchy = "created_at" date_hierarchy = "created_at"
@@ -82,7 +97,7 @@ class ReportCommentAdmin(admin.ModelAdmin):
@admin.register(ReportAdoptionNotice) @admin.register(ReportAdoptionNotice)
class ReportAdoptionNoticeAdmin(admin.ModelAdmin): class ReportAdoptionNoticeAdmin(SimpleHistoryAdmin):
list_display = ["user_comment", "reported_content_link"] list_display = ["user_comment", "reported_content_link"]
date_hierarchy = "created_at" date_hierarchy = "created_at"
@@ -97,7 +112,7 @@ class SpeciesSpecificURLInline(admin.StackedInline):
@admin.register(RescueOrganization) @admin.register(RescueOrganization)
class RescueOrganizationAdmin(admin.ModelAdmin): class RescueOrganizationAdmin(SimpleHistoryAdmin):
search_fields = ("name", "description", "internal_comment", "location_string", "location__city") search_fields = ("name", "description", "internal_comment", "location_string", "location__city")
list_display = ("name", "trusted", "allows_using_materials", "website") list_display = ("name", "trusted", "allows_using_materials", "website")
list_filter = ("allows_using_materials", "trusted", ("external_source_identifier", EmptyFieldListFilter)) list_filter = ("allows_using_materials", "trusted", ("external_source_identifier", EmptyFieldListFilter))
@@ -108,12 +123,12 @@ class RescueOrganizationAdmin(admin.ModelAdmin):
@admin.register(Text) @admin.register(Text)
class TextAdmin(admin.ModelAdmin): class TextAdmin(SimpleHistoryAdmin):
search_fields = ("title__icontains", "text_code__icontains",) search_fields = ("title__icontains", "text_code__icontains",)
@admin.register(Comment) @admin.register(Comment)
class CommentAdmin(admin.ModelAdmin): class CommentAdmin(SimpleHistoryAdmin):
list_filter = ("user",) list_filter = ("user",)
@@ -123,7 +138,7 @@ class BaseNotificationAdmin(admin.ModelAdmin):
@admin.register(SearchSubscription) @admin.register(SearchSubscription)
class SearchSubscriptionAdmin(admin.ModelAdmin): class SearchSubscriptionAdmin(SimpleHistoryAdmin):
list_filter = ("owner",) list_filter = ("owner",)
@@ -151,8 +166,17 @@ class IsImportantListFilter(admin.SimpleListFilter):
@admin.register(Location) @admin.register(Location)
class LocationAdmin(admin.ModelAdmin): class LocationAdmin(SimpleHistoryAdmin):
search_fields = ("name__icontains", "city__icontains") search_fields = ("name__icontains", "city__icontains")
list_display = ("name", "city", "slug")
@admin.display(description=_("Slug"))
def slug(self, obj):
if obj.importantlocation:
return obj.importantlocation.slug
else:
return None
list_filter = [IsImportantListFilter] list_filter = [IsImportantListFilter]
inlines = [ inlines = [
ImportantLocationInline, ImportantLocationInline,
@@ -160,23 +184,39 @@ class LocationAdmin(admin.ModelAdmin):
@admin.register(SocialMediaPost) @admin.register(SocialMediaPost)
class SocialMediaPostAdmin(admin.ModelAdmin): class SocialMediaPostAdmin(SimpleHistoryAdmin):
list_filter = ("platform",) list_filter = ("platform",)
@admin.register(Log) @admin.register(Log)
class LogAdmin(admin.ModelAdmin): class LogAdmin(ExtraButtonsMixin, admin.ModelAdmin):
ordering = ["-created_at"] ordering = ["-created_at"]
list_filter = ("action",) list_filter = ("action",)
list_display = ("action", "user", "created_at") list_display = ("action", "user", "created_at")
actions = ("export_as_csv",)
@admin.action(description=_("Ausgewählte Logs exportieren"))
def export_as_csv(self, request, queryset):
response = export_to_csv_generic(Log, queryset)
return response
@button()
def export_all_as_csv(self, request):
actual_queryset = Log.objects.all()
response = export_to_csv_generic(Log, actual_queryset)
return response
@link(href="https://www.google.com/", visible=lambda btn: True)
def invisible(self, button):
button.visible = False
admin.site.register(Animal) admin.site.register(Animal, SimpleHistoryAdmin)
admin.site.register(Species) admin.site.register(Species)
admin.site.register(Rule) admin.site.register(Rule, SimpleHistoryAdmin)
admin.site.register(Image) admin.site.register(Image)
admin.site.register(ModerationAction) admin.site.register(ModerationAction, SimpleHistoryAdmin)
admin.site.register(Language) admin.site.register(Language)
admin.site.register(Announcement) admin.site.register(Announcement, SimpleHistoryAdmin)
admin.site.register(Subscriptions) admin.site.register(Subscriptions, SimpleHistoryAdmin)
admin.site.register(Timestamp) admin.site.register(Timestamp)

View File

@@ -1,4 +1,4 @@
from ..models import Animal, RescueOrganization, AdoptionNotice, Species, Image, Location from ..models import Animal, RescueOrganization, AdoptionNotice, Species, Image, Location, SpeciesSpecificURL
from rest_framework import serializers from rest_framework import serializers
import math import math
@@ -144,10 +144,26 @@ class AnimalGetSerializer(serializers.ModelSerializer):
fields = "__all__" fields = "__all__"
class SpeciesSpecificURLSerializer(serializers.ModelSerializer):
class Meta:
model = SpeciesSpecificURL
fields = "__all__"
class RescueOrganizationSerializer(serializers.ModelSerializer): class RescueOrganizationSerializer(serializers.ModelSerializer):
url = serializers.SerializerMethodField()
species_specific_urls = SpeciesSpecificURLSerializer(many=True, read_only=True)
class Meta: class Meta:
model = RescueOrganization model = RescueOrganization
exclude = ["internal_comment", "allows_using_materials"] fields = ('id', 'name', 'url', 'trusted', 'location_string', 'instagram', "facebook", "fediverse_profile",
"email", "phone_number", "website", "updated_at", "created_at", "last_checked", "description",
"external_source_identifier", "external_object_identifier", "exclude_from_check",
"regular_check_status", "ongoing_communication", "twenty_id", "location", "parent_org", "specializations",
"species_specific_urls")
def get_url(self, obj):
return obj.get_absolute_url()
class ImageCreateSerializer(serializers.ModelSerializer): class ImageCreateSerializer(serializers.ModelSerializer):

View File

@@ -2,10 +2,11 @@ from django.urls import path
from .views import ( from .views import (
AdoptionNoticeApiView, AdoptionNoticeApiView,
AnimalApiView, RescueOrganizationApiView, AddImageApiView, SpeciesApiView, LocationApiView, AnimalApiView, RescueOrganizationApiView, AddImageApiView, SpeciesApiView, LocationApiView,
AdoptionNoticeGeoJSONView, RescueOrgGeoJSONView, AdoptionNoticePerOrgApiView AdoptionNoticeGeoJSONView, RescueOrgGeoJSONView, AdoptionNoticePerOrgApiView, index
) )
urlpatterns = [ urlpatterns = [
path("", index, name="api-base-url"),
path("adoption_notice", AdoptionNoticeApiView.as_view(), name="api-adoption-notice-list"), path("adoption_notice", AdoptionNoticeApiView.as_view(), name="api-adoption-notice-list"),
path("adoption_notice.geojson", AdoptionNoticeGeoJSONView.as_view(), name="api-adoption-notice-list-geojson"), path("adoption_notice.geojson", AdoptionNoticeGeoJSONView.as_view(), name="api-adoption-notice-list-geojson"),
path("adoption_notice/<int:id>/", AdoptionNoticeApiView.as_view(), name="api-adoption-notice-detail"), path("adoption_notice/<int:id>/", AdoptionNoticeApiView.as_view(), name="api-adoption-notice-detail"),

View File

@@ -1,4 +1,6 @@
from django.db.models import Q from django.db.models import Q
from django.shortcuts import redirect
from django.urls import reverse
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from rest_framework.generics import ListAPIView from rest_framework.generics import ListAPIView
@@ -450,3 +452,7 @@ class AdoptionNoticePerOrgApiView(APIView):
adoption_notices = temporary_an_storage adoption_notices = temporary_an_storage
serializer = AdoptionNoticeSerializer(adoption_notices, many=True, context={"request": request}) serializer = AdoptionNoticeSerializer(adoption_notices, many=True, context={"request": request})
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
def index(request):
return redirect(reverse("swagger-ui"))

View File

@@ -5,18 +5,17 @@ from .models import AdoptionNotice, Animal, Image, ReportAdoptionNotice, ReportC
Comment, SexChoicesWithAll, DistanceChoices, SpeciesSpecificURL, RescueOrganization Comment, SexChoicesWithAll, DistanceChoices, SpeciesSpecificURL, RescueOrganization
from django_registration.forms import RegistrationForm from django_registration.forms import RegistrationForm
from crispy_forms.helper import FormHelper from crispy_forms.helper import FormHelper
from crispy_forms.layout import Submit, Layout, Fieldset, HTML, Row, Column, Field, Hidden from crispy_forms.layout import Submit, Layout, Fieldset
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from notfellchen.settings import MEDIA_URL
from crispy_forms.layout import Div from crispy_forms.layout import Div
from .tools.model_helpers import reason_for_signup_label, reason_for_signup_help_text from .tools.model_helpers import reason_for_signup_label, reason_for_signup_help_text, AdoptionNoticeStatusChoices
def animal_validator(value: str): def animal_validator(value: str):
value = value.lower() value = value.lower()
animal_list = ["ratte", "farbratte", "katze", "hund", "kaninchen", "hase", "kuh", "fuchs", "cow", "rat", "cat", animal_list = ["ratte", "farbratte", "katze", "hund", "kaninchen", "hase", "kuh", "fuchs", "cow", "rat", "cat",
"dog", "rabbit", "fox", "fancy rat"] "dog", "rabbit", "fox", "fancy rat", "meerschweinchen"]
if value not in animal_list: if value not in animal_list:
raise forms.ValidationError(_("Dieses Tier kenne ich nicht. Probier ein anderes")) raise forms.ValidationError(_("Dieses Tier kenne ich nicht. Probier ein anderes"))
@@ -116,6 +115,12 @@ class ReportCommentForm(forms.ModelForm):
fields = ('reported_broken_rules', 'user_comment') fields = ('reported_broken_rules', 'user_comment')
class UserModCommentForm(forms.ModelForm):
class Meta:
model = User
fields = ('mod_notes',)
class CommentForm(forms.ModelForm): class CommentForm(forms.ModelForm):
class Meta: class Meta:
model = Comment model = Comment
@@ -178,3 +183,31 @@ class RescueOrgSearchForm(forms.Form):
location_string = forms.CharField(max_length=100, label=_("Stadt"), required=False) location_string = forms.CharField(max_length=100, label=_("Stadt"), required=False)
max_distance = forms.ChoiceField(choices=DistanceChoices, initial=DistanceChoices.TWENTY, max_distance = forms.ChoiceField(choices=DistanceChoices, initial=DistanceChoices.TWENTY,
label=_("Suchradius")) label=_("Suchradius"))
class RescueOrgSearchByNameForm(forms.Form):
template_name = "fellchensammlung/forms/form_snippets.html"
name = forms.CharField(max_length=100, label=_("Name der Organisation"), required=False)
class CloseAdoptionNoticeForm(forms.ModelForm):
template_name = "fellchensammlung/forms/form_snippets.html"
adoption_notice_status = forms.ChoiceField(choices=AdoptionNoticeStatusChoices.Closed,
label=_("Status"),
help_text=_("Gib den neuen Status der Vermittlung an"))
class Meta:
model = AdoptionNotice
fields = ('adoption_notice_status',)
class RescueOrgForm(forms.ModelForm):
template_name = "fellchensammlung/forms/form_snippets.html"
class Meta:
model = RescueOrganization
fields = ('name', 'allows_using_materials', 'location_string', "email", "phone_number", "website", "instagram",
"facebook", "fediverse_profile", "internal_comment", "description", "external_source_identifier",
"external_object_identifier",
"parent_org")

View File

@@ -0,0 +1,13 @@
from django.core.management import BaseCommand
from notfellchen import settings
class Command(BaseCommand):
help = 'Print the current settings'
def handle(self, *args, **options):
for key in settings.__dir__():
if key.startswith("_") or key == "SECRET_KEY":
continue
print(f"{key} = {getattr(settings, key)}")

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.2.8 on 2025-11-16 16:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0069_rescueorganization_regular_check_status'),
]
operations = [
migrations.AddField(
model_name='user',
name='mod_notes',
field=models.TextField(blank=True, null=True, verbose_name='Moderationsnotizen'),
),
migrations.AlterField(
model_name='user',
name='reason_for_signup',
field=models.TextField(help_text="Wir würden gerne wissen warum du dich registrierst, ob du dich z.B. Tiere eines bestimmten Tierheim einstellen willst 'nur mal gucken' willst. Beides ist toll! Wenn du für ein Tierheim/eine Pflegestelle arbeitest kontaktieren wir dich ggf. um dir erweiterte Rechte zu geben.", verbose_name='Grund für die Registrierung'),
),
]

View File

@@ -0,0 +1,367 @@
# Generated by Django 5.2.8 on 2025-11-16 17:37
import django.contrib.auth.validators
import django.db.models.deletion
import django.utils.timezone
import simple_history.models
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0070_user_mod_notes_alter_user_reason_for_signup'),
]
operations = [
migrations.CreateModel(
name='HistoricalAdoptionNotice',
fields=[
('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
('created_at', models.DateField(default=django.utils.timezone.now, verbose_name='Erstellt am')),
('updated_at', models.DateTimeField(blank=True, editable=False)),
('last_checked', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Zuletzt überprüft am')),
('searching_since', models.DateField(verbose_name='Sucht nach einem Zuhause seit')),
('name', models.CharField(max_length=200, verbose_name='Titel der Vermittlung')),
('description', models.TextField(blank=True, null=True, verbose_name='Beschreibung')),
('further_information', models.URLField(blank=True, help_text='Verlinke hier die Quelle der Vermittlung (z.B. die Website des Tierheims)', null=True, verbose_name='Link zu mehr Informationen')),
('group_only', models.BooleanField(default=False, verbose_name='Ausschließlich Gruppenadoption')),
('location_string', models.CharField(max_length=200, verbose_name='Ortsangabe')),
('adoption_notice_status', models.TextField(choices=[('active_searching', 'Searching'), ('active_interested', 'Interested'), ('awaiting_action_waiting_for_review', 'Waiting for review'), ('awaiting_action_needs_additional_info', 'Needs additional info'), ('awaiting_action_unchecked', 'Unchecked'), ('closed_successful_with_notfellchen', 'Successful (with Notfellchen)'), ('closed_successful_without_notfellchen', 'Successful (without Notfellchen)'), ('closed_animal_died', 'Animal died'), ('closed_for_other_adoption_notice', 'Closed for other adoption notice'), ('closed_not_open_for_adoption_anymore', 'Not open for adoption anymore'), ('closed_link_to_more_info_not_reachable', 'Der Link zu weiteren Informationen ist nicht mehr erreichbar.'), ('closed_other', 'Other (closed)'), ('disabled_against_the_rules', 'Against the rules'), ('disabled_other', 'Other (disabled)')], max_length=64, verbose_name='Status')),
('adoption_process', models.TextField(blank=True, choices=[('contact_person_in_an', 'Kontaktiere die Person im Vermittlungstext')], max_length=64, null=True, verbose_name='Adoptionsprozess')),
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField(db_index=True)),
('history_change_reason', models.CharField(max_length=100, null=True)),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('location', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='fellchensammlung.location')),
('organization', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='fellchensammlung.rescueorganization', verbose_name='Organisation')),
('owner', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Creator')),
],
options={
'verbose_name': 'historical Vermittlung',
'verbose_name_plural': 'historical Vermittlungen',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': ('history_date', 'history_id'),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.CreateModel(
name='HistoricalAnimal',
fields=[
('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
('date_of_birth', models.DateField(verbose_name='Geburtsdatum')),
('name', models.CharField(max_length=200, verbose_name='Name')),
('description', models.TextField(blank=True, null=True, verbose_name='Beschreibung')),
('sex', models.CharField(choices=[('F', 'Weiblich'), ('M', 'Männlich'), ('M_N', 'Männlich, kastriert'), ('F_N', 'Weiblich, kastriert'), ('I', 'Intergeschlechtlich')], max_length=20, verbose_name='Geschlecht')),
('updated_at', models.DateTimeField(blank=True, editable=False)),
('created_at', models.DateTimeField(blank=True, editable=False)),
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField(db_index=True)),
('history_change_reason', models.CharField(max_length=100, null=True)),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
('adoption_notice', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='fellchensammlung.adoptionnotice')),
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('owner', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL)),
('species', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='fellchensammlung.species', verbose_name='Tierart')),
],
options={
'verbose_name': 'historical Tier',
'verbose_name_plural': 'historical Tiere',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': ('history_date', 'history_id'),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.CreateModel(
name='HistoricalAnnouncement',
fields=[
('text_ptr', models.ForeignKey(auto_created=True, blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, parent_link=True, related_name='+', to='fellchensammlung.text')),
('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
('title', models.CharField(max_length=100, verbose_name='Titel')),
('content', models.TextField(verbose_name='Inhalt')),
('text_code', models.CharField(blank=True, max_length=24, verbose_name='Text code')),
('logged_in_only', models.BooleanField(default=False)),
('created_at', models.DateTimeField(blank=True, editable=False)),
('updated_at', models.DateTimeField(blank=True, editable=False)),
('publish_start_time', models.DateTimeField(verbose_name='Veröffentlichungszeitpunkt')),
('publish_end_time', models.DateTimeField(verbose_name='Veröffentlichungsende')),
('type', models.CharField(choices=[('important', 'important'), ('warning', 'warning'), ('info', 'info')], default='info', max_length=100)),
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField(db_index=True)),
('history_change_reason', models.CharField(max_length=100, null=True)),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('language', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='fellchensammlung.language', verbose_name='Sprache')),
],
options={
'verbose_name': 'historical Banner',
'verbose_name_plural': 'historical Banner',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': ('history_date', 'history_id'),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.CreateModel(
name='HistoricalComment',
fields=[
('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
('created_at', models.DateTimeField(blank=True, editable=False)),
('updated_at', models.DateTimeField(blank=True, editable=False)),
('text', models.TextField(verbose_name='Inhalt')),
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField(db_index=True)),
('history_change_reason', models.CharField(max_length=100, null=True)),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
('adoption_notice', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='fellchensammlung.adoptionnotice', verbose_name='Vermittlung')),
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('reply_to', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='fellchensammlung.comment', verbose_name='Antwort auf')),
('user', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Nutzer*in')),
],
options={
'verbose_name': 'historical Kommentar',
'verbose_name_plural': 'historical Kommentare',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': ('history_date', 'history_id'),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.CreateModel(
name='HistoricalModerationAction',
fields=[
('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
('action', models.CharField(choices=[('user_banned', 'User was banned'), ('content_deleted', 'Content was deleted'), ('comment', 'Comment was added'), ('other_action_taken', 'Other action was taken'), ('no_action_taken', 'No action was taken')], max_length=30)),
('created_at', models.DateTimeField(blank=True, editable=False)),
('updated_at', models.DateTimeField(blank=True, editable=False)),
('public_comment', models.TextField(blank=True)),
('private_comment', models.TextField(blank=True)),
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField(db_index=True)),
('history_change_reason', models.CharField(max_length=100, null=True)),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('report', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='fellchensammlung.report')),
],
options={
'verbose_name': 'historical Moderationsaktion',
'verbose_name_plural': 'historical Moderationsaktionen',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': ('history_date', 'history_id'),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.CreateModel(
name='HistoricalReport',
fields=[
('id', models.UUIDField(db_index=True, default=uuid.uuid4, help_text='ID dieses reports', verbose_name='ID')),
('status', models.CharField(choices=[('action taken', 'Action was taken'), ('no action taken', 'No action was taken'), ('waiting', 'Waiting for moderator action')], max_length=30)),
('user_comment', models.TextField(blank=True, verbose_name='Kommentar/Zusätzliche Information')),
('updated_at', models.DateTimeField(blank=True, editable=False, verbose_name='Zuletzt geändert am')),
('created_at', models.DateTimeField(blank=True, editable=False, verbose_name='Erstellt am')),
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField(db_index=True)),
('history_change_reason', models.CharField(max_length=100, null=True)),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'historical Meldung',
'verbose_name_plural': 'historical Meldungen',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': ('history_date', 'history_id'),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.CreateModel(
name='HistoricalRescueOrganization',
fields=[
('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
('name', models.CharField(max_length=200)),
('trusted', models.BooleanField(default=False, verbose_name='Vertrauenswürdig')),
('allows_using_materials', models.CharField(choices=[('allowed', 'Usage allowed'), ('requested', 'Usage requested'), ('denied', 'Usage denied'), ('other', "It's complicated"), ('not_asked', 'Not asked')], default='not_asked', max_length=200, verbose_name='Erlaubt Nutzung von Inhalten')),
('location_string', models.CharField(blank=True, max_length=200, null=True, verbose_name='Ort der Organisation')),
('instagram', models.URLField(blank=True, null=True, verbose_name='Instagram Profil')),
('facebook', models.URLField(blank=True, null=True, verbose_name='Facebook Profil')),
('fediverse_profile', models.URLField(blank=True, null=True, verbose_name='Fediverse Profil')),
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='E-Mail')),
('phone_number', models.CharField(blank=True, max_length=15, null=True, verbose_name='Telefonnummer')),
('website', models.URLField(blank=True, null=True, verbose_name='Website')),
('updated_at', models.DateTimeField(blank=True, editable=False)),
('created_at', models.DateTimeField(blank=True, editable=False)),
('last_checked', models.DateTimeField(blank=True, editable=False, verbose_name='Datum der letzten Prüfung')),
('internal_comment', models.TextField(blank=True, null=True, verbose_name='Interner Kommentar')),
('description', models.TextField(blank=True, null=True, verbose_name='Beschreibung')),
('external_object_identifier', models.CharField(blank=True, max_length=200, null=True, verbose_name='External Object Identifier')),
('external_source_identifier', models.CharField(blank=True, choices=[('OSM', 'Open Street Map')], max_length=200, null=True, verbose_name='External Source Identifier')),
('exclude_from_check', models.BooleanField(default=False, help_text='Organisation von der manuellen Überprüfung ausschließen, z.B. weil Tiere nicht online geführt werden', verbose_name='Von Prüfung ausschließen')),
('regular_check_status', models.CharField(choices=[('regular_check', 'Wird regelmäßig geprüft'), ('excluded_no_online_listing', 'Exkludiert: Tiere werden nicht online gelistet'), ('excluded_other_org', 'Exkludiert: Andere Organisation wird geprüft'), ('excluded_scope', 'Exkludiert: Organisation hat nie Notfellchen-relevanten Vermittlungen'), ('excluded_other', 'Exkludiert: Anderer Grund')], default='regular_check', help_text='Organisationen können, durch ändern dieser Einstellung, von der regelmäßigen Prüfung ausgeschlossen werden.', max_length=30, verbose_name='Status der regelmäßigen Prüfung')),
('ongoing_communication', models.BooleanField(default=False, help_text='Es findet gerade Kommunikation zwischen Notfellchen und der Organisation statt.', verbose_name='In aktiver Kommunikation')),
('twenty_id', models.UUIDField(blank=True, help_text='ID der der Organisation in Twenty', null=True, verbose_name='Twenty-ID')),
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField(db_index=True)),
('history_change_reason', models.CharField(max_length=100, null=True)),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('location', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='fellchensammlung.location')),
('parent_org', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='fellchensammlung.rescueorganization')),
],
options={
'verbose_name': 'historical Tierschutzorganisation',
'verbose_name_plural': 'historical Tierschutzorganisationen',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': ('history_date', 'history_id'),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.CreateModel(
name='HistoricalRule',
fields=[
('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
('title', models.CharField(max_length=200)),
('rule_text', models.TextField(verbose_name='Regeltext')),
('rule_identifier', models.CharField(help_text='Ein eindeutiger Identifikator der Regel. Ein Regelobjekt derselben Regel in einer anderen Sprache muss den gleichen Identifikator haben', max_length=24, verbose_name='Regel-ID')),
('updated_at', models.DateTimeField(blank=True, editable=False, verbose_name='Zuletzt geändert am')),
('created_at', models.DateTimeField(blank=True, editable=False, verbose_name='Erstellt am')),
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField(db_index=True)),
('history_change_reason', models.CharField(max_length=100, null=True)),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('language', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='fellchensammlung.language', verbose_name='Sprache')),
],
options={
'verbose_name': 'historical Regel',
'verbose_name_plural': 'historical Regeln',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': ('history_date', 'history_id'),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.CreateModel(
name='HistoricalSearchSubscription',
fields=[
('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
('sex', models.CharField(choices=[('F', 'Weiblich'), ('M', 'Männlich'), ('M_N', 'Männlich, kastriert'), ('F_N', 'Weiblich Kastriert'), ('I', 'Intergeschlechtlich'), ('A', 'Alle')], max_length=20, verbose_name='Geschlecht')),
('max_distance', models.IntegerField(choices=[(20, '20 km'), (50, '50 km'), (100, '100 km'), (200, '200 km'), (500, '500 km')], null=True)),
('updated_at', models.DateTimeField(blank=True, editable=False, verbose_name='Zuletzt geändert am')),
('created_at', models.DateTimeField(blank=True, editable=False, verbose_name='Erstellt am')),
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField(db_index=True)),
('history_change_reason', models.CharField(max_length=100, null=True)),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('location', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='fellchensammlung.location')),
('owner', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'historical Abonnierte Suche',
'verbose_name_plural': 'historical Abonnierte Suchen',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': ('history_date', 'history_id'),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.CreateModel(
name='HistoricalSocialMediaPost',
fields=[
('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, 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')),
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField(db_index=True)),
('history_change_reason', models.CharField(max_length=100, null=True)),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
('adoption_notice', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='fellchensammlung.adoptionnotice', verbose_name='Vermittlung')),
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'historical social media post',
'verbose_name_plural': 'historical social media posts',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': ('history_date', 'history_id'),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.CreateModel(
name='HistoricalSubscriptions',
fields=[
('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
('created_at', models.DateTimeField(blank=True, editable=False)),
('updated_at', models.DateTimeField(blank=True, editable=False)),
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField(db_index=True)),
('history_change_reason', models.CharField(max_length=100, null=True)),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
('adoption_notice', models.ForeignKey(blank=True, db_constraint=False, help_text='Vermittlung die abonniert wurde', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='fellchensammlung.adoptionnotice', verbose_name='Vermittlung')),
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('owner', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Nutzer*in')),
],
options={
'verbose_name': 'historical Abonnement',
'verbose_name_plural': 'historical Abonnements',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': ('history_date', 'history_id'),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.CreateModel(
name='HistoricalText',
fields=[
('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
('title', models.CharField(max_length=100, verbose_name='Titel')),
('content', models.TextField(verbose_name='Inhalt')),
('text_code', models.CharField(blank=True, max_length=24, verbose_name='Text code')),
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField(db_index=True)),
('history_change_reason', models.CharField(max_length=100, null=True)),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('language', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='fellchensammlung.language', verbose_name='Sprache')),
],
options={
'verbose_name': 'historical Text',
'verbose_name_plural': 'historical Texte',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': ('history_date', 'history_id'),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.CreateModel(
name='HistoricalUser',
fields=[
('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(db_index=True, error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('trust_level', models.IntegerField(choices=[(1, 'Member'), (2, 'Coordinator'), (3, 'Moderator'), (4, 'Admin')], default=1)),
('updated_at', models.DateTimeField(blank=True, editable=False)),
('reason_for_signup', models.TextField(help_text="Wir würden gerne wissen warum du dich registrierst, ob du dich z.B. Tiere eines bestimmten Tierheim einstellen willst 'nur mal gucken' willst. Beides ist toll! Wenn du für ein Tierheim/eine Pflegestelle arbeitest kontaktieren wir dich ggf. um dir erweiterte Rechte zu geben.", verbose_name='Grund für die Registrierung')),
('mod_notes', models.TextField(blank=True, null=True, verbose_name='Moderationsnotizen')),
('email_notifications', models.BooleanField(default=True, verbose_name='Benachrichtigung per E-Mail')),
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField(db_index=True)),
('history_change_reason', models.CharField(max_length=100, null=True)),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('organization_affiliation', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='fellchensammlung.rescueorganization', verbose_name='Organisation')),
('preferred_language', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='fellchensammlung.language', verbose_name='Bevorzugte Sprache')),
],
options={
'verbose_name': 'historical Nutzer*in',
'verbose_name_plural': 'historical Nutzer*innen',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': ('history_date', 'history_id'),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.2.8 on 2025-11-29 09:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0071_historicaladoptionnotice_historicalanimal_and_more'),
]
operations = [
migrations.AlterField(
model_name='adoptionnotice',
name='adoption_notice_status',
field=models.TextField(choices=[('active_searching', 'Searching'), ('active_interested', 'Interested'), ('awaiting_action_waiting_for_review', 'Waiting for review'), ('awaiting_action_needs_additional_info', 'Needs additional info'), ('awaiting_action_unchecked', 'Unchecked'), ('closed_successfully', 'Erfolgreich vermittelt'), ('closed_animal_died', 'Tier gestorben'), ('closed_for_other_adoption_notice', 'Vermittlung wurde zugunsten einer anderen geschlossen.'), ('closed_not_open_for_adoption_anymore', 'Tier(e) stehen nicht mehr zur Vermittlung bereit.'), ('closed_link_to_more_info_not_reachable', 'Der Link zu weiteren Informationen ist nicht mehr erreichbar.'), ('closed_other', 'Anderes'), ('disabled_against_the_rules', 'Against the rules'), ('disabled_other', 'Other (disabled)')], max_length=64, verbose_name='Status'),
),
migrations.AlterField(
model_name='historicaladoptionnotice',
name='adoption_notice_status',
field=models.TextField(choices=[('active_searching', 'Searching'), ('active_interested', 'Interested'), ('awaiting_action_waiting_for_review', 'Waiting for review'), ('awaiting_action_needs_additional_info', 'Needs additional info'), ('awaiting_action_unchecked', 'Unchecked'), ('closed_successfully', 'Erfolgreich vermittelt'), ('closed_animal_died', 'Tier gestorben'), ('closed_for_other_adoption_notice', 'Vermittlung wurde zugunsten einer anderen geschlossen.'), ('closed_not_open_for_adoption_anymore', 'Tier(e) stehen nicht mehr zur Vermittlung bereit.'), ('closed_link_to_more_info_not_reachable', 'Der Link zu weiteren Informationen ist nicht mehr erreichbar.'), ('closed_other', 'Anderes'), ('disabled_against_the_rules', 'Against the rules'), ('disabled_other', 'Other (disabled)')], max_length=64, verbose_name='Status'),
),
]

View File

@@ -0,0 +1,23 @@
import logging
from django.db import migrations
def migrate_status(apps, schema_editor):
# We can't import the model directly as it may be a newer
# version than this migration expects. We use the historical version.
AdoptionNotice = apps.get_model("fellchensammlung", "AdoptionNotice")
for adoption_notice in AdoptionNotice.objects.filter(
adoption_notice_status__in=("closed_successful_without_notfellchen", "closed_successful_with_notfellchen")):
adoption_notice.adoption_notice_status = "closed_successfully"
adoption_notice.save()
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0072_alter_adoptionnotice_adoption_notice_status_and_more'),
]
operations = [
migrations.RunPython(migrate_status),
]

View File

@@ -0,0 +1,104 @@
# Generated by Django 5.2.8 on 2026-01-20 21:09
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("fellchensammlung", "0073_adoption_notice_status_successful"),
]
operations = [
migrations.AlterField(
model_name="animal",
name="date_of_birth",
field=models.DateField(blank=True, null=True, verbose_name="Geburtsdatum"),
),
migrations.AlterField(
model_name="historicalanimal",
name="date_of_birth",
field=models.DateField(blank=True, null=True, verbose_name="Geburtsdatum"),
),
migrations.AlterField(
model_name="historicalrescueorganization",
name="external_object_identifier",
field=models.CharField(
blank=True,
help_text="Id des Objekts in der externen Datenbank (kann leer gelassen werden)",
max_length=200,
null=True,
verbose_name="External Object Identifier",
),
),
migrations.AlterField(
model_name="historicalrescueorganization",
name="external_source_identifier",
field=models.CharField(
blank=True,
choices=[("OSM", "Open Street Map")],
help_text="Name der Datenbank aus der die Tierschutzorganisation importiert wurde (kann leer gelassen werden)",
max_length=200,
null=True,
verbose_name="External Source Identifier",
),
),
migrations.AlterField(
model_name="historicalrescueorganization",
name="parent_org",
field=models.ForeignKey(
blank=True,
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="fellchensammlung.rescueorganization",
verbose_name="Übergeordnete Organisation",
),
),
migrations.AlterField(
model_name="rescueorganization",
name="external_object_identifier",
field=models.CharField(
blank=True,
help_text="Id des Objekts in der externen Datenbank (kann leer gelassen werden)",
max_length=200,
null=True,
verbose_name="External Object Identifier",
),
),
migrations.AlterField(
model_name="rescueorganization",
name="external_source_identifier",
field=models.CharField(
blank=True,
choices=[("OSM", "Open Street Map")],
help_text="Name der Datenbank aus der die Tierschutzorganisation importiert wurde (kann leer gelassen werden)",
max_length=200,
null=True,
verbose_name="External Source Identifier",
),
),
migrations.AlterField(
model_name="rescueorganization",
name="parent_org",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
to="fellchensammlung.rescueorganization",
verbose_name="Übergeordnete Organisation",
),
),
migrations.AlterField(
model_name="speciesspecificurl",
name="rescue_organization",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="species_specific_urls",
to="fellchensammlung.rescueorganization",
verbose_name="Tierschutzorganisation",
),
),
]

View File

@@ -7,6 +7,7 @@ from django.contrib.auth.models import Group
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
import base64 import base64
from simple_history.models import HistoricalRecords
from .tools import misc, geo from .tools import misc, geo
from notfellchen.settings import MEDIA_URL, base_url from notfellchen.settings import MEDIA_URL, base_url
@@ -121,11 +122,11 @@ class ExternalSourceChoices(models.TextChoices):
class AllowUseOfMaterialsChices(models.TextChoices): class AllowUseOfMaterialsChices(models.TextChoices):
USE_MATERIALS_ALLOWED = "allowed", _("Usage allowed") USE_MATERIALS_ALLOWED = "allowed", _("Nutzung erlaubt")
USE_MATERIALS_REQUESTED = "requested", _("Usage requested") USE_MATERIALS_REQUESTED = "requested", _("Nutzung angefragt")
USE_MATERIALS_DENIED = "denied", _("Usage denied") USE_MATERIALS_DENIED = "denied", _("Nutzung verweigert")
USE_MATERIALS_OTHER = "other", _("It's complicated") USE_MATERIALS_OTHER = "other", _("Es ist kompliziert")
USE_MATERIALS_NOT_ASKED = "not_asked", _("Not asked") USE_MATERIALS_NOT_ASKED = "not_asked", _("Nutzung noch nicht angefragt")
class Species(models.Model): class Species(models.Model):
@@ -166,10 +167,14 @@ class RescueOrganization(models.Model):
internal_comment = models.TextField(verbose_name=_("Interner Kommentar"), null=True, blank=True, ) internal_comment = models.TextField(verbose_name=_("Interner Kommentar"), null=True, blank=True, )
description = models.TextField(null=True, blank=True, verbose_name=_('Beschreibung')) # Markdown allowed description = models.TextField(null=True, blank=True, verbose_name=_('Beschreibung')) # Markdown allowed
external_object_identifier = models.CharField(max_length=200, null=True, blank=True, external_object_identifier = models.CharField(max_length=200, null=True, blank=True,
verbose_name=_('External Object Identifier')) verbose_name=_('External Object Identifier'),
help_text=_(
"Id des Objekts in der externen Datenbank (kann leer gelassen werden)"))
external_source_identifier = models.CharField(max_length=200, null=True, blank=True, external_source_identifier = models.CharField(max_length=200, null=True, blank=True,
choices=ExternalSourceChoices.choices, choices=ExternalSourceChoices.choices,
verbose_name=_('External Source Identifier')) verbose_name=_('External Source Identifier'),
help_text=_(
"Name der Datenbank aus der die Tierschutzorganisation importiert wurde (kann leer gelassen werden)"))
exclude_from_check = models.BooleanField(default=False, verbose_name=_('Von Prüfung ausschließen'), exclude_from_check = models.BooleanField(default=False, verbose_name=_('Von Prüfung ausschließen'),
help_text=_("Organisation von der manuellen Überprüfung ausschließen, " help_text=_("Organisation von der manuellen Überprüfung ausschließen, "
"z.B. weil Tiere nicht online geführt werden")) "z.B. weil Tiere nicht online geführt werden"))
@@ -182,11 +187,13 @@ class RescueOrganization(models.Model):
ongoing_communication = models.BooleanField(default=False, verbose_name=_('In aktiver Kommunikation'), ongoing_communication = models.BooleanField(default=False, verbose_name=_('In aktiver Kommunikation'),
help_text=_( help_text=_(
"Es findet gerade Kommunikation zwischen Notfellchen und der Organisation statt.")) "Es findet gerade Kommunikation zwischen Notfellchen und der Organisation statt."))
parent_org = models.ForeignKey("RescueOrganization", on_delete=models.PROTECT, blank=True, null=True) parent_org = models.ForeignKey("RescueOrganization", on_delete=models.PROTECT, blank=True, null=True,
verbose_name=_("Übergeordnete Organisation"))
# allows to specify if a rescue organization has a specialization for dedicated species # allows to specify if a rescue organization has a specialization for dedicated species
specializations = models.ManyToManyField(Species, blank=True) specializations = models.ManyToManyField(Species, blank=True)
twenty_id = models.UUIDField(verbose_name=_("Twenty-ID"), null=True, blank=True, twenty_id = models.UUIDField(verbose_name=_("Twenty-ID"), null=True, blank=True,
help_text=_("ID der der Organisation in Twenty")) help_text=_("ID der der Organisation in Twenty"))
history = HistoricalRecords()
class Meta: class Meta:
unique_together = ('external_object_identifier', 'external_source_identifier',) unique_together = ('external_object_identifier', 'external_source_identifier',)
@@ -250,6 +257,7 @@ class RescueOrganization(models.Model):
def set_checked(self): def set_checked(self):
self.last_checked = timezone.now() self.last_checked = timezone.now()
self._change_reason = 'Organization checked'
self.save() self.save()
@property @property
@@ -309,7 +317,9 @@ class User(AbstractUser):
organization_affiliation = models.ForeignKey(RescueOrganization, on_delete=models.PROTECT, null=True, blank=True, organization_affiliation = models.ForeignKey(RescueOrganization, on_delete=models.PROTECT, null=True, blank=True,
verbose_name=_('Organisation')) verbose_name=_('Organisation'))
reason_for_signup = models.TextField(verbose_name=reason_for_signup_label, help_text=reason_for_signup_help_text) reason_for_signup = models.TextField(verbose_name=reason_for_signup_label, help_text=reason_for_signup_help_text)
mod_notes = models.TextField(verbose_name=_("Moderationsnotizen"), null=True, blank=True)
email_notifications = models.BooleanField(verbose_name=_("Benachrichtigung per E-Mail"), default=True) email_notifications = models.BooleanField(verbose_name=_("Benachrichtigung per E-Mail"), default=True)
history = HistoricalRecords()
REQUIRED_FIELDS = ["reason_for_signup", "email"] REQUIRED_FIELDS = ["reason_for_signup", "email"]
class Meta: class Meta:
@@ -405,6 +415,7 @@ class AdoptionNotice(models.Model):
adoption_process = models.TextField(null=True, blank=True, adoption_process = models.TextField(null=True, blank=True,
max_length=64, verbose_name=_('Adoptionsprozess'), max_length=64, verbose_name=_('Adoptionsprozess'),
choices=AdoptionProcess) choices=AdoptionProcess)
history = HistoricalRecords()
@property @property
def animals(self): def animals(self):
@@ -419,7 +430,6 @@ class AdoptionNotice(models.Model):
@property @property
def num_per_sex(self): def num_per_sex(self):
print(f"{self.pk} x")
num_per_sex = dict() num_per_sex = dict()
for sex in SexChoices: for sex in SexChoices:
num_per_sex[sex] = len([animal for animal in self.animals if animal.sex == sex]) num_per_sex[sex] = len([animal for animal in self.animals if animal.sex == sex])
@@ -542,6 +552,13 @@ class AdoptionNotice(models.Model):
def _values_of(list_of_enums): def _values_of(list_of_enums):
return list(map(lambda x: x[0], list_of_enums)) return list(map(lambda x: x[0], list_of_enums))
@property
def status_category(self):
ansc = AdoptionNoticeStatusChoices
for status_category in [ansc.Active, ansc.Disabled, ansc.Closed, ansc.AwaitingAction]:
if self.adoption_notice_status in self._values_of(status_category.choices):
return status_category.__name__.lower()
@property @property
def is_active(self): def is_active(self):
return self.adoption_notice_status in self._values_of(AdoptionNoticeStatusChoices.Active.choices) return self.adoption_notice_status in self._values_of(AdoptionNoticeStatusChoices.Active.choices)
@@ -558,6 +575,19 @@ class AdoptionNotice(models.Model):
def is_awaiting_action(self): def is_awaiting_action(self):
return self.adoption_notice_status in self._values_of(AdoptionNoticeStatusChoices.AwaitingAction.choices) return self.adoption_notice_status in self._values_of(AdoptionNoticeStatusChoices.AwaitingAction.choices)
@property
def status_description_short(self):
if self.is_active:
return _("Vermittlung aktiv")
elif self.is_disabled:
return _("Vermittlung gesperrt")
elif self.is_closed:
return _("Vermittlung geschlossen")
elif self.is_awaiting_action:
return _("Wartet auf Freigabe von Moderator*innen")
else:
raise NotImplementedError()
@property @property
def status_description(self): def status_description(self):
return AdoptionNoticeStatusChoicesDescriptions.mapping[self.adoption_notice_status] return AdoptionNoticeStatusChoicesDescriptions.mapping[self.adoption_notice_status]
@@ -607,7 +637,7 @@ class Animal(models.Model):
verbose_name = _('Tier') verbose_name = _('Tier')
verbose_name_plural = _('Tiere') verbose_name_plural = _('Tiere')
date_of_birth = models.DateField(verbose_name=_('Geburtsdatum')) date_of_birth = models.DateField(verbose_name=_('Geburtsdatum'), null=True, blank=True)
name = models.CharField(max_length=200, verbose_name=_('Name')) name = models.CharField(max_length=200, verbose_name=_('Name'))
description = models.TextField(null=True, blank=True, verbose_name=_('Beschreibung')) description = models.TextField(null=True, blank=True, verbose_name=_('Beschreibung'))
species = models.ForeignKey(Species, on_delete=models.PROTECT, verbose_name=_("Tierart")) species = models.ForeignKey(Species, on_delete=models.PROTECT, verbose_name=_("Tierart"))
@@ -621,18 +651,25 @@ class Animal(models.Model):
owner = models.ForeignKey(User, on_delete=models.CASCADE) owner = models.ForeignKey(User, on_delete=models.CASCADE)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
history = HistoricalRecords()
def __str__(self): def __str__(self):
return f"{self.name}" return f"{self.name}"
@property @property
def age(self): def age(self):
return timezone.now().today().date() - self.date_of_birth if self.date_of_birth:
return timezone.now().today().date() - self.date_of_birth
else:
return None
@property @property
def hr_age(self): def hr_age(self):
"""Returns a human-readable age based on the date of birth.""" """Returns a human-readable age based on the date of birth."""
return misc.age_as_hr_string(self.age) if self.date_of_birth:
return misc.age_as_hr_string(self.age)
else:
return _("Unbekannt")
def get_photo(self): def get_photo(self):
""" """
@@ -684,6 +721,7 @@ class SearchSubscription(models.Model):
max_distance = models.IntegerField(choices=DistanceChoices.choices, null=True) max_distance = models.IntegerField(choices=DistanceChoices.choices, null=True)
updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Zuletzt geändert am")) updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Zuletzt geändert am"))
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Erstellt am")) created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Erstellt am"))
history = HistoricalRecords()
def __str__(self): def __str__(self):
if self.location and self.max_distance: if self.location and self.max_distance:
@@ -714,6 +752,7 @@ class Rule(models.Model):
"Identifikator haben")) "Identifikator haben"))
updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Zuletzt geändert am")) updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Zuletzt geändert am"))
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Erstellt am")) created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Erstellt am"))
history = HistoricalRecords()
def __str__(self): def __str__(self):
return self.title return self.title
@@ -739,6 +778,7 @@ class Report(models.Model):
user_comment = models.TextField(blank=True, verbose_name=_("Kommentar/Zusätzliche Information")) user_comment = models.TextField(blank=True, verbose_name=_("Kommentar/Zusätzliche Information"))
updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Zuletzt geändert am")) updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Zuletzt geändert am"))
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Erstellt am")) created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Erstellt am"))
history = HistoricalRecords()
def __str__(self): def __str__(self):
return f"[{self.status}]: {self.user_comment:.20}" return f"[{self.status}]: {self.user_comment:.20}"
@@ -825,6 +865,7 @@ class ModerationAction(models.Model):
# Only visible to moderator # Only visible to moderator
private_comment = models.TextField(blank=True) private_comment = models.TextField(blank=True)
report = models.ForeignKey(Report, on_delete=models.CASCADE) report = models.ForeignKey(Report, on_delete=models.CASCADE)
history = HistoricalRecords()
def __str__(self): def __str__(self):
return f"[{self.action}]: {self.public_comment}" return f"[{self.action}]: {self.public_comment}"
@@ -846,6 +887,7 @@ class Text(models.Model):
content = models.TextField(verbose_name="Inhalt") content = models.TextField(verbose_name="Inhalt")
language = models.ForeignKey(Language, verbose_name="Sprache", on_delete=models.PROTECT) language = models.ForeignKey(Language, verbose_name="Sprache", on_delete=models.PROTECT)
text_code = models.CharField(max_length=24, verbose_name="Text code", blank=True) text_code = models.CharField(max_length=24, verbose_name="Text code", blank=True)
history = HistoricalRecords()
class Meta: class Meta:
verbose_name = "Text" verbose_name = "Text"
@@ -889,6 +931,7 @@ class Announcement(Text):
INFO: "info", INFO: "info",
} }
type = models.CharField(choices=TYPES, max_length=100, default=INFO) type = models.CharField(choices=TYPES, max_length=100, default=INFO)
history = HistoricalRecords()
@property @property
def is_active(self): def is_active(self):
@@ -935,6 +978,7 @@ class Comment(models.Model):
adoption_notice = models.ForeignKey(AdoptionNotice, on_delete=models.CASCADE, verbose_name=_('Vermittlung')) adoption_notice = models.ForeignKey(AdoptionNotice, on_delete=models.CASCADE, verbose_name=_('Vermittlung'))
text = models.TextField(verbose_name="Inhalt") text = models.TextField(verbose_name="Inhalt")
reply_to = models.ForeignKey("self", verbose_name="Antwort auf", blank=True, null=True, on_delete=models.CASCADE) reply_to = models.ForeignKey("self", verbose_name="Antwort auf", blank=True, null=True, on_delete=models.CASCADE)
history = HistoricalRecords()
def __str__(self): def __str__(self):
return f"{self.user} at {self.created_at.strftime('%H:%M %d.%m.%y')}: {self.text:.10}" return f"{self.user} at {self.created_at.strftime('%H:%M %d.%m.%y')}: {self.text:.10}"
@@ -1006,6 +1050,7 @@ class Subscriptions(models.Model):
help_text=_("Vermittlung die abonniert wurde")) help_text=_("Vermittlung die abonniert wurde"))
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
history = HistoricalRecords()
def __str__(self): def __str__(self):
return f"{self.owner} - {self.adoption_notice}" return f"{self.owner} - {self.adoption_notice}"
@@ -1052,8 +1097,10 @@ class SpeciesSpecificURL(models.Model):
verbose_name_plural = _("Tierartspezifische URLs") verbose_name_plural = _("Tierartspezifische URLs")
species = models.ForeignKey(Species, on_delete=models.CASCADE, verbose_name=_("Tierart")) species = models.ForeignKey(Species, on_delete=models.CASCADE, verbose_name=_("Tierart"))
rescue_organization = models.ForeignKey(RescueOrganization, on_delete=models.CASCADE, rescue_organization = models.ForeignKey(RescueOrganization,
verbose_name=_("Tierschutzorganisation")) on_delete=models.CASCADE,
verbose_name=_("Tierschutzorganisation"),
related_name="species_specific_urls")
url = models.URLField(verbose_name=_("Tierartspezifische URL")) url = models.URLField(verbose_name=_("Tierartspezifische URL"))
@@ -1067,6 +1114,7 @@ class SocialMediaPost(models.Model):
choices=PlatformChoices.choices) choices=PlatformChoices.choices)
adoption_notice = models.ForeignKey(AdoptionNotice, on_delete=models.CASCADE, verbose_name=_('Vermittlung')) adoption_notice = models.ForeignKey(AdoptionNotice, on_delete=models.CASCADE, verbose_name=_('Vermittlung'))
url = models.URLField(verbose_name=_("URL")) url = models.URLField(verbose_name=_("URL"))
history = HistoricalRecords()
@staticmethod @staticmethod
def get_an_to_post(): def get_an_to_post():

View File

@@ -22,12 +22,24 @@ $confirm: hsl(133deg, 100%, calc(41% + 0%));
background-color: $grey-light !important; background-color: $grey-light !important;
border-radius: 5px; border-radius: 5px;
} }
.navbar-burger {
color: white;
}
.card-header { .card-header {
background-color: $grey-dark; background-color: $grey-dark;
} }
a.card-footer-item.is-danger { div.card-footer-item.is-danger {
color: black; background-color: #5a212d;
} }
div.card-footer-item.is-warning {
background-color: #523e13;
}
div.card-footer-item.is-confirm {
background-color: #00420f;
}
.tag { .tag {
color: $grey-dark; color: $grey-dark;
background-color: $grey-light; background-color: $grey-light;
@@ -245,6 +257,34 @@ IMAGES
flex: 1; flex: 1;
} }
.cover {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(192, 192, 192, 0.75);
z-index: 99;
display: flex;
justify-content: center;
align-items: center;
opacity: 1;
transition: opacity 500ms;
border-radius: inherit; // ensure border radius of cards is followed, see https://css-tricks.com/preventing-child-background-overflow-with-inherited-border-radii/
}
.cover:hover {
transition: opacity 500ms;
opacity: 0;
// For being able to click the image and for accessibility the cover should have display: none;
// This is accepted by W3C: https://front-end.social/@mia/109433817951030826
// However this does not yet seem to be supported to be animated by browsers.
// Chrome claims to have shipped it: https://chromestatus.com/feature/5154958272364544
// However I couldn't get it to work there too.
// display: none;
// transition: opacity 1000ms, display 100ms;
}
/** /**
AN Cards AN Cards
@@ -322,11 +362,10 @@ AN Cards
} }
.notification-container { .notification-container {
display: inline-block; display: inline-block;
position: relative; position: relative;
padding: 0; padding: 0;
} }
.notification-label { .notification-label {
@@ -335,16 +374,16 @@ AN Cards
/* Make the badge float in the top right corner of the button */ /* Make the badge float in the top right corner of the button */
.notification-badge { .notification-badge {
background-color: #fa3e3e; background-color: #fa3e3e;
border-radius: 2px; border-radius: 2px;
color: white; color: white;
padding: 1px 3px; padding: 1px 3px;
font-size: 8px; font-size: 8px;
position: absolute; /* Position the badge within the relatively positioned button */ position: absolute; /* Position the badge within the relatively positioned button */
top: 0; top: 0;
right: 0; right: 0;
} }

View File

@@ -33,6 +33,11 @@
<i class="fas fa-search fa-fw"></i> {% trans 'Suchen' %} <i class="fas fa-search fa-fw"></i> {% trans 'Suchen' %}
</button> </button>
</form> </form>
<hr>
<form method="post" autocomplete="off">
{% csrf_token %}
{{ org_name_search_form }}
</form>
</div> </div>
{% endif %} {% endif %}
</div> </div>
@@ -69,4 +74,57 @@
{% endfor %} {% endfor %}
</ul> </ul>
</nav> </nav>
<script>
$(document).ready(function () {
const $nameInput = $("#id_name");
$nameInput.wrap("<div class='dropdown' id='location-result-list'></div>");
const dropdown = $("#location-result-list");
$nameInput.wrap("<div class='dropdown-trigger'></div>");
$("<div class='dropdown-content' id='results'></div>").insertAfter($nameInput);
const $resultsList = $("#results");
$resultsList.wrap("<div class='dropdown-menu'></div>");
$nameInput.on("input", function () {
const query = $.trim($nameInput.val());
if (query.length < 3) {
dropdown.removeClass("is-active");
return;
}
$.ajax({
url: "{% api_base_url %}organizations/",
data: {
search: query
},
method: "GET",
dataType: "json",
success: function (data) {
$resultsList.empty();
dropdown.addClass("is-active");
if (data) {
const orgs = data.slice(0, 5);
$.each(orgs, function (index, org) {
const $listItem = $("<a>")
.addClass("dropdown-item")
.addClass("result-item")
.attr('href', org.url)
.text(org.name);
$resultsList.append($listItem);
});
}
},
error: function () {
$resultsList.html('<li class="result-item">{% trans 'Error fetching data. Please try again.' %}</li>');
}
});
});
});
</script>
{% endblock %} {% endblock %}

View File

@@ -114,7 +114,17 @@
<i class="fas fa-chart-line fa-fw"></i> {% trans 'Aufrufe' %} <i class="fas fa-chart-line fa-fw"></i> {% trans 'Aufrufe' %}
</a> </a>
{% endif %} {% endif %}
<hr class="dropdown-divider"> <hr class="dropdown-divider">
<a class="dropdown-item"
href="{% url 'adoption-notice-social-media-template-selection' adoption_notice_id=adoption_notice.pk %}">
<i class="fab fa-instagram fa-fw"
aria-hidden="true"></i> {% trans 'Social Media Vorlagen' %}
</a>
<hr class="dropdown-divider">
<a class="dropdown-item is-warning" <a class="dropdown-item is-warning"
href="{% url adoption_notice_meta|admin_urlname:'change' adoption_notice.pk %}"> href="{% url adoption_notice_meta|admin_urlname:'change' adoption_notice.pk %}">
<i class="fa-solid fa-tools fa-fw"></i> Admin interface <i class="fa-solid fa-tools fa-fw"></i> Admin interface
@@ -164,6 +174,9 @@
{% if adoption_notice.get_photos %} {% if adoption_notice.get_photos %}
<div class="column block"> <div class="column block">
<div class="card"> <div class="card">
{% if not adoption_notice.is_active %}
<div class="cover">{{ adoption_notice.status_description_short }}</div>
{% endif %}
<div class="grid card-content"> <div class="grid card-content">
<div class="gallery"> <div class="gallery">
{% with photo=adoption_notice.get_photos.0 %} {% with photo=adoption_notice.get_photos.0 %}

View File

@@ -24,11 +24,20 @@
<div class="block"> <div class="block">
{% include "fellchensammlung/partials/rescue_orgs/partial-rescue-organization-contact.html" %} {% include "fellchensammlung/partials/rescue_orgs/partial-rescue-organization-contact.html" %}
</div> </div>
<div class="block"> {% trust_level "MODERATOR" as coordinator_trust_level %}
<a class="button is-warning is-fullwidth" href="{% url org_meta|admin_urlname:'change' org.pk %}"> {% if request.user.trust_level >= coordinator_trust_level %}
<i class="fa-solid fa-tools fa-fw"></i> Admin interface <div class="block">
</a> <a class="button is-primary is-fullwidth"
</div> href="{% url 'rescue-organization-edit' rescue_organization_id=org.pk %}">
<i class="fa-solid fa-tools fa-fw"></i> {% translate 'Bearbeiten' %}
</a>
</div>
<div class="block">
<a class="button is-warning is-fullwidth" href="{% url org_meta|admin_urlname:'change' org.pk %}">
<i class="fa-solid fa-tools fa-fw"></i> Admin interface
</a>
</div>
{% endif %}
</div> </div>
<div class="column"> <div class="column">
{% include "fellchensammlung/partials/partial-map.html" %} {% include "fellchensammlung/partials/partial-map.html" %}

View File

@@ -27,33 +27,33 @@
<div class="block"> <div class="block">
<h2 class="title is-2">{% trans 'Profil verwalten' %}</h2> <h2 class="title is-2">{% trans 'Profil verwalten' %}</h2>
<div class="block"><strong>{% translate "E-Mail" %}:</strong> {{ user.email }}</div> <div class="block"><strong>{% translate "E-Mail" %}:</strong> {{ user.email }}</div>
<div class="block"> {% if user.id is request.user.id %}
<div class="field is-grouped is-grouped-multiline"> <div class="block">
<div class="control"> <div class="field is-grouped is-grouped-multiline">
<a class="button is-warning" <div class="control">
href="{% url 'account_change_password' %}">{% translate "Passwort ändern" %}</a> <a class="button is-warning"
</div> href="{% url 'account_change_password' %}">{% translate "Passwort ändern" %}</a>
<div class="control"> </div>
<a class="button is-warning" <div class="control">
href="{% url 'account_email' %}"> <a class="button is-warning"
{% translate "E-Mail Adresse ändern" %} href="{% url 'account_email' %}">
</a> {% translate "E-Mail Adresse ändern" %}
</div> </a>
<div class="control"> </div>
<a class="button is-warning" <div class="control">
href="{% url 'mfa_index' %}"> <a class="button is-warning"
{% translate "2-Faktor Authentifizierung" %} href="{% url 'mfa_index' %}">
</a> {% translate "2-Faktor Authentifizierung" %}
</div> </a>
<div class="control"> </div>
<a class="button is-info" href="{% url 'user-me-export' %}"> <div class="control">
{% translate "Daten exportieren" %} <a class="button is-info" href="{% url 'user-me-export' %}">
</a> {% translate "Daten exportieren" %}
</a>
</div>
</div> </div>
</div> </div>
</div>
{% if user.id is request.user.id %}
<div class="block"> <div class="block">
<h2 class="title is-2">{% trans 'Einstellungen' %}</h2> <h2 class="title is-2">{% trans 'Einstellungen' %}</h2>
<form class="block" action="" method="POST"> <form class="block" action="" method="POST">
@@ -98,15 +98,35 @@
</details> </details>
</div> </div>
<h2 class="title is-2">{% translate 'Benachrichtigungen' %}</h2>
{% include "fellchensammlung/lists/list-notifications.html" %}
<h2 class="title is-2">{% translate 'Abonnierte Suchen' %}</h2>
{% include "fellchensammlung/lists/list-search-subscriptions.html" %}
<h2 class="title is-2">{% translate 'Meine Vermittlungen' %}</h2>
{% include "fellchensammlung/lists/list-adoption-notices.html" %}
{% endif %} {% endif %}
{% if show_mod_actions %}
<div class="block">
<h2 class="title is-2">{% trans 'Moderation' %}</h2>
<div class="block">
<p><strong>{% translate 'Moderationsnotizen' %}:</strong> {{ user.mod_notes }}</p>
</div>
<div class="block">
{% if user.is_active %}
<a href="{% url 'user-deactivate' user_id=user.pk %}" class="button is-danger">
{% translate 'User deaktivieren' %}
</a>
{% else %}
<a href="{% url 'user-activate' user_id=user.pk %}" class="button is-info">
{% translate 'User aktivieren' %}
</a>
{% endif %}
</div>
</div>
{% endif %}
<h2 class="title is-2">{% translate 'Benachrichtigungen' %}</h2>
{% include "fellchensammlung/lists/list-notifications.html" %}
<h2 class="title is-2">{% translate 'Abonnierte Suchen' %}</h2>
{% include "fellchensammlung/lists/list-search-subscriptions.html" %}
<h2 class="title is-2">{% translate 'Vermittlungen' %}</h2>
{% include "fellchensammlung/lists/list-adoption-notices.html" %}
{% endblock %} {% endblock %}

View File

@@ -7,9 +7,14 @@
{% block content %} {% block content %}
<h1 class="title is-1">403 Forbidden</h1> <h1 class="title is-1">403 Forbidden</h1>
<p> <p>
{% blocktranslate %} {% if error_message %}
Diese Aktion ist dir nicht erlaubt. Logge dich ein oder nutze einen anderen Account. Wenn du denkst, dass hier {{ error_message }}
ein Fehler vorliegt, kontaktiere das Team! {% else %}
{% endblocktranslate %} {% blocktranslate %}
Diese Aktion ist dir nicht erlaubt. Logge dich ein oder nutze einen anderen Account. Wenn du denkst,
dass hier
ein Fehler vorliegt, kontaktiere das Team!
{% endblocktranslate %}
{% endif %}
</p> </p>
{% endblock %} {% endblock %}

View File

@@ -102,6 +102,10 @@
<a class="nav-link " href="{% url "modtools" %}"> <a class="nav-link " href="{% url "modtools" %}">
{% translate 'Moderationstools' %} {% translate 'Moderationstools' %}
</a> </a>
<br>
<a class="nav-link " href="{% url "rescue-organization-create" %}">
{% translate 'Tierschutzorganisation hinzufügen' %}
</a>
{% endif %} {% endif %}
<br/> <br/>
{% if request.user.is_superuser %} {% if request.user.is_superuser %}

View File

@@ -0,0 +1,17 @@
{% extends "fellchensammlung/base.html" %}
{% load i18n %}
{% block description %}
<meta name="description" content="{% trans 'User aktivieren' %}">
{% endblock %}
{% block content %}
<h1 class="title is-1">{% trans 'User aktivieren' %}</h1>
{% blocktranslate %}
Hier kannst du einen User manuell aktivieren und optional eine Notiz hinterlassen.
{% endblocktranslate %}
<form method="post">
{% csrf_token %}
{{ form }}
<button class="button is-info is-fullwidth" type="submit">{% translate "Aktivieren" %}</button>
</form>
{% endblock %}

View File

@@ -0,0 +1,19 @@
{% extends "fellchensammlung/base.html" %}
{% load i18n %}
{% block description %}
<meta name="description" content="{% trans 'User deaktivieren' %}">
{% endblock %}
{% block content %}
<h1 class="title is-1">{% trans 'User deaktivieren' %}</h1>
{% url "about" as rule_url %}
{% blocktranslate %}
Wenn dieser User unseren <a href='{{ rule_url }}'>Regeln</a> zuwider gehandelt hat oder seit langem inaktiv
ist, kannst du ihn deaktivieren.
{% endblocktranslate %}
<form method="post">
{% csrf_token %}
{{ form }}
<button class="button is-danger is-fullwidth" type="submit">{% translate "Deaktivieren" %}</button>
</form>
{% endblock %}

View File

@@ -0,0 +1,19 @@
{% extends "fellchensammlung/base.html" %}
{% load i18n %}
{% load widget_tweaks %}
{% block content %}
<h1 class="title is-1">
{% if rescue_org %}
{{ rescue_org.name }}
{% else %}
{% translate 'Tierschutzorganisation hinzufügen' %}
{% endif %}
</h1>
<form method="post">
{% csrf_token %}
{{ form }}
<input class="button is-primary" type="submit" value="{% translate "Speichern" %}">
</form>
{% endblock %}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 54 KiB

View File

@@ -6,21 +6,7 @@
<h1 class="title is-1">{% translate 'Vermittlung deaktivieren' %}</h1> <h1 class="title is-1">{% translate 'Vermittlung deaktivieren' %}</h1>
<form method="post" enctype="multipart/form-data"> <form method="post" enctype="multipart/form-data">
{% csrf_token %} {% csrf_token %}
<div class="field"> {{ form }}
<label class="label" for="reason_for_closing">{% translate 'Warum schließt du die Vermittlung?' %}</label>
<div class="control">
<div class="select">
<select id="reason_for_closing" name="reason_for_closing">
<option value="closed_successful_with_notfellchen">{% translate 'Vermittelt mit Hilfe von Notfellchen' %}</option>
<option value="closed_successful_without_notfellchen">{% translate 'Vermittelt ohne Hilfe von Notfellchen' %}</option>
<option value="closed_for_other_adoption_notice">{% translate 'Vermittlung zugunsten einer anderen geschlossen' %}</option>
<option value="closed_not_open_for_adoption_anymore">{% translate 'Nicht mehr zu vermitteln (z.B. aufgrund von Krankheit)' %}</option>
<option value="closed_animal_died">{% translate 'Tod des Tiers' %}</option>
<option value="closed_other">{% translate 'Anderer Grund' %}</option>
</select>
</div>
</div>
</div>
<input class="button is-warning" type="submit" value="{% translate "Vermittlung deaktivieren" %}"> <input class="button is-warning" type="submit" value="{% translate "Vermittlung deaktivieren" %}">
</form> </form>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,62 @@
{% extends "fellchensammlung/base.html" %}
{% load i18n %}
{% load widget_tweaks %}
{% load admin_urls %}
{% block title %}
<title>Social Media Post für {{ adoption_notice }}</title>
{% endblock %}
{% block content %}
<h1 class="title is-1">Social Media Post für {{ adoption_notice }}</h1>
<div class="columns block">
<div class="column">
<div class="box">
{% include 'fellchensammlung/partials/social_media/post-to-fedi.html' %}
<p class="block">
{% blocktranslate %}
Die Vermittlung wird auf unserem Fediverse-Account gepostet
{% endblocktranslate %}
</p>
</div>
</div>
<div class="column">
<div class="box block">
<a href="{% url 'adoption-notice-story-pic' adoption_notice.pk %}"
class="button is-primary is-fullwidth">
<i class="fab fa-instagram fa-fw"></i> {% trans 'Instagram Storyvorlage' %}
</a>
<p class="block">
{% blocktranslate %}
Eine Vorlage für eine Instagram-Story im Format 1080x1980px.
{% endblocktranslate %}
</p>
</div>
</div>
<div class="column">
<div class="box block">
<a href="{% url 'adoption-notice-sharepic' adoption_notice.pk %}"
class="button is-primary block is-fullwidth">
<i class="fab fa-instagram fa-fw"></i> {% trans 'Instagram Post' %}
</a>
<p class="block">
{% blocktranslate %}
Eine Vorlage für eine Instagram-Post mit einer Slide pro Tier.
{% endblocktranslate %}
</p>
</div>
</div>
</div>
<div class="block">
<p>
{% blocktranslate %}
Die Vorlagen werden idealerweise im Grafikprogramm Inkscape weiterbearbeitet.
{% endblocktranslate %}
</p>
<a href="https://inkscape.org/" class="button is-link block is-fullwidth">
<i class="fab fa-inkscape fa-fw"></i>
{% translate 'Inkscape herunterladen' %}
</a>
</div>
{% endblock %}

View File

@@ -20,39 +20,7 @@
</div> </div>
<div class="block"> <div class="block">
{% if action_was_posting %} {% include "fellchensammlung/partials/social_media/post-to-fedi.html" %}
{% 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>

View File

@@ -7,6 +7,9 @@
{% translate 'Adoptionsprozess' %} {% translate 'Adoptionsprozess' %}
</h2> </h2>
</div> </div>
{% if not adoption_notice.is_active %}
<div class="cover">{{ adoption_notice.status_description_short }}</div>
{% endif %}
<div class="card-content"> <div class="card-content">
{% include adoption_process_template %} {% include adoption_process_template %}
</div> </div>

View File

@@ -31,5 +31,14 @@
{{ adoption_notice.status_description }} {{ adoption_notice.status_description }}
</div> </div>
</article> </article>
{% elif adoption_notice.is_disabled %}
<article class="message is-warning">
<div class="message-header">
<p>{% translate 'Vermittlung wurde gesperrt' %}</p>
</div>
<div class="message-body content">
{{ adoption_notice.status_description }}
</div>
</article>
{% endif %} {% endif %}

View File

@@ -1,7 +1,7 @@
{% load i18n %} {% load i18n %}
{% load custom_tags %} {% load custom_tags %}
<div class="card"> <div class="card">
<a href="{{ adoption_notice.get_absolute_url }}" class="card-header"> <a href="{{ adoption_notice.get_absolute_url }}" class="card-header" target="_blank">
<div class="card-header-title"> <div class="card-header-title">
{{ adoption_notice.name }} {{ adoption_notice.name }}
</div> </div>
@@ -17,25 +17,21 @@
{% endif %} {% endif %}
</div> </div>
<div class="card-footer"> <div class="card-footer">
<div class="card-footer-item is-confirm"> <form class="card-footer-item is-confirm" method="post">
<form method="post"> {% csrf_token %}
{% csrf_token %} <input type="hidden"
<input type="hidden" name="adoption_notice_id"
name="adoption_notice_id" value="{{ adoption_notice.pk }}">
value="{{ adoption_notice.pk }}"> <input type="hidden" name="action" value="checked_active">
<input type="hidden" name="action" value="checked_active"> <button style="width: 100%" type="submit">{% translate "Vermittlung noch aktuell" %}</button>
<button class="" type="submit">{% translate "Vermittlung noch aktuell" %}</button> </form>
</form> <form class="card-footer-item is-warning" method="post">
</div> {% csrf_token %}
<div class="card-footer-item is-warning"> <input type="hidden"
<form method="post"> name="adoption_notice_id"
{% csrf_token %} value="{{ adoption_notice.pk }}">
<input type="hidden" <input type="hidden" name="action" value="checked_inactive">
name="adoption_notice_id" <button style="width:100%" type="submit">{% translate "Vermittlung inaktiv" %}</button>
value="{{ adoption_notice.pk }}"> </form>
<input type="hidden" name="action" value="checked_inactive">
<button class="" type="submit">{% translate "Vermittlung inaktiv" %}</button>
</form>
</div>
</div> </div>
</div> </div>

View File

@@ -71,7 +71,22 @@
value="{{ rescue_org.pk }}"> value="{{ rescue_org.pk }}">
<input type="hidden" name="action" value="checked"> <input type="hidden" name="action" value="checked">
<button class="" type="submit">{% translate "Organisation geprüft" %}</button> <button class="" type="submit">{% translate "Organisation geprüft" %}</button>
</form>
</div>
<div class="card-footer-item is-warning">
<form method="post">
{% csrf_token %}
<input type="hidden"
name="rescue_organization_id"
value="{{ rescue_org.pk }}">
<input type="hidden" name="action" value="toggle_active_communication">
<button class="" type="submit">
{% if rescue_org.ongoing_communication %}
{% translate "Aktive Kommunikation beendet" %}
{% else %}
{% translate "Aktive Kommunikation" %}
{% endif %}
</button>
</form> </form>
</div> </div>
<div class="card-footer-item is-danger"> <div class="card-footer-item is-danger">

View File

@@ -0,0 +1,38 @@
{% load i18n %}
{% 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">
{% if adoption_notice %}
<input type="hidden" name="adoption_notice_pk" value="{{ adoption_notice.pk }}">
{% endif %}
<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>

View File

@@ -5,6 +5,7 @@ from django.template.defaultfilters import stringfilter
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from urllib.parse import urlparse from urllib.parse import urlparse
from django.utils import timezone from django.utils import timezone
from django.urls import reverse
from fellchensammlung.tools.misc import time_since_as_hr_string from fellchensammlung.tools.misc import time_since_as_hr_string
from notfellchen import settings from notfellchen import settings
@@ -54,6 +55,11 @@ def get_oxitraffic_script_if_enabled():
return "" return ""
@register.simple_tag
def api_base_url():
return reverse("api-base-url")
@register.filter @register.filter
@stringfilter @stringfilter
def pointdecimal(value): def pointdecimal(value):

View File

@@ -1,8 +1,9 @@
import logging import logging
import requests import requests
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.utils.translation import gettext_lazy as _
from fellchensammlung.models import SocialMediaPost, PlatformChoices from fellchensammlung.models import SocialMediaPost, PlatformChoices, AdoptionNotice
from notfellchen import settings from notfellchen import settings
@@ -86,7 +87,10 @@ class FediClient:
def post_an_to_fedi(adoption_notice): def post_an_to_fedi(adoption_notice):
client = FediClient(settings.fediverse_access_token, settings.fediverse_api_base_url) try:
client = FediClient(settings.fediverse_access_token, settings.fediverse_api_base_url)
except AttributeError:
raise ConnectionError("Configuration for connecting to a Fediverse account is missing")
context = {"adoption_notice": adoption_notice} context = {"adoption_notice": adoption_notice}
status_text = render_to_string("fellchensammlung/misc/fediverse/an-post.md", context) status_text = render_to_string("fellchensammlung/misc/fediverse/an-post.md", context)
@@ -101,3 +105,33 @@ def post_an_to_fedi(adoption_notice):
platform=PlatformChoices.FEDIVERSE, platform=PlatformChoices.FEDIVERSE,
url=response['url'], ) url=response['url'], )
return post return post
def handle_post_fedi_action(adoption_notice: AdoptionNotice = SocialMediaPost.get_an_to_post()):
if adoption_notice is not None:
logging.info(f"Posting adoption notice: {adoption_notice} ({adoption_notice.id})")
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.")}
except ConnectionError as e:
logging.error(f"Could not post fediverse post: {e}")
context = {"action_was_posting": True,
"posted_successfully": False,
"error_message": _(
"Fehler beim Posten, in der Konfiguration fehlen Zugangsdaten zu einem Fediverse Account")}
else:
context = {"action_was_posting": True,
"posted_successfully": False,
"error_message": _("Keine Vermittlung zum Posten gefunden.")}
return context

View File

@@ -3,7 +3,7 @@ from django.template.loader import render_to_string
from fellchensammlung.models import AdoptionNotice from fellchensammlung.models import AdoptionNotice
def export_svg(adoption_notice): def export_svg(adoption_notice, template_name: str = "fellchensammlung/images/adoption-notice.svg"):
result = render_to_string(template_name="fellchensammlung/images/adoption-notice.svg", result = render_to_string(template_name=template_name,
context={"adoption_notice": adoption_notice, }) context={"adoption_notice": adoption_notice, })
return result return result

View File

@@ -2,14 +2,19 @@ from datetime import timedelta
from django.utils import timezone from django.utils import timezone
from fellchensammlung.models import User, AdoptionNotice, AdoptionNoticeStatusChoices, Animal, RescueOrganization from fellchensammlung.models import User, AdoptionNotice, AdoptionNoticeStatusChoices, Animal, RescueOrganization, \
AllowUseOfMaterialsChices
def get_rescue_org_check_stats(): def get_rescue_org_check_stats():
timeframe = timezone.now().date() - timedelta(days=14) timeframe = timezone.now().date() - timedelta(days=14)
num_rescue_orgs_to_check = RescueOrganization.objects.filter(exclude_from_check=False).filter( num_rescue_orgs_to_check = RescueOrganization.objects.filter(exclude_from_check=False,
ongoing_communication=False).exclude(
allows_using_materials=AllowUseOfMaterialsChices.USE_MATERIALS_DENIED).filter(
last_checked__lt=timeframe).count() last_checked__lt=timeframe).count()
num_rescue_orgs_checked = RescueOrganization.objects.filter(exclude_from_check=False).filter( num_rescue_orgs_checked = RescueOrganization.objects.filter(exclude_from_check=False,
ongoing_communication=False).exclude(
allows_using_materials=AllowUseOfMaterialsChices.USE_MATERIALS_DENIED).filter(
last_checked__gte=timeframe).count() last_checked__gte=timeframe).count()
try: try:

View File

@@ -81,6 +81,9 @@ def is_404(url):
class RequestProfiler: class RequestProfiler:
data = [] data = []
def clear(self):
self.data = []
def add_status(self, status): def add_status(self, status):
self.data.append((time.time(), status)) self.data.append((time.time(), status))

View File

@@ -69,14 +69,14 @@ class AdoptionNoticeStatusChoices:
UNCHECKED = "awaiting_action_unchecked", _("Unchecked") UNCHECKED = "awaiting_action_unchecked", _("Unchecked")
class Closed(TextChoices): class Closed(TextChoices):
SUCCESSFUL_WITH_NOTFELLCHEN = "closed_successful_with_notfellchen", _("Successful (with Notfellchen)") SUCCESSFUL = "closed_successfully", _("Erfolgreich vermittelt")
SUCCESSFUL_WITHOUT_NOTFELLCHEN = "closed_successful_without_notfellchen", _("Successful (without Notfellchen)") ANIMAL_DIED = "closed_animal_died", _("Tier gestorben")
ANIMAL_DIED = "closed_animal_died", _("Animal died") FOR_OTHER_ADOPTION_NOTICE = ("closed_for_other_adoption_notice",
FOR_OTHER_ADOPTION_NOTICE = "closed_for_other_adoption_notice", _("Closed for other adoption notice") _("Vermittlung wurde zugunsten einer anderen geschlossen."))
NOT_OPEN_ANYMORE = "closed_not_open_for_adoption_anymore", _("Not open for adoption anymore") NOT_OPEN_ANYMORE = "closed_not_open_for_adoption_anymore", _("Tier(e) stehen nicht mehr zur Vermittlung bereit.")
LINK_TO_MORE_INFO_NOT_REACHABLE = "closed_link_to_more_info_not_reachable", _( LINK_TO_MORE_INFO_NOT_REACHABLE = "closed_link_to_more_info_not_reachable", _(
"Der Link zu weiteren Informationen ist nicht mehr erreichbar.") "Der Link zu weiteren Informationen ist nicht mehr erreichbar.")
OTHER = "closed_other", _("Other (closed)") OTHER = "closed_other", _("Anderes")
class Disabled(TextChoices): class Disabled(TextChoices):
AGAINST_RULES = "disabled_against_the_rules", _("Against the rules") AGAINST_RULES = "disabled_against_the_rules", _("Against the rules")
@@ -97,8 +97,7 @@ class AdoptionNoticeStatusChoicesDescriptions:
_ansc = AdoptionNoticeStatusChoices # Mapping for readability _ansc = AdoptionNoticeStatusChoices # Mapping for readability
mapping = {_ansc.Active.SEARCHING.value: "", mapping = {_ansc.Active.SEARCHING.value: "",
_ansc.Active.INTERESTED: _("Jemand hat bereits Interesse an den Tieren."), _ansc.Active.INTERESTED: _("Jemand hat bereits Interesse an den Tieren."),
_ansc.Closed.SUCCESSFUL_WITH_NOTFELLCHEN: _("Vermittlung erfolgreich abgeschlossen."), _ansc.Closed.SUCCESSFUL: _("Vermittlung erfolgreich abgeschlossen."),
_ansc.Closed.SUCCESSFUL_WITHOUT_NOTFELLCHEN: _("Vermittlung erfolgreich abgeschlossen."),
_ansc.Closed.ANIMAL_DIED: _("Die zu vermittelnden Tiere sind über die Regenbrücke gegangen."), _ansc.Closed.ANIMAL_DIED: _("Die zu vermittelnden Tiere sind über die Regenbrücke gegangen."),
_ansc.Closed.FOR_OTHER_ADOPTION_NOTICE: _("Vermittlung wurde zugunsten einer anderen geschlossen."), _ansc.Closed.FOR_OTHER_ADOPTION_NOTICE: _("Vermittlung wurde zugunsten einer anderen geschlossen."),
_ansc.Closed.NOT_OPEN_ANYMORE: _("Tier(e) stehen nicht mehr zur Vermittlung bereit."), _ansc.Closed.NOT_OPEN_ANYMORE: _("Tier(e) stehen nicht mehr zur Vermittlung bereit."),
@@ -113,8 +112,8 @@ class AdoptionNoticeStatusChoicesDescriptions:
_ansc.AwaitingAction.UNCHECKED: _( _ansc.AwaitingAction.UNCHECKED: _(
"Vermittlung deaktiviert bis sie vom Team auf Aktualität geprüft wurde."), "Vermittlung deaktiviert bis sie vom Team auf Aktualität geprüft wurde."),
_ansc.Disabled.AGAINST_RULES: _("Vermittlung deaktiviert da sie gegen die Regeln verstößt."), _ansc.Disabled.AGAINST_RULES: _("Die Vermittlung wurde gesperrt, da sie gegen die Regeln verstößt."),
_ansc.Disabled.OTHER: _("Vermittlung deaktiviert.") _ansc.Disabled.OTHER: _("Vermittlung gesperrt.")
} }

View File

@@ -173,6 +173,7 @@ class AdoptionNoticeSearch:
class RescueOrgSearch: class RescueOrgSearch:
def __init__(self, request): def __init__(self, request):
self.name = None
self.area_search = None self.area_search = None
self.max_distance = None self.max_distance = None
self.location = None # Can either be Location (DjangoModel) or LocationProxy self.location = None # Can either be Location (DjangoModel) or LocationProxy
@@ -229,6 +230,7 @@ class RescueOrgSearch:
return fitting_rescue_orgs return fitting_rescue_orgs
def rescue_org_search_from_request(self, request): def rescue_org_search_from_request(self, request):
# Only search if request method is get with action search
if request.method == 'GET' and request.GET.get("action", False) == "search": if request.method == 'GET' and request.GET.get("action", False) == "search":
self.search_form = RescueOrgSearchForm(request.GET) self.search_form = RescueOrgSearchForm(request.GET)
self.search_form.is_valid() self.search_form.is_valid()

View File

@@ -30,9 +30,15 @@ urlpatterns = [
path("tier/<int:animal_id>/add-photo", views.add_photo_to_animal, name="animal-add-photo"), path("tier/<int:animal_id>/add-photo", views.add_photo_to_animal, name="animal-add-photo"),
# ex: /adoption_notice/7/ # ex: /adoption_notice/7/
path("vermittlung/<int:adoption_notice_id>/", views.adoption_notice_detail, name="adoption-notice-detail"), path("vermittlung/<int:adoption_notice_id>/", views.adoption_notice_detail, name="adoption-notice-detail"),
# ex: /adoption_notice/7/social-media-templates
path("vermittlung/<int:adoption_notice_id>/social-media-templates", views.adoption_notice_social_media_templates,
name="adoption-notice-social-media-template-selection"),
# ex: /adoption_notice/7/sharepic # ex: /adoption_notice/7/sharepic
path("vermittlung/<int:adoption_notice_id>/sharepic", views.adoption_notice_sharepic, path("vermittlung/<int:adoption_notice_id>/sharepic", views.adoption_notice_sharepic,
name="adoption-notice-sharepic"), name="adoption-notice-sharepic"),
# ex: /adoption_notice/7/story
path("vermittlung/<int:adoption_notice_id>/storypic", views.adoption_notice_story_pic,
name="adoption-notice-story-pic"),
# ex: /adoption_notice/7/edit # ex: /adoption_notice/7/edit
path("vermittlung/<int:adoption_notice_id>/edit", views.adoption_notice_edit, name="adoption-notice-edit"), path("vermittlung/<int:adoption_notice_id>/edit", views.adoption_notice_edit, name="adoption-notice-edit"),
# ex: /vermittlung/5/add-photo # ex: /vermittlung/5/add-photo
@@ -41,12 +47,15 @@ urlpatterns = [
# ex: /adoption_notice/2/add-animal # ex: /adoption_notice/2/add-animal
path("vermittlung/<int:adoption_notice_id>/add-animal", views.adoption_notice_add_animal, path("vermittlung/<int:adoption_notice_id>/add-animal", views.adoption_notice_add_animal,
name="adoption-notice-add-animal"), name="adoption-notice-add-animal"),
path("vermittlung/<int:adoption_notice_id>/close", views.deactivate_an, path("vermittlung/<int:adoption_notice_id>/close", views.close_adoption_notice,
name="adoption-notice-close"), name="adoption-notice-close"),
path("tierschutzorganisationen/", views.list_rescue_organizations, name="rescue-organizations"), path("tierschutzorganisationen/", views.list_rescue_organizations, name="rescue-organizations"),
path("tierschutzorganisationen/<int:rescue_organization_id>/", views.detail_view_rescue_organization, path("tierschutzorganisationen/<int:rescue_organization_id>/", views.detail_view_rescue_organization,
name="rescue-organization-detail"), name="rescue-organization-detail"),
path("tierschutzorganisationen/add", views.rescue_org_create_or_update, name="rescue-organization-create"),
path("tierschutzorganisationen/<int:rescue_organization_id>/edit", views.rescue_org_create_or_update,
name="rescue-organization-edit"),
path("tierschutzorganisationen/<int:rescue_organization_id>/exkludieren", views.exclude_from_regular_check, path("tierschutzorganisationen/<int:rescue_organization_id>/exkludieren", views.exclude_from_regular_check,
name="rescue-organization-exclude"), name="rescue-organization-exclude"),
path("tierschutzorganisationen/add-exclusion-reason", views.update_exclusion_reason, path("tierschutzorganisationen/add-exclusion-reason", views.update_exclusion_reason,
@@ -90,11 +99,12 @@ urlpatterns = [
########### ###########
# ex: user/1 # ex: user/1
path("user/<int:user_id>/", views.user_by_id, name="user-detail"), path("user/<int:user_id>/", views.user_by_id, name="user-detail"),
path("user/<int:user_id>/deactivate/", views.user_deactivate, name="user-deactivate"),
path("user/<int:user_id>/activate/", views.user_activate, name="user-activate"),
path("user/me/", views.my_profile, name="user-me"), path("user/me/", views.my_profile, name="user-me"),
path("user/notifications/", views.my_notifications, name="user-notifications"), path("user/notifications/", views.my_notifications, name="user-notifications"),
path('user/me/export/', views.export_own_profile, name='user-me-export'), path('user/me/export/', views.export_own_profile, name='user-me-export'),
path('change-language', views.change_language, name="change-language"), path('change-language', views.change_language, name="change-language"),
########### ###########
@@ -120,7 +130,7 @@ urlpatterns = [
################### ###################
## External Site ## ## External Site ##
################### ###################
path('bulma/external-site/', views.external_site_warning, name="external-site"), path('external-site/', views.external_site_warning, name="external-site"),
############### ###############
## TECHNICAL ## ## TECHNICAL ##

View File

@@ -13,7 +13,6 @@ 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,13 +21,14 @@ 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, Subscriptions, Notification, RescueOrganization, \ User, Location, Subscriptions, Notification, RescueOrganization, \
Species, Log, Timestamp, TrustLevel, SexChoicesWithAll, SearchSubscription, \ Species, Log, Timestamp, TrustLevel, SexChoicesWithAll, SearchSubscription, \
ImportantLocation, SpeciesSpecificURL, NotificationTypeChoices, SocialMediaPost ImportantLocation, SpeciesSpecificURL, NotificationTypeChoices, SocialMediaPost, AllowUseOfMaterialsChices
from .forms import AdoptionNoticeForm, ImageForm, ReportAdoptionNoticeForm, \ from .forms import AdoptionNoticeForm, ImageForm, ReportAdoptionNoticeForm, \
CommentForm, ReportCommentForm, AnimalForm, AdoptionNoticeFormAutoAnimal, SpeciesURLForm, RescueOrgInternalComment, \ CommentForm, ReportCommentForm, AnimalForm, AdoptionNoticeFormAutoAnimal, SpeciesURLForm, RescueOrgInternalComment, \
UpdateRescueOrgRegularCheckStatus UpdateRescueOrgRegularCheckStatus, UserModCommentForm, CloseAdoptionNoticeForm, RescueOrgSearchByNameForm, \
RescueOrgForm
from .models import Language, Announcement from .models import Language, Announcement
from .tools import i18n, img from .tools import i18n, img
from .tools.fedi import post_an_to_fedi from .tools.fedi import handle_post_fedi_action
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, get_rescue_org_check_stats from .tools.metrics import gather_metrics_data, get_rescue_org_check_stats
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, \
@@ -114,6 +114,13 @@ def handle_an_check_actions(request, action, adoption_notice=None):
def adoption_notice_detail(request, adoption_notice_id): def adoption_notice_detail(request, adoption_notice_id):
adoption_notice = get_object_or_404(AdoptionNotice, id=adoption_notice_id) adoption_notice = get_object_or_404(AdoptionNotice, id=adoption_notice_id)
if adoption_notice.is_disabled and not user_is_owner_or_trust_level(request.user, adoption_notice):
error_message = _("Die Vermittlung wurde versteckt und ist nur Admins zugänglich. Grund dafür kann z.b. ein "
"Regelverstoß sein.")
return render(request,
"fellchensammlung/errors/403.html",
context={"error_message": error_message},
status=403)
adoption_notice_meta = adoption_notice._meta adoption_notice_meta = adoption_notice._meta
if request.user.is_authenticated: if request.user.is_authenticated:
try: try:
@@ -604,6 +611,9 @@ def user_detail(request, user, token=None):
user_detail_profile.add_status("Finished - returning to renderer") user_detail_profile.add_status("Finished - returning to renderer")
if request.user.is_superuser: if request.user.is_superuser:
context["profile"] = user_detail_profile.as_relative_with_ms context["profile"] = user_detail_profile.as_relative_with_ms
if request.user.trust_level > TrustLevel.MODERATOR:
context["show_mod_actions"] = True
return render(request, 'fellchensammlung/details/detail-user.html', context=context) return render(request, 'fellchensammlung/details/detail-user.html', context=context)
@@ -678,6 +688,36 @@ def my_notifications(request):
return render(request, 'fellchensammlung/notifications.html', context=context) return render(request, 'fellchensammlung/notifications.html', context=context)
def user_activate(request, user_id):
return user_de_activation(request, user_id, True)
def user_deactivate(request, user_id):
return user_de_activation(request, user_id, False)
def user_de_activation(request, user_id, is_to_be_active):
"""
Activates or deactivates a user
"""
user = User.objects.get(id=user_id)
if request.method == 'POST':
form = UserModCommentForm(request.POST, instance=user)
if form.is_valid():
user_instance = form.save(commit=False)
user_instance.is_active = is_to_be_active
user_instance.save()
return redirect(reverse("user-detail", args=[user_instance.pk], ))
else:
form = UserModCommentForm(instance=user)
if is_to_be_active:
template = 'fellchensammlung/forms/form-activate-user.html'
else:
template = 'fellchensammlung/forms/form-deactivate-user.html'
return render(request, template, {'form': form})
@user_passes_test(user_is_trust_level_or_above) @user_passes_test(user_is_trust_level_or_above)
def modqueue(request): def modqueue(request):
open_reports = Report.objects.select_related("reportadoptionnotice", "reportcomment").filter(status=Report.WAITING) open_reports = Report.objects.select_related("reportadoptionnotice", "reportcomment").filter(status=Report.WAITING)
@@ -811,6 +851,7 @@ def list_rescue_organizations(request, species=None, template='fellchensammlung/
context = {"rescue_organizations_to_list": rescue_organizations_to_list, context = {"rescue_organizations_to_list": rescue_organizations_to_list,
"show_rescue_orgs": True, "show_rescue_orgs": True,
"elided_page_range": paginator.get_elided_page_range(page_number, on_each_side=2, on_ends=1), "elided_page_range": paginator.get_elided_page_range(page_number, on_each_side=2, on_ends=1),
"org_name_search_form": RescueOrgSearchByNameForm(),
} }
if org_search: if org_search:
additional_context = { additional_context = {
@@ -867,6 +908,9 @@ def rescue_organization_check(request, context=None):
if action == "checked": if action == "checked":
rescue_org.set_checked() rescue_org.set_checked()
Log.objects.create(user=request.user, action="rescue_organization_checked", ) Log.objects.create(user=request.user, action="rescue_organization_checked", )
elif action == "toggle_active_communication":
rescue_org.ongoing_communication = not rescue_org.ongoing_communication
rescue_org.save()
elif action == "set_species_url": elif action == "set_species_url":
species_url_form = SpeciesURLForm(request.POST) species_url_form = SpeciesURLForm(request.POST)
@@ -881,7 +925,9 @@ def rescue_organization_check(request, context=None):
comment_form.save() comment_form.save()
rescue_orgs_to_check = RescueOrganization.objects.filter(exclude_from_check=False, rescue_orgs_to_check = RescueOrganization.objects.filter(exclude_from_check=False,
ongoing_communication=False).order_by("last_checked")[:3] ongoing_communication=False).exclude(
allows_using_materials=AllowUseOfMaterialsChices.USE_MATERIALS_DENIED).order_by(
"last_checked")[:3]
rescue_orgs_with_ongoing_communication = RescueOrganization.objects.filter(ongoing_communication=True).order_by( rescue_orgs_with_ongoing_communication = RescueOrganization.objects.filter(ongoing_communication=True).order_by(
"updated_at") "updated_at")
rescue_orgs_last_checked = RescueOrganization.objects.filter().order_by("-last_checked")[:10] rescue_orgs_last_checked = RescueOrganization.objects.filter().order_by("-last_checked")[:10]
@@ -929,7 +975,7 @@ def exclude_from_regular_check(request, rescue_organization_id, source="organiza
if to_be_excluded: if to_be_excluded:
Log.objects.create(user=request.user, Log.objects.create(user=request.user,
action="rescue_organization_excluded_from_check", action="rescue_organization_excluded_from_check",
text=f"New status: {form.cleaned_data["regular_check_status"]}") text=f"New status: {form.cleaned_data['regular_check_status']}")
return redirect(reverse(source)) return redirect(reverse(source))
else: else:
@@ -959,44 +1005,74 @@ def moderation_tools_overview(request):
if request.method == "POST": if request.method == "POST":
action = request.POST.get("action") action = request.POST.get("action")
if action == "post_to_fedi": if action == "post_to_fedi":
adoption_notice = SocialMediaPost.get_an_to_post() context = handle_post_fedi_action()
if adoption_notice is not None:
logging.info(f"Posting adoption notice: {adoption_notice} ({adoption_notice.id})")
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) return render(request, 'fellchensammlung/mod-tool-overview.html', context=context)
def deactivate_an(request, adoption_notice_id): def close_adoption_notice(request, adoption_notice_id):
adoption_notice = get_object_or_404(AdoptionNotice, pk=adoption_notice_id) adoption_notice = get_object_or_404(AdoptionNotice, pk=adoption_notice_id)
if request.method == "POST": if request.method == "POST":
reason_for_closing = request.POST.get("reason_for_closing") form = CloseAdoptionNoticeForm(request.POST, instance=adoption_notice)
if reason_for_closing not in AdoptionNoticeStatusChoices.Closed.values: if form.is_valid():
return render(request, "fellchensammlung/errors/403.html", status=403) form.save()
adoption_notice.adoption_notice_status = reason_for_closing return redirect(reverse("adoption-notice-detail", args=[adoption_notice.pk], ))
adoption_notice.save() else:
return redirect(reverse("adoption-notice-detail", args=[adoption_notice.pk], )) form = CloseAdoptionNoticeForm(instance=adoption_notice)
context = {"adoption_notice": adoption_notice, } context = {"adoption_notice": adoption_notice, "form": form}
return render(request, 'fellchensammlung/misc/deactivate-an.html', context=context) return render(request, 'fellchensammlung/misc/deactivate-an.html', context=context)
def adoption_notice_sharepic(request, adoption_notice_id): def adoption_notice_sharepic(request, adoption_notice_id):
adoption_notice = get_object_or_404(AdoptionNotice, pk=adoption_notice_id) adoption_notice = get_object_or_404(AdoptionNotice, pk=adoption_notice_id)
svg_data = img.export_svg(adoption_notice) svg_data = img.export_svg(adoption_notice)
return HttpResponse(svg_data, content_type="image/svg+xml") return HttpResponse(svg_data, content_type="image/svg+xml",
headers={"Content-Disposition": f'attachment; filename="{adoption_notice.name}-post.svg"'},)
def adoption_notice_story_pic(request, adoption_notice_id):
adoption_notice = get_object_or_404(AdoptionNotice, pk=adoption_notice_id)
svg_data = img.export_svg(adoption_notice, "fellchensammlung/images/adoption-notice-story.svg")
return HttpResponse(svg_data, content_type="image/svg+xml",
headers={"Content-Disposition": f'attachment; filename="{adoption_notice.name}-story.svg"'})
def adoption_notice_social_media_templates(request, adoption_notice_id):
context = {}
if request.method == "POST":
action = request.POST.get("action")
if action == "post_to_fedi":
context = handle_post_fedi_action()
adoption_notice = get_object_or_404(AdoptionNotice, pk=adoption_notice_id)
context["adoption_notice"] = adoption_notice
return render(request, 'fellchensammlung/misc/social-media-template-selection.html', context=context)
@login_required
def rescue_org_create_or_update(request, rescue_organization_id=None):
"""
Create or update a rescue organization
"""
# Only users that are mods to create or edit it
if not user_is_trust_level_or_above(request.user, TrustLevel.MODERATOR):
return HttpResponseForbidden()
if rescue_organization_id:
rescue_org = get_object_or_404(RescueOrganization, pk=rescue_organization_id)
else:
rescue_org = None
if request.method == 'POST':
form = RescueOrgForm(request.POST, instance=rescue_org)
if form.is_valid():
rescue_org = form.save()
"""Log"""
Log.objects.create(user=request.user, action="add_rescue_org",
text=f"{request.user} hat Tierschutzorganisation {rescue_org.pk} geändert")
return redirect(reverse("rescue-organization-detail", args=[rescue_org.pk], ))
else:
form = RescueOrgForm(instance=rescue_org)
return render(request, 'fellchensammlung/forms/form-rescue-organization.html',
context={"form": form, "rescue_org": rescue_org})

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -75,8 +75,10 @@ except configparser.NoSectionError:
DEBUG = config.getboolean('django', 'debug', fallback=False) DEBUG = config.getboolean('django', 'debug', fallback=False)
# Internal IPs # Internal IPs
raw_config_value = config.get("django", "internal_ips", fallback=[]) internal_ip_raw_config_value = config.get("django", "internal_ips", fallback=None)
INTERNAL_IPS = json.loads(raw_config_value) if internal_ip_raw_config_value:
INTERNAL_IPS = json.loads(internal_ip_raw_config_value)
""" DATABASE """ """ DATABASE """
DB_BACKEND = config.get("database", "backend", fallback="sqlite3") DB_BACKEND = config.get("database", "backend", fallback="sqlite3")
@@ -89,7 +91,6 @@ DB_HOST = config.get("database", "host", fallback='')
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
LOCALE_PATHS = [os.path.join(BASE_DIR, 'locale')] LOCALE_PATHS = [os.path.join(BASE_DIR, 'locale')]
""" CELERY + KEYDB """ """ CELERY + KEYDB """
CELERY_BROKER_URL = config.get("celery", "broker", fallback="redis://localhost:6379/0") CELERY_BROKER_URL = config.get("celery", "broker", fallback="redis://localhost:6379/0")
CELERY_RESULT_BACKEND = config.get("celery", "backend", fallback="redis://localhost:6379/0") CELERY_RESULT_BACKEND = config.get("celery", "backend", fallback="redis://localhost:6379/0")
@@ -235,6 +236,8 @@ INSTALLED_APPS = [
'drf_spectacular_sidecar', # required for Django collectstatic discovery 'drf_spectacular_sidecar', # required for Django collectstatic discovery
'widget_tweaks', 'widget_tweaks',
"debug_toolbar", "debug_toolbar",
'admin_extra_buttons',
'simple_history',
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@@ -252,6 +255,7 @@ MIDDLEWARE = [
'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware',
# allauth middleware, needs to be after message middleware # allauth middleware, needs to be after message middleware
"allauth.account.middleware.AccountMiddleware", "allauth.account.middleware.AccountMiddleware",
'simple_history.middleware.HistoryRequestMiddleware',
] ]
ROOT_URLCONF = 'notfellchen.urls' ROOT_URLCONF = 'notfellchen.urls'