Compare commits

...

152 Commits

Author SHA1 Message Date
a7e85212c0 feat: Add verbose names 2025-01-11 14:19:23 +01:00
f1b3b660ff feat: Add notification for unchecked ANs 2025-01-11 14:19:02 +01:00
26cb60c1c8 style: make registration flow use cards 2025-01-11 11:35:05 +01:00
69e58f1e0a docs: add changelog for 0.4.0 2025-01-11 11:18:22 +01:00
5c33ac3833 meta: bump version to 0.4.0 2025-01-11 11:03:07 +01:00
fccfd59ea3 refactor: change order for reading convenience 2025-01-11 11:02:42 +01:00
50897b6d35 fix: remove double , when searching for city 2025-01-09 23:39:23 +01:00
8edfe8c401 feat: Re-add warning if place not found 2025-01-09 23:35:07 +01:00
0d82dba414 fix: Remove debug code 2025-01-09 23:28:28 +01:00
2dc038dfef feat: Show search radius only if a search center is given and correct zoom level if no location is given 2025-01-09 23:27:33 +01:00
c46a943c7f feat: make map circle alot more transparent 2025-01-09 23:19:02 +01:00
9f3592e64b refactor: remove pulsing dot 2025-01-09 23:17:12 +01:00
bc1f4e7ab7 feat: construct search results better 2025-01-09 23:15:10 +01:00
a2ef91e89a feat: Handle missing name 2025-01-09 23:14:41 +01:00
91d740511d feat: Search language specific 2025-01-09 22:39:34 +01:00
c6af3e8d04 feat: Only show existing data 2025-01-09 19:26:33 +01:00
0c94049e21 fix: Fix bug for ANs that have not been checked for more that three months 2025-01-09 06:59:09 +01:00
29f1d2f0f2 feat: Fill search query with detailed information to make sure photon will get the same location 2025-01-09 06:41:54 +01:00
2578e96b32 feat: Remove legacy feedback to users 2025-01-09 06:34:50 +01:00
907ed583cd feat: Add tooltip to report link 2025-01-09 06:30:22 +01:00
da51007b77 feat: Add tooltip for unsubscribe 2025-01-09 06:22:01 +01:00
087f58c9ac feat: Style buttons as in other forms 2025-01-09 06:14:45 +01:00
860da7f06a feat: Use unified button layout 2025-01-09 06:12:21 +01:00
457bee1ede feat: Add verbose names to report 2025-01-09 06:10:44 +01:00
3b37b5f588 docs: label 2025-01-09 06:07:54 +01:00
6229f0f8a2 feat: Label ANs as active/inactive 2025-01-09 06:06:32 +01:00
b2a3d910d9 feat: Make geocoding API configurable 2025-01-08 17:57:48 +01:00
33848cbe15 feat: mention photon in readme 2025-01-08 09:29:01 +01:00
cc97fe32aa feat: add search-as-you-type functionality 2025-01-08 09:18:48 +01:00
4576ac68e0 feat: move search location not found error to the place where the location would have been shown 2025-01-07 15:59:22 +01:00
7c076e0bc3 feat: add logging and string representation 2025-01-07 15:04:43 +01:00
74f54c7b31 fix: make sure that search radius and pins are not cast to int 2025-01-07 15:04:23 +01:00
87777cd5a4 feat: add pin of map center 2025-01-07 14:56:23 +01:00
eee4cdf86b feat: Show location when searching 2025-01-07 14:37:02 +01:00
b2d5265f7e feat: Use photon for querying 2025-01-07 12:48:01 +01:00
d4af2d88b4 refactor: Remove unused function 2025-01-07 12:47:26 +01:00
8b4f5713e3 test: Use Location Proxy for test 2025-01-07 12:46:19 +01:00
4bff268537 fix: fix test 2025-01-07 12:45:51 +01:00
57da42e4bd feat: allow markdown in animal description 2025-01-07 09:19:41 +01:00
2864d27a7f refactor: typo 2025-01-06 10:21:00 +01:00
0a73b5099e refactor: Remove unnecessary div 2025-01-06 10:20:41 +01:00
e3fb981542 fix: Don't squash font into each other in card 2025-01-06 10:20:16 +01:00
5e80d75c91 feat: Add overview page of animal shelters 2025-01-06 09:02:07 +01:00
e3833b4505 feat: Show position of shelter on the map 2025-01-06 08:36:51 +01:00
ab837ee80e feat: Add contact information to rescue org 2025-01-05 22:55:26 +01:00
f6c1224dde feat: Group buttons as edit buttons 2025-01-05 21:54:55 +01:00
a78d671b6d feat(accessibility): use h1 only once per site 2025-01-05 21:35:29 +01:00
fb9c78d96a feat: Add species specific URL to allow faster checking if new animals exist in this rescue org 2025-01-05 21:04:27 +01:00
4ef9da953c feat: Add annotation for API 2025-01-05 20:22:21 +01:00
aefeffd63a feat: Add post method to create rescue orgs 2025-01-05 19:20:34 +01:00
81cc5cd53d feat: Add external source and object identifier 2025-01-05 19:20:05 +01:00
002dded0d5 feat: Add Spectacutlar API schema generation 2025-01-05 16:55:23 +01:00
ad6e2f4e17 fix: translate 2025-01-05 09:17:43 +01:00
160e7166f8 feat: reduce heading spacing, adjust cards 2025-01-04 11:30:36 +01:00
867319fe9a feat: space card containers 2025-01-04 11:24:57 +01:00
13b67c1248 feat: Add last checked to updatequeue 2025-01-04 09:52:22 +01:00
4c4cf4afea fix: remove debug message 2025-01-04 09:50:42 +01:00
5f742c60db fix: fix missing d
otherwise throws unsupported format character 'W' (0x57) at index 13
2025-01-04 09:50:32 +01:00
568874e6dd feat: Make edit buttons flex 2025-01-04 09:48:16 +01:00
561a30b7ab feat: Represent last checked more human-readable 2025-01-04 09:48:05 +01:00
a8c837e9f6 feat: Make sure heading fills complete line 2025-01-04 09:06:58 +01:00
a75cacea66 feat: Make table in adoption notice responsive 2025-01-03 22:04:19 +01:00
b1e092769f fix: Apply vertical align to all children 2025-01-03 20:19:55 +01:00
5a93a1678c refactor: remove unnecessary class 2025-01-03 20:19:34 +01:00
28772e1f74 feat: Restyle using a proper container to group elements and not just put them in the heading 2025-01-03 19:04:32 +01:00
1f3c3ecaef feat: Add top and bottom option to tooltip 2025-01-03 18:46:22 +01:00
ab1e6a94d1 feat: add tooltip to subscribe bell 2025-01-03 18:32:34 +01:00
299653b53b fix: syntax 2025-01-03 11:40:03 +01:00
fe9352e628 feat: Add tooltip explaining the meaning of the checkmark 2025-01-03 11:18:10 +01:00
9fec95bd2e feat: add trusted checkmark 2025-01-02 19:16:22 +01:00
8e7cdafee0 fix: deal with undefined 2025-01-02 11:14:34 +01:00
6e2a2a1d5e fix: use builtin function
https://docs.djangoproject.com/en/5.1/topics/auth/default/
2025-01-02 00:16:42 +01:00
5197875431 refactor: formatting 2025-01-01 23:52:54 +01:00
d05bd45cf4 feat: restyle search subscriptions 2025-01-01 23:52:44 +01:00
0afb2bb0ce feat: add list of search subscriptions to user profile 2025-01-01 23:29:23 +01:00
d17fcc1da2 feat: add updated_at and created_at to search subscription 2025-01-01 23:05:22 +01:00
c508bc2cd1 test: fix test after map was included 2025-01-01 22:56:01 +01:00
20872e547b fix: add turf to VC 2025-01-01 21:02:10 +01:00
25b748d2be fix: Style buttons 2025-01-01 20:58:50 +01:00
1536bb302a fix: handle missing radius 2025-01-01 20:55:50 +01:00
d4ef706734 feat: reorder search options 2025-01-01 20:55:35 +01:00
3bdce18e9e feat: make zoom level dependent on search radius 2025-01-01 20:26:59 +01:00
8b4488484d feat: show radius and center map 2025-01-01 20:14:07 +01:00
3881a4f3b4 feat: add map to search 2025-01-01 19:48:33 +01:00
2dbd908f4c feat: add method to search active ANs 2025-01-01 19:43:50 +01:00
9d0eed5915 test: Add e2e test for distance 2025-01-01 18:59:01 +01:00
ee12bb5286 feat: add debugging statements 2025-01-01 17:52:46 +01:00
5669c822b9 test: fix test search 2025-01-01 17:52:28 +01:00
c1c4af6571 feat: Add logging 2025-01-01 17:35:27 +01:00
164ba7def2 feat: Add test for adoption_notice_fits_search 2025-01-01 17:23:38 +01:00
7035b1642e feat: streamline search_from pattern 2025-01-01 17:22:26 +01:00
b6fc5c634f feat: add debugging messages 2025-01-01 17:21:09 +01:00
0dfbd614ab fix: remove deprecated search position 2025-01-01 17:20:44 +01:00
2730ff3f51 refactor: Create shared task for post-AN stuff 2025-01-01 14:35:40 +01:00
fef211b2d0 feat: Add logging 2025-01-01 14:34:38 +01:00
f2e2599561 fix: Make sure that subscribed search is only checked when user is authenticated 2025-01-01 09:47:07 +01:00
a9c0f628f7 refactor: remove print 2025-01-01 09:46:20 +01:00
e2adb20231 feat: re-add locate to ease use 2025-01-01 09:44:56 +01:00
e8b3bf6516 fix: fix general notify 2025-01-01 00:57:37 +01:00
3306f3e783 feat: Add notification for newly created ANs 2025-01-01 00:30:14 +01:00
b993621773 feat: add unsubscribe functionality 2024-12-31 16:25:18 +01:00
3816290eb7 fix: Location matching logic 2024-12-31 15:40:57 +01:00
399ecf73ad feat: use location proxy to make Location search interface more intuitive 2024-12-31 15:40:33 +01:00
8e2c0e857c feat: Show subscribe/unsubscribe button depending on the user having this search already subscribed 2024-12-31 13:48:44 +01:00
3c7dcb4c51 feat: Allow location to be null in SearchSubscription 2024-12-31 13:47:38 +01:00
9e1ec1711b fix: Make Search and Search subscription are not the same if Search is not localized 2024-12-31 13:38:06 +01:00
bae4ee3d22 feat: Do not show subscribe button when not yetsearched 2024-12-31 13:37:31 +01:00
280eb83056 feat: Add function to convert a search subscription to a search 2024-12-31 13:28:41 +01:00
fca5879aeb test: Add tests for search equality 2024-12-31 13:28:14 +01:00
373a44c9da fix: notify only subscribers where the AN fits 2024-12-31 13:27:35 +01:00
674645c65c feat: Add string representation of search 2024-12-31 13:26:38 +01:00
c2b3ff2395 refactor: Rename radius to streamline although it would be a better description 2024-12-31 13:25:43 +01:00
d6740eb302 test: adjust to reflect changed field name 2024-12-31 13:14:13 +01:00
35a54474b4 test: fix name 2024-12-31 12:55:46 +01:00
6723dad4bd test: add owner to test to prevent random owner generation 2024-12-31 12:55:13 +01:00
b51d04ffd1 feat: Use label in string representation 2024-12-31 12:14:04 +01:00
a965f26d48 fix: use Sex choices with all 2024-12-31 11:38:52 +01:00
364a6f32f4 refactor: Typo 2024-12-31 11:35:29 +01:00
533142461a feat: Add string representation of SearchSubscriptions 2024-12-31 10:20:38 +01:00
481635ac4e feat: Add contributing doc 2024-12-31 10:18:18 +01:00
be6c30cb33 feat: Add SearchSubscriptions to admin 2024-12-31 10:03:56 +01:00
a617137fb0 fix: form submit must have name subscribe_to_search to trigger 2024-12-31 10:03:39 +01:00
8299162a77 formatting 2024-12-26 20:27:01 +01:00
085162d802 feat: Add is_subscribed method for searches 2024-12-26 20:24:35 +01:00
27b7e47f18 feat: Add SearchSubscriptions 2024-12-26 20:24:10 +01:00
be97ac32fb refactor: Move search into class 2024-12-26 16:55:37 +01:00
9ea00655d4 refactor: Use integer choice for search 2024-12-24 10:02:08 +01:00
9fffbffdb7 feat: add FAQ section to about 2024-12-24 09:05:11 +01:00
44cf2936d1 ui: Make button more buttony 2024-12-24 09:01:13 +01:00
579f59580c feat: Redirect user to adoption notice after adding photo to an animal 2024-12-18 10:17:27 +01:00
241841bc9b feat: Redirect user to adoption notice after editing an animal 2024-12-18 10:15:00 +01:00
78a6440f63 feat: Re-add text decoration for accessibility 2024-12-17 23:02:30 +01:00
9d521b0129 feat: Set title on user page 2024-12-17 22:55:46 +01:00
39079c3c8e feat: Add fallback if first and lastname is not defined 2024-12-17 22:55:30 +01:00
999c1a81b8 feat: Restructure user page 2024-12-17 22:51:04 +01:00
5a4720c41c feat: Show "No notifications" message 2024-12-17 22:46:27 +01:00
858c6d4468 feat: Use real toggle 2024-12-17 22:46:01 +01:00
4b45b01e2a feat: Add toggle for e-mail notifications 2024-12-17 21:42:10 +01:00
d0060ecf5e feat: Fully define place_not_found 2024-12-17 20:14:34 +01:00
d1eeaafc42 feat: Also search for description, internal comment and location of rescue orgs
icontains is default and therefore ommitted
https://docs.djangoproject.com/en/5.1/ref/contrib/admin/#django.contrib.admin.ModelAdmin.search_fields
2024-12-17 20:14:14 +01:00
9b824bc326 feat: Automatically subscribe user that created AN to AN 2024-12-14 13:24:51 +01:00
44f05cbb7d feat: Notify all subscribers of a adoption 2024-12-14 13:03:00 +01:00
0e4e531414 feat: Add 404 deactivation to instance health check 2024-12-14 09:32:37 +01:00
6a7b3f19e9 feat: Link Adoption Notice on notification 2024-12-14 09:31:46 +01:00
ec9f5b305c feat: Create notifications for 404 deactivation 2024-12-14 09:31:06 +01:00
e858f61b3f feat: Translate Species 2024-12-14 08:41:00 +01:00
a04270718f feat: Make announcement collapsable 2024-12-14 08:28:37 +01:00
a4f895de81 feat: add admin for Base Notification 2024-12-12 06:40:11 +01:00
b2d0e783be feat: make sure report flag stays in its lane
very demure, very mindful
2024-12-11 22:45:45 +01:00
4f5022e140 feat: Use card concept for about site 2024-11-30 09:37:14 +01:00
5771968981 refactor: remove unnecessary imports 2024-11-30 09:31:14 +01:00
b63b87872b feat: Improve accessibility by using correct heading layer 2024-11-30 09:31:00 +01:00
61 changed files with 1962 additions and 307 deletions

