Compare commits
199 Commits
3eb7dbe984
...
ci-test-co
Author | SHA1 | Date | |
---|---|---|---|
c9f46d7547 | |||
9f23f5768c | |||
19210f90cd | |||
462bb8f485 | |||
ea4d15b99a | |||
de30dfcb8b | |||
36a979954c | |||
71ef17dc97 | |||
206cd282e6 | |||
e399346c3e | |||
929c6dfff0 | |||
841b57fea2 | |||
9e5446ff1d | |||
3b79809b8c | |||
53e6db3655 | |||
424f91e919 | |||
84ce5f54b2 | |||
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 | |||
1594b754cb | |||
8ec27191b6 | |||
c1332ee1f0 | |||
f6240a7189 | |||
7a02774a29 | |||
8945fdc0f4 | |||
9f0a18ad91 | |||
e7f26dd23a | |||
fc5b1391df | |||
70bf8e2053 | |||
caf98ba60b | |||
d7e466050a | |||
34b707ef20 | |||
064a9bf83a | |||
93070a3bcd | |||
23c35fe7dd | |||
d2542060a1 | |||
89f74cb709 | |||
ec38012ecb | |||
72d45a4f47 | |||
8de5f162eb | |||
dc3859d589 | |||
b4f52c7876 | |||
885622e581 | |||
a42a3fa177 | |||
27541c6fb6 | |||
14547ad621 | |||
8d2d80c30e | |||
e6f5a42d15 | |||
052e42f76a |
4
.coveragerc
Normal file
4
.coveragerc
Normal file
@@ -0,0 +1,4 @@
|
||||
[run]
|
||||
omit =
|
||||
*/migrations/*
|
||||
*/tests/*
|
@@ -6,6 +6,9 @@ steps:
|
||||
commands:
|
||||
- cd docs && make html
|
||||
|
||||
when:
|
||||
event: [ tag, push ]
|
||||
|
||||
deploy:
|
||||
image: appleboy/drone-scp
|
||||
settings:
|
||||
@@ -19,6 +22,8 @@ steps:
|
||||
source: docs/_build/html/
|
||||
key:
|
||||
from_secret: ssh_key
|
||||
when:
|
||||
event: [ tag, push ]
|
||||
|
||||
|
||||
|
14
.woodpecker/test.yml
Normal file
14
.woodpecker/test.yml
Normal file
@@ -0,0 +1,14 @@
|
||||
---
|
||||
|
||||
steps:
|
||||
test:
|
||||
image: python
|
||||
commands:
|
||||
- python -m pip install '.[develop]'
|
||||
- coverage run --source='.' src/manage.py test src && coverage html
|
||||
- coverage html
|
||||
- cat htmlcov/index.html
|
||||
when:
|
||||
event: [tag, push]
|
||||
|
||||
|
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/)
|
47
README.md
47
README.md
@@ -77,20 +77,36 @@ docker push moanos/notfellchen:latest
|
||||
docker run -p8000:7345 moanos/notfellchen:latest
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Tests can be run with
|
||||
|
||||
```zsh
|
||||
nf test src
|
||||
```
|
||||
|
||||
If you want to report on code coverage run
|
||||
|
||||
```zsh
|
||||
coverage run --source='.' src/manage.py test src
|
||||
```
|
||||
|
||||
and
|
||||
|
||||
```
|
||||
coverage report
|
||||
```
|
||||
|
||||
## 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 +141,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.
|
||||
|
@@ -2,10 +2,10 @@
|
||||
API Documentation
|
||||
*****************
|
||||
|
||||
The Notfellchen API serves the purpose of supporting 3rd-person applications and anything you can think of basically.
|
||||
The Notfellchen API serves the purpose of supporting 3rd-person applications, whether you want to display data in a custom format or add data from other sources.
|
||||
|
||||
.. warning::
|
||||
The current API is limited in it's functionality. I you miss a specific feature please contact the developer!
|
||||
The current API is limited in it's functionality. I you miss a specific feature please contact the developers!
|
||||
|
||||
API Access
|
||||
==========
|
||||
@@ -14,17 +14,88 @@ Via browser
|
||||
-----------
|
||||
|
||||
When a user is logged in, they can easily access the API in their browser, authenticated by their session.
|
||||
The API endpoint can be found at /library/api/
|
||||
http://notfellchen.org/
|
||||
The API endpoint can be found at http://notfellchen.org/api/adoption_notices
|
||||
|
||||
Via token
|
||||
---------
|
||||
|
||||
.. warning::
|
||||
This is currently not supported.
|
||||
|
||||
All users are able to generate a token that allows them to use the API. This can be done in the user's profile.
|
||||
An application can then send this token in the request header for authorization.
|
||||
|
||||
.. code-block::
|
||||
$ curl -X GET http://notfellchen.org/api/adoption_notice -H 'Authorization: Token 49b39856955dc6e5cc04365498d4ad30ea3aed78'
|
||||
|
||||
|
||||
.. warning::
|
||||
Usage or creation of content still has to follow the terms of Notfellchen.org
|
||||
Copyright of content is often held by rescue organizations, so you are not allowed to simply mirror content.
|
||||
Talk to the Notfellchen-Team if you want develop such things.
|
||||
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
All Endpoints are documented at https://notfellchen.org/api/schema/swagger-ui/ or at https://notfellchen.org/api/schema/redoc/ if you prefer redoc.
|
||||
The OpenAI schema can be downloaded at https://notfellchen.org/api/schema/
|
||||
|
||||
Examples are documented here.
|
||||
|
||||
Get Adoption Notices
|
||||
++++++++++++++++++++
|
||||
|
||||
.. code-block::
|
||||
curl --request GET \
|
||||
--url https://notfellchen.org/api/adoption_notice \
|
||||
--header 'Authorization: {{token}}'
|
||||
|
||||
Create Adoption Notice
|
||||
++++++++++++++++++++++
|
||||
|
||||
.. code-block::
|
||||
curl --request POST \
|
||||
--url https://notfellchen.org/api/adoption_notice \
|
||||
--header 'Authorization: {{token}}' \
|
||||
--header 'content-type: multipart/form-data' \
|
||||
--form name=TestAdoption1 \
|
||||
--form searching_since=2024-11-19 \
|
||||
--form 'description=Lorem ipsum **dolor sit** amet' \
|
||||
--form further_information=https://notfellchen.org \
|
||||
--form location_string=Berlin \
|
||||
--form group_only=true
|
||||
|
||||
Add Animal to Adoption Notice
|
||||
+++++++++++++++++++++++++++++
|
||||
|
||||
.. code-block::
|
||||
curl --request POST \
|
||||
--url https://notfellchen.org/api/animals/ \
|
||||
--header 'Authorization: {{token}}' \
|
||||
--header 'content-type: multipart/form-data' \
|
||||
--form name=TestAnimal1 \
|
||||
--form date_of_birth=2024-11-19 \
|
||||
--form 'description=Lorem animal **dolor sit**.' \
|
||||
--form sex=F \
|
||||
--form species=1 \
|
||||
--form adoption_notice=1
|
||||
|
||||
Add picture to Animal or Adoption Notice
|
||||
++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
.. code-block::
|
||||
curl -X POST https://notfellchen.org/api/images/ \
|
||||
-H "Authorization: Token {{token}}" \
|
||||
-F "image=@256-256-crop.jpg" \
|
||||
-F "alt_text=Puppy enjoying the sunshine" \
|
||||
-F "attach_to_type=animal" \
|
||||
-F "attach_to=48
|
||||
|
||||
Species
|
||||
+++++++
|
||||
|
||||
Getting available species is mainly important when creating animals
|
||||
|
||||
.. code-block::
|
||||
curl --request GET \
|
||||
--url https://notfellchen.org/api/species \
|
||||
--header 'Authorization: {{token}}'
|
||||
|
@@ -5,7 +5,7 @@ Report a bug
|
||||
^^^^^^^^^^^^
|
||||
|
||||
To report a bug, file an issue on `Github
|
||||
<https://codeberg.org/moanos/notfellchen/issues>`_
|
||||
<https://github.com/moan0s/notfellchen/issues>`_
|
||||
|
||||
Try to include the following information:
|
||||
|
||||
@@ -29,7 +29,7 @@ To contribute simply clone the directory, make your changes and file a
|
||||
pull request.
|
||||
|
||||
If you want to know what can be done, have a look at the current `Github
|
||||
<https://codeberg.org/moanos/notfellchen/issues>`_.
|
||||
<https://github.com/moan0s/notfellchen/issues>`_.
|
||||
|
||||
Get in touch!
|
||||
^^^^^^^^^^^^^
|
||||
|
@@ -5,8 +5,7 @@ What qualifies as release?
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
A new release should be announced when a significant number functions, bugfixes or other improvements to the software
|
||||
is made. Usually this indicates a minor release.
|
||||
Major releases are yet to be determined.
|
||||
is made. Notfellchen follows `Semantic Versioning <https://semver.org/>`_.
|
||||
|
||||
What should be done before a release?
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
@@ -14,7 +13,7 @@ What should be done before a release?
|
||||
Tested basic functions
|
||||
######################
|
||||
|
||||
Run :command:`pytest`
|
||||
Run :command:`nf test src`
|
||||
|
||||
Test upgrade on a copy of a production database
|
||||
###############################################
|
||||
@@ -38,4 +37,4 @@ Do a final commit on this change, and tag the commit as release with appropriate
|
||||
git tag -a v1.0.0 -m "Releasing version v1.0.0"
|
||||
git push origin v1.0.0
|
||||
|
||||
Make sure the tag is visible on Codeberg and celebrate 🥳
|
||||
Make sure the tag is visible on GitHub/Codeberg and celebrate 🥳
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -8,13 +8,13 @@ build-backend = "setuptools.build_meta"
|
||||
name = "notfellchen"
|
||||
description = "A tool to help."
|
||||
authors = [
|
||||
{name = "moanos", email = "julian-samuel@gebuehr.net"},
|
||||
{ name = "moanos", email = "julian-samuel@gebuehr.net" },
|
||||
]
|
||||
maintainers = [
|
||||
{name = "moanos", email = "julian-samuel@gebuehr.net"},
|
||||
{ name = "moanos", email = "julian-samuel@gebuehr.net" },
|
||||
]
|
||||
keywords = ["animal", "adoption", "django", "rescue", ]
|
||||
license = {text = "AGPL-3.0-or-later"}
|
||||
license = { text = "AGPL-3.0-or-later" }
|
||||
classifiers = [
|
||||
"Environment :: Web",
|
||||
"License :: OSI Approved :: GNU Affero General Public License v3",
|
||||
@@ -24,14 +24,12 @@ classifiers = [
|
||||
]
|
||||
dependencies = [
|
||||
"Django",
|
||||
"coverage",
|
||||
"codecov",
|
||||
"sphinx",
|
||||
"sphinx-rtd-theme",
|
||||
"gunicorn",
|
||||
"fontawesomefree",
|
||||
"whitenoise",
|
||||
"model_bakery",
|
||||
"markdown",
|
||||
"Pillow",
|
||||
"django-registration",
|
||||
@@ -39,14 +37,17 @@ dependencies = [
|
||||
"django-crispy-forms",
|
||||
"crispy-bootstrap4",
|
||||
"djangorestframework",
|
||||
"celery[redis]"
|
||||
"celery[redis]",
|
||||
"drf-spectacular[sidecar]"
|
||||
]
|
||||
|
||||
dynamic = ["version", "readme"]
|
||||
|
||||
[project.optional-dependencies]
|
||||
develop = [
|
||||
"pytest",
|
||||
"pytest",
|
||||
"coverage",
|
||||
"model_bakery",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -61,6 +62,6 @@ nf = 'notfellchen.main:main'
|
||||
|
||||
|
||||
[tool.setuptools.dynamic]
|
||||
version = {attr = "notfellchen.__version__"}
|
||||
readme = {file = "README.md"}
|
||||
version = { attr = "notfellchen.__version__" }
|
||||
readme = { file = "README.md" }
|
||||
|
||||
|
@@ -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 _
|
||||
|
||||
|
||||
@@ -24,6 +25,13 @@ class AdoptionNoticeAdmin(admin.ModelAdmin):
|
||||
inlines = [
|
||||
StatusInline,
|
||||
]
|
||||
actions = ("activate",)
|
||||
|
||||
def activate(self, request, queryset):
|
||||
for obj in queryset:
|
||||
obj.set_active()
|
||||
|
||||
activate.short_description = _("Ausgewählte Vermittlungen aktivieren")
|
||||
|
||||
|
||||
# Re-register UserAdmin
|
||||
@@ -59,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>')
|
||||
@@ -85,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)
|
||||
|
@@ -1,10 +1,53 @@
|
||||
from ..models import AdoptionNotice
|
||||
from ..models import Animal, RescueOrganization, AdoptionNotice, Species, Image
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
|
||||
|
||||
class AdoptionNoticeSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = AdoptionNotice
|
||||
fields = ['created_at', 'last_checked', "searching_since", "name", "description", "further_information", "group_only"]
|
||||
fields = ['created_at', 'last_checked', "searching_since", "name", "description", "further_information",
|
||||
"group_only"]
|
||||
|
||||
|
||||
class AnimalCreateSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
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:
|
||||
model = Animal
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class RescueOrganizationSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = RescueOrganization
|
||||
exclude = ["internal_comment", "allows_using_materials"]
|
||||
|
||||
|
||||
class ImageCreateSerializer(serializers.ModelSerializer):
|
||||
@staticmethod
|
||||
def _animal_or_an(value):
|
||||
if not value in ["animal", "adoption_notice"]:
|
||||
raise serializers.ValidationError(
|
||||
'Set either animal or adoption_notice, depending on what type of object the image should be attached to.')
|
||||
|
||||
attach_to_type = serializers.CharField(validators=[_animal_or_an])
|
||||
attach_to = serializers.IntegerField()
|
||||
|
||||
class Meta:
|
||||
model = Image
|
||||
exclude = ["owner"]
|
||||
|
||||
|
||||
class SpeciesSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Species
|
||||
fields = "__all__"
|
||||
|
@@ -1,8 +1,16 @@
|
||||
from django.urls import path
|
||||
from .views import (
|
||||
AdoptionNoticeApiView
|
||||
AdoptionNoticeApiView,
|
||||
AnimalApiView, RescueOrganizationApiView, AddImageApiView, SpeciesApiView
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path('adoption_notice', AdoptionNoticeApiView.as_view()),
|
||||
path("adoption_notice", AdoptionNoticeApiView.as_view(), name="api-adoption-notice-list"),
|
||||
path("adoption_notice/<int:id>/", AdoptionNoticeApiView.as_view(), name="api-adoption-notice-detail"),
|
||||
path("animals/", AnimalApiView.as_view(), name="api-animal-list"),
|
||||
path("animals/<int:id>/", AnimalApiView.as_view(), name="api-animal-detail"),
|
||||
path("organizations/", RescueOrganizationApiView.as_view(), name="api-organization-list"),
|
||||
path("organizations/<int:id>/", RescueOrganizationApiView.as_view(), name="api-organization-detail"),
|
||||
path("images/", AddImageApiView.as_view(), name="api-add-image"),
|
||||
path("species/", SpeciesApiView.as_view(), name="api-species-list"),
|
||||
]
|
||||
|
@@ -1,37 +1,212 @@
|
||||
from django.contrib.auth.models import User
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from django.db import transaction
|
||||
from fellchensammlung.models import AdoptionNotice, Animal, Log, TrustLevel
|
||||
from fellchensammlung.tasks import post_adoption_notice_save
|
||||
from rest_framework import status
|
||||
from rest_framework import permissions
|
||||
from ..models import AdoptionNotice
|
||||
from .serializers import AdoptionNoticeSerializer
|
||||
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from .serializers import (
|
||||
AnimalGetSerializer,
|
||||
AnimalCreateSerializer,
|
||||
RescueOrganizationSerializer,
|
||||
AdoptionNoticeSerializer,
|
||||
ImageCreateSerializer,
|
||||
SpeciesSerializer, RescueOrgSerializer,
|
||||
)
|
||||
from fellchensammlung.models import Animal, RescueOrganization, AdoptionNotice, Species, Image
|
||||
from drf_spectacular.utils import extend_schema
|
||||
|
||||
class AdoptionNoticeApiView(APIView):
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
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):
|
||||
serializer_context = {
|
||||
'request': request,
|
||||
}
|
||||
"""
|
||||
Retrieve adoption notices with their related animals and images.
|
||||
"""
|
||||
adoption_notice_id = kwargs.get("id")
|
||||
if adoption_notice_id:
|
||||
try:
|
||||
adoption_notice = AdoptionNotice.objects.get(pk=adoption_notice_id)
|
||||
serializer = AdoptionNoticeSerializer(adoption_notice, context={"request": request})
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
except AdoptionNotice.DoesNotExist:
|
||||
return Response({"error": "Adoption notice not found."}, status=status.HTTP_404_NOT_FOUND)
|
||||
adoption_notices = AdoptionNotice.objects.all()
|
||||
serializer = AdoptionNoticeSerializer(adoption_notices, many=True, context=serializer_context)
|
||||
serializer = AdoptionNoticeSerializer(adoption_notices, many=True, context={"request": request})
|
||||
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):
|
||||
data = {
|
||||
'name': request.data.get('name'),
|
||||
"searching_since": request.data.get('searching_since'),
|
||||
"description": request.data.get('description'),
|
||||
"organization": request.data.get('organization'),
|
||||
"further_information": request.data.get('further_information'),
|
||||
"location_string": request.data.get('location_string'),
|
||||
"group_only": request.data.get('group_only'),
|
||||
"owner": request.data.get('owner')
|
||||
}
|
||||
serializer = AdoptionNoticeSerializer(data=data)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
"""
|
||||
API view to add an adoption notice.
|
||||
"""
|
||||
serializer = AdoptionNoticeSerializer(data=request.data, context={'request': request})
|
||||
if not serializer.is_valid():
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
adoption_notice = serializer.save(owner=request.user)
|
||||
|
||||
# Add the location
|
||||
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:
|
||||
adoption_notice.set_active()
|
||||
else:
|
||||
adoption_notice.set_unchecked()
|
||||
|
||||
# Log the action
|
||||
Log.objects.create(
|
||||
user=request.user,
|
||||
action="add_adoption_notice",
|
||||
text=f"{request.user} added adoption notice {adoption_notice.pk} via API",
|
||||
)
|
||||
|
||||
# Return success response with new adoption notice details
|
||||
return Response(
|
||||
{"message": "Adoption notice created successfully!", "id": adoption_notice.pk},
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
|
||||
|
||||
class AnimalApiView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
"""
|
||||
Get list of animals or a specific animal by ID.
|
||||
"""
|
||||
animal_id = kwargs.get("id")
|
||||
if animal_id:
|
||||
try:
|
||||
animal = Animal.objects.get(pk=animal_id)
|
||||
serializer = AnimalGetSerializer(animal, context={"request": request})
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
except Animal.DoesNotExist:
|
||||
return Response({"error": "Animal not found."}, status=status.HTTP_404_NOT_FOUND)
|
||||
animals = Animal.objects.all()
|
||||
serializer = AnimalGetSerializer(animals, many=True, context={"request": request})
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@transaction.atomic
|
||||
def post(self, request, *args, **kwargs):
|
||||
"""
|
||||
Create a new animal.
|
||||
"""
|
||||
serializer = AnimalCreateSerializer(data=request.data, context={"request": request})
|
||||
if serializer.is_valid():
|
||||
animal = serializer.save(owner=request.user)
|
||||
return Response(
|
||||
{"message": "Animal created successfully!", "id": animal.id},
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
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.
|
||||
"""
|
||||
org_id = kwargs.get("id")
|
||||
if org_id:
|
||||
try:
|
||||
organization = RescueOrganization.objects.get(pk=org_id)
|
||||
serializer = RescueOrganizationSerializer(organization, context={"request": request})
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
except RescueOrganization.DoesNotExist:
|
||||
return Response({"error": "Organization not found."}, status=status.HTTP_404_NOT_FOUND)
|
||||
organizations = RescueOrganization.objects.all()
|
||||
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.
|
||||
"""
|
||||
serializer = ImageCreateSerializer(data=request.data, context={"request": request})
|
||||
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.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")
|
||||
serializer.validated_data.pop('attach_to_type', None)
|
||||
serializer.validated_data.pop('attach_to', None)
|
||||
image = serializer.save(owner=request.user)
|
||||
object_to_attach_to.photos.add(image)
|
||||
return Response(
|
||||
{"message": "Image added successfully!", "id": image.id},
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class SpeciesApiView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@extend_schema(
|
||||
responses={200: SpeciesSerializer(many=True)}
|
||||
)
|
||||
def get(self, request, *args, **kwargs):
|
||||
"""
|
||||
Retrieve a list of species.
|
||||
"""
|
||||
species = Species.objects.all()
|
||||
serializer = SpeciesSerializer(species, many=True, context={"request": request})
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
@@ -1,12 +1,13 @@
|
||||
from django import forms
|
||||
|
||||
from .models import AdoptionNotice, Animal, Image, ReportAdoptionNotice, ReportComment, ModerationAction, User, Species, \
|
||||
Comment
|
||||
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):
|
||||
@@ -66,7 +67,8 @@ class AdoptionNoticeForm(forms.ModelForm):
|
||||
class AdoptionNoticeFormWithDateWidget(AdoptionNoticeForm):
|
||||
class Meta:
|
||||
model = AdoptionNotice
|
||||
fields = ['name', "group_only", "further_information", "description", "searching_since", "location_string", "organization"]
|
||||
fields = ['name', "group_only", "further_information", "description", "searching_since", "location_string",
|
||||
"organization"]
|
||||
widgets = {
|
||||
'searching_since': DateInput(),
|
||||
}
|
||||
@@ -123,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
|
||||
@@ -180,10 +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"))
|
||||
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)
|
||||
|
18
src/fellchensammlung/migrations/0024_alter_animal_sex.py
Normal file
18
src/fellchensammlung/migrations/0024_alter_animal_sex.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.1 on 2024-11-21 19:34
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fellchensammlung', '0023_user_email_notifications'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='animal',
|
||||
name='sex',
|
||||
field=models.CharField(choices=[('M_N', 'neutered male'), ('M', 'male'), ('F_N', 'neutered female'), ('F', 'female'), ('I', 'intersex')], max_length=20),
|
||||
),
|
||||
]
|
18
src/fellchensammlung/migrations/0025_alter_animal_sex.py
Normal file
18
src/fellchensammlung/migrations/0025_alter_animal_sex.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.1 on 2024-11-21 19:41
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fellchensammlung', '0024_alter_animal_sex'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='animal',
|
||||
name='sex',
|
||||
field=models.CharField(choices=[('F', 'Weiblich'), ('M', 'Männlich'), ('M_N', 'Männlich, kastriert'), ('F_N', 'Weiblich Kastriert'), ('I', 'Intersex')], max_length=20),
|
||||
),
|
||||
]
|
18
src/fellchensammlung/migrations/0026_alter_animal_sex.py
Normal file
18
src/fellchensammlung/migrations/0026_alter_animal_sex.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.1 on 2024-11-21 21:49
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fellchensammlung', '0025_alter_animal_sex'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='animal',
|
||||
name='sex',
|
||||
field=models.CharField(choices=[('F', 'Weiblich'), ('M', 'Männlich'), ('M_N', 'Männlich, kastriert'), ('F_N', 'Weiblich, kastriert'), ('I', 'Intergeschlechtlich')], max_length=20),
|
||||
),
|
||||
]
|
@@ -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'),
|
||||
),
|
||||
]
|
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.4 on 2025-01-14 06:28
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fellchensammlung', '0035_alter_image_alt_text_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='basenotification',
|
||||
name='read_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Gelesen am'),
|
||||
),
|
||||
]
|
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.4 on 2025-01-14 06:28
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fellchensammlung', '0036_basenotification_read_at'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='basenotification',
|
||||
name='title',
|
||||
field=models.CharField(max_length=100, verbose_name='Titel'),
|
||||
),
|
||||
]
|
@@ -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)
|
||||
@@ -240,15 +280,23 @@ class AdoptionNotice(models.Model):
|
||||
def sexes(self):
|
||||
sexes = set()
|
||||
for animal in self.animals:
|
||||
sexes.update(animal.sex)
|
||||
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:
|
||||
return "mixed"
|
||||
elif self.sexes.pop() == Animal.MALE:
|
||||
|
||||
sex = self.sexes.pop()
|
||||
if sex == SexChoices.MALE:
|
||||
return "male"
|
||||
elif self.sexes.pop() == Animal.FEMALE:
|
||||
elif sex == SexChoices.FEMALE:
|
||||
return "female"
|
||||
else:
|
||||
return "mixed"
|
||||
@@ -283,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
|
||||
@@ -347,20 +400,28 @@ class AdoptionNotice(models.Model):
|
||||
|
||||
def set_closed(self):
|
||||
self.last_checked = timezone.now()
|
||||
self.save()
|
||||
self.adoptionnoticestatus.set_closed()
|
||||
|
||||
def set_active(self):
|
||||
self.last_checked = timezone.now()
|
||||
self.save()
|
||||
if not hasattr(self, 'adoptionnoticestatus'):
|
||||
AdoptionNoticeStatus.create_other(self)
|
||||
self.adoptionnoticestatus.set_active()
|
||||
|
||||
def set_unchecked(self):
|
||||
self.last_checked = timezone.now()
|
||||
self.save()
|
||||
if not hasattr(self, 'adoptionnoticestatus'):
|
||||
AdoptionNoticeStatus.create_other(self)
|
||||
self.adoptionnoticestatus.set_unchecked()
|
||||
|
||||
for subscription in self.get_subscriptions():
|
||||
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)
|
||||
|
||||
|
||||
class AdoptionNoticeStatus(models.Model):
|
||||
"""
|
||||
@@ -457,24 +518,33 @@ class AdoptionNoticeStatus(models.Model):
|
||||
self.save()
|
||||
|
||||
|
||||
class Animal(models.Model):
|
||||
MALE_NEUTERED = "M_N"
|
||||
MALE = "M"
|
||||
FEMALE_NEUTERED = "F_N"
|
||||
FEMALE = "F"
|
||||
SEX_CHOICES = {
|
||||
MALE_NEUTERED: "neutered male",
|
||||
MALE: "male",
|
||||
FEMALE_NEUTERED: "neutered female",
|
||||
FEMALE: "female",
|
||||
}
|
||||
class SexChoices(models.TextChoices):
|
||||
FEMALE = "F", _("Weiblich")
|
||||
MALE = "M", _("Männlich")
|
||||
MALE_NEUTERED = "M_N", _("Männlich, kastriert")
|
||||
FEMALE_NEUTERED = "F_N", _("Weiblich, kastriert")
|
||||
INTER = "I", _("Intergeschlechtlich")
|
||||
|
||||
|
||||
class SexChoicesWithAll(models.TextChoices):
|
||||
FEMALE = "F", _("Weiblich")
|
||||
MALE = "M", _("Männlich")
|
||||
MALE_NEUTERED = "M_N", _("Männlich, kastriert")
|
||||
FEMALE_NEUTERED = "F_N", _("Weiblich Kastriert")
|
||||
INTER = "I", _("Intergeschlechtlich")
|
||||
ALL = "A", _("Alle")
|
||||
|
||||
|
||||
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, choices=SEX_CHOICES, )
|
||||
sex = models.CharField(
|
||||
max_length=20,
|
||||
choices=SexChoices.choices,
|
||||
)
|
||||
adoption_notice = models.ForeignKey(AdoptionNotice, on_delete=models.CASCADE)
|
||||
owner = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
@@ -511,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
|
||||
@@ -544,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)
|
||||
|
||||
@@ -604,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):
|
||||
@@ -711,7 +818,8 @@ class Comment(models.Model):
|
||||
class BaseNotification(models.Model):
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
title = models.CharField(max_length=100)
|
||||
read_at = models.DateTimeField(blank=True, null=True, verbose_name=_("Gelesen am"))
|
||||
title = models.CharField(max_length=100, verbose_name=_("Titel"))
|
||||
text = models.TextField(verbose_name="Inhalt")
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name=_('Nutzer*in'))
|
||||
read = models.BooleanField(default=False)
|
||||
@@ -722,6 +830,11 @@ class BaseNotification(models.Model):
|
||||
def get_absolute_url(self):
|
||||
self.user.get_notifications_url()
|
||||
|
||||
def mark_read(self):
|
||||
self.read = True
|
||||
self.read_at = timezone.now()
|
||||
self.save()
|
||||
|
||||
|
||||
class CommentNotification(BaseNotification):
|
||||
comment = models.ForeignKey(Comment, on_delete=models.CASCADE, verbose_name=_('Antwort'))
|
||||
@@ -731,6 +844,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'))
|
||||
@@ -765,3 +886,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"))
|
||||
|
@@ -22,6 +22,7 @@
|
||||
--text-three: var(--primary-light-one);
|
||||
--shadow-three: var(--primary-dark-one);
|
||||
}
|
||||
|
||||
/**************************/
|
||||
/* TAG SETTINGS (GENERAL) */
|
||||
/**************************/
|
||||
@@ -36,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 {
|
||||
@@ -64,7 +101,7 @@ td {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
th {
|
||||
thead td {
|
||||
border: 3px solid black;
|
||||
border-collapse: collapse;
|
||||
padding: 8px;
|
||||
@@ -98,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);
|
||||
}
|
||||
@@ -116,8 +158,8 @@ textarea {
|
||||
}
|
||||
}
|
||||
|
||||
.spaced {
|
||||
margin-bottom: 30px;
|
||||
.spaced > * {
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
/*******************************/
|
||||
@@ -140,9 +182,8 @@ textarea {
|
||||
|
||||
.profile-card {
|
||||
display: flex;
|
||||
border-radius: 0px 0px 8px 8px;
|
||||
background-color: var(--highlight-two);
|
||||
color: var(--highlight-one-text);
|
||||
align-items: center;
|
||||
|
||||
.btn2 {
|
||||
height: 40px;
|
||||
@@ -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,7 +255,162 @@ select, .button {
|
||||
margin: 5px;
|
||||
}
|
||||
|
||||
.btn3 {
|
||||
border: 1px solid black;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
font-size: medium;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
|
||||
/*********************/
|
||||
@@ -226,63 +426,143 @@ select, .button {
|
||||
background-color: var(--background-two);
|
||||
border-bottom-left-radius: 15px;
|
||||
border-bottom-right-radius: 15px;
|
||||
}
|
||||
|
||||
|
||||
.nav-link {
|
||||
color: var(--text-one);
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.header a, .header form {
|
||||
float: left;
|
||||
padding: 5px 12px 5px 12px;
|
||||
line-height: 25px;
|
||||
}
|
||||
|
||||
.header a:hover {
|
||||
background-color: var(--highlight-one);
|
||||
color: var(--highlight-one-text);
|
||||
}
|
||||
|
||||
.header a.active {
|
||||
background-color: dodgerblue;
|
||||
color: white;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.header-right select.option {
|
||||
color: #000;
|
||||
background-color: var(--highlight-one);
|
||||
border: 1px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
float: right;
|
||||
display: flex;
|
||||
border-radius: 0px 0px 15px 15px;
|
||||
background-color: var(--highlight-two);
|
||||
color: var(--highlight-one-text);
|
||||
padding: 5px 5px 0px 5px;
|
||||
height: 67px;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
/* W3C, IE 10+/ Edge, Firefox 16+, Chrome 26+, Opera 12+, Safari 7+ */
|
||||
color: #FFF;
|
||||
height: 50px;
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
.header a {
|
||||
float: none;
|
||||
display: block;
|
||||
text-align: left;
|
||||
#main-menu {
|
||||
order: -1;
|
||||
}
|
||||
|
||||
.menu {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
list-style-type: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.menu > li {
|
||||
margin: 0 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.menu-button-container {
|
||||
display: none;
|
||||
height: 100%;
|
||||
width: 30px;
|
||||
cursor: pointer;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: #4ab457;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
#menu-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.menu-button,
|
||||
.menu-button::before,
|
||||
.menu-button::after {
|
||||
display: block;
|
||||
background-color: #fff;
|
||||
position: absolute;
|
||||
height: 4px;
|
||||
width: 30px;
|
||||
transition: transform 400ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.menu-button::before {
|
||||
content: '';
|
||||
margin-top: -8px;
|
||||
}
|
||||
|
||||
.menu-button::after {
|
||||
content: '';
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
#menu-toggle:checked + .menu-button-container .menu-button::before {
|
||||
margin-top: 0px;
|
||||
transform: rotate(405deg);
|
||||
}
|
||||
|
||||
#menu-toggle:checked + .menu-button-container .menu-button {
|
||||
background: rgba(255, 255, 255, 0);
|
||||
}
|
||||
|
||||
#menu-toggle:checked + .menu-button-container .menu-button::after {
|
||||
margin-top: 0px;
|
||||
transform: rotate(-405deg);
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.menu-button-container {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
float: none;
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
margin-top: 50px;
|
||||
left: 0;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#menu-toggle ~ nav .menu li {
|
||||
height: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
transition: height 400ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
|
||||
#menu-toggle:checked ~ nav .menu li {
|
||||
height: 3em;
|
||||
padding: 1em;
|
||||
transition: height 400ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
|
||||
.header {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.menu > li {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
padding: 0.5em 0;
|
||||
width: 100%;
|
||||
color: white;
|
||||
background-color: var(--background-two);
|
||||
}
|
||||
|
||||
.menu > li:not(:last-child) {
|
||||
border-bottom: 1px solid #444;
|
||||
}
|
||||
|
||||
#header-sign-out, #header-change-language {
|
||||
display: none;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
.logo img {
|
||||
height: 40px;
|
||||
}
|
||||
@@ -431,9 +711,6 @@ select, .button {
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
.photos {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -468,12 +745,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;
|
||||
}
|
||||
@@ -499,7 +786,6 @@ select, .button {
|
||||
.btn-notification {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Make the badge float in the top right corner of the button */
|
||||
@@ -519,15 +805,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 {
|
||||
@@ -542,17 +843,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%;
|
||||
}
|
||||
|
||||
@@ -570,7 +869,7 @@ select, .button {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.comment, .notification {
|
||||
.comment, .notification, .search-subscription {
|
||||
flex: 1 100%;
|
||||
margin: 10px;
|
||||
border-radius: 8px;
|
||||
@@ -580,14 +879,22 @@ select, .button {
|
||||
}
|
||||
|
||||
|
||||
|
||||
.form-comments {
|
||||
.btn {
|
||||
margin: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
@@ -595,13 +902,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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -615,6 +915,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 */
|
||||
/************************/
|
||||
@@ -631,6 +968,14 @@ select, .button {
|
||||
border: rgba(17, 58, 224, 0.51) 4px solid;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #370707;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.error::before {
|
||||
content: "⚠️";
|
||||
}
|
||||
|
||||
/*******/
|
||||
/* MAP */
|
||||
@@ -644,6 +989,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_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_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 %}
|
@@ -1,41 +1,57 @@
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
|
||||
<div class="header">
|
||||
<a href="{% url "index" %}" class="logo"><img src={% static 'fellchensammlung/img/logo_transparent.png' %}></a>
|
||||
<nav id="nav" class="nav justify-content-center">
|
||||
<a class="nav-link " href="{% url "search" %}"><i class="fas fa-search"></i> {% translate 'Suchen' %}</a>
|
||||
<a class="nav-link " href="{% url "add-adoption" %}"><i
|
||||
class="fas fa-feather"></i> {% translate 'Vermittlung hinzufügen' %}</a>
|
||||
<a class="nav-link " href="{% url "about" %}"><i class="fas fa-info"></i> {% translate 'Über uns' %}</a>
|
||||
<a class="nav-link " href="{% url "rss" %}"><i class="fa-solid fa-rss"></i> {% translate 'RSS' %}</a>
|
||||
|
||||
</nav>
|
||||
<div class="header-right">
|
||||
<div class="profile-card">
|
||||
{% include "fellchensammlung/forms/change_language.html" %}
|
||||
{% if user.is_authenticated %}
|
||||
|
||||
<div class="btn2 button_darken btn-notification">
|
||||
<a href="{{ user.get_notifications_url }}">
|
||||
<i class="fa fa-bell" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% if user.get_num_unread_notifications > 0 %}
|
||||
<span class="button__badge">{{ user.get_num_unread_notifications }}</span>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
<a class="btn2" href="{% url 'user-me' %}"><i aria-hidden="true" class="fas fa-user"></i></a>
|
||||
<form class="btn2 button_darken" action="{% url 'logout' %}" method="post">
|
||||
{% csrf_token %}
|
||||
<button class="button" type="submit"><i aria-hidden="true" class="fas fa-sign-out"></i></button>
|
||||
</form>
|
||||
{% else %}
|
||||
<a class="btn2" href="{% url "django_registration_register" %}">{% translate "Registrieren" %}</a>
|
||||
<a class="btn2" href="{% url "login" %}"><i class="fa fa-sign-in" aria-hidden="true"></i></a>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
<section class="header">
|
||||
<div>
|
||||
<a href="{% url "index" %}" class="logo"><img src={% static 'fellchensammlung/img/logo_transparent.png' %}></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="profile-card">
|
||||
<div id="header-change-language">
|
||||
{% include "fellchensammlung/forms/change_language.html" %}
|
||||
</div>
|
||||
{% if user.is_authenticated %}
|
||||
<div class="btn2 button_darken btn-notification">
|
||||
<a href="{{ user.get_notifications_url }}">
|
||||
<i class="fa fa-bell" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% if user.get_num_unread_notifications > 0 %}
|
||||
<span class="button__badge">{{ user.get_num_unread_notifications }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<a class="btn2" href="{% url 'user-me' %}"><i aria-hidden="true" class="fas fa-user"></i></a>
|
||||
<form class="btn2 button_darken" id="header-sign-out" action="{% url 'logout' %}" method="post">
|
||||
{% csrf_token %}
|
||||
<button class="button" type="submit"><i aria-hidden="true" class="fas fa-sign-out"></i></button>
|
||||
</form>
|
||||
{% else %}
|
||||
<a class="btn2" href="{% url "django_registration_register" %}">{% translate "Registrieren" %}</a>
|
||||
<a class="btn2" href="{% url "login" %}"><i class="fa fa-sign-in" aria-hidden="true"></i></a>
|
||||
{% endif %}
|
||||
<input id="menu-toggle" type="checkbox"/>
|
||||
<label class='menu-button-container' for="menu-toggle">
|
||||
<div class='menu-button'></div>
|
||||
</label>
|
||||
|
||||
<nav id="main-menu">
|
||||
<ul class="menu">
|
||||
<li>
|
||||
<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
|
||||
class="fas fa-feather"></i> {% translate 'Vermittlung hinzufügen' %}</a></li>
|
||||
<li><a class="nav-link " href="{% url "about" %}"><i
|
||||
class="fas fa-info"></i> {% translate 'Über uns' %}
|
||||
</a>
|
||||
</li>
|
||||
<li><a class="nav-link " href="{% url "rss" %}"><i
|
||||
class="fa-solid fa-rss"></i> {% translate 'RSS' %}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</section>
|
||||
|
@@ -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
|
||||
|
||||
|
||||
@@ -53,11 +54,11 @@ def clean_locations(quiet=True):
|
||||
|
||||
def get_unchecked_adoption_notices(weeks=3):
|
||||
now = timezone.now()
|
||||
three_weeks_ago = now - timedelta(weeks=weeks)
|
||||
n_weeks_ago = now - timedelta(weeks=weeks)
|
||||
|
||||
# Query for active adoption notices that were checked in the last three weeks
|
||||
# Query for active adoption notices that were not checked in the last n weeks
|
||||
unchecked_adoptions = AdoptionNotice.objects.filter(
|
||||
last_checked__lte=three_weeks_ago
|
||||
last_checked__lte=n_weeks_ago
|
||||
)
|
||||
active_unchecked_adoptions = [adoption for adoption in unchecked_adoptions if adoption.is_active]
|
||||
return active_unchecked_adoptions
|
||||
@@ -71,7 +72,7 @@ def get_active_adoption_notices():
|
||||
|
||||
def deactivate_unchecked_adoption_notices():
|
||||
for adoption_notice in get_unchecked_adoption_notices(weeks=3):
|
||||
AdoptionNoticeStatus.objects.get(adoption_notice=adoption_notice).set_unchecked()
|
||||
adoption_notice.set_unchecked()
|
||||
|
||||
|
||||
def deactivate_404_adoption_notices():
|
||||
@@ -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,67 @@ 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()
|
||||
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)
|
||||
|
13
src/fellchensammlung/tools/notifications.py
Normal file
13
src/fellchensammlung/tools/notifications.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from fellchensammlung.models import User, AdoptionNoticeNotification, TrustLevel
|
||||
|
||||
|
||||
def notify_of_AN_to_be_checked(adoption_notice):
|
||||
if adoption_notice.is_disabled_unchecked:
|
||||
users_to_notify = set(User.objects.filter(trust_level__gt=TrustLevel.MODERATOR))
|
||||
users_to_notify.add(adoption_notice.owner)
|
||||
for user in users_to_notify:
|
||||
AdoptionNoticeNotification.objects.create(adoption_notice=adoption_notice,
|
||||
user=user,
|
||||
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
|
||||
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
|
||||
@@ -126,6 +130,8 @@ def adoption_notice_detail(request, adoption_notice_id):
|
||||
if action == "unsubscribe":
|
||||
subscription.delete()
|
||||
is_subscribed = False
|
||||
elif action == "subscribe":
|
||||
return redirect_to_login(next=request.path)
|
||||
else:
|
||||
raise PermissionDenied
|
||||
else:
|
||||
@@ -168,30 +174,44 @@ def animal_detail(request, animal_id):
|
||||
|
||||
|
||||
def search(request):
|
||||
place_not_found = None
|
||||
# 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':
|
||||
latest_adoption_list = AdoptionNotice.objects.order_by("-created_at")
|
||||
active_adoptions = [adoption for adoption in latest_adoption_list if adoption.is_active]
|
||||
|
||||
search_form = AdoptionNoticeSearchForm(request.POST)
|
||||
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
|
||||
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}
|
||||
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:
|
||||
raise PermissionDenied
|
||||
if request.user.is_authenticated:
|
||||
subscribed_search = search.get_subscription_or_none(request.user)
|
||||
else:
|
||||
latest_adoption_list = AdoptionNotice.objects.order_by("-created_at")
|
||||
active_adoptions = [adoption for adoption in latest_adoption_list if adoption.is_active]
|
||||
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)
|
||||
|
||||
|
||||
@@ -202,18 +222,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"]
|
||||
@@ -222,13 +237,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})
|
||||
@@ -281,7 +304,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})
|
||||
@@ -333,7 +356,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})
|
||||
@@ -346,7 +369,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:
|
||||
@@ -423,7 +446,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)
|
||||
@@ -447,6 +471,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":
|
||||
@@ -455,13 +483,16 @@ def my_profile(request):
|
||||
notification = CommentNotification.objects.get(pk=notification_id)
|
||||
except CommentNotification.DoesNotExist:
|
||||
notification = BaseNotification.objects.get(pk=notification_id)
|
||||
notification.read = True
|
||||
notification.save()
|
||||
notification.mark_read()
|
||||
elif action == "notification_mark_all_read":
|
||||
notifications = CommentNotification.objects.filter(user=request.user, mark_read=False)
|
||||
for notification in notifications:
|
||||
notification.read = True
|
||||
notification.save()
|
||||
notification.mark_read()
|
||||
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:
|
||||
@@ -502,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)
|
||||
|
||||
@@ -523,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)
|
||||
@@ -577,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 %}
|
@@ -3,25 +3,32 @@
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% if form.errors %}
|
||||
<p>{% translate "Dein Username oder Passwort ist falsch." %}</p>
|
||||
{% endif %}
|
||||
{% if form.errors %}
|
||||
<p>{% translate "Dein Username oder Passwort ist falsch." %}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if user.is_authenticated %}
|
||||
<p>{% translate "Du bist bereits eingeloggt." %}</p>
|
||||
{% else %} {% if next %}
|
||||
<p>{% translate "Bitte log dich ein um diese Seite sehen zu können." %}</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if user.is_authenticated %}
|
||||
<p>{% translate "Du bist bereits eingeloggt." %}</p>
|
||||
{% else %} {% if next %}
|
||||
<p class="card">{% translate "Bitte log dich ein um diese Seite sehen zu können." %}</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if not user.is_authenticated %}
|
||||
<form class="card" method="post" action="{% url 'login' %}">
|
||||
{% csrf_token %}
|
||||
{{ form.as_p }}
|
||||
<input class="btn" type="submit" value={% translate "Einloggen" %} />
|
||||
<input type="hidden" name="next" value="{{ next }}" />
|
||||
</form>
|
||||
{% if not user.is_authenticated %}
|
||||
<div class="card">
|
||||
<div class="container-edit-buttons">
|
||||
<form method="post" action="{% url 'login' %}">
|
||||
{% csrf_token %}
|
||||
{{ form.as_p }}
|
||||
<input class="btn" type="submit" value="{% translate 'Einloggen' %}"/>
|
||||
<input type="hidden" name="next" value="{{ next }}"/>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p><a class="btn2" href="{% url 'password_reset' %}">{% translate "Passwort vergessen?" %}</a></p>
|
||||
{% endif %}
|
||||
<div class="container-edit-buttons">
|
||||
<a class="btn btn-small" href="{% url 'password_reset' %}">{% translate "Passwort vergessen?" %}</a>
|
||||
<a class="btn btn-small" href="{% url 'django_registration_register' %}">{% translate "Registrieren" %}</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
@@ -10,7 +10,7 @@ from model_bakery import baker
|
||||
from fellchensammlung.models import AdoptionNotice
|
||||
|
||||
|
||||
class DeactiviationTest(TestCase):
|
||||
class DeactivationTest(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
now = timezone.now()
|
||||
@@ -19,16 +19,18 @@ class DeactiviationTest(TestCase):
|
||||
|
||||
cls.adoption1 = baker.make(AdoptionNotice,
|
||||
name="TestAdoption1",
|
||||
created_at=more_than_three_weeks_ago,
|
||||
last_checked=more_than_three_weeks_ago)
|
||||
created_at=more_than_three_weeks_ago)
|
||||
cls.adoption2 = baker.make(AdoptionNotice, name="TestAdoption2")
|
||||
cls.adoption3 = baker.make(AdoptionNotice,
|
||||
name="TestAdoption3",
|
||||
created_at=less_than_three_weeks_ago,
|
||||
last_checked=less_than_three_weeks_ago)
|
||||
created_at=less_than_three_weeks_ago)
|
||||
|
||||
cls.adoption1.set_active()
|
||||
cls.adoption1.last_checked = more_than_three_weeks_ago # Reset updated_at to simulate test conditions
|
||||
cls.adoption1.save()
|
||||
cls.adoption3.set_active()
|
||||
cls.adoption3.last_checked = less_than_three_weeks_ago # Reset updated_at to simulate test conditions
|
||||
cls.adoption3.save()
|
||||
|
||||
def test_get_unchecked_adoption_notices(self):
|
||||
result = get_unchecked_adoption_notices()
|
||||
@@ -94,4 +96,3 @@ class PingTest(TestCase):
|
||||
self.adoption2.refresh_from_db()
|
||||
self.assertTrue(self.adoption1.is_active)
|
||||
self.assertFalse(self.adoption2.is_active)
|
||||
|
||||
|
@@ -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,21 @@ 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
|
||||
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)
|
||||
|
||||
|
||||
|
@@ -4,7 +4,7 @@ from django.utils import timezone
|
||||
from django.test import TestCase
|
||||
from model_bakery import baker
|
||||
|
||||
from fellchensammlung.models import Announcement, Language, User, TrustLevel
|
||||
from fellchensammlung.models import Announcement, Language, User, TrustLevel, BaseNotification
|
||||
|
||||
|
||||
class UserTest(TestCase):
|
||||
@@ -77,3 +77,21 @@ class AnnouncementTest(TestCase):
|
||||
self.assertTrue(self.announcement2 not in active_announcements)
|
||||
self.assertTrue(self.announcement4 not in active_announcements)
|
||||
self.assertTrue(self.announcement5 in active_announcements)
|
||||
|
||||
|
||||
class TestNotifications(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.test_user_1 = User.objects.create(username="Testuser1", password="SUPERSECRET", email="test@example.org")
|
||||
|
||||
def test_mark_read(self):
|
||||
not1 = BaseNotification.objects.create(user=self.test_user_1, text="New rats to adopt", title="🔔 New Rat alert")
|
||||
not2 = BaseNotification.objects.create(user=self.test_user_1,
|
||||
text="New wombat to adopt", title="🔔 New Wombat alert")
|
||||
not1.mark_read()
|
||||
|
||||
self.assertTrue(not1.read)
|
||||
self.assertFalse(not2.read)
|
||||
self.assertTrue((timezone.now() - timedelta(hours=1)) < not1.read_at < timezone.now())
|
||||
self.assertIsNone(not2.read_at)
|
||||
|
||||
|
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())
|
34
src/tests/test_tools_notifications.py
Normal file
34
src/tests/test_tools_notifications.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from django.test import TestCase
|
||||
from model_bakery import baker
|
||||
|
||||
from fellchensammlung.models import User, TrustLevel, Species, Location, AdoptionNotice, AdoptionNoticeNotification
|
||||
from fellchensammlung.tools.notifications import notify_of_AN_to_be_checked
|
||||
|
||||
|
||||
class TestNotifications(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()
|
||||
|
||||
cls.adoption1 = baker.make(AdoptionNotice, name="TestAdoption1", owner=cls.test_user1,)
|
||||
cls.adoption1.set_unchecked() # Could also emit notification
|
||||
|
||||
def test_notify_of_AN_to_be_checked(self):
|
||||
notify_of_AN_to_be_checked(self.adoption1)
|
||||
self.assertTrue(AdoptionNoticeNotification.objects.filter(user=self.test_user0).exists())
|
||||
self.assertTrue(AdoptionNoticeNotification.objects.filter(user=self.test_user1).exists())
|
||||
self.assertFalse(AdoptionNoticeNotification.objects.filter(user=self.test_user2).exists())
|
0
src/tests/test_views/__init__.py
Normal file
0
src/tests/test_views/__init__.py
Normal file
@@ -4,7 +4,8 @@ from django.urls import reverse
|
||||
|
||||
from model_bakery import baker
|
||||
|
||||
from fellchensammlung.models import Animal, Species, AdoptionNotice, User, Location, AdoptionNoticeStatus, TrustLevel
|
||||
from fellchensammlung.models import Animal, Species, AdoptionNotice, User, Location, AdoptionNoticeStatus, TrustLevel, \
|
||||
Animal, Subscriptions
|
||||
from fellchensammlung.views import add_adoption_notice
|
||||
|
||||
|
||||
@@ -23,7 +24,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,11 +68,36 @@ 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)
|
||||
an = AdoptionNotice.objects.get(name="TestAdoption4")
|
||||
animals = Animal.objects.filter(adoption_notice=an)
|
||||
self.assertTrue(len(animals) == 2)
|
||||
|
||||
def test_creating_AN_as_user(self):
|
||||
self.client.login(username='testuser1', password='12345')
|
||||
|
||||
form_data = {"name": "TestAdoption5",
|
||||
"species": Species.objects.first().pk,
|
||||
"num_animals": "3",
|
||||
"date_of_birth": "2024-12-04",
|
||||
"sex": "M",
|
||||
"group_only": "on",
|
||||
"searching_since": "2024-11-10",
|
||||
"location_string": "München",
|
||||
"description": "Blaaaa",
|
||||
"further_information": "https://notfellchen.org/",
|
||||
"save-and-add-another-animal": "Speichern"}
|
||||
|
||||
response = self.client.post(reverse('add-adoption'), data=form_data)
|
||||
|
||||
self.assertTrue(response.status_code < 400)
|
||||
self.assertFalse(AdoptionNotice.objects.get(name="TestAdoption5").is_active)
|
||||
an = AdoptionNotice.objects.get(name="TestAdoption5")
|
||||
animals = Animal.objects.filter(adoption_notice=an)
|
||||
self.assertTrue(len(animals) == 3)
|
||||
self.assertTrue(an.sexes == set("M", ))
|
||||
|
||||
|
||||
class SearchTest(TestCase):
|
||||
@@ -92,7 +118,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 +146,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"})
|
||||
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 +179,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')
|
||||
@@ -193,3 +223,64 @@ class UpdateQueueTest(TestCase):
|
||||
self.assertFalse(self.adoption3.is_active)
|
||||
self.assertEqual(self.adoption3.adoptionnoticestatus.major_status, AdoptionNoticeStatus.CLOSED)
|
||||
|
||||
|
||||
class AdoptionDetailTest(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
test_user0 = User.objects.create_user(username='testuser0',
|
||||
first_name="Admin",
|
||||
last_name="BOFH",
|
||||
password='12345')
|
||||
test_user0.save()
|
||||
|
||||
test_user1 = User.objects.create_user(username='testuser1',
|
||||
first_name="Max",
|
||||
last_name="Müller",
|
||||
password='12345')
|
||||
test_user0.trust_level = TrustLevel.ADMIN
|
||||
test_user0.save()
|
||||
|
||||
adoption1 = baker.make(AdoptionNotice, name="TestAdoption1")
|
||||
adoption2 = baker.make(AdoptionNotice, name="TestAdoption2")
|
||||
adoption3 = baker.make(AdoptionNotice, name="TestAdoption3")
|
||||
|
||||
berlin = Location.get_location_from_string("Berlin")
|
||||
adoption1.location = berlin
|
||||
adoption1.save()
|
||||
|
||||
stuttgart = Location.get_location_from_string("Stuttgart")
|
||||
adoption3.location = stuttgart
|
||||
adoption3.save()
|
||||
|
||||
adoption1.set_active()
|
||||
adoption3.set_active()
|
||||
adoption2.set_unchecked()
|
||||
|
||||
|
||||
def test_subscribe(self):
|
||||
self.client.login(username='testuser0', password='12345')
|
||||
response = self.client.post(
|
||||
reverse('adoption-notice-detail', args=str(AdoptionNotice.objects.get(name="TestAdoption1").pk)),
|
||||
data={"action": "subscribe"})
|
||||
self.assertTrue(Subscriptions.objects.filter(owner__username="testuser0").exists())
|
||||
|
||||
|
||||
def test_unsubscribe(self):
|
||||
# Make sure subscription exists
|
||||
an = AdoptionNotice.objects.get(name="TestAdoption1")
|
||||
user = User.objects.get(username="testuser0")
|
||||
subscription = Subscriptions.objects.get_or_create(owner=user, adoption_notice=an)
|
||||
|
||||
# Unsubscribe
|
||||
self.client.login(username='testuser0', password='12345')
|
||||
response = self.client.post(
|
||||
reverse('adoption-notice-detail', args=str(an.pk)),
|
||||
data={"action": "unsubscribe"})
|
||||
self.assertFalse(Subscriptions.objects.filter(owner__username="testuser0").exists())
|
||||
|
||||
def test_login_required(self):
|
||||
response = self.client.post(
|
||||
reverse('adoption-notice-detail', args=str(AdoptionNotice.objects.get(name="TestAdoption1").pk)),
|
||||
data={"action": "subscribe"})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.url, "/accounts/login/?next=/vermittlung/1/")
|
43
src/tests/test_views/test_basic_views.py
Normal file
43
src/tests/test_views/test_basic_views.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from fellchensammlung.models import User, TrustLevel, AdoptionNotice, Species
|
||||
from model_bakery import baker
|
||||
|
||||
|
||||
class BasicViewTest(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
test_user0 = User.objects.create_user(username='testuser0',
|
||||
first_name="Admin",
|
||||
last_name="BOFH",
|
||||
password='12345')
|
||||
|
||||
test_user1 = User.objects.create_user(username='testuser1',
|
||||
first_name="Max",
|
||||
last_name="Müller",
|
||||
password='12345')
|
||||
test_user0.trust_level = TrustLevel.ADMIN
|
||||
test_user0.save()
|
||||
|
||||
ans = []
|
||||
for i in range(0, 8):
|
||||
ans.append(baker.make(AdoptionNotice, name=f"TestAdoption{i}"))
|
||||
for i in range(0, 4):
|
||||
AdoptionNotice.objects.get(name=f"TestAdoption{i}").set_active()
|
||||
|
||||
def test_index_logged_in(self):
|
||||
self.client.login(username='testuser0', password='12345')
|
||||
|
||||
response = self.client.get(reverse('index'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
# Check our user is logged in
|
||||
self.assertEqual(str(response.context['user']), 'testuser0')
|
||||
self.assertContains(response, "TestAdoption0")
|
||||
self.assertNotContains(response, "TestAdoption5") # Should not be active, therefore not shown
|
||||
|
||||
def test_index_anonymous(self):
|
||||
response = self.client.get(reverse('index'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "TestAdoption1")
|
||||
self.assertNotContains(response, "TestAdoption4") # Should not be active, therefore not shown
|
Reference in New Issue
Block a user