Compare commits
152 Commits
1594b754cb
...
a7e85212c0
Author | SHA1 | Date | |
---|---|---|---|
a7e85212c0 | |||
f1b3b660ff | |||
26cb60c1c8 | |||
69e58f1e0a | |||
5c33ac3833 | |||
fccfd59ea3 | |||
50897b6d35 | |||
8edfe8c401 | |||
0d82dba414 | |||
2dc038dfef | |||
c46a943c7f | |||
9f3592e64b | |||
bc1f4e7ab7 | |||
a2ef91e89a | |||
91d740511d | |||
c6af3e8d04 | |||
0c94049e21 | |||
29f1d2f0f2 | |||
2578e96b32 | |||
907ed583cd | |||
da51007b77 | |||
087f58c9ac | |||
860da7f06a | |||
457bee1ede | |||
3b37b5f588 | |||
6229f0f8a2 | |||
b2a3d910d9 | |||
33848cbe15 | |||
cc97fe32aa | |||
4576ac68e0 | |||
7c076e0bc3 | |||
74f54c7b31 | |||
87777cd5a4 | |||
eee4cdf86b | |||
b2d5265f7e | |||
d4af2d88b4 | |||
8b4f5713e3 | |||
4bff268537 | |||
57da42e4bd | |||
2864d27a7f | |||
0a73b5099e | |||
e3fb981542 | |||
5e80d75c91 | |||
e3833b4505 | |||
ab837ee80e | |||
f6c1224dde | |||
a78d671b6d | |||
fb9c78d96a | |||
4ef9da953c | |||
aefeffd63a | |||
81cc5cd53d | |||
002dded0d5 | |||
ad6e2f4e17 | |||
160e7166f8 | |||
867319fe9a | |||
13b67c1248 | |||
4c4cf4afea | |||
5f742c60db | |||
568874e6dd | |||
561a30b7ab | |||
a8c837e9f6 | |||
a75cacea66 | |||
b1e092769f | |||
5a93a1678c | |||
28772e1f74 | |||
1f3c3ecaef | |||
ab1e6a94d1 | |||
299653b53b | |||
fe9352e628 | |||
9fec95bd2e | |||
8e7cdafee0 | |||
6e2a2a1d5e | |||
5197875431 | |||
d05bd45cf4 | |||
0afb2bb0ce | |||
d17fcc1da2 | |||
c508bc2cd1 | |||
20872e547b | |||
25b748d2be | |||
1536bb302a | |||
d4ef706734 | |||
3bdce18e9e | |||
8b4488484d | |||
3881a4f3b4 | |||
2dbd908f4c | |||
9d0eed5915 | |||
ee12bb5286 | |||
5669c822b9 | |||
c1c4af6571 | |||
164ba7def2 | |||
7035b1642e | |||
b6fc5c634f | |||
0dfbd614ab | |||
2730ff3f51 | |||
fef211b2d0 | |||
f2e2599561 | |||
a9c0f628f7 | |||
e2adb20231 | |||
e8b3bf6516 | |||
3306f3e783 | |||
b993621773 | |||
3816290eb7 | |||
399ecf73ad | |||
8e2c0e857c | |||
3c7dcb4c51 | |||
9e1ec1711b | |||
bae4ee3d22 | |||
280eb83056 | |||
fca5879aeb | |||
373a44c9da | |||
674645c65c | |||
c2b3ff2395 | |||
d6740eb302 | |||
35a54474b4 | |||
6723dad4bd | |||
b51d04ffd1 | |||
a965f26d48 | |||
364a6f32f4 | |||
533142461a | |||
481635ac4e | |||
be6c30cb33 | |||
a617137fb0 | |||
8299162a77 | |||
085162d802 | |||
27b7e47f18 | |||
be97ac32fb | |||
9ea00655d4 | |||
9fffbffdb7 | |||
44cf2936d1 | |||
579f59580c | |||
241841bc9b | |||
78a6440f63 | |||
9d521b0129 | |||
39079c3c8e | |||
999c1a81b8 | |||
5a4720c41c | |||
858c6d4468 | |||
4b45b01e2a | |||
d0060ecf5e | |||
d1eeaafc42 | |||
9b824bc326 | |||
44f05cbb7d | |||
0e4e531414 | |||
6a7b3f19e9 | |||
ec9f5b305c | |||
e858f61b3f | |||
a04270718f | |||
a4f895de81 | |||
b2d0e783be | |||
4f5022e140 | |||
5771968981 | |||
b63b87872b |
15
CHANGELOG.md
Normal file
15
CHANGELOG.md
Normal 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/)
|
27
README.md
27
README.md
@ -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.
|
||||
|
@ -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
|
||||
|
||||
|
@ -39,7 +39,8 @@ dependencies = [
|
||||
"django-crispy-forms",
|
||||
"crispy-bootstrap4",
|
||||
"djangorestframework",
|
||||
"celery[redis]"
|
||||
"celery[redis]",
|
||||
"drf-spectacular[sidecar]"
|
||||
]
|
||||
|
||||
dynamic = ["version", "readme"]
|
||||
|
@ -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)
|
||||
|
@ -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:
|
||||
|
@ -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.
|
||||
|
@ -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)
|
||||
|
@ -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',),
|
||||
),
|
||||
]
|
25
src/fellchensammlung/migrations/0028_searchsubscription.py
Normal file
25
src/fellchensammlung/migrations/0028_searchsubscription.py
Normal 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)),
|
||||
],
|
||||
),
|
||||
]
|
@ -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),
|
||||
),
|
||||
]
|
@ -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',
|
||||
),
|
||||
]
|
@ -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),
|
||||
),
|
||||
]
|
@ -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),
|
||||
),
|
||||
]
|
@ -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'),
|
||||
),
|
||||
]
|
23
src/fellchensammlung/migrations/0034_speciesspecificurl.py
Normal file
23
src/fellchensammlung/migrations/0034_speciesspecificurl.py
Normal 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')),
|
||||
],
|
||||
),
|
||||
]
|
@ -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'),
|
||||
),
|
||||
]
|
@ -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"))
|
||||
|
@ -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;
|
||||
}
|
||||
|
BIN
src/fellchensammlung/static/fellchensammlung/img/pin.png
Normal file
BIN
src/fellchensammlung/static/fellchensammlung/img/pin.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.2 KiB |
21
src/fellchensammlung/static/fellchensammlung/js/custom.js
Normal file
21
src/fellchensammlung/static/fellchensammlung/js/custom.js
Normal 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, "", "")
|
||||
}
|
||||
|
37
src/fellchensammlung/static/fellchensammlung/js/turf.min.js
vendored
Normal file
37
src/fellchensammlung/static/fellchensammlung/js/turf.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -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():
|
||||
|
@ -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 %}
|
@ -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 %}
|
@ -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' %}">
|
||||
|
@ -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 %}
|
||||
|
@ -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 %}
|
@ -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>
|
||||
|
@ -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 %}
|
||||
|
@ -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 %}
|
@ -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
|
||||
|
@ -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>
|
||||
|
@ -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>
|
@ -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>
|
||||
|
@ -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>
|
@ -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>
|
||||
|
@ -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 }}">
|
||||
|
@ -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>
|
||||
|
@ -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"
|
||||
|
@ -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" %}
|
||||
|
@ -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>
|
||||
|
@ -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 }}">
|
||||
|
@ -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>
|
@ -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>
|
@ -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 %}
|
||||
|
@ -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)
|
||||
|
@ -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")))
|
||||
|
@ -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)
|
||||
|
11
src/fellchensammlung/tools/notifications.py
Normal file
11
src/fellchensammlung/tools/notifications.py
Normal 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.",
|
||||
)
|
151
src/fellchensammlung/tools/search.py
Normal file
151
src/fellchensammlung/tools/search.py
Normal 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
|
@ -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 ##
|
||||
|
@ -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):
|
||||
|
@ -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.
|
||||
|
@ -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,
|
||||
}
|
||||
|
@ -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 %}
|
@ -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 %}
|
@ -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 %}
|
@ -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
104
src/tests/test_search.py
Normal 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())
|
@ -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')
|
||||
|
Loading…
x
Reference in New Issue
Block a user