15
CHANGELOG.md Normal file
View File

@ -0,0 +1,15 @@
## Version 0.4.0
Version 0.4.0 has added support for search-as-you-type when searching for animals to adopt. Furthermore, the display of
maps in the search has been majorly improved.
Photon has been added as geocoding source option which allows to use this functionality.
Further improvements include the representation of rescue organizations and tooltips.
One of the biggest features is the addition of search subscriptions. These allow you to not only
search for currently active adoption notices but to subscribe to that search so that you get notified if there are new
rats in your search area in the future.
For developers the new API documentation might come in handy, it can be found at
[/api/schema/swagger-ui/](https://notfellchen.org/api/schema/swagger-ui/)

View File

@ -80,17 +80,13 @@ docker run -p8000:7345 moanos/notfellchen:latest
## Geocoding
Geocoding services (search map data by name, address or postcode) are provided via the
[Nominatim](https://nominatim.org/) API, powered by [OpenStreetMap](https://openstreetmap.org) data. Notfellchen uses
a selfhosted Nominatim instance to avoid overburdening the publicly hosted instance. Due to ressource constraints
geocoding is only supported for Germany right now.
ToDos
* [ ] Implement a report that shows the number of location strings that could not be converted into a location
* [x] Add a management command to re-query location strings to fill location
either [Nominatim](https://nominatim.org/) or [photon](https://github.com/komoot/photon) API, powered by [OpenStreetMap](https://openstreetmap.org) data.
Notfellchen uses a selfhosted Photon instance to avoid overburdening the publicly hosted instance.
## Maps
The map on the main homepage is powered by [Versatiles](https://versatiles.org), and rendered using [Maplibre](https://maplibre.org/).
The Versatiles server is self-hosted and does not send data to third parties.
## Translation
@ -125,3 +121,20 @@ Start beat
```zsh
celery -A notfellchen.celery beat
```
# Contributing
This project is currently solely developed by me, moanos. I'd like that to change and will be very happy for contributions
and shared responsibilities. Some ideas where you can look for contributing first
* CSS structure: It's a hot mess right now, and I'm happy it somehow works. As you might see, there is much room for improvement. Refactoring this and streamlining the look across the app would be amazing.
* Docker: If you know how to build a docker container that is a) smaller or b) utilizes staged builds this would be amazing. Any improvement welcome
* Testing: Writing tests is always welcome, and it's likely you discover a few bugs
I'm also very happy for all other contributions. Before you do large refactoring efforts or features, best write a short
issue for it before you spend a lot of work.
Send PRs either to [codeberg](https://codeberg.org/moanos/notfellchen) (preferred) or [Github](https://github.com/moan0s/notfellchen).
CI (currently only for dcumentation) runs via [git.hyteck.de](https://git.hyteck.de), you can also ask moanos for an account there.
Also welcome are new issues with suggestions or bugs and additions to the documentation.

View File

@ -24,4 +24,7 @@ console-only=true
app_log_level=INFO
django_log_level=INFO
[geocoding]
api_url=https://photon.hyteck.de/api
api_format=photon

View File

@ -39,7 +39,8 @@ dependencies = [
"django-crispy-forms",
"crispy-bootstrap4",
"djangorestframework",
"celery[redis]"
"celery[redis]",
"drf-spectacular[sidecar]"
]
dynamic = ["version", "readme"]

View File

@ -6,10 +6,11 @@ from django.utils.html import format_html
from django.urls import reverse
from django.utils.http import urlencode
from .models import User, Language, Text, ReportComment, ReportAdoptionNotice, Log, Timestamp
from .models import User, Language, Text, ReportComment, ReportAdoptionNotice, Log, Timestamp, SearchSubscription, \
SpeciesSpecificURL
from .models import Animal, Species, RescueOrganization, AdoptionNotice, Location, Rule, Image, ModerationAction, \
Comment, Report, Announcement, AdoptionNoticeStatus, User, Subscriptions
Comment, Report, Announcement, AdoptionNoticeStatus, User, Subscriptions, BaseNotification
from django.utils.translation import gettext_lazy as _
@ -66,6 +67,7 @@ class UserAdmin(admin.ModelAdmin):
export_as_csv.short_description = _("Ausgewählte User exportieren")
def _reported_content_link(obj):
reported_content = obj.reported_content
return format_html(f'<a href="{reported_content.get_absolute_url}">{reported_content}</a>')
@ -92,22 +94,39 @@ class ReportAdoptionNoticeAdmin(admin.ModelAdmin):
reported_content_link.short_description = "Reported Content"
class SpeciesSpecificURLInline(admin.StackedInline):
model = SpeciesSpecificURL
@admin.register(RescueOrganization)
class RescueOrganizationAdmin(admin.ModelAdmin):
search_fields = ("name__icontains",)
search_fields = ("name","description", "internal_comment", "location_string")
list_display = ("name", "trusted", "allows_using_materials", "website")
list_filter = ("allows_using_materials", "trusted",)
inlines = [
SpeciesSpecificURLInline,
]
@admin.register(Text)
class TextAdmin(admin.ModelAdmin):
search_fields = ("title__icontains", "text_code__icontains",)
@admin.register(Comment)
class CommentAdmin(admin.ModelAdmin):
list_filter = ("user",)
@admin.register(BaseNotification)
class BaseNotificationAdmin(admin.ModelAdmin):
list_filter = ("user", "read")
@admin.register(SearchSubscription)
class SearchSubscriptionAdmin(admin.ModelAdmin):
list_filter = ("owner",)
admin.site.register(Animal)
admin.site.register(Species)
admin.site.register(Location)

View File

@ -14,6 +14,11 @@ class AnimalCreateSerializer(serializers.ModelSerializer):
model = Animal
fields = ["name", "date_of_birth", "description", "species", "sex", "adoption_notice"]
class RescueOrgSerializer(serializers.ModelSerializer):
class Meta:
model = RescueOrganization
fields = ["name", "location_string", "instagram", "facebook", "fediverse_profile", "email", "phone_number",
"website", "description", "external_object_identifier", "external_source_identifier"]
class AnimalGetSerializer(serializers.ModelSerializer):
class Meta:

View File

@ -1,12 +1,8 @@
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from rest_framework import permissions
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from django.db import transaction
from fellchensammlung.models import AdoptionNotice, Animal, Log, TrustLevel
from fellchensammlung.tasks import add_adoption_notice_location
from fellchensammlung.tasks import post_adoption_notice_save
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from .serializers import (
@ -15,14 +11,25 @@ from .serializers import (
RescueOrganizationSerializer,
AdoptionNoticeSerializer,
ImageCreateSerializer,
SpeciesSerializer,
SpeciesSerializer, RescueOrgSerializer,
)
from fellchensammlung.models import Animal, RescueOrganization, AdoptionNotice, Species, Image
from drf_spectacular.utils import extend_schema
class AdoptionNoticeApiView(APIView):
permission_classes = [IsAuthenticated]
@extend_schema(
parameters=[
{
'name': 'id',
'required': False,
'description': 'ID of the adoption notice to retrieve.',
'type': int
},
],
responses={200: AdoptionNoticeSerializer(many=True)}
)
def get(self, request, *args, **kwargs):
"""
Retrieve adoption notices with their related animals and images.
@ -40,9 +47,13 @@ class AdoptionNoticeApiView(APIView):
return Response(serializer.data, status=status.HTTP_200_OK)
@transaction.atomic
@extend_schema(
request=AdoptionNoticeSerializer,
responses={201: 'Adoption notice created successfully!'}
)
def post(self, request, *args, **kwargs):
"""
API view to add an adoption notice.b
API view to add an adoption notice.
"""
serializer = AdoptionNoticeSerializer(data=request.data, context={'request': request})
if not serializer.is_valid():
@ -51,7 +62,7 @@ class AdoptionNoticeApiView(APIView):
adoption_notice = serializer.save(owner=request.user)
# Add the location
add_adoption_notice_location.delay_on_commit(adoption_notice.pk)
post_adoption_notice_save.delay_on_commit(adoption_notice.pk)
# Only set active when user has trust level moderator or higher
if request.user.trust_level >= TrustLevel.MODERATOR:
@ -73,6 +84,7 @@ class AdoptionNoticeApiView(APIView):
)
class AnimalApiView(APIView):
permission_classes = [IsAuthenticated]
@ -106,10 +118,20 @@ class AnimalApiView(APIView):
)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class RescueOrganizationApiView(APIView):
permission_classes = [IsAuthenticated]
@extend_schema(
parameters=[
{
'name': 'id',
'required': False,
'description': 'ID of the rescue organization to retrieve.',
'type': int
},
],
responses={200: RescueOrganizationSerializer(many=True)}
)
def get(self, request, *args, **kwargs):
"""
Get list of rescue organizations or a specific organization by ID.
@ -126,11 +148,32 @@ class RescueOrganizationApiView(APIView):
serializer = RescueOrganizationSerializer(organizations, many=True, context={"request": request})
return Response(serializer.data, status=status.HTTP_200_OK)
@transaction.atomic
@extend_schema(
request=RescueOrgSerializer, # Document the request body
responses={201: 'Rescue organization created/updated successfully!'}
)
def post(self, request, *args, **kwargs):
"""
Create or update a rescue organization.
"""
serializer = RescueOrgSerializer(data=request.data, context={"request": request})
if serializer.is_valid():
rescue_org = serializer.save(owner=request.user)
return Response(
{"message": "Rescue organization created/updated successfully!", "id": rescue_org.id},
status=status.HTTP_201_CREATED,
)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class AddImageApiView(APIView):
permission_classes = [IsAuthenticated]
@transaction.atomic
@extend_schema(
request=ImageCreateSerializer,
responses={201: 'Image added successfully!'}
)
def post(self, request, *args, **kwargs):
"""
Add an image to an animal or adoption notice.
@ -139,7 +182,7 @@ class AddImageApiView(APIView):
if serializer.is_valid():
if serializer.validated_data["attach_to_type"] == "animal":
object_to_attach_to = Animal.objects.get(id=serializer.validated_data["attach_to"])
elif serializer.fields["attach_to_type"] == "adoption_notice":
elif serializer.validated_data["attach_to_type"] == "adoption_notice":
object_to_attach_to = AdoptionNotice.objects.get(id=serializer.validated_data["attach_to"])
else:
raise ValueError("Unknown attach_to_type given, should not happen. Check serializer")
@ -157,6 +200,9 @@ class AddImageApiView(APIView):
class SpeciesApiView(APIView):
permission_classes = [IsAuthenticated]
@extend_schema(
responses={200: SpeciesSerializer(many=True)}
)
def get(self, request, *args, **kwargs):
"""
Retrieve a list of species.

View File

@ -1,12 +1,13 @@
from django import forms
from .models import AdoptionNotice, Animal, Image, ReportAdoptionNotice, ReportComment, ModerationAction, User, Species, \
Comment, SexChoicesWithAll
Comment, SexChoicesWithAll, DistanceChoices
from django_registration.forms import RegistrationForm
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Submit, Layout, Fieldset, HTML, Row, Column, Field, Hidden
from django.utils.translation import gettext_lazy as _
from notfellchen.settings import MEDIA_URL
from crispy_forms.layout import Div
def animal_validator(value: str):
@ -124,11 +125,21 @@ class ImageForm(forms.ModelForm):
self.helper.form_id = 'form-animal-photo'
self.helper.form_class = 'card'
self.helper.form_method = 'post'
if in_flow:
self.helper.add_input(Submit('save-and-add-another', _('Speichern und weiteres Foto hinzufügen')))
self.helper.add_input(Submit('submit', _('Speichern')))
submits= Div(Submit('submit', _('Speichern')),
Submit('save-and-add-another', _('Speichern und weiteres Foto hinzufügen')), css_class="container-edit-buttons")
else:
self.helper.add_input(Submit('submit', _('Submit')))
submits = Fieldset(Submit('submit', _('Speichern')), css_class="container-edit-buttons")
self.helper.layout = Layout(
Div(
'image',
'alt_text',
css_class="spaced",
),
submits
)
class Meta:
model = Image
@ -181,12 +192,8 @@ class CustomRegistrationForm(RegistrationForm):
self.helper.add_input(Submit('submit', _('Registrieren'), css_class="btn"))
def _get_distances():
return {i: i for i in [20, 50, 100, 200, 500]}
class AdoptionNoticeSearchForm(forms.Form):
location = forms.CharField(max_length=20, label=_("Stadt"), required=False)
max_distance = forms.ChoiceField(choices=_get_distances, label=_("Max. Distanz"))
sex = forms.ChoiceField(choices=SexChoicesWithAll, label=_("Geschlecht"), required=False,
initial=SexChoicesWithAll.ALL)
max_distance = forms.ChoiceField(choices=DistanceChoices, initial=DistanceChoices.ONE_HUNDRED, label=_("Suchradius"))
location_string = forms.CharField(max_length=100, label=_("Stadt"), required=False)

View File

@ -0,0 +1,27 @@
# Generated by Django 5.1.1 on 2024-12-14 07:57
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0026_alter_animal_sex'),
]
operations = [
migrations.AlterField(
model_name='animal',
name='species',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fellchensammlung.species', verbose_name='Tierart'),
),
migrations.CreateModel(
name='AndoptionNoticeNotification',
fields=[
('basenotification_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='fellchensammlung.basenotification')),
('adoption_notice', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='fellchensammlung.adoptionnotice', verbose_name='Vermittlung')),
],
bases=('fellchensammlung.basenotification',),
),
]

View File

@ -0,0 +1,25 @@
# Generated by Django 5.1.1 on 2024-12-26 15:56
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0027_alter_animal_species_andoptionnoticenotification'),
]
operations = [
migrations.CreateModel(
name='SearchSubscription',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('sex', models.CharField(choices=[('F', 'Weiblich'), ('M', 'Männlich'), ('M_N', 'Männlich, kastriert'), ('F_N', 'Weiblich, kastriert'), ('I', 'Intergeschlechtlich')], max_length=20)),
('radius', models.IntegerField(choices=[(20, '20 km'), (50, '50 km'), (100, '100 km'), (200, '200 km'), (500, '500 km')])),
('location', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fellchensammlung.location')),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@ -0,0 +1,22 @@
# Generated by Django 5.1.4 on 2024-12-31 10:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0028_searchsubscription'),
]
operations = [
migrations.RenameModel(
old_name='AndoptionNoticeNotification',
new_name='AdoptionNoticeNotification',
),
migrations.AlterField(
model_name='searchsubscription',
name='sex',
field=models.CharField(choices=[('F', 'Weiblich'), ('M', 'Männlich'), ('M_N', 'Männlich, kastriert'), ('F_N', 'Weiblich Kastriert'), ('I', 'Intergeschlechtlich'), ('A', 'Alle')], max_length=20),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.1.4 on 2024-12-31 12:23
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0029_rename_andoptionnoticenotification_adoptionnoticenotification_and_more'),
]
operations = [
migrations.RenameField(
model_name='searchsubscription',
old_name='radius',
new_name='max_distance',
),
]

View File

@ -0,0 +1,24 @@
# Generated by Django 5.1.4 on 2024-12-31 12:42
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0030_rename_radius_searchsubscription_max_distance'),
]
operations = [
migrations.AlterField(
model_name='searchsubscription',
name='location',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='fellchensammlung.location'),
),
migrations.AlterField(
model_name='searchsubscription',
name='max_distance',
field=models.IntegerField(choices=[(20, '20 km'), (50, '50 km'), (100, '100 km'), (200, '200 km'), (500, '500 km')], null=True),
),
]

View File

@ -0,0 +1,25 @@
# Generated by Django 5.1.4 on 2025-01-01 22:04
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0031_alter_searchsubscription_location_and_more'),
]
operations = [
migrations.AddField(
model_name='searchsubscription',
name='created_at',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AddField(
model_name='searchsubscription',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 5.1.4 on 2025-01-05 18:18
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0032_searchsubscription_created_at_and_more'),
]
operations = [
migrations.AddField(
model_name='rescueorganization',
name='external_object_identifier',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='External Object Identifier'),
),
migrations.AddField(
model_name='rescueorganization',
name='external_source_identifier',
field=models.CharField(blank=True, choices=[('OSM', 'Open Street Map')], max_length=200, null=True, verbose_name='External Source Identifier'),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 5.1.4 on 2025-01-05 19:36
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0033_rescueorganization_external_object_identifier_and_more'),
]
operations = [
migrations.CreateModel(
name='SpeciesSpecificURL',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('url', models.URLField(verbose_name='Tierartspezifische URL')),
('rescues_organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='fellchensammlung.rescueorganization', verbose_name='Tierschutzorganisation')),
('species', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='fellchensammlung.species', verbose_name='Tierart')),
],
),
]

View File

@ -0,0 +1,28 @@
# Generated by Django 5.1.4 on 2025-01-11 12:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0034_speciesspecificurl'),
]
operations = [
migrations.AlterField(
model_name='image',
name='alt_text',
field=models.TextField(max_length=2000, verbose_name='Alternativtext'),
),
migrations.AlterField(
model_name='report',
name='reported_broken_rules',
field=models.ManyToManyField(to='fellchensammlung.rule', verbose_name='Regeln gegen die verstoßen wurde'),
),
migrations.AlterField(
model_name='report',
name='user_comment',
field=models.TextField(blank=True, verbose_name='Kommentar/Zusätzliche Information'),
),
]

View File

@ -1,4 +1,6 @@
import uuid
from random import choices
from tabnanny import verbose
from django.db import models
from django.urls import reverse
@ -11,6 +13,8 @@ from django.contrib.auth.models import AbstractUser
from .tools import misc, geo
from notfellchen.settings import MEDIA_URL
from .tools.geo import LocationProxy, Position
from .tools.misc import age_as_hr_string, time_since_as_hr_string
class Language(models.Model):
@ -35,7 +39,7 @@ class Language(models.Model):
class Location(models.Model):
place_id = models.IntegerField()
place_id = models.IntegerField() # OSM id
latitude = models.FloatField()
longitude = models.FloatField()
name = models.CharField(max_length=2000)
@ -45,26 +49,30 @@ class Location(models.Model):
def __str__(self):
return f"{self.name} ({self.latitude:.5}, {self.longitude:.5})"
@property
def position(self):
return (self.latitude, self.longitude)
@property
def str_hr(self):
return f"{self.name.split(',')[0]}"
@staticmethod
def get_location_from_string(location_string):
geo_api = geo.GeoAPI()
geojson = geo_api.get_geojson_for_query(location_string)
if geojson is None:
try:
proxy = LocationProxy(location_string)
except ValueError:
return None
result = geojson[0]
if "name" in result:
name = result["name"]
else:
name = result["display_name"]
location = Location.get_location_from_proxy(proxy)
return location
@staticmethod
def get_location_from_proxy(proxy):
location = Location.objects.create(
place_id=result["place_id"],
latitude=result["lat"],
longitude=result["lon"],
name=name,
place_id=proxy.place_id,
latitude=proxy.latitude,
longitude=proxy.longitude,
name=proxy.name,
)
return location
@ -76,6 +84,10 @@ class Location(models.Model):
instance.save()
class ExternalSourceChoices(models.TextChoices):
OSM = "OSM", _("Open Street Map")
class RescueOrganization(models.Model):
def __str__(self):
return f"{self.name}"
@ -112,6 +124,11 @@ class RescueOrganization(models.Model):
created_at = models.DateTimeField(auto_now_add=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
external_object_identifier = models.CharField(max_length=200, null=True, blank=True,
verbose_name=_('External Object Identifier'))
external_source_identifier = models.CharField(max_length=200, null=True, blank=True,
choices=ExternalSourceChoices.choices,
verbose_name=_('External Source Identifier'))
def get_absolute_url(self):
return reverse("rescue-organization-detail", args=[str(self.pk)])
@ -120,6 +137,20 @@ class RescueOrganization(models.Model):
def adoption_notices(self):
return AdoptionNotice.objects.filter(organization=self)
@property
def position(self):
if self.location:
return Position(latitude=self.location.latitude, longitude=self.location.longitude)
else:
return None
@property
def description_short(self):
if self.description is None:
return ""
if len(self.description) > 200:
return self.description[:200] + f" ... [weiterlesen]({self.get_absolute_url()})"
# Admins can perform all actions and have the highest trust associated with them
# Moderators can make moderation decisions regarding the deletion of content
@ -157,12 +188,21 @@ class User(AbstractUser):
verbose_name = _('Nutzer*in')
verbose_name_plural = _('Nutzer*innen')
def get_full_name(self):
if self.first_name and self.last_name:
return self.first_name + self.last_name
else:
return self.username
def get_absolute_url(self):
return reverse("user-detail", args=[str(self.pk)])
def get_notifications_url(self):
return self.get_absolute_url()
def get_unread_notifications(self):
return BaseNotification.objects.filter(user=self, read=False)
def get_num_unread_notifications(self):
return BaseNotification.objects.filter(user=self, read=False).count()
@ -177,7 +217,7 @@ class User(AbstractUser):
class Image(models.Model):
image = models.ImageField(upload_to='images')
alt_text = models.TextField(max_length=2000)
alt_text = models.TextField(max_length=2000, verbose_name=_('Alternativtext'))
owner = models.ForeignKey(User, on_delete=models.CASCADE)
updated_at = models.DateTimeField(auto_now=True)
created_at = models.DateTimeField(auto_now_add=True)
@ -243,6 +283,11 @@ class AdoptionNotice(models.Model):
sexes.add(animal.sex)
return sexes
@property
def last_checked_hr(self):
time_since_last_checked = timezone.now() - self.last_checked
return time_since_as_hr_string(time_since_last_checked)
def sex_code(self):
# Treat Intersex as mixed in order to increase their visibility
if len(self.sexes) > 1:
@ -286,6 +331,11 @@ class AdoptionNotice(models.Model):
# returns all subscriptions to that adoption notice
return Subscriptions.objects.filter(adoption_notice=self)
@staticmethod
def get_active_ANs():
active_ans = [an for an in AdoptionNotice.objects.all() if an.is_active]
return active_ans
def get_photos(self):
"""
First trys to get group photos that are attached to the adoption notice if there is none it trys to fetch
@ -368,8 +418,8 @@ class AdoptionNotice(models.Model):
self.adoptionnoticestatus.set_unchecked()
for subscription in self.get_subscriptions():
notification_title = _("Vermittlung deaktiviert:") + f" {self}"
text = _("Die folgende Vermittlung wurde deaktiviert: ") + f"{self.name}, {self.get_absolute_url()}"
notification_title = _("Vermittlung deaktiviert:") + f" {self.name}"
text = _("Die folgende Vermittlung wurde deaktiviert: ") + f"[{self.name}]({self.get_absolute_url()})"
BaseNotification.objects.create(user=subscription.owner, text=text, title=notification_title)
@ -489,7 +539,7 @@ class Animal(models.Model):
date_of_birth = models.DateField(verbose_name=_('Geburtsdatum'))
name = models.CharField(max_length=200)
description = models.TextField(null=True, blank=True, verbose_name=_('Beschreibung'))
species = models.ForeignKey(Species, on_delete=models.PROTECT)
species = models.ForeignKey(Species, on_delete=models.PROTECT, verbose_name=_("Tierart"))
photos = models.ManyToManyField(Image, blank=True)
sex = models.CharField(
max_length=20,
@ -531,6 +581,40 @@ class Animal(models.Model):
return reverse('animal-detail', args=[str(self.id)])
class DistanceChoices(models.IntegerChoices):
TWENTY = 20, '20 km'
FIFTY = 50, '50 km'
ONE_HUNDRED = 100, '100 km'
TWO_HUNDRED = 200, '200 km'
FIVE_HUNDRED = 500, '500 km'
class SearchSubscription(models.Model):
"""
SearchSubscriptions allow a user to get a notification when a new AdoptionNotice is added that matches their Search
criteria. Search criteria are location, SexChoicesWithAll and distance
Process:
- User performs a normal search
- User clicks Button "Subscribe to this Search"
- SearchSubscription is added to database
- On new AdoptionNotice: Check all existing SearchSubscriptions for matches
- For matches: Send notification to user of the SearchSubscription
"""
owner = models.ForeignKey(User, on_delete=models.CASCADE)
location = models.ForeignKey(Location, on_delete=models.PROTECT, null=True)
sex = models.CharField(max_length=20, choices=SexChoicesWithAll.choices)
max_distance = models.IntegerField(choices=DistanceChoices.choices, null=True)
updated_at = models.DateTimeField(auto_now=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
if self.location and self.max_distance:
return f"{self.owner}: [{SexChoicesWithAll(self.sex).label}] {self.max_distance}km - {self.location}"
else:
return f"{self.owner}: [{SexChoicesWithAll(self.sex).label}]"
class Rule(models.Model):
"""
Class to store rules
@ -564,8 +648,8 @@ class Report(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, help_text=_('ID dieses reports'),
verbose_name=_('ID'))
status = models.CharField(max_length=30, choices=STATES)
reported_broken_rules = models.ManyToManyField(Rule)
user_comment = models.TextField(blank=True)
reported_broken_rules = models.ManyToManyField(Rule, verbose_name=_("Regeln gegen die verstoßen wurde"))
user_comment = models.TextField(blank=True, verbose_name=_("Kommentar/Zusätzliche Information"))
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@ -624,9 +708,12 @@ class ModerationAction(models.Model):
return f"[{self.action}]: {self.public_comment}"
"""
Membership
"""
class TextTypeChoices(models.TextChoices):
DEDICATED = "dedicated", _("Fest zugeordnet")
MALE = "M", _("Männlich")
MALE_NEUTERED = "M_N", _("Männlich, kastriert")
FEMALE_NEUTERED = "F_N", _("Weiblich, kastriert")
INTER = "I", _("Intergeschlechtlich")
class Text(models.Model):
@ -751,6 +838,14 @@ class CommentNotification(BaseNotification):
return self.comment.get_absolute_url
class AdoptionNoticeNotification(BaseNotification):
adoption_notice = models.ForeignKey(AdoptionNotice, on_delete=models.CASCADE, verbose_name=_('Vermittlung'))
@property
def url(self):
return self.adoption_notice.get_absolute_url
class Subscriptions(models.Model):
owner = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name=_('Nutzer*in'))
adoption_notice = models.ForeignKey(AdoptionNotice, on_delete=models.CASCADE, verbose_name=_('AdoptionNotice'))
@ -785,3 +880,13 @@ class Timestamp(models.Model):
def ___str__(self):
return f"[{self.key}] - {self.timestamp.strftime('%H:%M:%S %d-%m-%Y ')} - {self.data}"
class SpeciesSpecificURL(models.Model):
"""
Model that allows to specify a URL for a rescue organization where a certain species can be found
"""
species = models.ForeignKey(Species, on_delete=models.CASCADE, verbose_name=_("Tierart"))
rescues_organization = models.ForeignKey(RescueOrganization, on_delete=models.CASCADE,
verbose_name=_("Tierschutzorganisation"))
url = models.URLField(verbose_name=_("Tierartspezifische URL"))

View File

@ -37,13 +37,49 @@ body {
}
a {
color: inherit;
}
h1, h2, h3 {
margin-bottom: 5px;
margin-top: 5px;
}
table {
width: 100%;
}
a {
text-decoration: none;
color: inherit;
@media screen and (max-width: 600px) {
.responsive thead {
visibility: hidden;
height: 0;
position: absolute;
}
.responsive tr {
display: block;
}
.responsive td {
border: 1px solid;
border-bottom: none;
display: block;
font-size: .8em;
text-align: right;
width: 100%;
}
.responsive td::before {
content: attr(data-label);
float: left;
font-weight: bold;
text-transform: uppercase;
}
.responsive td:last-child {
border-bottom: 1px solid;
}
}
table {
@ -65,7 +101,7 @@ td {
padding: 5px;
}
th {
thead td {
border: 3px solid black;
border-collapse: collapse;
padding: 8px;
@ -99,11 +135,16 @@ textarea {
width: 100%;
}
.container-cards h1,
.container-cards h2 {
width: 100%; /* Make sure heading fills complete line */
}
.card {
flex: 1 25%;
margin: 10px;
border-radius: 8px;
padding: 5px;
padding: 8px;
background: var(--background-three);
color: var(--text-two);
}
@ -117,8 +158,8 @@ textarea {
}
}
.spaced {
margin-bottom: 30px;
.spaced > * {
margin: 10px;
}
/*******************************/
@ -201,7 +242,11 @@ select, .button {
display: block;
}
.btn2 {
a.btn, a.btn2, a.nav-link {
text-decoration: none;
}
.btn2, .btn3 {
background-color: var(--secondary-light-one);
color: var(--primary-dark-one);
padding: 8px;
@ -210,6 +255,158 @@ select, .button {
margin: 5px;
}
.btn3 {
border: 1px solid black;
}
.checkmark {
display: inline-block;
position: relative;
left: 0.2rem;
bottom: 0.075rem;
background-color: var(--primary-light-one);
color: var(--secondary-light-one);
border-radius: 0.5rem;
width: 1.5rem;
height: 1.5rem;
text-align: center;
}
.switch {
cursor: pointer;
display: inline-block;
}
.toggle-switch {
display: inline-block;
background: #ccc;
border-radius: 16px;
width: 58px;
height: 32px;
position: relative;
vertical-align: middle;
transition: background 0.25s;
}
.toggle-switch:before, .toggle-switch:after {
content: "";
}
.toggle-switch:before {
display: block;
background: linear-gradient(to bottom, #fff 0%, #eee 100%);
border-radius: 50%;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.25);
width: 24px;
height: 24px;
position: absolute;
top: 4px;
left: 4px;
transition: left 0.25s;
}
.toggle:hover .toggle-switch:before {
background: linear-gradient(to bottom, #fff 0%, #fff 100%);
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.5);
}
.checked + .toggle-switch {
background: #56c080;
}
.checked + .toggle-switch:before {
left: 30px;
}
.toggle-checkbox {
position: absolute;
visibility: hidden;
}
.slider-label {
margin-left: 5px;
position: relative;
top: 2px;
}
/* Refactor tooltip based on https://luigicavalieri.com/blog/css-tooltip-appearing-from-any-direction/ to allow different directions */
.tooltip {
display: inline-flex;
justify-content: center;
position: relative;
}
.tooltip:hover .tooltiptext {
display: flex;
opacity: 1;
visibility: visible;
}
.tooltip .tooltiptext {
border-radius: 4px;
bottom: calc(100% + 0.6em + 2px);
box-shadow: 0px 2px 4px #07172258;
background-color: var(--primary-dark-one);
color: var(--secondary-light-one);
font-size: 0.68rem;
justify-content: center;
line-height: 1.35em;
padding: 0.5em 0.7em;
position: absolute;
text-align: center;
width: 7rem;
z-index: 1;
display: flex;
opacity: 0;
transition: all 0.3s ease-in;
visibility: hidden;
}
.tooltip .tooltiptext::before {
border-width: 0.6em 0.8em 0;
border-color: transparent;
border-top-color: var(--primary-dark-one);
content: "";
display: block;
border-style: solid;
position: absolute;
top: 100%;
}
/* Makes the tooltip fly from above */
.tooltip.top .tooltiptext {
margin-bottom: 8px;
}
.tooltip.top:hover .tooltiptext {
margin-bottom: 0;
}
/* Make adjustments for bottom */
.tooltip.bottom .tooltiptext {
top: calc(100% + 0.6em + 2px);
margin-top: 8px;
}
.tooltip.bottom:hover .tooltiptext {
margin-top: 0;
}
.tooltip.bottom .tooltiptext::before {
transform: rotate(180deg);
/* 100% of the height of .tooltip */
bottom: 100%;
}
.tooltip:not(.top) .tooltiptext {
bottom: auto;
}
.tooltip:not(.top) .tooltiptext::before {
top: auto;
}
/*********************/
/* UNIQUE COMPONENTS */
@ -543,12 +740,22 @@ select, .button {
.header-card-adoption-notice {
display: flex;
justify-content: space-evenly;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
}
.search-subscription-header {
display: flex;
justify-content: space-evenly;
flex-wrap: wrap;
h3 {
width: 80%;
}
}
.table-adoption-notice-info {
margin-top: 10px;
}
@ -593,15 +800,30 @@ select, .button {
.adoption-card-report-link, .notification-card-mark-read {
margin-left: auto;
font-size: 2rem;
padding: 10px;
}
.adoption-card-report-link {
margin-right: 12px;
}
.notification-card-mark-read {
display: inline;
}
.heading-card-adoption-notice {
.inline-container {
display: inline-block;
}
.inline-container > * {
vertical-align: middle;
}
h2.heading-card-adoption-notice {
font-size: 2rem;
line-height: 2rem;
word-wrap: anywhere;
width: 80%;
}
.tags {
@ -616,17 +838,15 @@ select, .button {
}
.detail-adoption-notice-header h1 {
width: 50%;
display: inline-block;
}
.detail-adoption-notice-header a {
display: inline-block;
float: right;
}
@media (max-width: 920px) {
.detail-adoption-notice-header h1 {
.detail-adoption-notice-header .inline-container {
width: 100%;
}
@ -644,7 +864,7 @@ select, .button {
padding: 5px;
}
.comment, .notification {
.comment, .notification, .search-subscription {
flex: 1 100%;
margin: 10px;
border-radius: 8px;
@ -660,7 +880,16 @@ select, .button {
}
}
.announcement {
.announcement-header {
font-size: 1.2rem;
margin: 0px;
padding: 0px;
color: var(--text-two);
text-shadow: none;
font-weight: bold;
}
div.announcement {
flex: 1 100%;
margin: 10px;
border-radius: 8px;
@ -668,13 +897,6 @@ select, .button {
background: var(--background-three);
color: var(--text-two);
h1 {
font-size: 1.2rem;
margin: 0px;
padding: 0px;
color: var(--text-two);
text-shadow: none;
}
}
@ -688,6 +910,43 @@ select, .button {
}
.half {
width: 49%;
}
#results {
margin-top: 10px;
list-style-type: none;
padding: 0;
}
.result-item {
padding: 8px;
margin: 4px 0;
background-color: #ddd1a5;
cursor: pointer;
border-radius: 8px;
}
.result-item:hover {
background-color: #ede1b5;
}
.label {
border-radius: 8px;
padding: 4px;
color: #fff;
}
.active-adoption {
background-color: #4a9455;
}
.inactive-adoption {
background-color: #000;
}
/************************/
/* GENERAL HIGHLIGHTING */
/************************/
@ -704,6 +963,14 @@ select, .button {
border: rgba(17, 58, 224, 0.51) 4px solid;
}
.error {
color: #370707;
font-weight: bold;
}
.error::before {
content: "⚠️";
}
/*******/
/* MAP */
@ -717,6 +984,11 @@ select, .button {
cursor: pointer;
}
.animal-shelter-marker {
background-image: url('../img/animal_shelter.png');
!important;
}
.maplibregl-popup {
max-width: 600px !important;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -0,0 +1,21 @@
function ifdef(variable, prefix = "", suffix = "") {
if (variable !== undefined) {
return prefix + variable + suffix;
} else {
return "";
}
}
function geojson_to_summary(location) {
if (ifdef(location.properties.name) !== "") {
return location.properties.name + ifdef(location.properties.city, " (", ")");
} else {
return ifdef(location.properties.street, "", ifdef(location.properties.housenumber, " ","")) + ifdef(location.properties.city, ", ", "") + ifdef(location.properties.countrycode, ", ", "")
}
}
function geojson_to_searchable_string(location) {
return ifdef(location.properties.name, "", ", ") + ifdef(location.properties.street, "", ifdef(location.properties.housenumber, " ",", ")) + ifdef(location.properties.city, "", ", ") + ifdef(location.properties.country, "", "")
}

File diff suppressed because one or more lines are too long

View File

@ -1,3 +1,5 @@
import logging
from celery.app import shared_task
from django.utils import timezone
from notfellchen.celery import app as celery_app
@ -5,6 +7,8 @@ from .mail import send_notification_email
from .tools.admin import clean_locations, deactivate_unchecked_adoption_notices, deactivate_404_adoption_notices
from .tools.misc import healthcheck_ok
from .models import Location, AdoptionNotice, Timestamp
from .tools.notifications import notify_moderators_of_AN_to_be_checked
from .tools.search import notify_search_subscribers
def set_timestamp(key: str):
@ -34,12 +38,15 @@ def task_deactivate_unchecked():
set_timestamp("task_deactivate_404_adoption_notices")
@celery_app.task(name="commit.add_location")
def add_adoption_notice_location(pk):
@celery_app.task(name="commit.post_an_save")
def post_adoption_notice_save(pk):
instance = AdoptionNotice.objects.get(pk=pk)
Location.add_location_to_object(instance)
set_timestamp("add_adoption_notice_location")
logging.info(f"Location was added to Adoption notice {pk}")
notify_search_subscribers(instance, only_if_active=True)
notify_moderators_of_AN_to_be_checked(instance)
@celery_app.task(name="tools.healthcheck")
def task_healthcheck():

View File

@ -6,25 +6,50 @@
{% block content %}
{% if about_us %}
<h1>{{ about_us.title }}</h1>
{{ about_us.content | render_markdown }}
<div class="card">
<h1>{{ about_us.title }}</h1>
<p>
{{ about_us.content | render_markdown }}
</p>
</div>
{% endif %}
<h1>{% translate "Regeln" %}</h1>
<h2>{% translate "Regeln" %}</h2>
{% include "fellchensammlung/lists/list-rules.html" %}
{% if faq %}
<div class="card">
<h2>{{ faq.title }}</h2>
<p>
{{ faq.content | render_markdown }}
</p>
</div>
{% endif %}
{% if privacy_statement %}
<h1>{{ privacy_statement.title }}</h1>
{{ privacy_statement.content | render_markdown }}
<div class="card">
<h2>{{ privacy_statement.title }}</h2>
<p>
{{ privacy_statement.content | render_markdown }}
</p>
</div>
{% endif %}
{% if terms_of_service %}
<h1>{{ terms_of_service.title }}</h1>
{{ terms_of_service.content | render_markdown }}
<div class="card">
<h2>{{ terms_of_service.title }}</h2>
<p>
{{ terms_of_service.content | render_markdown }}
</p>
</div>
{% endif %}
{% if imprint %}
<h1>{{ imprint.title }}</h1>
{{ imprint.content | render_markdown }}
<div class="card">
<h2>{{ imprint.title }}</h2>
<p>
{{ imprint.content | render_markdown }}
</p>
</div>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,13 @@
{% extends "fellchensammlung/base_generic.html" %}
{% load i18n %}
{% block title %}<title>{% translate "Tierschutzorganisationen" %}</title>{% endblock %}
{% block content %}
<div class="container-cards">
<div class="card">
{% include "fellchensammlung/partials/partial-map.html" %}
</div>
</div>
{% include "fellchensammlung/lists/list-animal-shelters.html" %}
{% endblock %}

View File

@ -14,6 +14,8 @@
<link href="{% static 'fontawesomefree/css/brands.css' %}" rel="stylesheet" type="text/css">
<link href="{% static 'fontawesomefree/css/solid.css' %}" rel="stylesheet" type="text/css">
<script src="{% static 'fellchensammlung/js/custom.js' %}"></script>
<link rel="apple-touch-icon" sizes="180x180" href="{% static 'fellchensammlung/favicon/apple-touch-icon.png' %}">
<link rel="icon" type="image/png" sizes="32x32" href="{% static 'fellchensammlung/favicon/favicon-32x32.png' %}">
<link rel="icon" type="image/png" sizes="16x16" href="{% static 'fellchensammlung/favicon/favicon-16x16.png' %}">

View File

@ -5,17 +5,57 @@
{% block title %}<title>{{ org.name }}</title>{% endblock %}
{% block content %}
<div class="card">
<h1>{{ org.name }}</h1>
<div class="container-cards">
<div class="card half">
<h1>{{ org.name }}</h1>
<b><i class="fa-solid fa-location-dot"></i></b>
{% if org.location %}
{{ org.location.str_hr }}
{% else %}
{{ org.location_string }}
{% endif %}
<p>{{ org.description | render_markdown }}</p>
<b><i class="fa-solid fa-location-dot"></i></b>
{% if org.location %}
{{ org.location.str_hr }}
{% else %}
{{ org.location_string }}
{% endif %}
<p>{{ org.description | render_markdown }}</p>
<table class="responsive">
<thead>
<tr>
{% if org.website %}
<td>{% translate "Website" %}</td>
{% endif %}
{% if org.phone_number %}
<td>{% translate "Telefonnummer" %}</td>
{% endif %}
{% if org.email %}
<td>{% translate "E-Mail" %}</td>
{% endif %}
</tr>
</thead>
<tr>
{% if org.website %}
<td data-label="{% trans 'Website' %} ">
{{ org.website }}
</td>
{% endif %}
{% if org.phone_number %}
<td data-label="{% trans 'Telefonnummer' %}">
{{ org.phone_number }}
</td>
{% endif %}
{% if org.email %}
<td data-label="{% trans 'E-Mail' %}">
{{ org.email }}
</td>
{% endif %}
</tr>
</table>
</div>
<div class="card half">
{% include "fellchensammlung/partials/partial-map.html" %}
</div>
</div>
<h2>{% translate 'Vermittlungen der Organisation' %}</h2>
<div class="container-cards">
{% if org.adoption_notices %}

View File

@ -1,50 +1,83 @@
{% extends "fellchensammlung/base_generic.html" %}
{% load i18n %}
{% block title %}<title>{{ user.get_full_name }}</title>{% endblock %}
{% block content %}
<h1>{{ user.get_full_name }}</h1>
<div class="spaced">
<p><strong>{% translate "Username" %}:</strong> {{ user.username }}</p>
<p><strong>{% translate "E-Mail" %}:</strong> {{ user.email }}</p>
{% if user.preferred_language %}
<p><strong>{% translate "Sprache" %}:</strong> {{ user.preferred_language }}</p>
{% else %}
<p>{% translate "Keine bevorzugte Sprache gesetzt." %}</p>
{% endif %}
<div class="container-cards">
{% if user.id is request.user.id %}
<div class="card">
{% if token %}
<form action="" method="POST">
{% csrf_token %}
<p class="text-muted"><strong>{% translate "API token:" %}</strong> {{ token }}</p>
<input class="btn" type="submit" name="delete_token"
value={% translate "Delete API token" %}>
</form>
{% else %}
<p>{% translate "Kein API-Token vorhanden." %}</p>
<form action="" method="POST">
{% csrf_token %}
<input class="btn" type="submit" name="create_token"
value={% translate "Create API token" %}>
</form>
{% endif %}
<div class="container-cards">
<h2>{% trans 'Daten' %}</h2>
<div class="card">
<p><strong>{% translate "Username" %}:</strong> {{ user.username }}</p>
<p><strong>{% translate "E-Mail" %}:</strong> {{ user.email }}</p>
</div>
</div><p>
</div>
<div class="container-cards">
<h2>{% trans 'Profil verwalten' %}</h2>
<div class="container-comment-form">
<h2>{% trans 'Profil verwalten' %}</h2>
<p>
<a class="btn2" href="{% url 'password_change' %}">{% translate "Change password" %}</a>
<a class="btn2" href="{% url 'user-me-export' %}">{% translate "Daten exportieren" %}</a>
</p>
</div>
</p>
</div>
{% if user.id is request.user.id %}
<div class="detail-animal-header"><h2>{% trans 'Einstellungen' %}</h2></div>
<div class="container-cards">
<form class="card" action="" method="POST">
{% csrf_token %}
{% if user.email_notifications %}
<label class="toggle">
<input type="submit" class="toggle-checkbox checked" name="toggle_email_notifications">
<div class="toggle-switch round "></div>
<span class="slider-label">
{% translate 'E-Mail Benachrichtigungen' %}
</span>
</label>
{% else %}
<label class="toggle">
<input type="submit" class="toggle-checkbox" name="toggle_email_notifications">
<div class="toggle-switch round"></div>
<span class="slider-label">
{% translate 'E-Mail Benachrichtigungen' %}
</span>
</label>
{% endif %}
</form>
<div class="card">
{% if token %}
<form action="" method="POST">
{% csrf_token %}
<p class="text-muted"><strong>{% translate "API token:" %}</strong> {{ token }}</p>
<input class="btn" type="submit" name="delete_token"
value={% translate "Delete API token" %}>
</form>
{% else %}
<p>{% translate "Kein API-Token vorhanden." %}</p>
<form action="" method="POST">
{% csrf_token %}
<input class="btn" type="submit" name="create_token"
value={% translate "Create API token" %}>
</form>
{% endif %}
</div>
</div>
<h2>{% translate 'Benachrichtigungen' %}</h2>
{% include "fellchensammlung/lists/list-notifications.html" %}
<h2>{% translate 'Abonnierte Suchen' %}</h2>
{% include "fellchensammlung/lists/list-search-subscriptions.html" %}
<h2>{% translate 'Meine Vermittlungen' %}</h2>
{% include "fellchensammlung/lists/list-adoption-notices.html" %}
{% endif %}
</div>
{% endblock %}

View File

@ -6,23 +6,39 @@
{% block content %}
<div class="detail-adoption-notice-header">
<h1 class="detail-adoption-notice-header">{{ adoption_notice.name }}
<div class="inline-container">
<h1>{{ adoption_notice.name }}</h1>
{% if not is_subscribed %}
<form class="notification-card-mark-read" method="post">
{% csrf_token %}
<input type="hidden" name="action" value="subscribe">
<input type="hidden" name="adoption_notice_id" value="{{ adoption_notice.pk }}">
<button class="btn2" type="submit" id="submit"><i class="fa-solid fa-bell"></i></button>
</form>
<div class="tooltip bottom">
<form class="notification-card-mark-read" method="post">
{% csrf_token %}
<input type="hidden" name="action" value="subscribe">
<input type="hidden" name="adoption_notice_id" value="{{ adoption_notice.pk }}">
<button class="btn2" type="submit" id="submit"><i class="fa-solid fa-bell"></i></button>
</form>
<span class="tooltiptext">
{% translate 'Abonniere diese Vermittlung um bei Kommentaren oder Statusänderungen benachrichtigt zu werden' %}
</span>
</div>
{% else %}
<form class="notification-card-mark-read" method="post">
{% csrf_token %}
<input type="hidden" name="action" value="unsubscribe">
<input type="hidden" name="adoption_notice_id" value="{{ adoption_notice.pk }}">
<button class="btn2" type="submit" id="submit"><i class="fa-solid fa-bell-slash"></i></button>
</form>
<div class="tooltip bottom">
<form class="notification-card-mark-read" method="post">
{% csrf_token %}
<input type="hidden" name="action" value="unsubscribe">
<input type="hidden" name="adoption_notice_id" value="{{ adoption_notice.pk }}">
<button class="btn2" type="submit" id="submit"><i class="fa-solid fa-bell-slash"></i></button>
</form>
<span class="tooltiptext">
{% translate 'Deabonnieren. Du bekommst keine Benachrichtigungen zu dieser Vermittlung mehr' %}
</span>
</div>
{% endif %}
</h1>
{% if adoption_notice.is_active %}
<span id="submit" class="label active-adoption" style=>{% trans 'Aktive Vermittlung' %}</span>
{% else %}
<span id="submit" class="label inactive-adoption" style=>{% trans 'Vermittlung inaktiv' %}</span>
{% endif %}
</div>
{% if has_edit_permission %}
<a class="btn2"
href="{% url 'adoption-notice-add-photo' adoption_notice_id=adoption_notice.pk %}">{% translate 'Foto hinzufügen' %}</a>
@ -31,18 +47,20 @@
{% endif %}
</div>
<div class="table-adoption-notice-info">
<table>
<table class="responsive">
<thead>
<tr>
<th>{% translate "Ort" %}</th>
<td>{% translate "Ort" %}</td>
{% if adoption_notice.organization %}
<th>{% translate "Organisation" %}</th>
<td>{% translate "Organisation" %}</td>
{% endif %}
<th>{% translate "Suchen seit" %}</th>
<th>{% translate "Zuletzt aktualisiert" %}</th>
<th>{% translate "Weitere Informationen" %}</th>
<td>{% translate "Suchen seit" %}</td>
<td>{% translate "Zuletzt aktualisiert" %}</td>
<td>{% translate "Weitere Informationen" %}</td>
</tr>
</thead>
<tr>
<td>
<td data-label="{% trans 'Ort' %} ">
{% if adoption_notice.location %}
{{ adoption_notice.location }}
{% else %}
@ -50,13 +68,29 @@
{% endif %}
</td>
{% if adoption_notice.organization %}
<td><a href="{{adoption_notice.organization.get_absolute_url }}">{{ adoption_notice.organization }}</a></td>
<td data-label="{% trans 'Organisation' %}">
<div>
<a href="{{ adoption_notice.organization.get_absolute_url }}">{{ adoption_notice.organization }}</a>
{% if adoption_notice.organization.trusted %}
<div class="tooltip top">
<div class="checkmark"><i class="fa-solid fa-check"></i></div>
<span class="tooltiptext">
{% translate 'Diese Organisation kennt sich mit Ratten aus und achtet auf gute Abgabebedingungen' %}
</span>
</div>
{% endif %}
</div>
</td>
{% endif %}
<td>{{ adoption_notice.searching_since }}</td>
<td>{{ adoption_notice.last_checked | date:'d. F Y' }}</td>
{% if adoption_notice.further_information %}
<td>
<td data-label="{% trans 'Suchen seit' %}">{{ adoption_notice.searching_since }}</td>
<td data-label="{% trans 'Zuletzt aktualisiert' %}">
{{ adoption_notice.last_checked_hr }}
</td>
<td data-label="{% trans 'Weitere Informationen' %}">
{% if adoption_notice.further_information %}
<form method="get" action="{% url 'external-site' %}">
<input type="hidden" name="url" value="{{ adoption_notice.further_information }}">
<button class="btn" type="submit" id="submit">
@ -64,10 +98,10 @@
class="fa-solid fa-arrow-up-right-from-square"></i>
</button>
</form>
</td>
{% else %}
<td>-</td>
{% endif %}
{% else %}
-
{% endif %}
</td>
</tr>
</table>
</div>

View File

@ -9,7 +9,7 @@
Lade hier ein Foto hoch - wähle den Titel wie du willst und mach bitte eine Bildbeschreibung,
damit die Fotos auch für blinde und sehbehinderte Personen zugänglich sind.
{% endblocktranslate %}
<p><a class="btn" href="https://www.dbsv.org/bildbeschreibung-4-regeln.html">{% translate 'Anleitung für Bildbeschreibungen' %}</a></p>
<p><a class="btn2" href="https://www.dbsv.org/bildbeschreibung-4-regeln.html">{% translate 'Anleitung für Bildbeschreibungen' %}</a></p>
</p>
<div class="container-form">
{% crispy form %}

View File

@ -7,6 +7,6 @@
<form method = "post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p }}
<button class="button-report" type="submit">{% translate "Melden" %}</button>
<button class="btn2" type="submit">{% translate "Melden" %}</button>
</form>
{% endblock %}

View File

@ -37,8 +37,8 @@
<nav id="main-menu">
<ul class="menu">
<li>
<a class="nav-link " href="{% url "search" %}"><i
class="fas fa-search"></i> {% translate 'Suchen' %}
<a class="nav-link " href="{% url "search" %}">
<i class="fas fa-search"></i> {% translate 'Suchen' %}
</a>
</li>
<li><a class="nav-link " href="{% url "add-adoption" %}"><i

View File

@ -106,7 +106,15 @@
{% csrf_token %}
<input type="hidden" name="action" value="deactivate_unchecked_adoption_notices">
<button class="btn" type="submit" id="submit">
<i class="fa-solid fa-broom"></i> {% translate "Deaktivire ungeprüfte Vermittlungen" %}
<i class="fa-solid fa-broom"></i> {% translate "Deaktiviere ungeprüfte Vermittlungen" %}
</button>
</form>
<form class="notification-card-mark-read" method="post">
{% csrf_token %}
<input type="hidden" name="action" value="deactivate_404">
<button class="btn" type="submit" id="submit">
<i class="fa-solid fa-broom"></i> {% translate "Deaktiviere 404 Vermittlungen" %}
</button>
</form>
</div>

View File

@ -0,0 +1,10 @@
{% load i18n %}
<div class="container-cards spaced">
{% if rescue_organizations %}
{% for rescue_organization in rescue_organizations %}
{% include "fellchensammlung/partials/partial-rescue-organization.html" %}
{% endfor %}
{% else %}
<p>{% translate "Keine Tierschutzorganisationen gefunden." %}</p>
{% endif %}
</div>

View File

@ -1,5 +1,10 @@
{% load i18n %}
<div class="container-cards">
{% for notification in notifications %}
{% include "fellchensammlung/partials/partial-notification.html" %}
{% endfor %}
{% if notifications %}
{% for notification in notifications %}
{% include "fellchensammlung/partials/partial-notification.html" %}
{% endfor %}
{% else %}
<p>{% translate 'Keine ungelesenen Benachrichtigungen' %}</p>
{% endif %}
</div>

View File

@ -0,0 +1,10 @@
{% load i18n %}
<div class="container-cards">
{% if search_subscriptions %}
{% for search_subscription in search_subscriptions %}
{% include "fellchensammlung/partials/partial-search-subscription.html" %}
{% endfor %}
{% else %}
<p>{% translate 'Keine abonnierten Suchen' %}</p>
{% endif %}
</div>

View File

@ -2,31 +2,34 @@
{% load i18n %}
<div class="card">
<div>
<div class="header-card-adoption-notice">
<h1><a class="heading-card-adoption-notice"
href="{{ adoption_notice.get_absolute_url }}"> {{ adoption_notice.name }}</a></h1>
<div class="header-card-adoption-notice">
<h2 class="heading-card-adoption-notice"><a
href="{{ adoption_notice.get_absolute_url }}"> {{ adoption_notice.name }}</a></h2>
<div class="tooltip bottom">
<a class="adoption-card-report-link" href="{{ adoption_notice.get_report_url }}"><i
class="fa-solid fa-flag"></i></a>
<span class="tooltiptext">
{% translate 'Melde diese Vermittlung an Moderator*innen' %}
</span>
</div>
<p>
<b><i class="fa-solid fa-location-dot"></i></b>
{% if adoption_notice.location %}
{{ adoption_notice.location.str_hr }}
{% else %}
{{ adoption_notice.location_string }}
{% endif %}
</p>
<p>
{% if adoption_notice.description_short %}
{{ adoption_notice.description_short | render_markdown }}
{% endif %}
</p>
{% if adoption_notice.get_photo %}
<div class="adoption-notice-img img-small">
<img src="{{ MEDIA_URL }}/{{ adoption_notice.get_photo.image }}"
alt="{{ adoption_notice.get_photo.alt_text }}">
</div>
{% endif %}
</div>
</div>
<p>
<b><i class="fa-solid fa-location-dot"></i></b>
{% if adoption_notice.location %}
{{ adoption_notice.location.str_hr }}
{% else %}
{{ adoption_notice.location_string }}
{% endif %}
</p>
<p>
{% if adoption_notice.description_short %}
{{ adoption_notice.description_short | render_markdown }}
{% endif %}
</p>
{% if adoption_notice.get_photo %}
<div class="adoption-notice-img img-small">
<img src="{{ MEDIA_URL }}/{{ adoption_notice.get_photo.image }}"
alt="{{ adoption_notice.get_photo.alt_text }}">
</div>
{% endif %}
</div>

View File

@ -1,4 +1,5 @@
{% load i18n %}
{% load custom_tags %}
<div class="card">
<div class="detail-animal-header">
<h1><a href="{% url 'animal-detail' animal_id=animal.pk %}">{{ animal.name }}</a></h1>
@ -19,7 +20,7 @@
</div>
{% if animal.description %}
<p>{{ animal.description }}</p>
<p>{{ animal.description | render_markdown }}</p>
{% endif %}
{% for photo in animal.get_photos %}
<img src="{{ MEDIA_URL }}/{{ photo.image }}" alt="{{ photo.alt_text }}">

View File

@ -1,10 +1,11 @@
{% load i18n %}
{% load custom_tags %}
<div class="announcement {{ announcement.type }}">
<div class="announcement-header">
<h1 class="announcement">{{ announcement.title }}</h1>
</div>
<p>
{{ announcement.content | render_markdown }}
</p>
<details class="announcement" open>
<summary class="announcement-header">{{ announcement.title }}</summary>
<p>
{{ announcement.content | render_markdown }}
</p>
</details>
</div>

View File

@ -4,10 +4,11 @@
<h1>
<a href="{{ adoption_notice.get_absolute_url }}">{{ adoption_notice.name }}</a>
</h1>
<i>{% translate 'Zuletzt geprüft:' %} {{ adoption_notice.last_checked_hr }}</i>
{% if adoption_notice.further_information %}
<p>{% translate "Externe Quelle" %}: {{ adoption_notice.link_to_more_information | safe }}</p>
{% endif %}
<div>
<div class="container-edit-buttons">
<form method="post">
{% csrf_token %}
<input type="hidden"

View File

@ -1,7 +1,7 @@
{% load i18n %}
<div class="container-comments">
<h2>{% translate 'Comments' %}</h2>
<h2>{% translate 'Kommentare' %}</h2>
{% if adoption_notice.comments %}
{% for comment in adoption_notice.comments %}
{% include "fellchensammlung/partials/partial-comment.html" %}

View File

@ -6,35 +6,141 @@
<script src="{% settings_value "MAP_TILE_SERVER" %}/assets/maplibre-gl/maplibre-gl.js"></script>
<link href="{% settings_value "MAP_TILE_SERVER" %}/assets/maplibre-gl/maplibre-gl.css" rel="stylesheet"/>
<!-- add Turf see https://maplibre.org/maplibre-gl-js/docs/examples/draw-a-radius/ -->
<script src="{% static 'fellchensammlung/js/turf.min.js' %}"></script>
<!-- add container for the map -->
<div id="map" style="width:100%;aspect-ratio:16/9"></div>
<!-- start map -->
<script>
{% if zoom_level %}
var zoom_level = {{ zoom_level }};
{% else %}
var zoom_level = 4;
{% endif %}
{% if map_center %}
var map_center = [{{ map_center.longitude | pointdecimal }}, {{ map_center.latitude | pointdecimal }}];
{% else %}
var map_center = [10.49, 50.68]; <!-- Point middle of Germany -->
zoom_level = 4; //Overwrite zoom level when no place is found
{% endif %}
let map = new maplibregl.Map({
container: 'map',
style: '{% static "fellchensammlung/map/styles/colorful.json" %}',
center: [10.49, 50.68],
zoom: 5
center: map_center,
zoom: zoom_level
}).addControl(new maplibregl.NavigationControl());
{% for adoption_notice in adoption_notices_map %}
{% if adoption_notice.location %}
// create the popup
const popup_{{ forloop.counter }} = new maplibregl.Popup({offset: 25}).setHTML(`{% include "fellchensammlung/partials/partial-adoption-notice-minimal.html" %}`);
// create the popup
const popup_{{ forloop.counter }} = new maplibregl.Popup({offset: 25}).setHTML(`{% include "fellchensammlung/partials/partial-adoption-notice-minimal.html" %}`);
// create DOM element for the marker
const el_{{ forloop.counter }} = document.createElement('div');
el_{{ forloop.counter }}.id = 'marker_{{ forloop.counter }}';
el_{{ forloop.counter }}.classList.add('marker');
// create DOM element for the marker
const el_{{ forloop.counter }} = document.createElement('div');
el_{{ forloop.counter }}.id = 'marker_{{ forloop.counter }}';
el_{{ forloop.counter }}.classList.add('marker');
const location_popup_{{ forloop.counter }} = [{{ adoption_notice.location.longitude | pointdecimal }}, {{ adoption_notice.location.latitude | pointdecimal }}];
// create the marker
new maplibregl.Marker({element: el_{{ forloop.counter }}})
.setLngLat(location_popup_{{ forloop.counter }})
.setPopup(popup_{{ forloop.counter }}) // sets a popup on this marker
.addTo(map);
const location_popup_{{ forloop.counter }} = [{{ adoption_notice.location.longitude | pointdecimal }}, {{ adoption_notice.location.latitude | pointdecimal }}];
// create the marker
new maplibregl.Marker({element: el_{{ forloop.counter }}})
.setLngLat(location_popup_{{ forloop.counter }})
.setPopup(popup_{{ forloop.counter }}) // sets a popup on this marker
.addTo(map);
{% endif %}
{% endfor %}
{% for rescue_organization in rescue_organizations %}
{% if rescue_organization.location %}
// create the popup
const popup_{{ forloop.counter }} = new maplibregl.Popup({offset: 25}).setHTML(`{% include "fellchensammlung/partials/partial-rescue-organization.html" %}`);
// create DOM element for the marker
const el_{{ forloop.counter }} = document.createElement('div');
el_{{ forloop.counter }}.id = 'marker_{{ forloop.counter }}';
el_{{ forloop.counter }}.classList.add('animal-shelter-marker', 'marker');
const location_popup_{{ forloop.counter }} = [{{ rescue_organization.location.longitude | pointdecimal }}, {{ rescue_organization.location.latitude | pointdecimal }}];
// create the marker
new maplibregl.Marker({element: el_{{ forloop.counter }}})
.setLngLat(location_popup_{{ forloop.counter }})
.setPopup(popup_{{ forloop.counter }}) // sets a popup on this marker
.addTo(map);
{% endif %}
{% endfor %}
map.on('load', async () => {
image = await map.loadImage('{% static "fellchensammlung/img/pin.png" %}');
map.addImage('pin', image.data);
{% for map_pin in map_pins %}
map.addSource('point', {
'type': 'geojson',
'data': {
'type': 'FeatureCollection',
'features': [
{
'type': 'Feature',
'geometry': {
'type': 'Point',
'coordinates': [{{ map_pin.location.longitude | pointdecimal }}, {{ map_pin.location.latitude | pointdecimal }}]
}
}
]
}
});
{% endfor %}
map.addLayer({
'id': 'pints',
'type': 'symbol',
'source': 'point',
'layout': {
'icon-image': 'pin',
'icon-size': 0.1
}
});
});
{% if search_center %}
var search_center = [{{ search_center.longitude | pointdecimal }}, {{ search_center.latitude | pointdecimal }}];
map.on('load', () => {
const radius = {{ search_radius }}; // kilometer
const options = {
steps: 64,
units: 'kilometers'
};
const circle = turf.circle(search_center, radius, options);
// Add the circle as a GeoJSON source
map.addSource('location-radius', {
type: 'geojson',
data: circle
});
// Add a fill layer with some transparency
map.addLayer({
id: 'location-radius',
type: 'fill',
source: 'location-radius',
paint: {
'fill-color': 'rgba(140,207,255,0.3)',
'fill-opacity': 0.5
}
});
// Add a line layer to draw the circle outline
map.addLayer({
id: 'location-radius-outline',
type: 'line',
source: 'location-radius',
paint: {
'line-color': '#0094ff',
'line-width': 3
}
});
});
{% endif %}
</script>

View File

@ -16,7 +16,7 @@
<p><b>{% translate "Kommentar zur Meldung" %}:</b>
{{ report.user_comment }}
</p>
<div>
<div class="container-edit-buttons">
<form action="allow" class="">
{% csrf_token %}
<input type="hidden" name="report_id" value="{{ report.pk }}">

View File

@ -0,0 +1,22 @@
{% load custom_tags %}
{% load i18n %}
<div class="card">
<div>
<h2 class="heading-card-adoption-notice"><a
href="{{ rescue_organization.get_absolute_url }}"> {{ rescue_organization.name }}</a></h2>
<p>
<b><i class="fa-solid fa-location-dot"></i></b>
{% if rescue_organization.location %}
{{ rescue_organization.location.str_hr }}
{% else %}
{{ rescue_organization.location_string }}
{% endif %}
</p>
<p>
{% if rescue_organization.description_short %}
{{ rescue_organization.description_short | render_markdown }}
{% endif %}
</p>
</div>
</div>

View File

@ -0,0 +1,25 @@
{% load i18n %}
{% load custom_tags %}
<div class="search-subscription">
<div class="search-subscription-header">
<h3>{{ search_subscription }}</h3>
<form class="" method="POST">
{% csrf_token %}
<input type="hidden" name="action" value="search_subscription_delete">
<input type="hidden" name="search_subscription_id" value="{{ search_subscription.pk }}">
<button class="btn3" type="submit" id="submit"><i class="fa-solid fa-close"></i></button>
</form>
</div>
<table>
<tr>
<th>{% trans 'Geschlecht' %}</th>
<th>{% trans 'Suchort' %}</th>
<th>{% trans 'Suchradius' %}</th>
</tr>
<tr>
<td>{{ search_subscription.sex }}</td>
<td>{{ search_subscription.location }}</td>
<td>{{ search_subscription.max_distance }}km</td>
</tr>
</table>
</div>

View File

@ -4,15 +4,85 @@
{% block title %}<title>{% translate "Suche" %}</title>{% endblock %}
{% block content %}
<form class="form-search card" method="post">
{% csrf_token %}
<input type="hidden" name="longitude" maxlength="200" id="longitude">
<input type="hidden" name="latitude" maxlength="200" id="latitude">
{{ search_form.as_p }}
<input class="btn" type="submit" value="Search" name="search">
</form>
{% if place_not_found %}
<p class="error">{% translate "Ort nicht gefunden" %}</p>
{% endif %}
{% get_current_language as LANGUAGE_CODE_CURRENT %}
<div class="container-cards">
<form class="form-search card half" method="post">
{% csrf_token %}
<input type="hidden" name="longitude" maxlength="200" id="longitude">
<input type="hidden" name="latitude" maxlength="200" id="latitude">
<input type="hidden" id="place_id" name="place_id">
{{ search_form.as_p }}
<ul id="results"></ul>
<div class="container-edit-buttons">
<button class="btn" type="submit" value="search" name="search">
<i class="fas fa-search"></i> {% trans 'Suchen' %}
</button>
{% if searched %}
{% if subscribed_search %}
<button class="btn" type="submit" value="{{ subscribed_search.pk }}"
name="unsubscribe_to_search">
<i class="fas fa-bell-slash"></i> {% trans 'Suche nicht mehr abonnieren' %}
</button>
{% else %}
<button class="btn" type="submit" name="subscribe_to_search">
<i class="fas fa-bell"></i> {% trans 'Suche abonnieren' %}
</button>
{% endif %}
{% endif %}
</div>
{% if place_not_found %}
<p class="error">
{% trans 'Ort nicht gefunden' %}
</p>
{% endif %}
</form>
<div class="card half">
{% include "fellchensammlung/partials/partial-map.html" %}
</div>
</div>
{% include "fellchensammlung/lists/list-adoption-notices.html" %}
<script>
const locationInput = document.getElementById('id_location_string');
const resultsList = document.getElementById('results');
const placeIdInput = document.getElementById('place_id');
locationInput.addEventListener('input', async function () {
const query = locationInput.value.trim();
if (query.length < 3) {
resultsList.innerHTML = ''; // Don't search for or show results if input is less than 3 characters
return;
}
try {
const response = await fetch(`{{ geocoding_api_url }}/?q=${encodeURIComponent(query)}&limit=5&lang={{ LANGUAGE_CODE_CURRENT }}`);
const data = await response.json();
if (data && data.features) {
resultsList.innerHTML = ''; // Clear previous results
const locations = data.features.slice(0, 5); // Show only the first 5 results
locations.forEach(location => {
const listItem = document.createElement('li');
listItem.classList.add('result-item');
listItem.textContent = geojson_to_summary(location);
// Add event when user clicks on a result location
listItem.addEventListener('click', () => {
locationInput.value = geojson_to_searchable_string(location); // Set input field to selected location
resultsList.innerHTML = ''; // Clear the results after selecting a location
});
resultsList.appendChild(listItem);
});
}
} catch (error) {
console.error('Error fetching location data:', error);
resultsList.innerHTML = '<li class="result-item">Error fetching data. Please try again.</li>';
}
});
</script>
{% endblock %}

View File

@ -3,7 +3,8 @@ import logging
from django.utils import timezone
from datetime import timedelta
from fellchensammlung.models import AdoptionNotice, Location, RescueOrganization, AdoptionNoticeStatus, Log
from fellchensammlung.models import AdoptionNotice, Location, RescueOrganization, AdoptionNoticeStatus, Log, \
AdoptionNoticeNotification
from fellchensammlung.tools.misc import is_404
@ -82,3 +83,10 @@ def deactivate_404_adoption_notices():
logging_msg = f"Automatically set Adoption Notice {adoption_notice.id} closed as link to more information returened 404"
logging.info(logging_msg)
Log.objects.create(action="automated", text=logging_msg)
deactivation_message = f'Die Vermittlung [{adoption_notice.name}]({adoption_notice.get_absolute_url()}) wurde automatisch deaktiviert, da die Website unter "Mehr Informationen" nicht mehr online ist.'
for subscription in adoption_notice.get_subscriptions():
AdoptionNoticeNotification.objects.create(user=subscription.owner,
title="Vermittlung deaktiviert",
adoption_notice=adoption_notice,
text=deactivation_message)

View File

@ -1,4 +1,6 @@
import logging
from collections import namedtuple
import requests
import json
from math import radians, sqrt, sin, cos, atan2
@ -6,6 +8,23 @@ from math import radians, sqrt, sin, cos, atan2
from notfellchen import __version__ as nf_version
from notfellchen import settings
Position = namedtuple('Position', ['latitude', 'longitude'])
def zoom_level_for_radius(radius) -> int:
if radius is None:
return 4
if radius <= 20:
return 8
if radius <= 50:
return 7
if radius <= 150:
return 6
if radius <= 300:
return 5
else:
return 4
def calculate_distance_between_coordinates(position1, position2):
"""
@ -29,6 +48,8 @@ def calculate_distance_between_coordinates(position1, position2):
distance_in_km = earth_radius_km * c
logging.debug(f"Calculated Distance: {distance_in_km:.5}km")
return distance_in_km
@ -46,8 +67,44 @@ class RequestMock:
return ResponseMock()
class GeoFeature:
@staticmethod
def geofeatures_from_photon_result(result):
geofeatures = []
for feature in result["features"]:
geojson = {}
try:
geojson['name'] = feature["properties"]["name"]
except KeyError:
geojson['name'] = feature["properties"]["street"]
geojson['place_id'] = feature["properties"]["osm_id"]
geojson['lat'] = feature["geometry"]["coordinates"][1]
geojson['lon'] = feature["geometry"]["coordinates"][0]
geofeatures.append(geojson)
return geofeatures
@staticmethod
def geofeatures_from_nominatim_result(result):
geofeatures = []
for feature in result:
geojson = {}
if "name" in feature:
geojson['name'] = feature["name"]
else:
geojson['name'] = feature["display_name"]
geojson['place_id'] = feature["place_id"]
geojson['lat'] = feature["lat"]
geojson['lon'] = feature["lon"]
geofeatures.append(geojson)
return geofeatures
class GeoAPI:
api_url = settings.GEOCODING_API_URL
api_format = settings.GEOCODING_API_FORMAT
assert api_format in ['nominatim', 'photon']
# Set User-Agent headers as required by most usage policies (and it's the nice thing to do)
headers = {
'User-Agent': f"Notfellchen {nf_version}",
@ -62,34 +119,68 @@ class GeoAPI:
else:
self.requests = requests
def get_coordinates_from_query(self, location_string):
try:
result = \
self.requests.get(self.api_url, {"q": location_string, "format": "jsonv2"}, headers=self.headers).json()[0]
except IndexError:
return None
return result["lat"], result["lon"]
def _get_raw_response(self, location_string):
result = self.requests.get(self.api_url, {"q": location_string, "format": "jsonv2"}, headers=self.headers)
return result.content
def get_geojson_for_query(self, location_string):
def get_geojson_for_query(self, location_string, language="de"):
try:
result = self.requests.get(self.api_url,
{"q": location_string,
"format": "jsonv2"},
headers=self.headers).json()
if self.api_format == 'nominatim':
logging.info(f"Querying nominatim instance for: {location_string} ({self.api_url})")
result = self.requests.get(self.api_url,
{"q": location_string,
"format": "jsonv2"},
headers=self.headers).json()
geofeatures = GeoFeature.geofeatures_from_nominatim_result(result)
elif self.api_format == 'photon':
logging.info(f"Querying photon instance for: {location_string} ({self.api_url})")
result = self.requests.get(self.api_url,
{"q": location_string, "lang": language},
headers=self.headers).json()
logging.warning(result)
geofeatures = GeoFeature.geofeatures_from_photon_result(result)
else:
raise NotImplementedError
except Exception as e:
logging.warning(f"Exception {e} when querying Nominatim")
logging.warning(f"Exception {e} when querying geocoding server")
return None
if len(result) == 0:
logging.warning(f"Couldn't find a result for {location_string} when querying Nominatim")
if len(geofeatures) == 0:
logging.warning(f"Couldn't find a result for {location_string} when querying geocoding server")
return None
return result
return geofeatures
class LocationProxy:
"""
Location proxy is used as a precursor to the location model without the need to create unnecessary database objects
"""
def __init__(self, location_string):
"""
Creates the location proxy from the location string
"""
self.geo_api = GeoAPI()
geofeatures = self.geo_api.get_geojson_for_query(location_string)
if geofeatures is None:
raise ValueError
result = geofeatures[0]
self.name = result["name"]
self.place_id = result["place_id"]
self.latitude = result["lat"]
self.longitude = result["lon"]
def __eq__(self, other):
return self.place_id == other.place_id
def __str__(self):
return self.name
@property
def position(self):
return (self.latitude, self.longitude)
if __name__ == "__main__":
geo = GeoAPI(debug=False)
print(geo.get_coordinates_from_query("12101"))
print(calculate_distance_between_coordinates(('48.4949904', '9.040330235970146'), ("48.648333", "9.451111")))

View File

@ -1,6 +1,9 @@
import datetime as datetime
import logging
from django.utils.translation import ngettext
from django.utils.translation import gettext as _
from notfellchen import settings
import requests
@ -29,6 +32,31 @@ def age_as_hr_string(age: datetime.timedelta) -> str:
return f'{days:.0f} Tag{pluralize(days)}'
def time_since_as_hr_string(age: datetime.timedelta) -> str:
days = age.days
weeks = age.days / 7
months = age.days / 30
years = age.days / 365
if years >= 1:
text = ngettext(
"vor einem Jahr",
"vor %(years)d Tagen",
years,
) % {
"years": years,
}
elif months >= 3:
text = _("vor %(month)d Monaten") % {"month": months}
elif weeks >= 3:
text = _("vor %(weeks)d Wochen") % {"weeks": weeks}
else:
if days == 0:
text = _("Heute")
else:
text = ngettext("vor einem Tag","vor %(count)d Tagen", days,) % {"count": days,}
return text
def healthcheck_ok():
try:
requests.get(settings.HEALTHCHECKS_URL, timeout=10)

View File

@ -0,0 +1,11 @@
from fellchensammlung.models import User, AdoptionNoticeNotification, TrustLevel
def notify_moderators_of_AN_to_be_checked(adoption_notice):
if adoption_notice.is_disabled_unchecked:
for moderator in User.objects.filter(trust_level__gt=TrustLevel.MODERATOR):
AdoptionNoticeNotification.objects.create(adoption_notice=adoption_notice,
user=moderator,
title=f" Prüfe Vermittlung {adoption_notice}",
text=f"{adoption_notice} muss geprüft werden bevor sie veröffentlicht wird.",
)

View File

@ -0,0 +1,151 @@
import logging
from django.utils.translation import gettext_lazy as _
from .geo import LocationProxy, Position
from ..forms import AdoptionNoticeSearchForm
from ..models import SearchSubscription, AdoptionNotice, AdoptionNoticeNotification, SexChoicesWithAll, Location
def notify_search_subscribers(adoption_notice: AdoptionNotice, only_if_active : bool = True):
"""
This functions checks for all search subscriptions if the new adoption notice fits the search.
If the new adoption notice fits the search subscription, it sends a notification to the user that created the search.
"""
logging.debug(f"Notifying {adoption_notice}.")
if only_if_active and not adoption_notice.is_active:
logging.debug(f"No notifications triggered for adoption notice {adoption_notice} because it's not active.")
return
for search_subscription in SearchSubscription.objects.all():
logging.debug(f"Search subscription {search_subscription} found.")
search = Search(search_subscription=search_subscription)
if search.adoption_notice_fits_search(adoption_notice):
notification_text = f"{_('Zu deiner Suche')} {search_subscription} wurde eine neue Vermittlung gefunden"
AdoptionNoticeNotification.objects.create(user=search_subscription.owner,
title=f"{_('Neue Vermittlung')}: {adoption_notice}",
adoption_notice=adoption_notice,
text=notification_text)
logging.debug(f"Notification for search subscription {search_subscription} was sent.")
else:
logging.debug(f"Adoption notice {adoption_notice} was not fitting the search subscription.")
logging.info(f"Subscribers for AN {adoption_notice.pk} have been notified\n")
class Search:
def __init__(self, request=None, search_subscription=None):
self.sex = None
self.area_search = None
self.max_distance = None
self.location = None # Can either be Location (DjangoModel) or LocationProxy
self.place_not_found = False # Indicates that a location was given but could not be geocoded
self.search_form = None
# Either place_id or location string must be set for area search
self.location_string = None
if request:
self.search_from_request(request)
elif search_subscription:
self.search_from_search_subscription(search_subscription)
def __str__(self):
return f"Search: {self.sex=}, {self.location=}, {self.area_search=}, {self.max_distance=}"
def __eq__(self, other):
"""
Custom equals that also supports SearchSubscriptions
Only allowed to be called for located subscriptions
"""
# If both locations are empty check only for sex
if self.location is None and other.location is None:
return self.sex == other.sex
# If one location is empty and the other is not, they are not equal
elif self.location is not None and other.location is None or self.location is None and other.location is not None:
return False
return self.location == other.location and self.sex == other.sex and self.max_distance == other.max_distance
def _locate(self):
try:
self.location = LocationProxy(self.location_string)
except ValueError:
self.place_not_found = True
@property
def position(self):
if self.area_search and not self.place_not_found:
return Position(latitude=self.location.latitude, longitude=self.location.longitude)
else:
return None
def adoption_notice_fits_search(self, adoption_notice: AdoptionNotice):
# Make sure sex is set and sex is not set to all (then it can be disregarded)
if self.sex is not None and self.sex != SexChoicesWithAll.ALL:
# AN does not fit search if search sex is not in available sexes of this AN
if not self.sex in adoption_notice.sexes:
logging.debug("Sex mismatch")
return False
# make sure it's an area search and the place is found to check location
if self.area_search and not self.place_not_found:
# If adoption notice is in not in search distance, return false
if not adoption_notice.in_distance(self.location.position, self.max_distance):
logging.debug("Area mismatch")
return False
return True
def get_adoption_notices(self):
adoptions = AdoptionNotice.objects.order_by("-created_at")
# Filter for active adoption notices
adoptions = [adoption for adoption in adoptions if adoption.is_active]
# Check if adoption notice fits search.
adoptions = [adoption for adoption in adoptions if self.adoption_notice_fits_search(adoption)]
return adoptions
def search_from_request(self, request):
if request.method == 'POST':
self.search_form = AdoptionNoticeSearchForm(request.POST)
self.search_form.is_valid()
self.sex = self.search_form.cleaned_data["sex"]
if self.search_form.cleaned_data["location_string"] != "" and self.search_form.cleaned_data[
"max_distance"] != "":
self.area_search = True
self.location_string = self.search_form.cleaned_data["location_string"]
self.max_distance = int(self.search_form.cleaned_data["max_distance"])
self._locate()
else:
self.search_form = AdoptionNoticeSearchForm()
def search_from_search_subscription(self, search_subscription: SearchSubscription):
self.sex = search_subscription.sex
self.location = search_subscription.location
self.area_search = True
self.max_distance = search_subscription.max_distance
def subscribe(self, user):
logging.info(f"{user} subscribed to search")
if isinstance(self.location, LocationProxy):
self.location = Location.get_location_from_proxy(self.location)
SearchSubscription.objects.create(owner=user,
location=self.location,
sex=self.sex,
max_distance=self.max_distance)
def get_subscription_or_none(self, user):
user_subscriptions = SearchSubscription.objects.filter(owner=user)
for subscription in user_subscriptions:
if self == subscription:
return subscription
def is_subscribed(self, user):
"""
Returns true if a user is already subscribed to a search with these parameters
"""
subscription = self.get_subscription_or_none()
if subscription is None:
return False
else:
return True

View File

@ -5,6 +5,7 @@ from .forms import CustomRegistrationForm
from .feeds import LatestAdoptionNoticesFeed
from . import views
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView
urlpatterns = [
path("", views.index, name="index"),
@ -24,6 +25,8 @@ urlpatterns = [
path("vermittlung/<int:adoption_notice_id>/add-photo", views.add_photo_to_adoption_notice, name="adoption-notice-add-photo"),
# ex: /adoption_notice/2/add-animal
path("vermittlung/<int:adoption_notice_id>/add-animal", views.adoption_notice_add_animal, name="adoption-notice-add-animal"),
path("tierschutzorganisationen/", views.list_rescue_organizations, name="rescue-organizations"),
path("organisation/<int:rescue_organization_id>/", views.detail_view_rescue_organization,
name="rescue-organization-detail"),
@ -82,6 +85,10 @@ urlpatterns = [
## API ##
#########
path('api/', include('fellchensammlung.api.urls')),
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
# Optional UI:
path('api/schema/swagger-ui/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
path('api/schema/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'),
###################
## External Site ##

View File

@ -1,5 +1,6 @@
import logging
from django.contrib.auth.views import redirect_to_login
from django.http import HttpResponseRedirect, JsonResponse, HttpResponse
from django.shortcuts import render, redirect
from django.urls import reverse
@ -16,17 +17,20 @@ from notfellchen import settings
from fellchensammlung import logger
from .models import AdoptionNotice, Text, Animal, Rule, Image, Report, ModerationAction, \
User, Location, AdoptionNoticeStatus, Subscriptions, CommentNotification, BaseNotification, RescueOrganization, \
Species, Log, Timestamp, TrustLevel, SexChoicesWithAll
Species, Log, Timestamp, TrustLevel, SexChoicesWithAll, SearchSubscription, AdoptionNoticeNotification
from .forms import AdoptionNoticeForm, AdoptionNoticeFormWithDateWidget, ImageForm, ReportAdoptionNoticeForm, \
CommentForm, ReportCommentForm, AnimalForm, \
AdoptionNoticeSearchForm, AnimalFormWithDateWidget, AdoptionNoticeFormWithDateWidgetAutoAnimal
from .models import Language, Announcement
from .tools.geo import GeoAPI
from .tools.geo import GeoAPI, zoom_level_for_radius
from .tools.metrics import gather_metrics_data
from .tools.admin import clean_locations, get_unchecked_adoption_notices, deactivate_unchecked_adoption_notices
from .tasks import add_adoption_notice_location
from .tools.admin import clean_locations, get_unchecked_adoption_notices, deactivate_unchecked_adoption_notices, \
deactivate_404_adoption_notices
from .tasks import post_adoption_notice_save
from rest_framework.authtoken.models import Token
from .tools.search import Search
def user_is_trust_level_or_above(user, trust_level=TrustLevel.MODERATOR):
return user.is_authenticated and user.trust_level >= trust_level
@ -168,37 +172,44 @@ def animal_detail(request, animal_id):
def search(request):
place_not_found = None
latest_adoption_list = AdoptionNotice.objects.order_by("-created_at")
active_adoptions = [adoption for adoption in latest_adoption_list if adoption.is_active]
# A user just visiting the search site did not search, only upon completing the search form a user has really
# searched. This will toggle the "subscribe" button
searched = False
search = Search()
search.search_from_request(request)
if request.method == 'POST':
sex = request.POST.get("sex")
if sex != SexChoicesWithAll.ALL:
active_adoptions = [adoption for adoption in active_adoptions if sex in adoption.sexes]
search_form = AdoptionNoticeSearchForm(request.POST)
search_form.is_valid()
if search_form.cleaned_data["location"] == "":
adoption_notices_in_distance = active_adoptions
place_not_found = False
else:
max_distance = int(request.POST.get('max_distance'))
if max_distance == "":
max_distance = None
geo_api = GeoAPI()
search_position = geo_api.get_coordinates_from_query(request.POST['location'])
if search_position is None:
place_not_found = True
adoption_notices_in_distance = active_adoptions
searched = True
if "subscribe_to_search" in request.POST:
# Make sure user is logged in
if not request.user.is_authenticated:
return redirect_to_login(next=request.path)
search.subscribe(request.user)
if "unsubscribe_to_search" in request.POST:
if not request.user.is_authenticated:
return redirect_to_login(next=request.path)
search_subscription = SearchSubscription.objects.get(pk=request.POST["unsubscribe_to_search"])
if search_subscription.owner == request.user:
search_subscription.delete()
else:
adoption_notices_in_distance = [a for a in active_adoptions if a.in_distance(search_position, max_distance)]
context = {"adoption_notices": adoption_notices_in_distance, "search_form": search_form,
"place_not_found": place_not_found}
raise PermissionDenied
if request.user.is_authenticated:
subscribed_search = search.get_subscription_or_none(request.user)
else:
search_form = AdoptionNoticeSearchForm()
context = {"adoption_notices": active_adoptions, "search_form": search_form}
subscribed_search = None
context = {"adoption_notices": search.get_adoption_notices(),
"search_form": search.search_form,
"place_not_found": search.place_not_found,
"subscribed_search": subscribed_search,
"searched": searched,
"adoption_notices_map": AdoptionNotice.get_active_ANs(),
"map_center": search.position,
"search_center": search.position,
"map_pins": [search],
"location": search.location,
"search_radius": search.max_distance,
"zoom_level": zoom_level_for_radius(search.max_distance),
"geocoding_api_url": settings.GEOCODING_API_URL, }
return render(request, 'fellchensammlung/search.html', context=context)
@ -209,18 +220,13 @@ def add_adoption_notice(request):
in_adoption_notice_creation_flow=True)
if form.is_valid():
instance = form.save(commit=False)
instance.owner = request.user
instance.save()
an_instance = form.save(commit=False)
an_instance.owner = request.user
"""Spin up a task that adds the location"""
add_adoption_notice_location.delay_on_commit(instance.pk)
# Set correct status
if request.user.trust_level >= TrustLevel.MODERATOR:
instance.set_active()
an_instance.set_active()
else:
instance.set_unchecked()
an_instance.set_unchecked()
# Get the species and number of animals from the form
species = form.cleaned_data["species"]
@ -229,13 +235,21 @@ def add_adoption_notice(request):
date_of_birth = form.cleaned_data["date_of_birth"]
for i in range(0, num_animals):
Animal.objects.create(owner=request.user,
name=f"{species} {i + 1}", adoption_notice=instance, species=species, sex=sex,
name=f"{species} {i + 1}", adoption_notice=an_instance, species=species, sex=sex,
date_of_birth=date_of_birth)
"""Log"""
Log.objects.create(user=request.user, action="add_adoption_notice",
text=f"{request.user} hat Vermittlung {instance.pk} hinzugefügt")
return redirect(reverse("adoption-notice-detail", args=[instance.pk]))
text=f"{request.user} hat Vermittlung {an_instance.pk} hinzugefügt")
"""Spin up a task that adds the location and notifies search subscribers"""
post_adoption_notice_save.delay(an_instance.id)
"""Subscriptions"""
# Automatically subscribe user that created AN to AN
Subscriptions.objects.create(owner=request.user, adoption_notice=an_instance)
return redirect(reverse("adoption-notice-detail", args=[an_instance.pk]))
else:
form = AdoptionNoticeFormWithDateWidgetAutoAnimal(in_adoption_notice_creation_flow=True)
return render(request, 'fellchensammlung/forms/form_add_adoption.html', {'form': form})
@ -288,7 +302,7 @@ def add_photo_to_animal(request, animal_id):
form = ImageForm(in_flow=True)
return render(request, 'fellchensammlung/forms/form-image.html', {'form': form})
else:
return redirect(reverse("animal-detail", args=[animal_id]))
return redirect(reverse("adoption-notice-detail", args=[animal.adoption_notice.pk], ))
else:
form = ImageForm(in_flow=True)
return render(request, 'fellchensammlung/forms/form-image.html', {'form': form})
@ -340,7 +354,7 @@ def animal_edit(request, animal_id):
"""Log"""
Log.objects.create(user=request.user, action="add_photo_to_animal",
text=f"{request.user} hat Tier {animal.pk} zum Tier geändert")
return redirect(reverse("animal-detail", args=[animal.pk], ))
return redirect(reverse("adoption-notice-detail", args=[animal.adoption_notice.pk], ))
else:
form = AnimalForm(instance=animal)
return render(request, 'fellchensammlung/forms/form-adoption-notice.html', context={"form": form})
@ -353,7 +367,7 @@ def about(request):
lang = Language.objects.get(languagecode=language_code)
legal = {}
for text_code in ["terms_of_service", "privacy_statement", "imprint", "about_us"]:
for text_code in ["terms_of_service", "privacy_statement", "imprint", "about_us", "faq"]:
try:
legal[text_code] = Text.objects.get(text_code=text_code, language=lang, )
except Text.DoesNotExist:
@ -430,7 +444,8 @@ def report_detail_success(request, report_id):
def user_detail(request, user, token=None):
context = {"user": user,
"adoption_notices": AdoptionNotice.objects.filter(owner=user),
"notifications": BaseNotification.objects.filter(user=user, read=False)}
"notifications": BaseNotification.objects.filter(user=user, read=False),
"search_subscriptions": SearchSubscription.objects.filter(owner=user), }
if token is not None:
context["token"] = token
return render(request, 'fellchensammlung/details/detail-user.html', context=context)
@ -454,6 +469,10 @@ def my_profile(request):
Token.objects.create(user=request.user)
elif "delete_token" in request.POST:
Token.objects.get(user=request.user).delete()
elif "toggle_email_notifications" in request.POST:
user = request.user
user.email_notifications = not user.email_notifications
user.save()
action = request.POST.get("action")
if action == "notification_mark_read":
@ -469,6 +488,11 @@ def my_profile(request):
for notification in notifications:
notification.read = True
notification.save()
elif action == "search_subscription_delete":
search_subscription_id = request.POST.get("search_subscription_id")
SearchSubscription.objects.get(pk=search_subscription_id).delete()
logging.info(f"Deleted subscription {search_subscription_id}")
try:
token = Token.objects.get(user=request.user)
except Token.DoesNotExist:
@ -509,7 +533,7 @@ def updatequeue(request):
def map(request):
adoption_notices = AdoptionNotice.objects.all() #TODO: Filter to active
adoption_notices = AdoptionNotice.get_active_ANs()
context = {"adoption_notices_map": adoption_notices}
return render(request, 'fellchensammlung/map.html', context=context)
@ -530,6 +554,8 @@ def instance_health_check(request):
clean_locations(quiet=False)
elif action == "deactivate_unchecked_adoption_notices":
deactivate_unchecked_adoption_notices()
elif action == "deactivate_404":
deactivate_404_adoption_notices()
number_of_adoption_notices = AdoptionNotice.objects.all().count()
none_geocoded_adoption_notices = AdoptionNotice.objects.filter(location__isnull=True)
@ -584,9 +610,16 @@ def external_site_warning(request):
return render(request, 'fellchensammlung/external_site_warning.html', context=context)
def list_rescue_organizations(request):
rescue_organizations = RescueOrganization.objects.all()
context = {"rescue_organizations": rescue_organizations}
return render(request, 'fellchensammlung/animal-shelters.html', context=context)
def detail_view_rescue_organization(request, rescue_organization_id):
org = RescueOrganization.objects.get(pk=rescue_organization_id)
return render(request, 'fellchensammlung/details/detail-rescue-organization.html', context={"org": org})
return render(request, 'fellchensammlung/details/detail-rescue-organization.html',
context={"org": org, "map_center": org.position, "zoom_level": 6, "rescue_organizations": [org]})
def export_own_profile(request):

View File

@ -1,4 +1,4 @@
__version__ = "0.3.1"
__version__ = "0.4.0"
# This will make sure the app is always imported when
# Django starts so that shared_task will use this app.

View File

@ -90,6 +90,9 @@ HEALTHCHECKS_URL = config.get("monitoring", "healthchecks_url", fallback=None)
""" GEOCODING """
GEOCODING_API_URL = config.get("geocoding", "api_url", fallback="https://nominatim.hyteck.de/search")
# GEOCODING_API_FORMAT is allowed to be one of ['nominatim', 'photon']
GEOCODING_API_FORMAT = config.get("geocoding", "api_format", fallback="nominatim")
""" Tile Server """
MAP_TILE_SERVER = config.get("map", "tile_server", fallback="https://tiles.hyteck.de")
@ -169,7 +172,9 @@ INSTALLED_APPS = [
'crispy_forms',
"crispy_bootstrap4",
"rest_framework",
'rest_framework.authtoken'
'rest_framework.authtoken',
'drf_spectacular',
'drf_spectacular_sidecar', # required for Django collectstatic discovery
]
MIDDLEWARE = [
@ -283,5 +288,16 @@ REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework.authentication.TokenAuthentication',
'rest_framework.authentication.SessionAuthentication',
]
],
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
}
SPECTACULAR_SETTINGS = {
'SWAGGER_UI_DIST': 'SIDECAR', # shorthand to use the sidecar instead
'SWAGGER_UI_FAVICON_HREF': 'SIDECAR',
'REDOC_DIST': 'SIDECAR',
'TITLE': 'Notfellchen API',
'DESCRIPTION': 'Adopt a animal in need',
'VERSION': '1.0.0',
'SERVE_INCLUDE_SCHEMA': False,
}

View File

@ -2,5 +2,7 @@
{% load i18n %}
{% block content %}
<p>{% translate "Dein Account ist nun aktiviert. Viel Spaß!" %}</p>
<div class="card">
<p>{% translate "Dein Account ist nun aktiviert. Viel Spaß!" %}</p>
</div>
{% endblock %}

View File

@ -3,13 +3,15 @@
{% load crispy_forms_tags %}
{% block content %}
<div class="card">
{% if not user.is_authenticated %}
<form action="" method="post">
{% csrf_token %}
{{ form.as_p }}
<input type="submit" class="btn2" value={% translate 'Absenden' %}>
<input type="submit" class="btn" value={% translate 'Absenden' %}>
</form>
{% else %}
<p>{% translate "Du bist bereits eingeloggt." %}</p>
{% endif %}
</div>
{% endblock %}

View File

@ -2,5 +2,10 @@
{% load i18n %}
{% block content %}
<p>{% translate "Du bist nun registriert. Du hast eine E-Mail mit einem Link zur Aktivierung deines Kontos bekommen." %}</p>
<p class="card">
{% blocktranslate %}
Du bist nun registriert und hast eine E-Mail mit einem Link zur Aktivierung deines Kontos bekommen. Solltest
du die E-Mail nicht erhalten haben oder andere Fragen haben, schreib uns an info@notfellchen.org
{% endblocktranslate %}
</p>
{% endblock %}

View File

@ -1,4 +1,4 @@
from fellchensammlung.tools.geo import calculate_distance_between_coordinates
from fellchensammlung.tools.geo import calculate_distance_between_coordinates, LocationProxy
from django.test import TestCase
@ -22,3 +22,22 @@ class DistanceTest(TestCase):
except AssertionError as e:
print(f"Distance calculation failed. Expected {distance}, got {result}")
raise e
def test_e2e_distance(self):
l_stuttgart = LocationProxy("Stuttgart")
l_tue = LocationProxy("Tübingen")
# Should be 30km
print(f"{l_stuttgart.position} -> {l_tue.position}")
distance_tue_stuttgart = calculate_distance_between_coordinates(l_stuttgart.position, l_tue.position)
self.assertLess(distance_tue_stuttgart, 50)
self.assertGreater(distance_tue_stuttgart, 20)
l_ueberlingen = LocationProxy("Überlingen")
l_pfullendorf = LocationProxy("Pfullendorf")
# Should be 18km
distance_ueberlingen_pfullendorf = calculate_distance_between_coordinates(l_ueberlingen.position, l_pfullendorf.position)
self.assertLess(distance_ueberlingen_pfullendorf, 21)
self.assertGreater(distance_ueberlingen_pfullendorf, 15)

104
src/tests/test_search.py Normal file
View File

@ -0,0 +1,104 @@
from time import sleep
from django.test import TestCase
from django.urls import reverse
from fellchensammlung.models import SearchSubscription, User, TrustLevel, AdoptionNotice, Location, SexChoicesWithAll, \
Animal, Species, AdoptionNoticeNotification, SexChoices
from model_bakery import baker
from fellchensammlung.tools.geo import LocationProxy
from fellchensammlung.tools.search import Search, notify_search_subscribers
class TestSearch(TestCase):
@classmethod
def setUpTestData(cls):
cls.test_user0 = User.objects.create_user(username='testuser0',
first_name="Admin",
last_name="BOFH",
password='12345')
cls.test_user1 = User.objects.create_user(username='testuser1',
first_name="Max",
last_name="Müller",
password='12345')
cls.test_user2 = User.objects.create_user(username='testuser2',
first_name="Miriam",
last_name="Müller",
password='12345')
cls.test_user0.trust_level = TrustLevel.ADMIN
cls.test_user0.save()
rat = baker.make(Species, name="Farbratte")
cls.location_stuttgart = Location.get_location_from_string("Stuttgart")
cls.location_tue = Location.get_location_from_string("Kirchentellinsfurt")
cls.location_berlin = Location.get_location_from_string("Berlin")
cls.adoption1 = baker.make(AdoptionNotice, name="TestAdoption1", owner=cls.test_user0,
location=cls.location_stuttgart)
rat1 = baker.make(Animal,
name="Rat1",
adoption_notice=cls.adoption1,
species=rat,
sex=SexChoices.MALE,
description="Eine unglaublich süße Ratte")
cls.adoption1.set_active()
cls.adoption2 = baker.make(AdoptionNotice, name="TestAdoption2", owner=cls.test_user0, location=cls.location_berlin)
rat2 = baker.make(Animal,
name="Rat2",
adoption_notice=cls.adoption2,
species=rat,
sex=SexChoices.FEMALE,
description="Eine unglaublich süße Ratte")
cls.adoption2.set_active()
cls.subscription1 = SearchSubscription.objects.create(owner=cls.test_user1,
location=cls.location_tue,
max_distance=50,
sex=SexChoicesWithAll.MALE)
cls.subscription2 = SearchSubscription.objects.create(owner=cls.test_user2,
location=cls.location_berlin,
max_distance=50,
sex=SexChoicesWithAll.ALL)
def test_equals(self):
search_subscription1 = SearchSubscription.objects.create(owner=self.test_user0,
location=self.location_stuttgart,
sex=SexChoicesWithAll.ALL,
max_distance=100
)
search1 = Search()
search1.search_position = LocationProxy("Stuttgart").position
search1.max_distance = 100
search1.area_search = True
search1.sex = SexChoicesWithAll.ALL
search1.location_string = "Stuttgart"
search1._locate()
self.assertEqual(search_subscription1, search1)
def test_adoption_notice_fits_search(self):
search1 = Search(search_subscription=self.subscription1)
self.assertTrue(search1.adoption_notice_fits_search(self.adoption1))
self.assertFalse(search1.adoption_notice_fits_search(self.adoption2))
search2 = Search(search_subscription=self.subscription2)
self.assertFalse(search2.adoption_notice_fits_search(self.adoption1))
self.assertTrue(search2.adoption_notice_fits_search(self.adoption2))
def test_notification(self):
"""
Simulates as if a new adoption notice is added and checks if Notifications are triggered.
Must simulate adding a new adoption notice, the actual celery task can not be tested as celery can not
be tested as it can't access the in-memory test database.
https://stackoverflow.com/questions/46530784/make-django-test-case-database-visible-to-celery/46564964#46564964
"""
notify_search_subscribers(self.adoption1)
self.assertTrue(AdoptionNoticeNotification.objects.filter(user=self.test_user1, adoption_notice=self.adoption1).exists())
self.assertFalse(AdoptionNoticeNotification.objects.filter(user=self.test_user2).exists())

View File

@ -23,7 +23,7 @@ class AnimalAndAdoptionTest(TestCase):
test_user0.trust_level = TrustLevel.ADMIN
test_user0.save()
adoption1 = baker.make(AdoptionNotice, name="TestAdoption1")
adoption1 = baker.make(AdoptionNotice, name="TestAdoption1", owner=test_user0)
rat = baker.make(Species, name="Farbratte")
rat1 = baker.make(Animal,
@ -67,7 +67,6 @@ class AnimalAndAdoptionTest(TestCase):
"save-and-add-another-animal": "Speichern"}
response = self.client.post(reverse('add-adoption'), data=form_data)
print(response.content)
self.assertTrue(response.status_code < 400)
self.assertTrue(AdoptionNotice.objects.get(name="TestAdoption4").is_active)
@ -92,7 +91,8 @@ class SearchTest(TestCase):
berlin = Location.get_location_from_string("Berlin")
adoption1.location = berlin
adoption1.save()
stuttgart = Location.get_location_from_string("Tübingen")
stuttgart = Location.get_location_from_string("Stuttgart")
adoption3.location = stuttgart
adoption3.save()
@ -119,11 +119,14 @@ class SearchTest(TestCase):
self.assertContains(response, "TestAdoption3")
self.assertNotContains(response, "TestAdoption2")
def test_plz_search(self):
response = self.client.post(reverse('search'), {"max_distance": 100, "location": "Berlin", "sex": "A"})
def test_location_search(self):
response = self.client.post(reverse('search'), {"max_distance": 50, "location_string": "Berlin", "sex": "A"})
self.assertEqual(response.status_code, 200)
self.assertContains(response, "TestAdoption1")
self.assertNotContains(response, "TestAdoption3")
# We can't use assertContains because TestAdoption3 will always be in response at is included in map
# In order to test properly, we need to only care for the context that influences the list display
an_names = [a.name for a in response.context["adoption_notices"]]
self.assertTrue("TestAdoption1" in an_names) # Adoption in Berlin
self.assertFalse("TestAdoption3" in an_names) # Adoption in Stuttgart
class UpdateQueueTest(TestCase):
@ -149,7 +152,7 @@ class UpdateQueueTest(TestCase):
def test_login_required(self):
response = self.client.get(reverse('updatequeue'))
self.assertEqual(response.status_code, 302)
self.assertEquals(response.url, "/accounts/login/?next=/updatequeue/")
self.assertEqual(response.url, "/accounts/login/?next=/updatequeue/")
def test_set_updated(self):
self.client.login(username='testuser0', password='12345')