Compare commits

..

129 Commits

Author SHA1 Message Date
88987a973e feat: Manually craft the add adoption form to work with bulma
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-05-11 16:21:06 +02:00
93ffbe09af feat: Link to new layout 2025-05-11 13:48:25 +02:00
e11848ea72 feat: Add js to close notifications 2025-05-11 13:43:26 +02:00
8bc9d12bfa feat: Add basic bulma form to add adoptions 2025-05-11 13:43:10 +02:00
1dbfdccb89 feat: Add rules to TOS page 2025-05-11 13:42:51 +02:00
f085f5dcf5 feat: Remove leftover span 2025-05-11 09:11:07 +02:00
33579e8446 feat: Allow searching for rescue orgs 2025-05-11 08:55:29 +02:00
a852da365f feat: Remove about us from main menu 2025-05-10 14:13:00 +02:00
b53095ae17 feat: Add onpagers for imprint, privacy and terms of service 2025-05-10 13:35:53 +02:00
3d7780e0ba feat: style 2025-05-10 13:15:37 +02:00
478636bd98 feat: add bulma about 2025-05-10 13:12:58 +02:00
d9ebee1e07 feat: add bulma comment section 2025-05-10 12:02:11 +02:00
23e154bce6 feat: further restructure search 2025-05-10 09:26:49 +02:00
5624f59258 feat: Add image for animal selter 2025-05-10 08:56:00 +02:00
56df942dd0 feat: map 2025-05-10 08:55:23 +02:00
2dcb5fbf88 feat: Structure list a bit more 2025-05-09 21:54:06 +02:00
7a84b470f9 feat: Structure list a bit more 2025-05-09 21:53:59 +02:00
76232b7a0f feat: make sure maps extends over all available height, round corners 2025-05-09 21:16:54 +02:00
349af16075 feat: pad content 2025-05-09 21:16:17 +02:00
8641bead80 feat: make display of location nicer 2025-05-09 21:03:21 +02:00
eb930b71d6 fix: remove debug statements 2025-05-09 21:03:00 +02:00
ae4ba06abf fix: use normal partial 2025-05-09 21:01:33 +02:00
a2e237a81f feat: Make card heading more noticeable 2025-05-09 20:58:23 +02:00
f90c8c7e8c feat: Add name to header 2025-05-09 20:57:58 +02:00
c316c74aff feat: fix map title 2025-05-09 20:41:04 +02:00
93dd0ae4f6 feat: Add bulma map 2025-05-09 20:40:54 +02:00
f79bb355cf feat: Link to bulma url 2025-05-09 20:20:04 +02:00
45a534a042 feat: Add index page in bulma 2025-05-09 20:12:12 +02:00
2106a3423f feat: Remove compass and add fullscreen option 2025-05-09 18:34:09 +02:00
d3f7274e92 feat: Restructure search and add blocks 2025-05-09 18:27:52 +02:00
5f576896b7 feat: Add custom form rendering to support bulma 2025-05-09 18:15:17 +02:00
4a3cbfb8b0 feat: wrap blocks 2025-05-09 17:15:16 +02:00
3e93fe1a7a feat: Remove redundant heading "Pictures" 2025-05-09 17:14:55 +02:00
965e055ef1 feat: Add bulma search 2025-05-09 17:14:34 +02:00
13a0da6e46 feat: Move sex overview to partial 2025-05-09 17:13:31 +02:00
1bb05dbf1c feat: Add tags for sex 2025-05-01 18:41:35 +02:00
4c9c1e13a5 feat: further redesign 2025-05-01 18:15:25 +02:00
99cde15966 feat: Add filter for important locations 2025-04-28 22:46:18 +02:00
f2edc23e75 feat: Make cities visible at lower zoomlevels 2025-04-27 23:34:13 +02:00
8aab4a13ae feat: Exchange pin with circle
Allows to still see a cities label
2025-04-27 23:33:41 +02:00
226102ccaf feat: Display proper 404 when location is not found 2025-04-27 15:05:22 +02:00
3d088c55d7 fix: Adjust to use new versatiles structure
See https://docs.versatiles.org/compendium/specification_frontend.html
2025-04-27 14:31:53 +02:00
bb14a346cb feat: Add important locations to search around 2025-04-27 14:06:17 +02:00
f387930dee feat: Allow longer placids with no restrictions on int 2025-04-27 00:21:58 +02:00
fe63e3b25c feat: Link organization Website directly 2025-04-26 23:03:02 +02:00
23adeb06e6 feat: Allow getting and setting photos with ANs in API 2025-04-25 19:23:45 +02:00
c1bd458c80 feat: Allow adding locations and organizations to ANs in API 2025-04-25 19:18:06 +02:00
2a1d4178d7 feat: Allow creating locations via API 2025-04-24 22:35:38 +02:00
f9a37b299d feat: Extend location model to allow specifying address 2025-04-24 19:43:48 +02:00
9950e87501 feat: Make use of footer items 2025-04-24 18:50:48 +02:00
eff1ba6513 feat: Make use of footer items 2025-04-23 21:14:16 +02:00
bb085aa9a8 feat: Add basic bulma version of comment form 2025-04-23 21:12:22 +02:00
b0dc0f9d78 feat: Make photos to be in card 2025-04-23 20:56:19 +02:00
d1a51b019c feat: Make columns to stack vertically on mobile 2025-04-23 20:53:26 +02:00
b7fade55fb feat: Make header of description card header title 2025-04-23 20:20:24 +02:00
79461518a3 feat: add bulma animal cards 2025-04-10 15:12:39 +02:00
8059d5d23f feat: add photoswipe to adoption notice detail page 2025-04-10 15:12:21 +02:00
3098eacfb4 feat: only exclude the static folder in root from VSC 2025-04-10 15:11:26 +02:00
f3d1e1c203 feat: make headings strong 2025-04-07 21:32:46 +02:00
e6a985ddfa feat: Add initial bulma version of adoption notice detail page 2025-04-07 21:30:14 +02:00
388cc327be feat: Add cards 2025-04-06 10:48:03 +02:00
13adc695f6 feat: Style headings and add change langueg form 2025-04-06 10:25:37 +02:00
f2c7943247 fix: Add missing div 2025-04-06 10:03:52 +02:00
112fd52864 feat: Add footer 2025-04-06 10:03:35 +02:00
8279385966 feat: Rename bulma styleguide and add navigation 2025-04-06 09:05:50 +02:00
1a9692949f feat: add alt text to logo 2025-04-06 09:05:00 +02:00
e7af49b309 feat: add optional address data to location 2025-04-06 08:38:34 +02:00
b822914db3 feat: Allow updating existing rescue organizations 2025-03-21 16:11:58 +01:00
9ad33efe08 feat: Allow filtering for external object id and source 2025-03-21 15:53:58 +01:00
bd8f9fc1b7 feat: Ensure External object identifier and external source identifier are unique together 2025-03-21 15:26:01 +01:00
4a2c18be4d feat: replace upload script with version of g 2025-03-21 12:56:34 +01:00
479aba0195 fix: Adjust maplibre paths for versatiles 0.15.X
See https://github.com/versatiles-org/versatiles-docker/issues/16
2025-03-21 00:51:58 +01:00
1299fcac84 refactor: Rename 2025-03-21 00:29:23 +01:00
884a07f87b feat: Use choices and fix bug where default was not honored 2025-03-21 00:28:15 +01:00
6557e9f9eb feat: Auto-add location to rescue org 2025-03-20 23:26:16 +01:00
602cef1302 refactor: use bulma.min.css 2025-03-20 19:20:40 +01:00
b400db603a refactor: Remove js part -> model 2025-03-20 19:12:32 +01:00
0397311f6e feat: Add bulma and base for bulma styleguide 2025-03-20 19:12:32 +01:00
abce89c829 feat: Add bulma and base for bulma styleguide 2025-03-20 18:58:40 +01:00
bbad63a460 fix: Adjust site language based on selected language 2025-03-20 18:34:25 +01:00
d940630086 refactor: formatting 2025-03-17 21:08:41 +01:00
37ecf28f2f feat: add link to original content
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-03-10 22:12:35 +01:00
12d5a976cc feat: add link to original content 2025-03-10 22:12:28 +01:00
9086e2e75b fix: Show name of reported content 2025-03-10 22:04:14 +01:00
3607eb0e4e feat: Add message when no comment is added to report 2025-03-10 21:09:06 +01:00
3daf83d725 tests: Fix testing for edit buttons 2025-03-09 18:05:29 +01:00
5ad0cb74cc feat: Only show edit buttons when mod 2025-03-09 18:05:15 +01:00
9ae64e8cb1 feat: Auto add now the date oflast checked
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-03-09 17:46:13 +01:00
1b5a0c71e0 feat: Add rescue organization check 2025-03-09 09:46:56 +01:00
4d4f11c479 feat: Make string translatable 2025-03-09 09:16:37 +01:00
835c89d1d4 test: Add test for details of comment reports 2025-02-05 22:32:18 +01:00
46bf07dd8d test: Add test for reporting comments anon and logged in 2025-02-05 22:11:59 +01:00
f557672586 test: Add test for reporting rules anon 2025-02-05 20:17:36 +01:00
4e27e1be7f test: Add test for about page and for reporting rules logged in 2025-01-27 18:51:14 +01:00
6d390ad21e test: Add test for (un)subscribes of searches 2025-01-26 20:19:18 +01:00
2f2543160e test: Add test for unauthenticated (un) subscribes of searches 2025-01-26 19:01:39 +01:00
64a9db133e feat: default to photon for geocoding 2025-01-26 18:16:39 +01:00
712c3d32f3 feat: Add styleguide setup 2025-01-26 18:16:39 +01:00
8998bbdf6d
Merge pull request #9 from moan0s/shelter-fixes
Add script to upload data of animal shelters
2025-01-26 18:04:48 +01:00
ff31caa139 refactor: Move script to dedicated folder 2025-01-26 18:01:38 +01:00
ad06829c31 feat: Add debug information 2025-01-26 18:00:23 +01:00
03a48da355 feat: Make instance and secrets CLI argument 2025-01-26 17:59:48 +01:00
885bed888d feat: Raise connection error upon unexpected error
This e.g. makes sure the API is not bombarded with unauthorized calls if the token is wrong
2025-01-26 17:07:50 +01:00
0051cb07c9 refactor: formatting 2025-01-26 17:06:41 +01:00
8858cff9cf fix: Construct necessary location string
I'd be better to directly create a location here but I for now want to make as little modifications as possible
2025-01-26 17:05:51 +01:00
70e2af6172 fix: fix key 2025-01-26 17:04:56 +01:00
461abd2e46 fix: don't try to save owner 2025-01-26 17:04:17 +01:00
Salil
d7269106db
Added - animal_shelter Get Data
Import all German animal shelters
2025-01-24 12:22:28 +05:30
77fb99a527
Merge pull request #6 from Deadpool2000/patch-1
Create robots.txt
2025-01-22 20:28:39 +01:00
38a56daa24 feat: Add sitemap 2025-01-22 11:01:31 +01:00
Salil
ac0749797f
Create robots.txt
- robots.txt file added in src/fellchensammlung/static/
2025-01-22 10:03:30 +05:30
f193f7d7ca test: Add tests for AN edit 2025-01-19 23:11:07 +01:00
43657e0862 test: Add tests for comment 2025-01-19 22:07:21 +01:00
68ad366f74 test: Add tests for comment 2025-01-19 09:00:53 +01:00
350d2c5da9 feat: Return HTTP response 2025-01-19 09:00:26 +01:00
462bb8f485 refactor: formatting 2025-01-18 21:43:37 +01:00
ea4d15b99a tests: Add test for index view 2025-01-18 21:43:00 +01:00
de30dfcb8b tests: Add test for unsubscribe 2025-01-18 21:39:45 +01:00
36a979954c feat: make sure owner also gets notified, add test 2025-01-18 18:53:55 +01:00
71ef17dc97 feat: Move pytest, coverage and model bakery to develop dependencies 2025-01-18 18:30:02 +01:00
206cd282e6 feat: Add coverage report instructions 2025-01-18 18:29:04 +01:00
e399346c3e feat: Add tests for subscription functionality 2025-01-18 16:17:22 +01:00
929c6dfff0 feat: Add registration button 2025-01-18 15:47:07 +01:00
841b57fea2 feat: Make sure subscribe leads to login flow 2025-01-18 15:25:27 +01:00
9e5446ff1d feat: Test adding a adoption as user 2025-01-18 15:22:08 +01:00
3b79809b8c docs: various 2025-01-18 09:07:33 +01:00
53e6db3655 refactor: Remove print 2025-01-14 07:31:00 +01:00
424f91e919 feat: Add a timestamp for when a notification is read 2025-01-14 07:30:36 +01:00
84ce5f54b2 refactor: Remove unnecessary print 2025-01-14 07:27:03 +01:00
94 changed files with 6488 additions and 1227 deletions

4
.coveragerc Normal file
View File

@ -0,0 +1,4 @@
[run]
omit =
*/migrations/*
*/tests/*

2
.gitignore vendored
View File

@ -4,7 +4,7 @@
notfellchen notfellchen
# Media storage # Media storage
static /static
media media

View File

@ -77,6 +77,26 @@ docker push moanos/notfellchen:latest
docker run -p8000:7345 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
Geocoding services (search map data by name, address or postcode) are provided via the Geocoding services (search map data by name, address or postcode) are provided via the

View File

@ -36,6 +36,11 @@ An application can then send this token in the request header for authorization.
Endpoints 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 Get Adoption Notices
++++++++++++++++++++ ++++++++++++++++++++

View File

@ -5,7 +5,7 @@ Report a bug
^^^^^^^^^^^^ ^^^^^^^^^^^^
To report a bug, file an issue on `Github 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: Try to include the following information:
@ -29,7 +29,7 @@ To contribute simply clone the directory, make your changes and file a
pull request. pull request.
If you want to know what can be done, have a look at the current `Github 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! Get in touch!
^^^^^^^^^^^^^ ^^^^^^^^^^^^^

View File

@ -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 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. is made. Notfellchen follows `Semantic Versioning <https://semver.org/>`_.
Major releases are yet to be determined.
What should be done before a release? What should be done before a release?
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
@ -14,7 +13,7 @@ What should be done before a release?
Tested basic functions Tested basic functions
###################### ######################
Run :command:`pytest` Run :command:`nf test src`
Test upgrade on a copy of a production database 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 tag -a v1.0.0 -m "Releasing version v1.0.0"
git push origin 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 🥳

View File

@ -8,13 +8,13 @@ build-backend = "setuptools.build_meta"
name = "notfellchen" name = "notfellchen"
description = "A tool to help." description = "A tool to help."
authors = [ authors = [
{name = "moanos", email = "julian-samuel@gebuehr.net"}, { name = "moanos", email = "julian-samuel@gebuehr.net" },
] ]
maintainers = [ maintainers = [
{name = "moanos", email = "julian-samuel@gebuehr.net"}, { name = "moanos", email = "julian-samuel@gebuehr.net" },
] ]
keywords = ["animal", "adoption", "django", "rescue", ] keywords = ["animal", "adoption", "django", "rescue", ]
license = {text = "AGPL-3.0-or-later"} license = { text = "AGPL-3.0-or-later" }
classifiers = [ classifiers = [
"Environment :: Web", "Environment :: Web",
"License :: OSI Approved :: GNU Affero General Public License v3", "License :: OSI Approved :: GNU Affero General Public License v3",
@ -24,14 +24,12 @@ classifiers = [
] ]
dependencies = [ dependencies = [
"Django", "Django",
"coverage",
"codecov", "codecov",
"sphinx", "sphinx",
"sphinx-rtd-theme", "sphinx-rtd-theme",
"gunicorn", "gunicorn",
"fontawesomefree", "fontawesomefree",
"whitenoise", "whitenoise",
"model_bakery",
"markdown", "markdown",
"Pillow", "Pillow",
"django-registration", "django-registration",
@ -47,7 +45,9 @@ dynamic = ["version", "readme"]
[project.optional-dependencies] [project.optional-dependencies]
develop = [ develop = [
"pytest", "pytest",
"coverage",
"model_bakery",
] ]
[project.urls] [project.urls]
@ -62,6 +62,6 @@ nf = 'notfellchen.main:main'
[tool.setuptools.dynamic] [tool.setuptools.dynamic]
version = {attr = "notfellchen.__version__"} version = { attr = "notfellchen.__version__" }
readme = {file = "README.md"} readme = { file = "README.md" }

View File

@ -0,0 +1,105 @@
import argparse
import json
import os
import requests
from tqdm import tqdm
DEFAULT_OSM_DATA_FILE = "export.geojson"
def parse_args():
"""Parse command-line arguments."""
parser = argparse.ArgumentParser(description="Upload animal shelter data to the Notfellchen API.")
parser.add_argument("--api-token", type=str, help="API token for authentication.")
parser.add_argument("--instance", type=str, help="API instance URL.")
parser.add_argument("--data-file", type=str, help="Path to the GeoJSON file containing (only) animal shelters.")
return parser.parse_args()
def get_config():
"""Get configuration from environment variables or command-line arguments."""
args = parse_args()
api_token = args.api_token or os.getenv("NOTFELLCHEN_API_TOKEN")
instance = args.instance or os.getenv("NOTFELLCHEN_INSTANCE")
data_file = args.data_file or os.getenv("NOTFELLCHEN_DATA_FILE", DEFAULT_OSM_DATA_FILE)
if not api_token or not instance:
raise ValueError("API token and instance URL must be provided via environment variables or CLI arguments.")
return api_token, instance, data_file
def get_or_none(data, key):
if key in data["properties"].keys():
return data["properties"][key]
else:
return ""
def choose(keys, data, replace=False):
for key in keys:
if key in data.keys():
if replace:
return data[key].replace(" ", "").replace("-", "").replace("(", "").replace(")", "")
else:
return data[key]
return None
def add(value, platform):
if value != "":
if value.find(platform) == -1:
return f"https://www.{platform}.com/{value}"
else:
return value
else:
return None
def https(value):
if value is not None and value != "":
value = value.replace("http://", "")
if value.find("https") == -1:
return f"https://{value}"
else:
return value
else:
return None
def main():
api_token, instance, data_file = get_config()
# Set headers and endpoint
endpoint = f"{instance}/api/organizations/"
h = {'Authorization': f'Token {api_token}', "content-type": "application/json"}
with open(data_file, encoding="utf8") as f:
d = json.load(f)
for idx, tierheim in tqdm(enumerate(d["features"])):
if "name" not in tierheim["properties"].keys() or "addr:city" not in tierheim["properties"].keys():
continue
data = {"name": tierheim["properties"]["name"],
"location_string": f"{get_or_none(tierheim, "addr:street")} {get_or_none(tierheim, "addr:housenumber")}, {get_or_none(tierheim, "addr:postcode")} {tierheim["properties"]["addr:city"]}",
"phone_number": choose(("contact:phone", "phone"), tierheim["properties"], replace=True),
"fediverse_profile": get_or_none(tierheim, "contact:mastodon"),
"facebook": https(add(get_or_none(tierheim, "contact:facebook"), "facebook")),
"instagram": https(add(get_or_none(tierheim, "contact:instagram"), "instagram")),
"website": https(choose(("contact:website", "website"), tierheim["properties"])),
"email": choose(("contact:email", "email"), tierheim["properties"]),
"description": get_or_none(tierheim, "opening_hours"),
"external_object_identifier": f"{tierheim["id"]}",
"external_source_identifier": "OSM"
}
result = requests.post(endpoint, json=data, headers=h)
if result.status_code != 201:
print(f"{idx} {tierheim["properties"]["name"]}:{result.status_code} {result.json()}")
if __name__ == "__main__":
main()

View File

@ -7,7 +7,7 @@ from django.urls import reverse
from django.utils.http import urlencode from django.utils.http import urlencode
from .models import User, Language, Text, ReportComment, ReportAdoptionNotice, Log, Timestamp, SearchSubscription, \ from .models import User, Language, Text, ReportComment, ReportAdoptionNotice, Log, Timestamp, SearchSubscription, \
SpeciesSpecificURL SpeciesSpecificURL, ImportantLocation
from .models import Animal, Species, RescueOrganization, AdoptionNotice, Location, Rule, Image, ModerationAction, \ from .models import Animal, Species, RescueOrganization, AdoptionNotice, Location, Rule, Image, ModerationAction, \
Comment, Report, Announcement, AdoptionNoticeStatus, User, Subscriptions, BaseNotification Comment, Report, Announcement, AdoptionNoticeStatus, User, Subscriptions, BaseNotification
@ -94,12 +94,14 @@ class ReportAdoptionNoticeAdmin(admin.ModelAdmin):
reported_content_link.short_description = "Reported Content" reported_content_link.short_description = "Reported Content"
class SpeciesSpecificURLInline(admin.StackedInline): class SpeciesSpecificURLInline(admin.StackedInline):
model = SpeciesSpecificURL model = SpeciesSpecificURL
@admin.register(RescueOrganization) @admin.register(RescueOrganization)
class RescueOrganizationAdmin(admin.ModelAdmin): class RescueOrganizationAdmin(admin.ModelAdmin):
search_fields = ("name","description", "internal_comment", "location_string") search_fields = ("name", "description", "internal_comment", "location_string")
list_display = ("name", "trusted", "allows_using_materials", "website") list_display = ("name", "trusted", "allows_using_materials", "website")
list_filter = ("allows_using_materials", "trusted",) list_filter = ("allows_using_materials", "trusted",)
@ -122,14 +124,46 @@ class CommentAdmin(admin.ModelAdmin):
class BaseNotificationAdmin(admin.ModelAdmin): class BaseNotificationAdmin(admin.ModelAdmin):
list_filter = ("user", "read") list_filter = ("user", "read")
@admin.register(SearchSubscription) @admin.register(SearchSubscription)
class SearchSubscriptionAdmin(admin.ModelAdmin): class SearchSubscriptionAdmin(admin.ModelAdmin):
list_filter = ("owner",) list_filter = ("owner",)
class ImportantLocationInline(admin.StackedInline):
model = ImportantLocation
class IsImportantListFilter(admin.SimpleListFilter):
# See https://docs.djangoproject.com/en/5.1/ref/contrib/admin/filters/#modeladmin-list-filters
title = _('Is Important Location?')
parameter_name = 'important'
def lookups(self, request, model_admin):
return (
('is_important', _('Important Location')),
('is_normal', _('Normal Location')),
)
def queryset(self, request, queryset):
if self.value() == 'is_important':
return queryset.filter(importantlocation__isnull=False)
else:
return queryset.filter(importantlocation__isnull=True)
@admin.register(Location)
class LocationAdmin(admin.ModelAdmin):
search_fields = ("name__icontains", "city__icontains")
list_filter = [IsImportantListFilter]
inlines = [
ImportantLocationInline,
]
admin.site.register(Animal) admin.site.register(Animal)
admin.site.register(Species) admin.site.register(Species)
admin.site.register(Location)
admin.site.register(Rule) admin.site.register(Rule)
admin.site.register(Image) admin.site.register(Image)
admin.site.register(ModerationAction) admin.site.register(ModerationAction)

View File

@ -1,12 +1,35 @@
from ..models import Animal, RescueOrganization, AdoptionNotice, Species, Image from ..models import Animal, RescueOrganization, AdoptionNotice, Species, Image, Location
from rest_framework import serializers from rest_framework import serializers
class AdoptionNoticeSerializer(serializers.HyperlinkedModelSerializer): class AdoptionNoticeSerializer(serializers.HyperlinkedModelSerializer):
location = serializers.PrimaryKeyRelatedField(
queryset=Location.objects.all(),
required=False,
allow_null=True
)
location_details = serializers.StringRelatedField(source='location', read_only=True)
organization = serializers.PrimaryKeyRelatedField(
queryset=RescueOrganization.objects.all(),
required=False,
allow_null=True
)
organization = serializers.PrimaryKeyRelatedField(
queryset=RescueOrganization.objects.all(),
required=False,
allow_null=True
)
photos = serializers.PrimaryKeyRelatedField(
queryset=Image.objects.all(),
many=True,
required=False
)
class Meta: class Meta:
model = AdoptionNotice model = AdoptionNotice
fields = ['created_at', 'last_checked', "searching_since", "name", "description", "further_information", fields = ['created_at', 'last_checked', "searching_since", "name", "description", "further_information",
"group_only"] "group_only", "location", "location_details", "organization", "photos"]
class AnimalCreateSerializer(serializers.ModelSerializer): class AnimalCreateSerializer(serializers.ModelSerializer):
@ -14,12 +37,14 @@ class AnimalCreateSerializer(serializers.ModelSerializer):
model = Animal model = Animal
fields = ["name", "date_of_birth", "description", "species", "sex", "adoption_notice"] fields = ["name", "date_of_birth", "description", "species", "sex", "adoption_notice"]
class RescueOrgSerializer(serializers.ModelSerializer): class RescueOrgSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = RescueOrganization model = RescueOrganization
fields = ["name", "location_string", "instagram", "facebook", "fediverse_profile", "email", "phone_number", fields = ["name", "location_string", "instagram", "facebook", "fediverse_profile", "email", "phone_number",
"website", "description", "external_object_identifier", "external_source_identifier"] "website", "description", "external_object_identifier", "external_source_identifier"]
class AnimalGetSerializer(serializers.ModelSerializer): class AnimalGetSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Animal model = Animal
@ -51,3 +76,9 @@ class SpeciesSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Species model = Species
fields = "__all__" fields = "__all__"
class LocationSerializer(serializers.ModelSerializer):
class Meta:
model = Location
fields = "__all__"

View File

@ -1,7 +1,7 @@
from django.urls import path from django.urls import path
from .views import ( from .views import (
AdoptionNoticeApiView, AdoptionNoticeApiView,
AnimalApiView, RescueOrganizationApiView, AddImageApiView, SpeciesApiView AnimalApiView, RescueOrganizationApiView, AddImageApiView, SpeciesApiView, LocationApiView
) )
urlpatterns = [ urlpatterns = [
@ -13,4 +13,5 @@ urlpatterns = [
path("organizations/<int:id>/", RescueOrganizationApiView.as_view(), name="api-organization-detail"), path("organizations/<int:id>/", RescueOrganizationApiView.as_view(), name="api-organization-detail"),
path("images/", AddImageApiView.as_view(), name="api-add-image"), path("images/", AddImageApiView.as_view(), name="api-add-image"),
path("species/", SpeciesApiView.as_view(), name="api-species-list"), path("species/", SpeciesApiView.as_view(), name="api-species-list"),
path("locations/", LocationApiView.as_view(), name="api-locations-list"),
] ]

View File

@ -1,8 +1,11 @@
from django.db.models import Q
from fellchensammlung.api.serializers import LocationSerializer
from rest_framework.views import APIView from rest_framework.views import APIView
from rest_framework.response import Response from rest_framework.response import Response
from django.db import transaction from django.db import transaction
from fellchensammlung.models import AdoptionNotice, Animal, Log, TrustLevel from fellchensammlung.models import AdoptionNotice, Animal, Log, TrustLevel, Location
from fellchensammlung.tasks import post_adoption_notice_save from fellchensammlung.tasks import post_adoption_notice_save, post_rescue_org_save
from rest_framework import status from rest_framework import status
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from .serializers import ( from .serializers import (
@ -16,6 +19,7 @@ from .serializers import (
from fellchensammlung.models import Animal, RescueOrganization, AdoptionNotice, Species, Image from fellchensammlung.models import Animal, RescueOrganization, AdoptionNotice, Species, Image
from drf_spectacular.utils import extend_schema from drf_spectacular.utils import extend_schema
class AdoptionNoticeApiView(APIView): class AdoptionNoticeApiView(APIView):
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
@ -84,7 +88,6 @@ class AdoptionNoticeApiView(APIView):
) )
class AnimalApiView(APIView): class AnimalApiView(APIView):
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
@ -118,6 +121,7 @@ class AnimalApiView(APIView):
) )
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class RescueOrganizationApiView(APIView): class RescueOrganizationApiView(APIView):
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
@ -129,14 +133,44 @@ class RescueOrganizationApiView(APIView):
'description': 'ID of the rescue organization to retrieve.', 'description': 'ID of the rescue organization to retrieve.',
'type': int 'type': int
}, },
{
'name': 'trusted',
'required': False,
'description': 'Filter by trusted status (true/false).',
'type': bool
},
{
'name': 'external_object_identifier',
'required': False,
'description': 'Filter by external object identifier. Use "None" to filter for an empty field',
'type': str
},
{
'name': 'external_source_identifier',
'required': False,
'description': 'Filter by external source identifier. Use "None" to filter for an empty field',
'type': str
},
{
'name': 'search',
'required': False,
'description': 'Search by organization name or location name/city.',
'type': str
},
], ],
responses={200: RescueOrganizationSerializer(many=True)} responses={200: RescueOrganizationSerializer(many=True)}
) )
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
""" """
Get list of rescue organizations or a specific organization by ID. Get list of rescue organizations or a specific organization by ID or get a list with available filters for
- external_object_identifier
- external_source_identifier
""" """
org_id = kwargs.get("id") org_id = request.query_params.get("id")
external_object_identifier = request.query_params.get("external_object_identifier")
external_source_identifier = request.query_params.get("external_source_identifier")
search_query = request.query_params.get("search")
if org_id: if org_id:
try: try:
organization = RescueOrganization.objects.get(pk=org_id) organization = RescueOrganization.objects.get(pk=org_id)
@ -144,14 +178,33 @@ class RescueOrganizationApiView(APIView):
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
except RescueOrganization.DoesNotExist: except RescueOrganization.DoesNotExist:
return Response({"error": "Organization not found."}, status=status.HTTP_404_NOT_FOUND) return Response({"error": "Organization not found."}, status=status.HTTP_404_NOT_FOUND)
organizations = RescueOrganization.objects.all() organizations = RescueOrganization.objects.all()
if external_object_identifier:
if external_object_identifier == "None":
external_object_identifier = None
organizations = organizations.filter(external_object_identifier=external_object_identifier)
if external_source_identifier:
if external_source_identifier == "None":
external_source_identifier = None
organizations = organizations.filter(external_source_identifier=external_source_identifier)
if search_query:
organizations = organizations.filter(
Q(name__icontains=search_query) |
Q(location_string__icontains=search_query) |
Q(location__name__icontains=search_query) |
Q(location__city__icontains=search_query)
)
serializer = RescueOrganizationSerializer(organizations, many=True, context={"request": request}) serializer = RescueOrganizationSerializer(organizations, many=True, context={"request": request})
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
@transaction.atomic @transaction.atomic
@extend_schema( @extend_schema(
request=RescueOrgSerializer, # Document the request body request=RescueOrgSerializer,
responses={201: 'Rescue organization created/updated successfully!'} responses={201: 'Rescue organization created successfully!'}
) )
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
""" """
@ -159,11 +212,39 @@ class RescueOrganizationApiView(APIView):
""" """
serializer = RescueOrgSerializer(data=request.data, context={"request": request}) serializer = RescueOrgSerializer(data=request.data, context={"request": request})
if serializer.is_valid(): if serializer.is_valid():
rescue_org = serializer.save(owner=request.user) rescue_org = serializer.save()
# Add the location
post_rescue_org_save.delay_on_commit(rescue_org.pk)
return Response( return Response(
{"message": "Rescue organization created/updated successfully!", "id": rescue_org.id}, {"message": "Rescue organization created successfully!", "id": rescue_org.id},
status=status.HTTP_201_CREATED, status=status.HTTP_201_CREATED,
) )
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@transaction.atomic
@extend_schema(
request=RescueOrgSerializer,
responses={200: 'Rescue organization updated successfully!'}
)
def patch(self, request, *args, **kwargs):
"""
Partially update a rescue organization.
"""
org_id = kwargs.get("id")
if not org_id:
return Response({"error": "ID is required for updating."}, status=status.HTTP_400_BAD_REQUEST)
try:
organization = RescueOrganization.objects.get(pk=org_id)
except RescueOrganization.DoesNotExist:
return Response({"error": "Organization not found."}, status=status.HTTP_404_NOT_FOUND)
serializer = RescueOrgSerializer(organization, data=request.data, partial=True, context={"request": request})
if serializer.is_valid():
serializer.save()
return Response({"message": "Rescue organization updated successfully!"}, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class AddImageApiView(APIView): class AddImageApiView(APIView):
@ -210,3 +291,63 @@ class SpeciesApiView(APIView):
species = Species.objects.all() species = Species.objects.all()
serializer = SpeciesSerializer(species, many=True, context={"request": request}) serializer = SpeciesSerializer(species, many=True, context={"request": request})
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
class LocationApiView(APIView):
permission_classes = [IsAuthenticated]
@extend_schema(
parameters=[
{
'name': 'id',
'required': False,
'description': 'ID of the location to retrieve.',
'type': int
},
],
responses={200: LocationSerializer(many=True)}
)
def get(self, request, *args, **kwargs):
"""
Retrieve a location
"""
location_id = kwargs.get("id")
if location_id:
try:
location = Location.objects.get(pk=location_id)
serializer = LocationSerializer(location, context={"request": request})
return Response(serializer.data, status=status.HTTP_200_OK)
except Location.DoesNotExist:
return Response({"error": "Location not found."}, status=status.HTTP_404_NOT_FOUND)
locations = Location.objects.all()
serializer = LocationSerializer(locations, many=True, context={"request": request})
return Response(serializer.data, status=status.HTTP_200_OK)
@transaction.atomic
@extend_schema(
request=LocationSerializer,
responses={201: 'Location created successfully!'}
)
def post(self, request, *args, **kwargs):
"""
API view to add a location
"""
serializer = LocationSerializer(data=request.data, context={'request': request})
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
location = serializer.save()
# Log the action
Log.objects.create(
user=request.user,
action="add_location",
text=f"{request.user} added adoption notice {location.pk} via API",
)
# Return success response with new adoption notice details
return Response(
{"message": "Location created successfully!", "id": location.pk},
status=status.HTTP_201_CREATED,
)

View File

@ -22,6 +22,15 @@ class DateInput(forms.DateInput):
input_type = 'date' input_type = 'date'
class BulmaAdoptionNoticeForm(forms.ModelForm):
template_name = "fellchensammlung/forms/form_snippets.html"
class Meta:
model = AdoptionNotice
fields = ['name', "group_only", "further_information", "description", "searching_since", "location_string",
"organization"]
class AdoptionNoticeForm(forms.ModelForm): class AdoptionNoticeForm(forms.ModelForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
if 'in_adoption_notice_creation_flow' in kwargs: if 'in_adoption_notice_creation_flow' in kwargs:
@ -127,8 +136,9 @@ class ImageForm(forms.ModelForm):
self.helper.form_method = 'post' self.helper.form_method = 'post'
if in_flow: if in_flow:
submits= Div(Submit('submit', _('Speichern')), submits = Div(Submit('submit', _('Speichern')),
Submit('save-and-add-another', _('Speichern und weiteres Foto hinzufügen')), css_class="container-edit-buttons") Submit('save-and-add-another', _('Speichern und weiteres Foto hinzufügen')),
css_class="container-edit-buttons")
else: else:
submits = Fieldset(Submit('submit', _('Speichern')), css_class="container-edit-buttons") submits = Fieldset(Submit('submit', _('Speichern')), css_class="container-edit-buttons")
self.helper.layout = Layout( self.helper.layout = Layout(
@ -140,7 +150,6 @@ class ImageForm(forms.ModelForm):
submits submits
) )
class Meta: class Meta:
model = Image model = Image
fields = ('image', 'alt_text') fields = ('image', 'alt_text')
@ -164,7 +173,7 @@ class CommentForm(forms.ModelForm):
self.helper = FormHelper() self.helper = FormHelper()
self.helper.form_class = 'form-comments' self.helper.form_class = 'form-comments'
self.helper.add_input(Hidden('action', 'comment')) self.helper.add_input(Hidden('action', 'comment'))
self.helper.add_input(Submit('submit', _('Kommentieren'), css_class="btn2")) self.helper.add_input(Submit('submit', _('Kommentieren'), css_class="button is-primary"))
class Meta: class Meta:
model = Comment model = Comment
@ -181,7 +190,8 @@ class CustomRegistrationForm(RegistrationForm):
class Meta(RegistrationForm.Meta): class Meta(RegistrationForm.Meta):
model = User model = User
captcha = forms.CharField(validators=[animal_validator], label=_("Nenne eine bekannte Tierart"), help_text=_("Bitte nenne hier eine bekannte Tierart (z.B. ein Tier das an der Leine geführt wird). Das Fragen wir dich um sicherzustellen, dass du kein Roboter bist.")) captcha = forms.CharField(validators=[animal_validator], label=_("Nenne eine bekannte Tierart"), help_text=_(
"Bitte nenne hier eine bekannte Tierart (z.B. ein Tier das an der Leine geführt wird). Das Fragen wir dich um sicherzustellen, dass du kein Roboter bist."))
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -193,7 +203,10 @@ class CustomRegistrationForm(RegistrationForm):
class AdoptionNoticeSearchForm(forms.Form): class AdoptionNoticeSearchForm(forms.Form):
template_name = "fellchensammlung/forms/form_snippets.html"
sex = forms.ChoiceField(choices=SexChoicesWithAll, label=_("Geschlecht"), required=False, sex = forms.ChoiceField(choices=SexChoicesWithAll, label=_("Geschlecht"), required=False,
initial=SexChoicesWithAll.ALL) initial=SexChoicesWithAll.ALL)
max_distance = forms.ChoiceField(choices=DistanceChoices, initial=DistanceChoices.ONE_HUNDRED, label=_("Suchradius")) max_distance = forms.ChoiceField(choices=DistanceChoices, initial=DistanceChoices.ONE_HUNDRED,
label=_("Suchradius"))
location_string = forms.CharField(max_length=100, label=_("Stadt"), required=False) location_string = forms.CharField(max_length=100, label=_("Stadt"), required=False)

View File

@ -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'),
),
]

View File

@ -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'),
),
]

View File

@ -0,0 +1,20 @@
# Generated by Django 5.1.4 on 2025-03-09 08:31
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0037_alter_basenotification_title'),
]
operations = [
migrations.AddField(
model_name='rescueorganization',
name='last_checked',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='Datum der letzten Prüfung'),
preserve_default=False,
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.1.4 on 2025-03-09 16:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0038_rescueorganization_last_checked'),
]
operations = [
migrations.AlterField(
model_name='rescueorganization',
name='last_checked',
field=models.DateTimeField(auto_now_add=True, verbose_name='Datum der letzten Prüfung'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.1.4 on 2025-03-20 23:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0039_alter_rescueorganization_last_checked'),
]
operations = [
migrations.AlterField(
model_name='rescueorganization',
name='allows_using_materials',
field=models.CharField(choices=[('allowed', 'Usage allowed'), ('requested', 'Usage requested'), ('denied', 'Usage denied'), ('other', "It's complicated"), ('not_asked', 'Not asked')], default='not_asked', max_length=200, verbose_name='Erlaubt Nutzung von Inhalten'),
),
]

View File

@ -0,0 +1,42 @@
# Generated by Django 5.1.4 on 2025-04-06 06:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0040_alter_rescueorganization_allows_using_materials'),
]
operations = [
migrations.AddField(
model_name='location',
name='city',
field=models.CharField(blank=True, max_length=200, null=True),
),
migrations.AddField(
model_name='location',
name='country',
field=models.CharField(blank=True, help_text='Standardisierter Ländercode nach ISO 3166-1 ALPHA-2', max_length=2, null=True, verbose_name='Ländercode'),
),
migrations.AddField(
model_name='location',
name='housenumber',
field=models.CharField(blank=True, max_length=20, null=True),
),
migrations.AddField(
model_name='location',
name='postcode',
field=models.CharField(blank=True, max_length=20, null=True),
),
migrations.AddField(
model_name='location',
name='street',
field=models.CharField(blank=True, max_length=200, null=True),
),
migrations.AlterUniqueTogether(
name='rescueorganization',
unique_together={('external_object_identifier', 'external_source_identifier')},
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.1.4 on 2025-04-24 17:13
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0041_location_city_location_country_location_housenumber_and_more'),
]
operations = [
migrations.AddField(
model_name='location',
name='county',
field=models.CharField(blank=True, max_length=200, null=True),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.1.4 on 2025-04-24 17:41
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0042_location_county'),
]
operations = [
migrations.RenameField(
model_name='location',
old_name='country',
new_name='countrycode',
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.1.4 on 2025-04-26 22:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0043_rename_country_location_countrycode'),
]
operations = [
migrations.AlterField(
model_name='location',
name='place_id',
field=models.CharField(max_length=200),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 5.1.4 on 2025-04-27 11:31
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0044_alter_location_place_id'),
]
operations = [
migrations.CreateModel(
name='ImportantLocation',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('slug', models.SlugField(unique=True)),
('name', models.CharField(max_length=200)),
('location', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='fellchensammlung.location')),
],
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 5.1.4 on 2025-04-27 11:51
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0045_importantlocation'),
]
operations = [
migrations.AlterField(
model_name='importantlocation',
name='location',
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='fellchensammlung.location'),
),
]

View File

@ -3,6 +3,7 @@ from random import choices
from tabnanny import verbose from tabnanny import verbose
from django.db import models from django.db import models
from django.template.defaultfilters import slugify
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.utils import timezone from django.utils import timezone
@ -39,15 +40,28 @@ class Language(models.Model):
class Location(models.Model): class Location(models.Model):
place_id = models.IntegerField() # OSM id place_id = models.CharField(max_length=200) # OSM id
latitude = models.FloatField() latitude = models.FloatField()
longitude = models.FloatField() longitude = models.FloatField()
name = models.CharField(max_length=2000) name = models.CharField(max_length=2000)
city = models.CharField(max_length=200, blank=True, null=True)
housenumber = models.CharField(max_length=20, blank=True, null=True)
postcode = models.CharField(max_length=20, blank=True, null=True)
street = models.CharField(max_length=200, blank=True, null=True)
county = models.CharField(max_length=200, blank=True, null=True)
# Country code as per ISO 3166-1 alpha-2
# https://en.wikipedia.org/wiki/List_of_ISO_3166_country_codes
countrycode = models.CharField(max_length=2, verbose_name=_("Ländercode"),
help_text=_("Standardisierter Ländercode nach ISO 3166-1 ALPHA-2"),
blank=True, null=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
def __str__(self): def __str__(self):
return f"{self.name} ({self.latitude:.5}, {self.longitude:.5})" if self.city and self.postcode:
return f"{self.city} ({self.postcode})"
else:
return f"{self.name} ({self.latitude:.5}, {self.longitude:.5})"
@property @property
def position(self): def position(self):
@ -73,6 +87,11 @@ class Location(models.Model):
latitude=proxy.latitude, latitude=proxy.latitude,
longitude=proxy.longitude, longitude=proxy.longitude,
name=proxy.name, name=proxy.name,
postcode=proxy.postcode,
city=proxy.city,
street=proxy.street,
county=proxy.county,
countrycode=proxy.countrycode,
) )
return location return location
@ -84,33 +103,33 @@ class Location(models.Model):
instance.save() instance.save()
class ImportantLocation(models.Model):
location = models.OneToOneField(Location, on_delete=models.CASCADE)
slug = models.SlugField(unique=True)
name = models.CharField(max_length=200)
class ExternalSourceChoices(models.TextChoices): class ExternalSourceChoices(models.TextChoices):
OSM = "OSM", _("Open Street Map") OSM = "OSM", _("Open Street Map")
class AllowUseOfMaterialsChices(models.TextChoices):
USE_MATERIALS_ALLOWED = "allowed", _("Usage allowed")
USE_MATERIALS_REQUESTED = "requested", _("Usage requested")
USE_MATERIALS_DENIED = "denied", _("Usage denied")
USE_MATERIALS_OTHER = "other", _("It's complicated")
USE_MATERIALS_NOT_ASKED = "not_asked", _("Not asked")
class RescueOrganization(models.Model): class RescueOrganization(models.Model):
def __str__(self): def __str__(self):
return f"{self.name}" return f"{self.name}"
USE_MATERIALS_ALLOWED = "allowed"
USE_MATERIALS_REQUESTED = "requested"
USE_MATERIALS_DENIED = "denied"
USE_MATERIALS_OTHER = "other"
USE_MATERIALS_NOT_ASKED = "not_asked"
ALLOW_USE_MATERIALS_CHOICE = {
USE_MATERIALS_ALLOWED: "Usage allowed",
USE_MATERIALS_REQUESTED: "Usage requested",
USE_MATERIALS_DENIED: "Usage denied",
USE_MATERIALS_OTHER: "It's complicated",
USE_MATERIALS_NOT_ASKED: "Not asked"
}
name = models.CharField(max_length=200) name = models.CharField(max_length=200)
trusted = models.BooleanField(default=False, verbose_name=_('Vertrauenswürdig')) trusted = models.BooleanField(default=False, verbose_name=_('Vertrauenswürdig'))
allows_using_materials = models.CharField(max_length=200, allows_using_materials = models.CharField(max_length=200,
default=ALLOW_USE_MATERIALS_CHOICE[USE_MATERIALS_NOT_ASKED], default=AllowUseOfMaterialsChices.USE_MATERIALS_NOT_ASKED,
choices=ALLOW_USE_MATERIALS_CHOICE, choices=AllowUseOfMaterialsChices.choices,
verbose_name=_('Erlaubt Nutzung von Inhalten')) verbose_name=_('Erlaubt Nutzung von Inhalten'))
location_string = models.CharField(max_length=200, verbose_name=_("Ort der Organisation")) location_string = models.CharField(max_length=200, verbose_name=_("Ort der Organisation"))
location = models.ForeignKey(Location, on_delete=models.PROTECT, blank=True, null=True) location = models.ForeignKey(Location, on_delete=models.PROTECT, blank=True, null=True)
@ -122,6 +141,7 @@ class RescueOrganization(models.Model):
website = models.URLField(null=True, blank=True, verbose_name=_('Website')) website = models.URLField(null=True, blank=True, verbose_name=_('Website'))
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
last_checked = models.DateTimeField(auto_now_add=True, verbose_name=_('Datum der letzten Prüfung'))
internal_comment = models.TextField(verbose_name=_("Interner Kommentar"), null=True, blank=True, ) internal_comment = models.TextField(verbose_name=_("Interner Kommentar"), null=True, blank=True, )
description = models.TextField(null=True, blank=True, verbose_name=_('Beschreibung')) # Markdown allowed description = models.TextField(null=True, blank=True, verbose_name=_('Beschreibung')) # Markdown allowed
external_object_identifier = models.CharField(max_length=200, null=True, blank=True, external_object_identifier = models.CharField(max_length=200, null=True, blank=True,
@ -130,6 +150,9 @@ class RescueOrganization(models.Model):
choices=ExternalSourceChoices.choices, choices=ExternalSourceChoices.choices,
verbose_name=_('External Source Identifier')) verbose_name=_('External Source Identifier'))
class Meta:
unique_together = ('external_object_identifier', 'external_source_identifier',)
def get_absolute_url(self): def get_absolute_url(self):
return reverse("rescue-organization-detail", args=[str(self.pk)]) return reverse("rescue-organization-detail", args=[str(self.pk)])
@ -149,7 +172,20 @@ class RescueOrganization(models.Model):
if self.description is None: if self.description is None:
return "" return ""
if len(self.description) > 200: if len(self.description) > 200:
return self.description[:200] + f" ... [weiterlesen]({self.get_absolute_url()})" return self.description[:200] + _(f" ... [weiterlesen]({self.get_absolute_url()})")
def set_checked(self):
self.last_checked = timezone.now()
self.save()
@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)
@property
def species_urls(self):
return SpeciesSpecificURL.objects.filter(organization=self)
# Admins can perform all actions and have the highest trust associated with them # Admins can perform all actions and have the highest trust associated with them
@ -261,11 +297,13 @@ class AdoptionNotice(models.Model):
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
last_checked = models.DateTimeField(verbose_name=_('Zuletzt überprüft am'), default=timezone.now) last_checked = models.DateTimeField(verbose_name=_('Zuletzt überprüft am'), default=timezone.now)
searching_since = models.DateField(verbose_name=_('Sucht nach einem Zuhause seit')) searching_since = models.DateField(verbose_name=_('Sucht nach einem Zuhause seit'))
name = models.CharField(max_length=200) name = models.CharField(max_length=200, verbose_name=_('Titel der Vermittlung'))
description = models.TextField(null=True, blank=True, verbose_name=_('Beschreibung')) description = models.TextField(null=True, blank=True, verbose_name=_('Beschreibung'))
organization = models.ForeignKey(RescueOrganization, blank=True, null=True, on_delete=models.SET_NULL, organization = models.ForeignKey(RescueOrganization, blank=True, null=True, on_delete=models.SET_NULL,
verbose_name=_('Organisation')) verbose_name=_('Organisation'))
further_information = models.URLField(null=True, blank=True, verbose_name=_('Link zu mehr Informationen')) further_information = models.URLField(null=True, blank=True,
verbose_name=_('Link zu mehr Informationen'),
help_text=_("Verlinke hier die Quelle der Vermittlung (z.B. die Website des Tierheims"))
group_only = models.BooleanField(default=False, verbose_name=_('Ausschließlich Gruppenadoption')) group_only = models.BooleanField(default=False, verbose_name=_('Ausschließlich Gruppenadoption'))
photos = models.ManyToManyField(Image, blank=True) photos = models.ManyToManyField(Image, blank=True)
location_string = models.CharField(max_length=200, verbose_name=_("Ortsangabe")) location_string = models.CharField(max_length=200, verbose_name=_("Ortsangabe"))
@ -283,6 +321,13 @@ class AdoptionNotice(models.Model):
sexes.add(animal.sex) sexes.add(animal.sex)
return sexes return sexes
@property
def num_per_sex(self):
num_per_sex = dict()
for sex in SexChoices:
num_per_sex[sex] = self.animals.filter(sex=sex).count
return num_per_sex
@property @property
def last_checked_hr(self): def last_checked_hr(self):
time_since_last_checked = timezone.now() - self.last_checked time_since_last_checked = timezone.now() - self.last_checked
@ -323,6 +368,10 @@ class AdoptionNotice(models.Model):
"""Returns the url to access a detailed page for the adoption notice.""" """Returns the url to access a detailed page for the adoption notice."""
return reverse('adoption-notice-detail', args=[str(self.id)]) return reverse('adoption-notice-detail', args=[str(self.id)])
def get_absolute_url_bulma(self):
"""Returns the url to access a detailed page for the adoption notice."""
return reverse('adoption-notice-detail-bulma', args=[str(self.id)])
def get_report_url(self): def get_report_url(self):
"""Returns the url to report an adoption notice.""" """Returns the url to report an adoption notice."""
return reverse('report-adoption-notice', args=[str(self.id)]) return reverse('report-adoption-notice', args=[str(self.id)])
@ -666,6 +715,31 @@ class Report(models.Model):
def get_moderation_actions(self): def get_moderation_actions(self):
return ModerationAction.objects.filter(report=self) return ModerationAction.objects.filter(report=self)
@property
def reported_content(self):
"""
Dynamically fetch the reported content based on subclass.
The alternative would be to use the ContentType framework:
https://docs.djangoproject.com/en/5.1/ref/contrib/contenttypes/
"""
if hasattr(self, "reportadoptionnotice"):
return self.reportadoptionnotice.adoption_notice
elif hasattr(self, "reportcomment"):
return self.reportcomment.reported_comment
return None
@property
def reported_content_url(self):
"""
Same as reported_content, just for url
"""
if hasattr(self, "reportadoptionnotice"):
print(self.reportadoptionnotice.adoption_notice.get_absolute_url)
return self.reportadoptionnotice.adoption_notice.get_absolute_url
elif hasattr(self, "reportcomment"):
return self.reportcomment.reported_comment.get_absolute_url
return None
class ReportAdoptionNotice(Report): class ReportAdoptionNotice(Report):
adoption_notice = models.ForeignKey("AdoptionNotice", on_delete=models.CASCADE) adoption_notice = models.ForeignKey("AdoptionNotice", on_delete=models.CASCADE)
@ -674,6 +748,9 @@ class ReportAdoptionNotice(Report):
def reported_content(self): def reported_content(self):
return self.adoption_notice return self.adoption_notice
def __str__(self):
return f"Report der Vermittlung {self.adoption_notice}"
class ReportComment(Report): class ReportComment(Report):
reported_comment = models.ForeignKey("Comment", on_delete=models.CASCADE) reported_comment = models.ForeignKey("Comment", on_delete=models.CASCADE)
@ -818,7 +895,8 @@ class Comment(models.Model):
class BaseNotification(models.Model): class BaseNotification(models.Model):
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
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") text = models.TextField(verbose_name="Inhalt")
user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name=_('Nutzer*in')) user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name=_('Nutzer*in'))
read = models.BooleanField(default=False) read = models.BooleanField(default=False)
@ -829,6 +907,11 @@ class BaseNotification(models.Model):
def get_absolute_url(self): def get_absolute_url(self):
self.user.get_notifications_url() self.user.get_notifications_url()
def mark_read(self):
self.read = True
self.read_at = timezone.now()
self.save()
class CommentNotification(BaseNotification): class CommentNotification(BaseNotification):
comment = models.ForeignKey(Comment, on_delete=models.CASCADE, verbose_name=_('Antwort')) comment = models.ForeignKey(Comment, on_delete=models.CASCADE, verbose_name=_('Antwort'))

View File

@ -1,6 +1,6 @@
from django.db.models.signals import post_save from django.db.models.signals import post_save
from django.dispatch import receiver from django.dispatch import receiver
from fellchensammlung.models import BaseNotification, CommentNotification, User, TrustLevel from fellchensammlung.models import BaseNotification, CommentNotification, User, TrustLevel, RescueOrganization
from .tasks import task_send_notification_email from .tasks import task_send_notification_email
from notfellchen.settings import host from notfellchen.settings import host
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -18,6 +18,13 @@ def base_notification_receiver(sender, instance: BaseNotification, created: bool
else: else:
task_send_notification_email.delay(instance.pk) task_send_notification_email.delay(instance.pk)
@receiver(post_save, sender=RescueOrganization)
def rescue_org_receiver(sender, instance: RescueOrganization, created: bool, **kwargs):
if instance.location:
return
else:
task_send_notification_email.delay(instance.pk)
@receiver(post_save, sender=User) @receiver(post_save, sender=User)
def notification_new_user(sender, instance: User, created: bool, **kwargs): def notification_new_user(sender, instance: User, created: bool, **kwargs):

View File

@ -0,0 +1,47 @@
from django.contrib.sitemaps import Sitemap
from django.urls import reverse
from .models import AdoptionNotice, RescueOrganization
class StaticViewSitemap(Sitemap):
priority = 0.8
changefreq = "weekly"
def items(self):
return ["index", "search", "map", "about", "rescue-organizations"]
def location(self, item):
return reverse(item)
class AdoptionNoticeSitemap(Sitemap):
priority = 0.5
changefreq = "daily"
def items(self):
return AdoptionNotice.get_active_ANs()
def lastmod(self, obj):
return obj.updated_at
class AnimalSitemap(Sitemap):
priority = 0.2
changefreq = "daily"
def items(self):
return AdoptionNotice.objects.all()
def lastmod(self, obj):
return obj.updated_at
class RescueOrganizationSitemap(Sitemap):
priority = 0.3
changefreq = "weekly"
def items(self):
return RescueOrganization.objects.all()
def lastmod(self, obj):
return obj.updated_at

View File

@ -0,0 +1,60 @@
/***************/
/* MAIN COLORS */
/***************/
:root {
--primary-light-one: #5daa68;
--primary-light-two: #4a9455;
--primary-semidark-one: #356c3c;
--primary-dark-one: #17311b;
--secondary-light-one: #faf1cf;
--secondary-light-two: #e1d7b5;
--background-one: var(--primary-light-one);
--background-two: var(--primary-light-two);
--background-three: var(--secondary-light-one);
--background-four: var(--primary-dark-one);
--highlight-one: var(--primary-dark-one);
--highlight-one-text: var(--secondary-light-one);
--highlight-two: var(--primary-semidark-one);
--text-one: var(--secondary-light-one);
--shadow-one: var(--primary-dark-one);
--text-two: var(--primary-dark-one);
--text-three: var(--primary-light-one);
--shadow-three: var(--primary-dark-one);
}
.content {
padding: 10px;
}
/*******/
/* MAP */
/*******/
.map {
border-radius: 8px;
width:100%;
height:100%
}
.marker {
background-image: url('../img/logo_transparent.png');
background-size: cover;
width: 50px;
height: 50px;
cursor: pointer;
}
.animal-shelter-marker {
background-image: url('../img/animal_shelter.png');
!important;
}
.maplibregl-popup {
max-width: 600px !important;
}
.map-in-content #map {
max-height: 500px;
width: 90%;
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,420 @@
/*! PhotoSwipe main CSS by Dmytro Semenov | photoswipe.com */
.pswp {
--pswp-bg: #000;
--pswp-placeholder-bg: #222;
--pswp-root-z-index: 100000;
--pswp-preloader-color: rgba(79, 79, 79, 0.4);
--pswp-preloader-color-secondary: rgba(255, 255, 255, 0.9);
/* defined via js:
--pswp-transition-duration: 333ms; */
--pswp-icon-color: #fff;
--pswp-icon-color-secondary: #4f4f4f;
--pswp-icon-stroke-color: #4f4f4f;
--pswp-icon-stroke-width: 2px;
--pswp-error-text-color: var(--pswp-icon-color);
}
/*
Styles for basic PhotoSwipe (pswp) functionality (sliding area, open/close transitions)
*/
.pswp {
position: fixed;
z-index: var(--pswp-root-z-index);
display: none;
touch-action: none;
outline: 0;
opacity: 0.003;
contain: layout style size;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
/* Prevents focus outline on the root element,
(it may be focused initially) */
.pswp:focus {
outline: 0;
}
.pswp * {
box-sizing: border-box;
}
.pswp img {
max-width: none;
}
.pswp--open {
display: block;
}
.pswp,
.pswp__bg {
transform: translateZ(0);
will-change: opacity;
}
.pswp__bg {
opacity: 0.005;
background: var(--pswp-bg);
}
.pswp,
.pswp__scroll-wrap {
overflow: hidden;
}
.pswp,
.pswp__scroll-wrap,
.pswp__bg,
.pswp__container,
.pswp__item,
.pswp__content,
.pswp__img,
.pswp__zoom-wrap {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.pswp {
position: fixed;
}
.pswp__img,
.pswp__zoom-wrap {
width: auto;
height: auto;
}
.pswp--click-to-zoom.pswp--zoom-allowed .pswp__img {
cursor: -webkit-zoom-in;
cursor: -moz-zoom-in;
cursor: zoom-in;
}
.pswp--click-to-zoom.pswp--zoomed-in .pswp__img {
cursor: move;
cursor: -webkit-grab;
cursor: -moz-grab;
cursor: grab;
}
.pswp--click-to-zoom.pswp--zoomed-in .pswp__img:active {
cursor: -webkit-grabbing;
cursor: -moz-grabbing;
cursor: grabbing;
}
/* :active to override grabbing cursor */
.pswp--no-mouse-drag.pswp--zoomed-in .pswp__img,
.pswp--no-mouse-drag.pswp--zoomed-in .pswp__img:active,
.pswp__img {
cursor: -webkit-zoom-out;
cursor: -moz-zoom-out;
cursor: zoom-out;
}
/* Prevent selection and tap highlights */
.pswp__container,
.pswp__img,
.pswp__button,
.pswp__counter {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.pswp__item {
/* z-index for fade transition */
z-index: 1;
overflow: hidden;
}
.pswp__hidden {
display: none !important;
}
/* Allow to click through pswp__content element, but not its children */
.pswp__content {
pointer-events: none;
}
.pswp__content > * {
pointer-events: auto;
}
/*
PhotoSwipe UI
*/
/*
Error message appears when image is not loaded
(JS option errorMsg controls markup)
*/
.pswp__error-msg-container {
display: grid;
}
.pswp__error-msg {
margin: auto;
font-size: 1em;
line-height: 1;
color: var(--pswp-error-text-color);
}
/*
class pswp__hide-on-close is applied to elements that
should hide (for example fade out) when PhotoSwipe is closed
and show (for example fade in) when PhotoSwipe is opened
*/
.pswp .pswp__hide-on-close {
opacity: 0.005;
will-change: opacity;
transition: opacity var(--pswp-transition-duration) cubic-bezier(0.4, 0, 0.22, 1);
z-index: 10; /* always overlap slide content */
pointer-events: none; /* hidden elements should not be clickable */
}
/* class pswp--ui-visible is added when opening or closing transition starts */
.pswp--ui-visible .pswp__hide-on-close {
opacity: 1;
pointer-events: auto;
}
/* <button> styles, including css reset */
.pswp__button {
position: relative;
display: block;
width: 50px;
height: 60px;
padding: 0;
margin: 0;
overflow: hidden;
cursor: pointer;
background: none;
border: 0;
box-shadow: none;
opacity: 0.85;
-webkit-appearance: none;
-webkit-touch-callout: none;
}
.pswp__button:hover,
.pswp__button:active,
.pswp__button:focus {
transition: none;
padding: 0;
background: none;
border: 0;
box-shadow: none;
opacity: 1;
}
.pswp__button:disabled {
opacity: 0.3;
cursor: auto;
}
.pswp__icn {
fill: var(--pswp-icon-color);
color: var(--pswp-icon-color-secondary);
}
.pswp__icn {
position: absolute;
top: 14px;
left: 9px;
width: 32px;
height: 32px;
overflow: hidden;
pointer-events: none;
}
.pswp__icn-shadow {
stroke: var(--pswp-icon-stroke-color);
stroke-width: var(--pswp-icon-stroke-width);
fill: none;
}
.pswp__icn:focus {
outline: 0;
}
/*
div element that matches size of large image,
large image loads on top of it,
used when msrc is not provided
*/
div.pswp__img--placeholder,
.pswp__img--with-bg {
background: var(--pswp-placeholder-bg);
}
.pswp__top-bar {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 60px;
display: flex;
flex-direction: row;
justify-content: flex-end;
z-index: 10;
/* allow events to pass through top bar itself */
pointer-events: none !important;
}
.pswp__top-bar > * {
pointer-events: auto;
/* this makes transition significantly more smooth,
even though inner elements are not animated */
will-change: opacity;
}
/*
Close button
*/
.pswp__button--close {
margin-right: 6px;
}
/*
Arrow buttons
*/
.pswp__button--arrow {
position: absolute;
top: 0;
width: 75px;
height: 100px;
top: 50%;
margin-top: -50px;
}
.pswp__button--arrow:disabled {
display: none;
cursor: default;
}
.pswp__button--arrow .pswp__icn {
top: 50%;
margin-top: -30px;
width: 60px;
height: 60px;
background: none;
border-radius: 0;
}
.pswp--one-slide .pswp__button--arrow {
display: none;
}
/* hide arrows on touch screens */
.pswp--touch .pswp__button--arrow {
visibility: hidden;
}
/* show arrows only after mouse was used */
.pswp--has_mouse .pswp__button--arrow {
visibility: visible;
}
.pswp__button--arrow--prev {
right: auto;
left: 0px;
}
.pswp__button--arrow--next {
right: 0px;
}
.pswp__button--arrow--next .pswp__icn {
left: auto;
right: 14px;
/* flip horizontally */
transform: scale(-1, 1);
}
/*
Zoom button
*/
.pswp__button--zoom {
display: none;
}
.pswp--zoom-allowed .pswp__button--zoom {
display: block;
}
/* "+" => "-" */
.pswp--zoomed-in .pswp__zoom-icn-bar-v {
display: none;
}
/*
Loading indicator
*/
.pswp__preloader {
position: relative;
overflow: hidden;
width: 50px;
height: 60px;
margin-right: auto;
}
.pswp__preloader .pswp__icn {
opacity: 0;
transition: opacity 0.2s linear;
animation: pswp-clockwise 600ms linear infinite;
}
.pswp__preloader--active .pswp__icn {
opacity: 0.85;
}
@keyframes pswp-clockwise {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/*
"1 of 10" counter
*/
.pswp__counter {
height: 30px;
margin: 15px 0 0 20px;
font-size: 14px;
line-height: 30px;
color: var(--pswp-icon-color);
text-shadow: 1px 1px 3px var(--pswp-icon-color-secondary);
opacity: 0.85;
}
.pswp--one-slide .pswp__counter {
display: none;
}

View File

@ -259,6 +259,11 @@ a.btn, a.btn2, a.nav-link {
border: 1px solid black; border: 1px solid black;
} }
.btn-small {
font-size: medium;
padding: 6px;
}
.checkmark { .checkmark {
display: inline-block; display: inline-block;
position: relative; position: relative;

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,11 @@
import PhotoSwipeLightbox from 'https://unpkg.com/photoswipe/dist/photoswipe-lightbox.esm.js';
const lightbox = new PhotoSwipeLightbox({
gallery: '#my-gallery',
children: 'a',
pswpModule: () => import('https://unpkg.com/photoswipe'),
});
lightbox.init();

View File

@ -0,0 +1,32 @@
document.addEventListener('DOMContentLoaded', () => {
// Get all "navbar-burger" elements
const $navbarBurgers = Array.prototype.slice.call(document.querySelectorAll('.navbar-burger'), 0);
// Add a click event on each of them
$navbarBurgers.forEach( el => {
el.addEventListener('click', () => {
// Get the target from the "data-target" attribute
const target = el.dataset.target;
const $target = document.getElementById(target);
// Toggle the "is-active" class on both the "navbar-burger" and the "navbar-menu"
el.classList.toggle('is-active');
$target.classList.toggle('is-active');
});
});
});
// Looks for all notifications with a delete and allows closing them when pressing delete
document.addEventListener('DOMContentLoaded', () => {
(document.querySelectorAll('.notification .delete') || []).forEach(($delete) => {
const $notification = $delete.parentNode;
$delete.addEventListener('click', () => {
$notification.parentNode.removeChild($notification);
});
});
});

View File

@ -0,0 +1,7 @@
User-agent: *
Disallow: /admin/
User-agent: OpenAI
Disallow: /
Sitemap: https://notfellchen.org/sitemap.xml

View File

@ -6,8 +6,8 @@ from notfellchen.celery import app as celery_app
from .mail import send_notification_email from .mail import send_notification_email
from .tools.admin import clean_locations, deactivate_unchecked_adoption_notices, deactivate_404_adoption_notices from .tools.admin import clean_locations, deactivate_unchecked_adoption_notices, deactivate_404_adoption_notices
from .tools.misc import healthcheck_ok from .tools.misc import healthcheck_ok
from .models import Location, AdoptionNotice, Timestamp from .models import Location, AdoptionNotice, Timestamp, RescueOrganization
from .tools.notifications import notify_moderators_of_AN_to_be_checked from .tools.notifications import notify_of_AN_to_be_checked
from .tools.search import notify_search_subscribers from .tools.search import notify_search_subscribers
@ -46,7 +46,7 @@ def post_adoption_notice_save(pk):
logging.info(f"Location was added to Adoption notice {pk}") logging.info(f"Location was added to Adoption notice {pk}")
notify_search_subscribers(instance, only_if_active=True) notify_search_subscribers(instance, only_if_active=True)
notify_moderators_of_AN_to_be_checked(instance) notify_of_AN_to_be_checked(instance)
@celery_app.task(name="tools.healthcheck") @celery_app.task(name="tools.healthcheck")
def task_healthcheck(): def task_healthcheck():
@ -57,3 +57,10 @@ def task_healthcheck():
@shared_task @shared_task
def task_send_notification_email(notification_pk): def task_send_notification_email(notification_pk):
send_notification_email(notification_pk) send_notification_email(notification_pk)
@celery_app.task(name="commit.post_rescue_org_save")
def post_rescue_org_save(pk):
instance = RescueOrganization.objects.get(pk=pk)
Location.add_location_to_object(instance)
set_timestamp("add_rescue_org_location")
logging.info(f"Location was added to Rescue Organization {pk}")

View File

@ -0,0 +1,43 @@
{% load custom_tags %}
{% load i18n %}
{% load static %}
{% get_current_language as LANGUAGE_CODE%}
<!DOCTYPE html>
<html lang="{{ LANGUAGE_CODE }}">
<head>
{% block title %}{% endblock %}
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="{% translate "Farbratten aus dem Tierschutz finden und adoptieren" %}">
<!-- Add additional CSS in static file -->
<link rel="stylesheet" href="{% static 'fellchensammlung/css/bulma-styles.css' %}">
<link rel="stylesheet" href="{% static 'fellchensammlung/css/bulma.min.css' %}">
<link rel="stylesheet" href="https://unpkg.com/photoswipe@5.2.2/dist/photoswipe.css">
<link href="{% static 'fontawesomefree/css/fontawesome.css' %}" rel="stylesheet" type="text/css">
<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>
<script src="{% static 'fellchensammlung/js/toggles.js' %}"></script>
<script type="module" src="{% static 'fellchensammlung/js/photoswipe.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' %}">
{% get_oxitraffic_script_if_enabled %}
</head>
<body>
{% block header %}
{% include "fellchensammlung/bulma-header.html" %}
{% endblock %}
<div class="content">
{% block content %}{% endblock %}
</div>
{% block footer %}
{% include "fellchensammlung/bulma-footer.html" %}
{% endblock %}
</body>
</html>

View File

@ -1,7 +1,8 @@
{% load custom_tags %} {% load custom_tags %}
{% load i18n %} {% load i18n %}
{% get_current_language as LANGUAGE_CODE%}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="{{ LANGUAGE_CODE }}">
<head> <head>
{% block title %}{% endblock %} {% block title %}{% endblock %}
<meta charset="utf-8"> <meta charset="utf-8">

View File

@ -0,0 +1,27 @@
{% extends "fellchensammlung/base_bulma.html" %}
{% load i18n %}
{% load custom_tags %}
{% block title %}<title>{% translate "Über uns" %}</title>{% endblock %}
{% block content %}
{% if about_us %}
<div class="block">
<h1 class="title is-1">{{ about_us.title }}</h1>
<div class="content">
{{ about_us.content | render_markdown }}
</div>
</div>
{% endif %}
{% if faq %}
<div class="card">
<div class="card-header">
<h2 class="card-header-title">{{ faq.title }}</h2>
</div>
<div class="card-content">
{{ faq.content | render_markdown }}
</div>
</div>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,83 @@
{% load static %}
{% load i18n %}
<footer class="footer">
<div class="columns">
<div class="column">
<div class="block">
<h3 class="bd-footer-title title is-3 has-text-left">
Notfellchen
</h3>
<!-- footer content -->
<p class="bd-footer-link
has-text-left">
Für Menschen die Ratten aus dem Tierschutz ein liebendes Zuhause geben wollen.
</p>
</div>
<div class="block">
<h3 class="bd-footer-title title is-5">
{% trans 'Sprache ändern' %}
</h3>
{% include "fellchensammlung/forms/change_language.html" %}
</div>
</div>
<div class="column">
<h4 class="bd-footer-title title is-4 has-text-justify">
{% translate 'Über uns' %}
</h4>
<a class="bd-footer-link" href="{% url "about-bulma" %}">
{% translate 'Das Notfellchen Projekt' %}
</a>
<br/>
<a class="bd-footer-link" href="{% url "terms-of-service" %}">
{% translate 'Nutzungsbedingungen' %}
</a>
<br/>
<a class="bd-footer-link" href="{% url "privacy" %}">
{% translate 'Datenschutz' %}
</a>
<br/>
<a class="bd-footer-link" href="{% url "imprint" %}">
{% translate 'Impressum' %}
</a>
<br/>
</div>
<div class="column">
<h4 class="bd-footer-title title is-4 has-text-justify">
Technisches
</h4>
<p class="bd-footer-link">
<a class="nav-link " href="{% url "rss" %}">
<i class="fa-solid fa-rss"></i> {% translate 'RSS' %}
</a>
<br/>
<a href="https://dokumentation.notfellchen.org/">
<span class="icon-text">
<span>{% translate 'Dokumentation' %}</span>
</span>
</a>
<br/>
<a href="mailto:info@notfellchen.org">
<span class="icon-text">
<span>{% translate 'Probleme melden' %}</span>
</span>
</a>
<br/>
<a href="https://codeberg.org/moanos/notfellchen">
<span class="icon-text">
<span>{% trans 'Code' %}</span>
</span>
</a>
</p>
</div>
</div>
</footer>

View File

@ -0,0 +1,43 @@
{% load static %}
{% load i18n %}
<nav class="navbar" role="navigation" aria-label="main navigation">
<div class="navbar-brand">
<a class="navbar-item" href="{% url 'index-bulma' %}">
<img src="{% static 'fellchensammlung/img/logo_transparent.png' %}" alt="{% trans 'Notfellchen Logo' %}">
<h1 class="title is-4">notfellchen.org</h1>
</a>
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="navbarBasicExample">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div id="navbarBasicExample" class="navbar-menu">
<div class="navbar-start">
<a class="navbar-item" href="{% url 'search-bulma' %}">
<i class="fas fa-search"></i> {% translate 'Suchen' %}
</a>
<a class="navbar-item" href="{% url "add-adoption-bulma" %}">
<i class="fas fa-feather"></i> {% translate 'Vermittlung hinzufügen' %}
</a>
<div class="navbar-end">
<div class="navbar-item">
<div class="buttons">
<a class="button is-primary" href="{% url "django_registration_register" %}">
<strong>{% translate "Registrieren" %}</strong>
</a>
<a class="button is-light" href="{% url "login" %}">
<strong>{% translate "Login" %}</strong>
</a>
</div>
</div>
</div>
</div>
</div>
</nav>

View File

@ -0,0 +1,34 @@
{% extends "fellchensammlung/base_bulma.html" %}
{% load i18n %}
{% load custom_tags %}
{% block title %}<title>{% translate "Notfellchen - Farbratten aus dem Tierschutz adoptieren" %}</title>{% endblock %}
{% block content %}
{% for announcement in announcements %}
{% include "fellchensammlung/partials/bulma-partial-announcement.html" %}
{% endfor %}
{% if introduction %}
<h1>{{ introduction.title }}</h1>
{{ introduction.content | render_markdown }}
{% endif %}
<h2>{% translate "Aktuelle Vermittlungen" %}</h2>
<div class="block">
{% include "fellchensammlung/lists/bulma-list-adoption-notices.html" %}
<a class="button is-primary" href="{% url 'search' %}">{% translate "Mehr Vermittlungen" %}</a>
</div>
<div class="block" style="height: 50vh">
{% include "fellchensammlung/partials/bulma-partial-map.html" %}
</div>
{% if how_to %}
<div class="card">
<h1>{{ how_to.title }}</h1>
{{ how_to.content | render_markdown }}
</div>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,9 @@
{% extends "fellchensammlung/base_bulma.html" %}
{% load i18n %}
{% block title %}<title>{% translate "Karte" %}</title>{% endblock %}
{% block content %}
<div style="height:70vh">
{% include "fellchensammlung/partials/bulma-partial-map.html" %}
</div>
{% endblock %}

View File

@ -0,0 +1,15 @@
{% extends "fellchensammlung/base_bulma.html" %}
{% load i18n %}
{% load custom_tags %}
{% block title %}<title>{{ text.title }}</title>{% endblock %}
{% block content %}
<div class="block">
<h1 class="title is-1">{{ text.title }}</h1>
<div class="content">
{{ text.content | render_markdown }}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,97 @@
{% extends "fellchensammlung/base_bulma.html" %}
{% load i18n %}
{% block title %}<title>{% translate "Suche" %}</title>{% endblock %}
{% block content %}
{% get_current_language as LANGUAGE_CODE_CURRENT %}
<div class="columns">
<div class="column is-two-thirds">
<div style="height: 50vh">
{% include "fellchensammlung/partials/bulma-partial-map.html" %}
</div>
</div>
<div class="column">
<form class="block" 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">
<!--- https://docs.djangoproject.com/en/5.2/topics/forms/#reusable-form-templates -->
{{ search_form }}
<ul id="results"></ul>
<button class="button is-primary" type="submit" value="search" name="search">
<i class="fas fa-search"></i> {% trans 'Suchen' %}
</button>
{% if searched %}
{% if subscribed_search %}
<button class="button" 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="button" type="submit" name="subscribe_to_search">
<i class="fas fa-bell"></i> {% trans 'Suche abonnieren' %}
</button>
{% endif %}
{% endif %}
</form>
<div class="block">
{% if place_not_found %}
<div class="block notification is-warning">
<p>
{% trans 'Ort nicht gefunden' %}
</p>
</div>
{% endif %}
</div>
</div>
</div>
<div class="">
{% include "fellchensammlung/lists/bulma-list-adoption-notices.html" %}
</div>
<script>
const locationInput = document.getElementById('id_location_string');
const resultsList = document.getElementById('results');
const placeIdInput = document.getElementById('place_id');
locationInput.addEventListener('input', async function () {
const query = locationInput.value.trim();
if (query.length < 3) {
resultsList.innerHTML = ''; // Don't search for or show results if input is less than 3 characters
return;
}
try {
const response = await fetch(`{{ geocoding_api_url }}/?q=${encodeURIComponent(query)}&limit=5&lang={{ LANGUAGE_CODE_CURRENT }}`);
const data = await response.json();
if (data && data.features) {
resultsList.innerHTML = ''; // Clear previous results
const locations = data.features.slice(0, 5); // Show only the first 5 results
locations.forEach(location => {
const listItem = document.createElement('li');
listItem.classList.add('result-item');
listItem.textContent = geojson_to_summary(location);
// Add event when user clicks on a result location
listItem.addEventListener('click', () => {
locationInput.value = geojson_to_searchable_string(location); // Set input field to selected location
resultsList.innerHTML = ''; // Clear the results after selecting a location
});
resultsList.appendChild(listItem);
});
}
} catch (error) {
console.error('Error fetching location data:', error);
resultsList.innerHTML = '<li class="result-item">Error fetching data. Please try again.</li>';
}
});
</script>
{% endblock %}

View File

@ -0,0 +1,120 @@
{% extends "fellchensammlung/base_bulma.html" %}
{% load i18n %}
{% load static %}
{% block title %}<title>{% translate "Styleguide für Bulma" %}</title>{% endblock %}
{% block content %}
<section class="section">
<div class="container">
<h1 class="title">
Hello World
</h1>
<p class="subtitle">
Notfellchen bald mit <strong>Bulma</strong>?
</p>
</div>
<div class="grid">
<div class="card">
<div class="card-image">
<figure class="image">
<img
src="{% static 'fellchensammlung/img/example_rat_single.png' %}"
alt="Placeholder image"
/>
</figure>
</div>
<div class="card-content">
<div class="media">
<div class="media-left">
<figure class="image is-48x48">
<img
src="https://bulma.io/assets/images/placeholders/96x96.png"
alt="Placeholder image"
/>
</figure>
</div>
<div class="media-content">
<p class="title is-4">John Smith</p>
<p class="subtitle is-6">@johnsmith</p>
</div>
</div>
<div class="content">
Süße Ratte sucht Zuhause
<a href="#">#responsive</a>
<br/>
<time datetime="2016-1-1">11:09 PM - 1 Jan 2016</time>
</div>
</div>
</div>
<div class="card">
<div class="card-image">
<figure class="image">
<img
src="{% static 'fellchensammlung/img/example_rat_single.png' %}"
alt="Placeholder image"
/>
</figure>
</div>
<div class="card-content">
<div class="media">
<div class="media-left">
<figure class="image is-48x48">
<img
src="https://bulma.io/assets/images/placeholders/96x96.png"
alt="Placeholder image"
/>
</figure>
</div>
<div class="media-content">
<p class="title is-4">John Smith</p>
<p class="subtitle is-6">@johnsmith</p>
</div>
</div>
<div class="content">
Süßeste Ratte sucht Zuhause
<a href="#">#responsive</a>
<br/>
<time datetime="2016-1-1">11:09 PM - 1 Jan 2016</time>
</div>
</div>
</div>
<div class="card">
<div class="card-image">
<figure class="image">
<img
src="{% static 'fellchensammlung/img/example_rat_single.png' %}"
alt="Placeholder image"
/>
</figure>
</div>
<div class="card-content">
<div class="media">
<div class="media-left">
<figure class="image is-48x48">
<img
src="https://bulma.io/assets/images/placeholders/96x96.png"
alt="Placeholder image"
/>
</figure>
</div>
<div class="media-content">
<p class="title is-4">John Smith</p>
<p class="subtitle is-6">@johnsmith</p>
</div>
</div>
<div class="content">
Süßere Ratte sucht Zuhause
<a href="#">#responsive</a>
<br/>
<time datetime="2016-1-1">11:09 PM - 1 Jan 2016</time>
</div>
</div>
</div>
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,17 @@
{% extends "fellchensammlung/base_bulma.html" %}
{% load i18n %}
{% load custom_tags %}
{% block title %}<title>{% translate "Über uns" %}</title>{% endblock %}
{% block content %}
<h2 class="title is-2">{% translate "Regeln" %}</h2>
{% include "fellchensammlung/lists/bulma-list-rules.html" %}
<div class="block">
<h1 class="title is-1">{{ text.title }}</h1>
<div class="content">
{{ text.content | render_markdown }}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,113 @@
{% extends "fellchensammlung/base_bulma.html" %}
{% load custom_tags %}
{% load i18n %}
{% load static %}
{% block title %}<title>{{ adoption_notice.name }}</title>{% endblock %}
{% block content %}
<div class="card">
<div class="card-header">
<h1 class="card-header-title title is-2">{{ adoption_notice.name }}</h1>
</div>
<div class="card-content">
<div class="grid">
<div class="cell">
<!--- General Information --->
<div class="grid">
<div class="cell">
<h2><strong>{% translate "Ort" %}</strong></h2>
<p>{% if adoption_notice.location %}
{{ adoption_notice.location }}
{% else %}
{{ adoption_notice.location_string }}
{% endif %}</p>
</div>
<div class="cell">
{% include "fellchensammlung/partials/bulma-sex-overview.html" %}
</div>
</div>
</div>
</div>
<div class="columns">
<!--- Images --->
<div class="column block">
<div class="card">
<div class="grid card-content">
<div class="cell" id="my-gallery">
{% for photo in adoption_notice.get_photos %}
<a href="{{ MEDIA_URL }}/{{ photo.image }}"
data-pswp-width="{{ photo.image.width }}"
data-pswp-height="{{ photo.image.height }}"
target="_blank">
<img style="height: 12rem" src="{{ MEDIA_URL }}/{{ photo.image }}"
alt="{ photo.alt_text }}"/>
</a>
{% endfor %}
</div>
</div>
</div>
</div>
<!--- Description --->
<div class="column block">
<div class="card">
<div class="card-header">
<h1 class="card-header-title title is-2">{% translate "Beschreibung" %}</h1>
</div>
<div class="card-content">
<p class="expandable">{% if adoption_notice.description %}
{{ adoption_notice.description | render_markdown }}
{% else %}
{% translate "Keine Beschreibung angegeben" %}
{% endif %}
</p>
</div>
</div>
</div>
</div>
</div>
<div class="card-footer">
{% if has_edit_permission %}
<div class="card-footer-item">
<div class="column">
<a class="button is-primary is-light"
href="{% url 'adoption-notice-add-photo' adoption_notice_id=adoption_notice.pk %}">
{% translate 'Foto hinzufügen' %}
</a>
</div>
<div class="card-footer-item">
<a class="button is-primary"
href="{% url 'adoption-notice-edit' adoption_notice_id=adoption_notice.pk %}">
{% translate 'Bearbeiten' %}
</a>
</div>
</div>
{% endif %}
</div>
</div>
<div class="columns">
{% for animal in adoption_notice.animals %}
<div class="column">
{% include "fellchensammlung/partials/bulma-partial-animal-card.html" %}
</div>
{% endfor %}
</div>
<div class="block">
{% if adoption_notice.further_information %}
<form method="get" action="{% url 'external-site' %}">
<input type="hidden" name="url" value="{{ adoption_notice.further_information }}">
<button class="button is-primary is-fullwidth" type="submit" id="submit">
{{ adoption_notice.further_information | domain }} <i
class="fa-solid fa-arrow-up-right-from-square"></i>
</button>
</form>
{% endif %}
</div>
<div class="block">
{% include "fellchensammlung/partials/bulma-partial-comment-section.html" %}
</div>
{% endblock %}

View File

@ -0,0 +1,95 @@
{% extends "fellchensammlung/base_bulma.html" %}
{% load i18n %}
{% load crispy_forms_tags %}
{% load widget_tweaks %}
{% block title %}<title>{% translate "Vermittlung hinzufügen" %}</title>{% endblock %}
{% block content %}
<h1>{% translate "Vermitteln" %}</h1>
<div class="notification">
<button class="delete"></button>
<p>
{% url 'terms-of-service' as rules_url %}
{% trans "Regeln" as rules_text %}
{% blocktranslate with rules_link='<a href="'|add:rules_url|add:'">'|add:rules_text|add:'</a>'|safe %}
Bitte mach dich zunächst mit unseren {{ rules_link }} vertraut. Dann trage hier die ersten Informationen
ein.
Fotos kannst du im nächsten Schritt hinzufügen.
{% endblocktranslate %}
</p>
</div>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="field">
<label class="label" for="an-name">{{ form.name.label }}
{% if form.name.field.required %}<span class="special_class">*</span>{% endif %}</label>
{{ form.name|add_class:"input"|attr:"id:an-name" }}
</div>
<div class="field">
<label class="label" for="an-description">{% translate 'Beschreibung' %}</label>
{{ form.description|add_class:"input textarea"|attr:"rows:3"|attr:"id:an-description" }}
</div>
<div class="field">
<label class="label" for="an-location">{{ form.location_string.label }}</label>
{{ form.location_string|add_class:"input"|attr:"id:an-location" }}
</div>
<div class="field">
<label class="checkbox" for="an-group-only">{{ form.group_only.label }}</label>
{{ form.group_only|add_class:"checkbox"|attr:"id:an-group-only" }}
</div>
<div class="field">
<label class="label" for="an-searching-since">{{ form.searching_since.label }}</label>
{{ form.searching_since|add_class:"input"|attr:"id:an-searching-since"|attr:"type:date" }}
</div>
<div class="notification">
<button class="delete"></button>
<p>
{% blocktranslate %}
Gibt hier schonmal erste Details zu den Tieren an.
Wenn du Details und Fotos zu den Tieren hinzufügen willst oder ihr Geschlecht und Geburtsdatum
anpassen
willst,
kannst du das im nächsten Schritt tun.
{% endblocktranslate %}
</p>
</div>
<div class="field">
<label class="label" for="an-species">{% translate 'Tierart' %}</label>
<div class="select">
{{ form.species|attr:"id:an-species" }}
</div>
</div>
<div class="field">
<label class="label" for="an-num-animals">{{ form.num_animals.label }}</label>
{{ form.num_animals|add_class:"input"|attr:"id:an-num-animals" }}
</div>
<div class="field">
<label class="label" for="an-sex">{% translate 'Geschlecht' %}</label>
<div class="select">
{{ form.sex|attr:"id:an-sex" }}
</div>
</div>
<div class="field">
<label class="label" for="an-date-of-birth">{{ form.date_of_birth.label }}</label>
{{ form.date_of_birth|add_class:"input"|attr:"id:an-date-of-birth"|attr:"type:date" }}
</div>
<input class="button is-primary" type="submit" value="{% translate "Speichern" %}">
</form>
{% endblock %}

View File

@ -0,0 +1,15 @@
{% load i18n %}
{% load crispy_forms_tags %}
<div class="card">
<div class="card-header">
<div class="card-header-title">
{% blocktrans %}
Als {{ user }} kommentieren
{% endblocktrans %}
</div>
</div>
<div class="card-content">
{% crispy comment_form %}
</div>
</div>

View File

@ -1,5 +1,5 @@
{% load i18n %} {% load i18n %}
<form class="btn2" action="{% url 'change-language' %}" method="post" onchange='this.form.submit()'> <form class="btn2 select" action="{% url 'change-language' %}" method="post" onchange='this.form.submit()'>
{% csrf_token %} {% csrf_token %}
<select name="language" onchange='this.form.submit()'> <select name="language" onchange='this.form.submit()'>
{% get_current_language as LANGUAGE_CODE_CURRENT %} {% get_current_language as LANGUAGE_CODE_CURRENT %}

View File

@ -0,0 +1,26 @@
<!--- See https://docs.djangoproject.com/en/5.2/topics/forms/#reusable-form-templates -->
{% load custom_tags %}
{% for field in form %}
<div class="field">
<label class="label">
{{ field.label }}
</label>
<div class="control">
{% if field|widget_type == 'TextInput' %}
{{ field|add_class:"input" }}
{% elif field|widget_type == 'Select' %}
<div class="select">
{{ field }}
</div>
{% else %}
{{ field|add_class:"input" }}
{% endif %}
</div>
<div class="help is-danger">
{{ field.errors }}
</div>
</div>
{% endfor %}

View File

@ -3,7 +3,9 @@
<section class="header"> <section class="header">
<div> <div>
<a href="{% url "index" %}" class="logo"><img src={% static 'fellchensammlung/img/logo_transparent.png' %}></a> <a href="{% url "index" %}" class="logo">
<img src="{% static 'fellchensammlung/img/logo_transparent.png' %}" alt="{% trans 'Notfellchen Logo' %}">
</a>
</div> </div>
<div class="profile-card"> <div class="profile-card">
@ -27,7 +29,7 @@
</form> </form>
{% else %} {% else %}
<a class="btn2" href="{% url "django_registration_register" %}">{% translate "Registrieren" %}</a> <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> <a class="btn2" href="{% url "login" %}"><i class="fa fa-sign-in" aria-label="Login"></i></a>
{% endif %} {% endif %}
<input id="menu-toggle" type="checkbox"/> <input id="menu-toggle" type="checkbox"/>
<label class='menu-button-container' for="menu-toggle"> <label class='menu-button-container' for="menu-toggle">

View File

@ -0,0 +1,12 @@
{% load i18n %}
{% if adoption_notices %}
<div class="grid">
{% for adoption_notice in adoption_notices %}
<div class="cell">
{% include "fellchensammlung/partials/bulma-partial-adoption-notice-minimal.html" %}
</div>
{% endfor %}
</div>
{% else %}
<p>{% translate "Keine Vermittlungen gefunden." %}</p>
{% endif %}

View File

@ -0,0 +1,5 @@
<div class="container-cards">
{% for rule in rules %}
{% include "fellchensammlung/partials/bulma-partial-rule.html" %}
{% endfor %}
</div>

View File

@ -1,6 +1,6 @@
{% extends "fellchensammlung/base_generic.html" %} {% extends "fellchensammlung/base_generic.html" %}
{% load i18n %} {% load i18n %}
{% block title %}<title>{% translate "Karte" %}</title> %} {% block title %}<title>{% translate "Karte" %}</title>{% endblock %}
{% block content %} {% block content %}
<div class="card"> <div class="card">

View File

@ -0,0 +1,40 @@
{% load custom_tags %}
{% load i18n %}
<h2 class="heading-card-adoption-notice title is-4">
<a href="{{ adoption_notice.get_absolute_url_bulma }}"> {{ adoption_notice.name }}</a>
</h2>
<div class="grid mb-0">
<div class="cell">
<!--- General Information --->
<div class="grid">
<div class="cell">
<p>
<b><i class="fa-solid fa-location-dot"></i></b>
{% if adoption_notice.location %}
{{ adoption_notice.location }}
{% else %}
{{ adoption_notice.location_string }}
{% endif %}</p>
</div>
<div class="cell">
{% include "fellchensammlung/partials/bulma-sex-overview.html" %}
</div>
</div>
</div>
</div>
{% 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 %}

View File

@ -0,0 +1,42 @@
{% load custom_tags %}
{% load i18n %}
<div class="card">
<div class="header-card-adoption-notice">
<h2 class="heading-card-adoption-notice title is-4">
<a href="{{ adoption_notice.get_absolute_url_bulma }}"> {{ adoption_notice.name }}</a>
</h2>
</div>
<div class="grid">
<div class="cell">
<!--- General Information --->
<div class="grid">
<div class="cell">
<p>
<b><i class="fa-solid fa-location-dot"></i></b>
{% if adoption_notice.location %}
{{ adoption_notice.location }}
{% else %}
{{ adoption_notice.location_string }}
{% endif %}</p>
</div>
<div class="cell">
{% include "fellchensammlung/partials/bulma-sex-overview.html" %}
</div>
</div>
</div>
</div>
{% if adoption_notice.get_photo %}
<div class="adoption-notice-img img-small">
<img src="{{ MEDIA_URL }}/{{ adoption_notice.get_photo.image }}"
alt="{{ adoption_notice.get_photo.alt_text }}">
</div>
{% endif %}
</div>

View File

@ -0,0 +1,38 @@
{% load i18n %}
{% load custom_tags %}
<div class="card">
<div class="card-header">
<h1 class="card-header-title">
<a href="{% url 'animal-detail' animal_id=animal.pk %}">{{ animal.name }}</a>
</h1>
<div class="tags">
<div class="tag species">{{ animal.species }}</div>
<div class="tag sex">{{ animal.get_sex_display }}</div>
</div>
</div>
<div class="card-content">
{% if animal.description %}
<p>{{ animal.description | render_markdown }}</p>
{% endif %}
<div class="cell" id="my-gallery">
{% for photo in animal.get_photos %}
<a href="{{ MEDIA_URL }}/{{ photo.image }}"
data-pswp-width="{{ photo.image.width }}"
data-pswp-height="{{ photo.image.height }}"
target="_blank">
<img src="{{ MEDIA_URL }}/{{ photo.image }}" alt="{{ photo.alt_text }}">
</a>
{% endfor %}
</div>
<!--- Assume a user does not have edit permissions on animal if they have no other edit permission --->
{% if has_edit_permission %}
<div class="card-footer">
<a class="card-footer-item button" href="{% url 'animal-edit' animal_id=animal.pk %}">{% translate 'Bearbeiten' %}</a>
<a class="card-footer-item button"
href="{% url 'animal-add-photo' animal_id=animal.pk %}">{% translate 'Foto hinzufügen' %}</a>
</div>
{% endif %}
</div>
</div>

View File

@ -0,0 +1,25 @@
{% load i18n %}
<div class="card">
<div class="card-header">
<h2 class="card-header-title title is-2">{% translate 'Kommentare' %}</h2>
</div>
<div class="card-content">
{% if adoption_notice.comments %}
{% for comment in adoption_notice.comments %}
{% include "fellchensammlung/partials/bulma-partial-comment.html" %}
{% endfor %}
{% else %}
<p class="is-italic">{% translate 'Noch keine Kommentare' %}</p>
{% endif %}
</div>
<footer class="card-footer">
{% if user.is_authenticated %}
{% include "fellchensammlung/forms/bulma-form-comment.html" %}
{% else %}
<p class="card-footer-item">
{% translate 'Du musst dich einloggen um Kommentare zu hinterlassen' %}
</p>
{% endif %}
</footer>
</div>

View File

@ -0,0 +1,27 @@
{% load i18n %}
{% load custom_tags %}
<div class="card">
<div class="card-header">
<div class="card-header-title content">
<b class="">{{ comment.user }}</b> <span class="tag"><time class="">{{ comment.created_at }}</time></span>
</div>
</div>
<div class="card-content">
<p class="content">
{{ comment.text | render_markdown }}
</p>
</div>
<div class="card-footer">
<a class="card-footer-item is-danger" href="{{ comment.get_report_url }}">
<span class="icon-text">
<span class="icon">
<i class="fa-solid fa-flag"></i>
</span>
<span>{% trans 'Melden' %}</span>
</span>
</a>
</div>
</div>

View File

@ -0,0 +1,149 @@
{% load static %}
{% load custom_tags %}
{% load i18n %}
<!-- add MapLibre JavaScript and CSS -->
<script src="{% settings_value "MAP_TILE_SERVER" %}/assets/lib/maplibre-gl/maplibre-gl.js"></script>
<link href="{% settings_value "MAP_TILE_SERVER" %}/assets/lib/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" class="map"></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/style.json" %}',
center: map_center,
zoom: zoom_level
});
map.addControl(new maplibregl.FullscreenControl());
map.addControl(new maplibregl.NavigationControl({showCompass: false}));
{% 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/bulma-partial-adoption-notice-minimal-map.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');
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_{{ forloop.counter }}', {
'type': 'geojson',
'data': {
'type': 'FeatureCollection',
'features': [
{
'type': 'Feature',
'geometry': {
'type': 'Point',
'coordinates': [{{ map_pin.location.longitude | pointdecimal }}, {{ map_pin.location.latitude | pointdecimal }}]
}
}
]
}
});
map.addLayer({
'id': 'point_{{ forloop.counter }}',
'type': 'circle',
'source': 'point_{{ forloop.counter }}',
'paint': {
'circle-radius': 18,
'circle-color': '#ff878980'
}
});
{% endfor %}
});
{% if search_center %}
var search_center = [{{ search_center.longitude | pointdecimal }}, {{ search_center.latitude | pointdecimal }}];
map.on('load', () => {
const radius = {{ search_radius }}; // kilometer
const options = {
steps: 64,
units: 'kilometers'
};
const circle = turf.circle(search_center, radius, options);
// Add the circle as a GeoJSON source
map.addSource('location-radius', {
type: 'geojson',
data: circle
});
// Add a fill layer with some transparency
map.addLayer({
id: 'location-radius',
type: 'fill',
source: 'location-radius',
paint: {
'fill-color': 'rgba(140,207,255,0.3)',
'fill-opacity': 0.5
}
});
// Add a line layer to draw the circle outline
map.addLayer({
id: 'location-radius-outline',
type: 'line',
source: 'location-radius',
paint: {
'line-color': '#0094ff',
'line-width': 3
}
});
});
{% endif %}
</script>

View File

@ -0,0 +1,9 @@
{% load custom_tags %}
<div class="card">
<div class="card-header">
<h2 class="card-header-title">{{ rule.title }}</h2>
</div>
<div class="card-content">
<p class="content">{{ rule.rule_text | render_markdown }}</p>
</div>
</div>

View File

@ -0,0 +1,44 @@
{% load static %}
{% load i18n %}
<div class="grid">
{% if adoption_notice.num_per_sex.F > 0 %}
<span class="cell icon-text tag is-medium">
<span class="has-text-weight-bold is-size-4">{{ adoption_notice.num_per_sex.F }} x </span>
<span class="icon">
<img class="icon" src="{% static 'fellchensammlung/img/sexes/Female.png' %}"
alt="{% translate 'weibliche Tiere' %}">
</span>
</span>
{% endif %}
{% if adoption_notice.num_per_sex.I > 0 %}
<span class="cell icon-text tag is-medium">
<span class="has-text-weight-bold is-size-4">{{ adoption_notice.num_per_sex.I }}</span>
<span class="icon">
<img class="icon"
src="{% static 'fellchensammlung/img/sexes/Intersex.png' %}"
alt="{% translate 'intersexuelle Tiere' %}">
</span>
</span>
{% endif %}
{% if adoption_notice.num_per_sex.M > 0 %}
<span class="cell icon-text tag is-medium">
<span class="has-text-weight-bold is-size-4">{{ adoption_notice.num_per_sex.M }}</span>
<span class="icon">
<img class="icon" src="{% static 'fellchensammlung/img/sexes/Male.png' %}"
alt="{% translate 'männliche Tiere' %}">
</span>
</span>
{% endif %}
{% if adoption_notice.num_per_sex.M_N > 0 %}
<span class="cell icon-text tag is-medium">
<span class="has-text-weight-bold is-size-4">{{ adoption_notice.num_per_sex.M_N }}</span>
<span class="icon">
<img class="icon"
src="{% static 'fellchensammlung/img/sexes/Male Neutered.png' %}"
alt="{% translate 'männlich, kastrierte Tiere' %}">
</span>
</span>
{% endif %}
</div>

View File

@ -0,0 +1,21 @@
{% load i18n %}
{% load custom_tags %}
<div class="card">
<h1>
<a href="{{ rescue_org.get_absolute_url }}">{{ rescue_org.name }}</a>
</h1>
<i>{% translate 'Zuletzt geprüft:' %} {{ rescue_org.last_checked_hr }}</i>
{% if rescue_org.website %}
<p>{% translate "Website" %}: <a href="{{ rescue_org.website }}">{{ rescue_org.website }}</a></p>
{% endif %}
<div class="container-edit-buttons">
<form method="post">
{% csrf_token %}
<input type="hidden"
name="rescue_organization_id"
value="{{ rescue_org.pk }}">
<input type="hidden" name="action" value="checked">
<button class="btn" type="submit">{% translate "Organisation geprüft" %}</button>
</form>
</div>
</div>

View File

@ -3,8 +3,8 @@
{% load i18n %} {% load i18n %}
<!-- add MapLibre JavaScript and CSS --> <!-- add MapLibre JavaScript and CSS -->
<script src="{% settings_value "MAP_TILE_SERVER" %}/assets/maplibre-gl/maplibre-gl.js"></script> <script src="{% settings_value "MAP_TILE_SERVER" %}/assets/lib/maplibre-gl/maplibre-gl.js"></script>
<link href="{% settings_value "MAP_TILE_SERVER" %}/assets/maplibre-gl/maplibre-gl.css" rel="stylesheet"/> <link href="{% settings_value "MAP_TILE_SERVER" %}/assets/lib/maplibre-gl/maplibre-gl.css" rel="stylesheet"/>
<!-- add Turf see https://maplibre.org/maplibre-gl-js/docs/examples/draw-a-radius/ --> <!-- add Turf see https://maplibre.org/maplibre-gl-js/docs/examples/draw-a-radius/ -->
<script src="{% static 'fellchensammlung/js/turf.min.js' %}"></script> <script src="{% static 'fellchensammlung/js/turf.min.js' %}"></script>
@ -29,7 +29,7 @@
let map = new maplibregl.Map({ let map = new maplibregl.Map({
container: 'map', container: 'map',
style: '{% static "fellchensammlung/map/styles/colorful.json" %}', style: '{% static "fellchensammlung/map/styles/colorful/style.json" %}',
center: map_center, center: map_center,
zoom: zoom_level zoom: zoom_level
}).addControl(new maplibregl.NavigationControl()); }).addControl(new maplibregl.NavigationControl());
@ -76,7 +76,7 @@
image = await map.loadImage('{% static "fellchensammlung/img/pin.png" %}'); image = await map.loadImage('{% static "fellchensammlung/img/pin.png" %}');
map.addImage('pin', image.data); map.addImage('pin', image.data);
{% for map_pin in map_pins %} {% for map_pin in map_pins %}
map.addSource('point', { map.addSource('point_{{ forloop.counter }}', {
'type': 'geojson', 'type': 'geojson',
'data': { 'data': {
'type': 'FeatureCollection', 'type': 'FeatureCollection',
@ -91,16 +91,16 @@
] ]
} }
}); });
map.addLayer({
'id': 'point_{{ forloop.counter }}',
'type': 'circle',
'source': 'point_{{ forloop.counter }}',
'paint': {
'circle-radius': 18,
'circle-color': '#ff878980'
}
});
{% endfor %} {% endfor %}
map.addLayer({
'id': 'pints',
'type': 'symbol',
'source': 'point',
'layout': {
'icon-image': 'pin',
'icon-size': 0.1
}
});
}); });
{% if search_center %} {% if search_center %}

View File

@ -1,9 +1,7 @@
{% load i18n %} {% load i18n %}
<div class="report card"> <div class="report card">
<h2> <h2>
{% blocktranslate %} {% translate 'Meldung von ' %} <a href="{{ report.reported_content_url }}"><i>{{ report.reported_content }}</i></a>
Meldung von {{ report.reported_content }}
{% endblocktranslate %}
</h2> </h2>
{% if report.reported_broken_rules %} {% if report.reported_broken_rules %}
{% translate "Regeln gegen die Verstoßen wurde" %} {% translate "Regeln gegen die Verstoßen wurde" %}
@ -13,19 +11,25 @@
{% endfor %} {% endfor %}
</ul> </ul>
{% endif %} {% endif %}
<p><b>{% translate "Kommentar zur Meldung" %}:</b> <p>
{{ report.user_comment }} {% if report.user_comment %}
<b>{% translate "Kommentar zur Meldung" %}:</b> {{ report.user_comment }}
{% else %}
<i>{% translate 'Es wurde kein Kommentar zur Meldung hinzugefügt.' %}</i>
{% endif %}
</p> </p>
<div class="container-edit-buttons"> {% if is_mod_or_above %}
<form action="allow" class=""> <div class="container-edit-buttons">
{% csrf_token %} <form action="allow" class="">
<input type="hidden" name="report_id" value="{{ report.pk }}"> {% csrf_token %}
<button class="btn allow" type="submit">{% translate "Inhalt genehmigen" %}</button> <input type="hidden" name="report_id" value="{{ report.pk }}">
</form> <button class="btn allow" type="submit">{% translate "Inhalt genehmigen" %}</button>
<form action="disallow" class=""> </form>
{% csrf_token %} <form action="disallow" class="">
<input type="hidden" name="report_id" value="{{ report.pk }}"> {% csrf_token %}
<button class="btn allow" type="submit">{% translate "Inhalt als gesperrt kennzeichnen" %}</button> <input type="hidden" name="report_id" value="{{ report.pk }}">
</form> <button class="btn allow" type="submit">{% translate "Inhalt als gesperrt kennzeichnen" %}</button>
</div> </form>
</div>
{% endif %}
</div> </div>

View File

@ -0,0 +1,12 @@
{% extends "fellchensammlung/base_generic.html" %}
{% load i18n %}
{% block content %}
<h1>{% translate "Aktualitätscheck" %}</h1>
<p>{% translate "Überprüfe ob im Tierheim neue Vermittlungen ein Zuhause suchen" %}</p>
<div class="container-cards spaced">
<h1>{% translate 'Organisation zur Überprüfung' %}</h1>
{% for rescue_org in rescue_orgs %}
{% include "fellchensammlung/partials/partial-check-rescue-org.html" %}
{% endfor %}
</div>
{% endblock %}

View File

@ -11,7 +11,7 @@
<input type="hidden" name="longitude" maxlength="200" id="longitude"> <input type="hidden" name="longitude" maxlength="200" id="longitude">
<input type="hidden" name="latitude" maxlength="200" id="latitude"> <input type="hidden" name="latitude" maxlength="200" id="latitude">
<input type="hidden" id="place_id" name="place_id"> <input type="hidden" id="place_id" name="place_id">
{{ search_form.as_p }} {{ search_form }}
<ul id="results"></ul> <ul id="results"></ul>
<div class="container-edit-buttons"> <div class="container-edit-buttons">
<button class="btn" type="submit" value="search" name="search"> <button class="btn" type="submit" value="search" name="search">

View File

@ -0,0 +1,35 @@
{% extends "fellchensammlung/base_generic.html" %}
{% load i18n %}
{% load static %}
{% block title %}<title>{% translate "Styleguide" %}</title>{% endblock %}
{% block content %}
<h1>This is a heading</h1>
<p>And this is a short paragraph below</p>
<div class="container-cards">
<h2>Card Containers</h2>
<div class="card">
<h3>I am a card</h3>
<p>Cards are responsive. Use them to display multiple items of the same category</p>
</div>
<div class="card">
<h3>Photos</h3>
<p>Cards are responsive. Use them to display multiple items of the same category</p>
<img src="{% static 'fellchensammlung/img/example_rat_single.png' %}" alt="A rat sitting on a wooden house">
</div>
</div>
<div class="container-cards">
<form class="form-search card half" method="post">
<label for="inputA">Input Alpha</label>
<input name="inputA" maxlength="200" id="inputA">
<label for="inputB">Beta</label>
<input name="inputB" maxlength="200" id="inputB">
<label for="id_location_string">Ort</label>
<input name="location_string" id="id_location_string">
</form>
<div class="card half">
{% include "fellchensammlung/partials/partial-map.html" %}
</div>
</div>
{% include "fellchensammlung/lists/list-adoption-notices.html" %}
{% endblock %}

View File

@ -49,6 +49,7 @@ def get_oxitraffic_script_if_enabled():
else: else:
return "" return ""
@register.filter @register.filter
@stringfilter @stringfilter
def pointdecimal(value): def pointdecimal(value):
@ -57,6 +58,7 @@ def pointdecimal(value):
except ValueError: except ValueError:
return value return value
@register.filter @register.filter
@stringfilter @stringfilter
def domain(url): def domain(url):
@ -68,6 +70,17 @@ def domain(url):
except ValueError: except ValueError:
return url return url
@register.simple_tag @register.simple_tag
def settings_value(name): def settings_value(name):
return getattr(settings, name) return getattr(settings, name)
@register.filter(name='add_class')
def add_class(field, css_class):
return field.as_widget(attrs={"class": css_class})
@register.filter
def widget_type(field):
return field.field.widget.__class__.__name__

View File

@ -74,13 +74,21 @@ class GeoFeature:
geofeatures = [] geofeatures = []
for feature in result["features"]: for feature in result["features"]:
geojson = {} geojson = {}
try: # Necessary features
geojson['name'] = feature["properties"]["name"]
except KeyError:
geojson['name'] = feature["properties"]["street"]
geojson['place_id'] = feature["properties"]["osm_id"] geojson['place_id'] = feature["properties"]["osm_id"]
geojson['lat'] = feature["geometry"]["coordinates"][1] geojson['lat'] = feature["geometry"]["coordinates"][1]
geojson['lon'] = feature["geometry"]["coordinates"][0] geojson['lon'] = feature["geometry"]["coordinates"][0]
try:
geojson['name'] = feature["properties"]["name"]
except KeyError:
geojson['name'] = feature["properties"]["osm_id"]
optional_keys = ["housenumber", "street", "city", "postcode", "county", "countrycode"]
for key in optional_keys:
try:
geojson[key] = feature["properties"][key]
except KeyError:
pass
geofeatures.append(geojson) geofeatures.append(geojson)
return geofeatures return geofeatures
@ -137,7 +145,6 @@ class GeoAPI:
result = self.requests.get(self.api_url, result = self.requests.get(self.api_url,
{"q": location_string, "lang": language}, {"q": location_string, "lang": language},
headers=self.headers).json() headers=self.headers).json()
logging.warning(result)
geofeatures = GeoFeature.geofeatures_from_photon_result(result) geofeatures = GeoFeature.geofeatures_from_photon_result(result)
else: else:
raise NotImplementedError raise NotImplementedError
@ -162,6 +169,7 @@ class LocationProxy:
""" """
self.geo_api = GeoAPI() self.geo_api = GeoAPI()
geofeatures = self.geo_api.get_geojson_for_query(location_string) geofeatures = self.geo_api.get_geojson_for_query(location_string)
if geofeatures is None: if geofeatures is None:
raise ValueError raise ValueError
result = geofeatures[0] result = geofeatures[0]
@ -169,6 +177,12 @@ class LocationProxy:
self.place_id = result["place_id"] self.place_id = result["place_id"]
self.latitude = result["lat"] self.latitude = result["lat"]
self.longitude = result["lon"] self.longitude = result["lon"]
optional_keys = ["housenumber", "street", "city", "postcode", "county", "countrycode"]
for key in optional_keys:
try:
self.__setattr__(key, result[key])
except KeyError:
self.__setattr__(key, None)
def __eq__(self, other): def __eq__(self, other):
return self.place_id == other.place_id return self.place_id == other.place_id

View File

@ -0,0 +1,23 @@
from django.utils import translation
from fellchensammlung.models import Language, Text
def get_text_by_language(text_code, lang=None):
if lang is None:
language_code = translation.get_language()
lang = Language.objects.get(languagecode=language_code)
return Text.objects.get(text_code=text_code, language=lang, )
def get_texts_by_language(text_codes):
language_code = translation.get_language()
lang = Language.objects.get(languagecode=language_code)
texts = {}
for text_code in text_codes:
try:
texts[text_code] = get_text_by_language(text_code, lang)
except Text.DoesNotExist:
texts[text_code] = None
return texts

View File

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

View File

@ -6,7 +6,7 @@ from ..forms import AdoptionNoticeSearchForm
from ..models import SearchSubscription, AdoptionNotice, AdoptionNoticeNotification, SexChoicesWithAll, Location from ..models import SearchSubscription, AdoptionNotice, AdoptionNoticeNotification, SexChoicesWithAll, Location
def notify_search_subscribers(adoption_notice: AdoptionNotice, only_if_active : bool = True): 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. 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. If the new adoption notice fits the search subscription, it sends a notification to the user that created the search.
@ -36,7 +36,7 @@ class Search:
self.sex = None self.sex = None
self.area_search = None self.area_search = None
self.max_distance = None self.max_distance = None
self.location = None # Can either be Location (DjangoModel) or LocationProxy self.location = None # Can either be Location (DjangoModel) or LocationProxy
self.place_not_found = False # Indicates that a location was given but could not be geocoded self.place_not_found = False # Indicates that a location was given but could not be geocoded
self.search_form = None self.search_form = None
# Either place_id or location string must be set for area search # Either place_id or location string must be set for area search
@ -47,7 +47,6 @@ class Search:
elif search_subscription: elif search_subscription:
self.search_from_search_subscription(search_subscription) self.search_from_search_subscription(search_subscription)
def __str__(self): def __str__(self):
return f"Search: {self.sex=}, {self.location=}, {self.area_search=}, {self.max_distance=}" return f"Search: {self.sex=}, {self.location=}, {self.area_search=}, {self.max_distance=}"
@ -93,7 +92,6 @@ class Search:
return False return False
return True return True
def get_adoption_notices(self): def get_adoption_notices(self):
adoptions = AdoptionNotice.objects.order_by("-created_at") adoptions = AdoptionNotice.objects.order_by("-created_at")
# Filter for active adoption notices # Filter for active adoption notices
@ -118,13 +116,21 @@ class Search:
else: else:
self.search_form = AdoptionNoticeSearchForm() self.search_form = AdoptionNoticeSearchForm()
def search_from_predefined_i_location(self, i_location, max_distance=100):
self.sex = SexChoicesWithAll.ALL
self.location = i_location.location
self.area_search = True
self.search_form = AdoptionNoticeSearchForm(initial={"location_string": self.location.name,
"max_distance": max_distance,
"sex": SexChoicesWithAll.ALL})
self.max_distance = max_distance
def search_from_search_subscription(self, search_subscription: SearchSubscription): def search_from_search_subscription(self, search_subscription: SearchSubscription):
self.sex = search_subscription.sex self.sex = search_subscription.sex
self.location = search_subscription.location self.location = search_subscription.location
self.area_search = True self.area_search = True
self.max_distance = search_subscription.max_distance self.max_distance = search_subscription.max_distance
def subscribe(self, user): def subscribe(self, user):
logging.info(f"{user} subscribed to search") logging.info(f"{user} subscribed to search")
if isinstance(self.location, LocationProxy): if isinstance(self.location, LocationProxy):

View File

@ -7,8 +7,18 @@ from .feeds import LatestAdoptionNoticesFeed
from . import views from . import views
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView
from django.contrib.sitemaps.views import sitemap
from .sitemap import StaticViewSitemap, AdoptionNoticeSitemap, AnimalSitemap
sitemaps = {
"static": StaticViewSitemap,
"vermittlungen": AdoptionNoticeSitemap,
"tiere": AnimalSitemap,
}
urlpatterns = [ urlpatterns = [
path("", views.index, name="index"), path("", views.index, name="index"),
path("bulma/", views.index_bulma, name="index-bulma"),
path("rss/", LatestAdoptionNoticesFeed(), name="rss"), path("rss/", LatestAdoptionNoticesFeed(), name="rss"),
path("metrics/", views.metrics, name="metrics"), path("metrics/", views.metrics, name="metrics"),
# ex: /animal/5/ # ex: /animal/5/
@ -19,12 +29,15 @@ urlpatterns = [
path("tier/<int:animal_id>/add-photo", views.add_photo_to_animal, name="animal-add-photo"), path("tier/<int:animal_id>/add-photo", views.add_photo_to_animal, name="animal-add-photo"),
# ex: /adoption_notice/7/ # ex: /adoption_notice/7/
path("vermittlung/<int:adoption_notice_id>/", views.adoption_notice_detail, name="adoption-notice-detail"), path("vermittlung/<int:adoption_notice_id>/", views.adoption_notice_detail, name="adoption-notice-detail"),
path("bulma/vermittlung/<int:adoption_notice_id>/", views.adoption_notice_detail_bulma, name="adoption-notice-detail-bulma"),
# ex: /adoption_notice/7/edit # ex: /adoption_notice/7/edit
path("vermittlung/<int:adoption_notice_id>/edit", views.adoption_notice_edit, name="adoption-notice-edit"), path("vermittlung/<int:adoption_notice_id>/edit", views.adoption_notice_edit, name="adoption-notice-edit"),
# ex: /vermittlung/5/add-photo # ex: /vermittlung/5/add-photo
path("vermittlung/<int:adoption_notice_id>/add-photo", views.add_photo_to_adoption_notice, name="adoption-notice-add-photo"), 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 # 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("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("tierschutzorganisationen/", views.list_rescue_organizations, name="rescue-organizations"),
path("organisation/<int:rescue_organization_id>/", views.detail_view_rescue_organization, path("organisation/<int:rescue_organization_id>/", views.detail_view_rescue_organization,
@ -32,12 +45,21 @@ urlpatterns = [
# ex: /search/ # ex: /search/
path("suchen/", views.search, name="search"), path("suchen/", views.search, name="search"),
path("bulma/suchen/", views.search_bulma, name="search-bulma"),
path("suchen/<slug:important_location_slug>", views.search_important_locations, name="search-by-location"),
# ex: /map/ # ex: /map/
path("map/", views.map, name="map"), path("map/", views.map, name="map"),
# ex: /map/
path("bulma/map/", views.map_bulma, name="map-bulma"),
# ex: /vermitteln/ # ex: /vermitteln/
path("vermitteln/", views.add_adoption_notice, name="add-adoption"), path("vermitteln/", views.add_adoption_notice, name="add-adoption"),
path("bulma/vermitteln/", views.add_adoption_notice_bulma, name="add-adoption-bulma"),
path("ueber-uns/", views.about, name="about"), path("ueber-uns/", views.about, name="about"),
path("bulma/ueber-uns/", views.about_bulma, name="about-bulma"),
path("impressum/", views.imprint, name="imprint"),
path("terms-of-service/", views.terms_of_service, name="terms-of-service"),
path("datenschutz/", views.privacy, name="privacy"),
################ ################
## Moderation ## ## Moderation ##
@ -48,9 +70,11 @@ urlpatterns = [
path("meldung/<uuid:report_id>/", views.report_detail, name="report-detail"), path("meldung/<uuid:report_id>/", views.report_detail, name="report-detail"),
path("meldung/<uuid:report_id>/sucess", views.report_detail_success, name="report-detail-success"), path("meldung/<uuid:report_id>/sucess", views.report_detail_success, name="report-detail-success"),
path("modqueue/", views.modqueue, name="modqueue"), path("modqueue/", views.modqueue, name="modqueue"),
path("updatequeue/", views.updatequeue, name="updatequeue"), path("updatequeue/", views.updatequeue, name="updatequeue"),
path("organization-check/", views.rescue_organization_check, name="organization-check"),
########### ###########
## USERS ## ## USERS ##
########### ###########
@ -95,4 +119,11 @@ urlpatterns = [
################### ###################
path('external-site/', views.external_site_warning, name="external-site"), path('external-site/', views.external_site_warning, name="external-site"),
###############
## TECHNICAL ##
###############
path("sitemap.xml", sitemap, {"sitemaps": sitemaps}, name="django.contrib.sitemaps.views.sitemap"),
path("styleguide", views.styleguide, name="styleguide"),
path("styleguide-bulma", views.styleguide_bulma, name="styleguide-bulma"),
] ]

View File

@ -2,7 +2,8 @@ import logging
from django.contrib.auth.views import redirect_to_login from django.contrib.auth.views import redirect_to_login
from django.http import HttpResponseRedirect, JsonResponse, HttpResponse from django.http import HttpResponseRedirect, JsonResponse, HttpResponse
from django.shortcuts import render, redirect from django.http.response import HttpResponseForbidden
from django.shortcuts import render, redirect, get_object_or_404
from django.urls import reverse from django.urls import reverse
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.utils import translation from django.utils import translation
@ -17,11 +18,14 @@ from notfellchen import settings
from fellchensammlung import logger from fellchensammlung import logger
from .models import AdoptionNotice, Text, Animal, Rule, Image, Report, ModerationAction, \ from .models import AdoptionNotice, Text, Animal, Rule, Image, Report, ModerationAction, \
User, Location, AdoptionNoticeStatus, Subscriptions, CommentNotification, BaseNotification, RescueOrganization, \ User, Location, AdoptionNoticeStatus, Subscriptions, CommentNotification, BaseNotification, RescueOrganization, \
Species, Log, Timestamp, TrustLevel, SexChoicesWithAll, SearchSubscription, AdoptionNoticeNotification Species, Log, Timestamp, TrustLevel, SexChoicesWithAll, SearchSubscription, AdoptionNoticeNotification, \
ImportantLocation
from .forms import AdoptionNoticeForm, AdoptionNoticeFormWithDateWidget, ImageForm, ReportAdoptionNoticeForm, \ from .forms import AdoptionNoticeForm, AdoptionNoticeFormWithDateWidget, ImageForm, ReportAdoptionNoticeForm, \
CommentForm, ReportCommentForm, AnimalForm, \ CommentForm, ReportCommentForm, AnimalForm, \
AdoptionNoticeSearchForm, AnimalFormWithDateWidget, AdoptionNoticeFormWithDateWidgetAutoAnimal AdoptionNoticeSearchForm, AnimalFormWithDateWidget, AdoptionNoticeFormWithDateWidgetAutoAnimal, \
BulmaAdoptionNoticeForm
from .models import Language, Announcement from .models import Language, Announcement
from .tools import i18n
from .tools.geo import GeoAPI, zoom_level_for_radius from .tools.geo import GeoAPI, zoom_level_for_radius
from .tools.metrics import gather_metrics_data from .tools.metrics import gather_metrics_data
from .tools.admin import clean_locations, get_unchecked_adoption_notices, deactivate_unchecked_adoption_notices, \ from .tools.admin import clean_locations, get_unchecked_adoption_notices, deactivate_unchecked_adoption_notices, \
@ -62,6 +66,22 @@ def index(request):
return render(request, 'fellchensammlung/index.html', context=context) return render(request, 'fellchensammlung/index.html', context=context)
def index_bulma(request):
"""View function for home page of site."""
latest_adoption_list = AdoptionNotice.objects.filter(
adoptionnoticestatus__major_status=AdoptionNoticeStatus.ACTIVE).order_by("-created_at")
active_adoptions = [adoption for adoption in latest_adoption_list if adoption.is_active]
language_code = translation.get_language()
lang = Language.objects.get(languagecode=language_code)
active_announcements = Announcement.get_active_announcements(lang)
context = {"adoption_notices": active_adoptions[:5], "adoption_notices_map": active_adoptions,
"announcements": active_announcements}
Text.get_texts(["how_to", "introduction"], lang, context)
return render(request, 'fellchensammlung/bulma-index.html', context=context)
def change_language(request): def change_language(request):
if request.method == 'POST': if request.method == 'POST':
language_code = request.POST.get('language') language_code = request.POST.get('language')
@ -79,9 +99,11 @@ def change_language(request):
return response return response
else: else:
return render(request, 'fellchensammlung/index.html') return render(request, 'fellchensammlung/index.html')
else:
return render(request, 'fellchensammlung/index.html')
def adoption_notice_detail(request, adoption_notice_id): def adoption_notice_detail(request, adoption_notice_id, template=None):
adoption_notice = AdoptionNotice.objects.get(id=adoption_notice_id) adoption_notice = AdoptionNotice.objects.get(id=adoption_notice_id)
if request.user.is_authenticated: if request.user.is_authenticated:
try: try:
@ -130,13 +152,23 @@ def adoption_notice_detail(request, adoption_notice_id):
if action == "unsubscribe": if action == "unsubscribe":
subscription.delete() subscription.delete()
is_subscribed = False is_subscribed = False
elif action == "subscribe":
return redirect_to_login(next=request.path)
else: else:
raise PermissionDenied return HttpResponseForbidden()
else: else:
comment_form = CommentForm(instance=adoption_notice) comment_form = CommentForm(instance=adoption_notice)
context = {"adoption_notice": adoption_notice, "comment_form": comment_form, "user": request.user, context = {"adoption_notice": adoption_notice, "comment_form": comment_form, "user": request.user,
"has_edit_permission": has_edit_permission, "is_subscribed": is_subscribed} "has_edit_permission": has_edit_permission, "is_subscribed": is_subscribed}
return render(request, 'fellchensammlung/details/detail_adoption_notice.html', context=context) if template is not None:
return render(request, template, context=context)
else:
return render(request, 'fellchensammlung/details/detail_adoption_notice.html', context=context)
def adoption_notice_detail_bulma(request, adoption_notice_id):
return adoption_notice_detail(request, adoption_notice_id,
template='fellchensammlung/details/bulma-detail-adoption-notice.html')
@login_required() @login_required()
@ -171,7 +203,31 @@ def animal_detail(request, animal_id):
return render(request, 'fellchensammlung/details/detail_animal.html', context=context) return render(request, 'fellchensammlung/details/detail_animal.html', context=context)
def search(request): def search_important_locations(request, important_location_slug):
i_location = get_object_or_404(ImportantLocation, slug=important_location_slug)
search = Search()
search.search_from_predefined_i_location(i_location)
context = {"adoption_notices": search.get_adoption_notices(),
"search_form": search.search_form,
"place_not_found": search.place_not_found,
"subscribed_search": None,
"searched": False,
"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)
def search_bulma(request):
return search(request, "fellchensammlung/bulma-search.html")
def search(request, templatename="fellchensammlung/search.html"):
# A user just visiting the search site did not search, only upon completing the search form a user has really # 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. This will toggle the "subscribe" button
searched = False searched = False
@ -210,7 +266,7 @@ def search(request):
"search_radius": search.max_distance, "search_radius": search.max_distance,
"zoom_level": zoom_level_for_radius(search.max_distance), "zoom_level": zoom_level_for_radius(search.max_distance),
"geocoding_api_url": settings.GEOCODING_API_URL, } "geocoding_api_url": settings.GEOCODING_API_URL, }
return render(request, 'fellchensammlung/search.html', context=context) return render(request, templatename, context=context)
@login_required @login_required
@ -255,6 +311,51 @@ def add_adoption_notice(request):
return render(request, 'fellchensammlung/forms/form_add_adoption.html', {'form': form}) return render(request, 'fellchensammlung/forms/form_add_adoption.html', {'form': form})
@login_required
def add_adoption_notice_bulma(request):
if request.method == 'POST':
print("dada")
form = AdoptionNoticeFormWithDateWidgetAutoAnimal(request.POST)
if form.is_valid():
print("dodo")
an_instance = form.save(commit=False)
an_instance.owner = request.user
if request.user.trust_level >= TrustLevel.MODERATOR:
an_instance.set_active()
else:
an_instance.set_unchecked()
# Get the species and number of animals from the form
species = form.cleaned_data["species"]
sex = form.cleaned_data["sex"]
num_animals = form.cleaned_data["num_animals"]
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=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 {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-bulma", args=[an_instance.pk]))
else:
print(form.errors)
else:
form = AdoptionNoticeFormWithDateWidgetAutoAnimal()
return render(request, 'fellchensammlung/forms/bulma-form-add-adoption.html', {'form': form})
@login_required @login_required
def adoption_notice_add_animal(request, adoption_notice_id): def adoption_notice_add_animal(request, adoption_notice_id):
# Only users that are mods or owners of the adoption notice are allowed to add to it # Only users that are mods or owners of the adoption notice are allowed to add to it
@ -363,15 +464,7 @@ def animal_edit(request, animal_id):
def about(request): def about(request):
rules = Rule.objects.all() rules = Rule.objects.all()
language_code = translation.get_language() legal = i18n.get_texts_by_language(["terms_of_service", "privacy_statement", "imprint", "about_us", "faq"])
lang = Language.objects.get(languagecode=language_code)
legal = {}
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:
legal[text_code] = None
context = {"rules": rules, } context = {"rules": rules, }
context.update(legal) context.update(legal)
@ -382,6 +475,47 @@ def about(request):
) )
def about_bulma(request):
context = i18n.get_texts_by_language(["about_us", "faq"])
return render(
request,
"fellchensammlung/bulma-about.html",
context=context
)
def render_text(request, text):
context = {"text": text}
return render(
request,
"fellchensammlung/bulma-one-text.html",
context=context
)
def imprint(request):
text = i18n.get_text_by_language("imprint")
return render_text(request, text)
def privacy(request):
text = i18n.get_text_by_language("privacy_statement")
return render_text(request, text)
def terms_of_service(request):
text = i18n.get_text_by_language("terms_of_service")
rules = Rule.objects.all()
context = {"rules": rules, "text": text}
return render(
request,
"fellchensammlung/bulma-terms-of-service.html",
context=context
)
def report_adoption(request, adoption_notice_id): def report_adoption(request, adoption_notice_id):
""" """
Form to report adoption notices Form to report adoption notices
@ -426,10 +560,13 @@ def report_detail(request, report_id, form_complete=False):
""" """
Detailed view of a report, including moderation actions Detailed view of a report, including moderation actions
""" """
report = Report.objects.get(pk=report_id) # Prefetching reduces the number of queries to the database that are needed (see reported_content)
report = Report.objects.select_related("reportadoptionnotice", "reportcomment").get(pk=report_id)
moderation_actions = ModerationAction.objects.filter(report_id=report_id) moderation_actions = ModerationAction.objects.filter(report_id=report_id)
is_mod_or_above = user_is_trust_level_or_above(request.user, TrustLevel.MODERATOR)
context = {"report": report, "moderation_actions": moderation_actions, "form_complete": form_complete} context = {"report": report, "moderation_actions": moderation_actions,
"form_complete": form_complete, "is_mod_or_above": is_mod_or_above}
return render(request, 'fellchensammlung/details/detail-report.html', context) return render(request, 'fellchensammlung/details/detail-report.html', context)
@ -481,13 +618,11 @@ def my_profile(request):
notification = CommentNotification.objects.get(pk=notification_id) notification = CommentNotification.objects.get(pk=notification_id)
except CommentNotification.DoesNotExist: except CommentNotification.DoesNotExist:
notification = BaseNotification.objects.get(pk=notification_id) notification = BaseNotification.objects.get(pk=notification_id)
notification.read = True notification.mark_read()
notification.save()
elif action == "notification_mark_all_read": elif action == "notification_mark_all_read":
notifications = CommentNotification.objects.filter(user=request.user, mark_read=False) notifications = CommentNotification.objects.filter(user=request.user, mark_read=False)
for notification in notifications: for notification in notifications:
notification.read = True notification.mark_read()
notification.save()
elif action == "search_subscription_delete": elif action == "search_subscription_delete":
search_subscription_id = request.POST.get("search_subscription_id") search_subscription_id = request.POST.get("search_subscription_id")
SearchSubscription.objects.get(pk=search_subscription_id).delete() SearchSubscription.objects.get(pk=search_subscription_id).delete()
@ -502,7 +637,7 @@ def my_profile(request):
@user_passes_test(user_is_trust_level_or_above) @user_passes_test(user_is_trust_level_or_above)
def modqueue(request): def modqueue(request):
open_reports = Report.objects.filter(status=Report.WAITING) open_reports = Report.objects.select_related("reportadoptionnotice", "reportcomment").filter(status=Report.WAITING)
context = {"reports": open_reports} context = {"reports": open_reports}
return render(request, 'fellchensammlung/modqueue.html', context=context) return render(request, 'fellchensammlung/modqueue.html', context=context)
@ -532,10 +667,14 @@ def updatequeue(request):
return render(request, 'fellchensammlung/updatequeue.html', context=context) return render(request, 'fellchensammlung/updatequeue.html', context=context)
def map(request): def map(request, templatename='fellchensammlung/map.html'):
adoption_notices = AdoptionNotice.get_active_ANs() adoption_notices = AdoptionNotice.get_active_ANs()
context = {"adoption_notices_map": adoption_notices} context = {"adoption_notices_map": adoption_notices}
return render(request, 'fellchensammlung/map.html', context=context) return render(request, templatename, context=context)
def map_bulma(request):
return map(request, templatename='fellchensammlung/bulma-map.html')
def metrics(request): def metrics(request):
@ -632,3 +771,28 @@ def export_own_profile(request):
ANs_as_json = serialize('json', ANs) ANs_as_json = serialize('json', ANs)
full_json = f"{user_as_json}, {ANs_as_json}" full_json = f"{user_as_json}, {ANs_as_json}"
return HttpResponse(full_json, content_type="application/json") return HttpResponse(full_json, content_type="application/json")
def styleguide(request):
context = {"geocoding_api_url": settings.GEOCODING_API_URL, }
return render(request, 'fellchensammlung/styleguide.html', context=context)
def styleguide_bulma(request):
return render(request, 'fellchensammlung/bulma-styleguide.html')
@login_required
def rescue_organization_check(request):
if request.method == "POST":
rescue_org = RescueOrganization.objects.get(id=request.POST.get("rescue_organization_id"))
edit_permission = user_is_trust_level_or_above(request.user, TrustLevel.MODERATOR)
if not edit_permission:
return render(request, "fellchensammlung/errors/403.html", status=403)
action = request.POST.get("action")
if action == "checked":
rescue_org.set_checked()
last_checked_rescue_orgs = RescueOrganization.objects.order_by("last_checked")
context = {"rescue_orgs": last_checked_rescue_orgs, }
return render(request, 'fellchensammlung/rescue-organization-check.html', context=context)

View File

@ -89,9 +89,9 @@ CELERY_RESULT_BACKEND = config.get("celery", "backend", fallback="redis://localh
HEALTHCHECKS_URL = config.get("monitoring", "healthchecks_url", fallback=None) HEALTHCHECKS_URL = config.get("monitoring", "healthchecks_url", fallback=None)
""" GEOCODING """ """ GEOCODING """
GEOCODING_API_URL = config.get("geocoding", "api_url", fallback="https://nominatim.hyteck.de/search") GEOCODING_API_URL = config.get("geocoding", "api_url", fallback="https://photon.hyteck.de/api")
# GEOCODING_API_FORMAT is allowed to be one of ['nominatim', 'photon'] # GEOCODING_API_FORMAT is allowed to be one of ['nominatim', 'photon']
GEOCODING_API_FORMAT = config.get("geocoding", "api_format", fallback="nominatim") GEOCODING_API_FORMAT = config.get("geocoding", "api_format", fallback="photon")
""" Tile Server """ """ Tile Server """
MAP_TILE_SERVER = config.get("map", "tile_server", fallback="https://tiles.hyteck.de") MAP_TILE_SERVER = config.get("map", "tile_server", fallback="https://tiles.hyteck.de")
@ -168,6 +168,7 @@ INSTALLED_APPS = [
'django.contrib.sessions', 'django.contrib.sessions',
'django.contrib.messages', 'django.contrib.messages',
'django.contrib.staticfiles', 'django.contrib.staticfiles',
"django.contrib.sitemaps",
'fontawesomefree', 'fontawesomefree',
'crispy_forms', 'crispy_forms',
"crispy_bootstrap4", "crispy_bootstrap4",
@ -175,6 +176,7 @@ INSTALLED_APPS = [
'rest_framework.authtoken', 'rest_framework.authtoken',
'drf_spectacular', 'drf_spectacular',
'drf_spectacular_sidecar', # required for Django collectstatic discovery 'drf_spectacular_sidecar', # required for Django collectstatic discovery
'widget_tweaks'
] ]
MIDDLEWARE = [ MIDDLEWARE = [

View File

@ -3,25 +3,32 @@
{% block content %} {% block content %}
{% if form.errors %} {% if form.errors %}
<p>{% translate "Dein Username oder Passwort ist falsch." %}</p> <p>{% translate "Dein Username oder Passwort ist falsch." %}</p>
{% endif %} {% endif %}
{% if user.is_authenticated %} {% if user.is_authenticated %}
<p>{% translate "Du bist bereits eingeloggt." %}</p> <p>{% translate "Du bist bereits eingeloggt." %}</p>
{% else %} {% if next %} {% else %} {% if next %}
<p>{% translate "Bitte log dich ein um diese Seite sehen zu können." %}</p> <p class="card">{% translate "Bitte log dich ein um diese Seite sehen zu können." %}</p>
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if not user.is_authenticated %} {% if not user.is_authenticated %}
<form class="card" method="post" action="{% url 'login' %}"> <div class="card">
{% csrf_token %} <div class="container-edit-buttons">
{{ form.as_p }} <form method="post" action="{% url 'login' %}">
<input class="btn" type="submit" value={% translate "Einloggen" %} /> {% csrf_token %}
<input type="hidden" name="next" value="{{ next }}" /> {{ form.as_p }}
</form> <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> <div class="container-edit-buttons">
{% endif %} <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 %} {% endblock %}

View File

@ -27,7 +27,6 @@ class DistanceTest(TestCase):
l_stuttgart = LocationProxy("Stuttgart") l_stuttgart = LocationProxy("Stuttgart")
l_tue = LocationProxy("Tübingen") l_tue = LocationProxy("Tübingen")
# Should be 30km # Should be 30km
print(f"{l_stuttgart.position} -> {l_tue.position}")
distance_tue_stuttgart = calculate_distance_between_coordinates(l_stuttgart.position, l_tue.position) distance_tue_stuttgart = calculate_distance_between_coordinates(l_stuttgart.position, l_tue.position)
self.assertLess(distance_tue_stuttgart, 50) self.assertLess(distance_tue_stuttgart, 50)
self.assertGreater(distance_tue_stuttgart, 20) self.assertGreater(distance_tue_stuttgart, 20)

View File

@ -4,7 +4,7 @@ from django.utils import timezone
from django.test import TestCase from django.test import TestCase
from model_bakery import baker 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): class UserTest(TestCase):
@ -77,3 +77,21 @@ class AnnouncementTest(TestCase):
self.assertTrue(self.announcement2 not in active_announcements) self.assertTrue(self.announcement2 not in active_announcements)
self.assertTrue(self.announcement4 not in active_announcements) self.assertTrue(self.announcement4 not in active_announcements)
self.assertTrue(self.announcement5 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)

View 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())

View File

@ -1,198 +0,0 @@
from django.test import TestCase
from django.contrib.auth.models import Permission
from django.urls import reverse
from model_bakery import baker
from fellchensammlung.models import Animal, Species, AdoptionNotice, User, Location, AdoptionNoticeStatus, TrustLevel
from fellchensammlung.views import add_adoption_notice
class AnimalAndAdoptionTest(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()
adoption1 = baker.make(AdoptionNotice, name="TestAdoption1", owner=test_user0)
rat = baker.make(Species, name="Farbratte")
rat1 = baker.make(Animal,
name="Rat1",
adoption_notice=adoption1,
species=rat,
description="Eine unglaublich süße Ratte")
def test_detail_animal(self):
self.client.login(username='testuser0', password='12345')
response = self.client.post(reverse('animal-detail', args="1"))
self.assertEqual(response.status_code, 200)
# Check our user is logged in
self.assertEqual(str(response.context['user']), 'testuser0')
self.assertContains(response, "Rat1")
def test_detail_animal_notice(self):
self.client.login(username='testuser0', password='12345')
response = self.client.post(reverse('adoption-notice-detail', args="1"))
self.assertEqual(response.status_code, 200)
# Check our user is logged in
self.assertEqual(str(response.context['user']), 'testuser0')
self.assertContains(response, "TestAdoption1")
self.assertContains(response, "Rat1")
def test_creating_AN_as_admin(self):
self.client.login(username='testuser0', password='12345')
form_data = {"name": "TestAdoption4",
"species": Species.objects.first().pk,
"num_animals": "2",
"date_of_birth": "2024-11-04",
"sex": "M",
"group_only": "on",
"searching_since": "2024-11-10",
"location_string": "Mannheim",
"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.assertTrue(AdoptionNotice.objects.get(name="TestAdoption4").is_active)
class SearchTest(TestCase):
@classmethod
def setUpTestData(cls):
test_user0 = User.objects.create_user(username='testuser0',
first_name="Admin",
last_name="BOFH",
password='12345')
test_user0.save()
# Location of Berlin: lat 52.5170365 lon 13.3888599 PLZ 10115 (Mitte)
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_basic_view(self):
response = self.client.get(reverse('search'))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "TestAdoption1")
self.assertNotContains(response, "TestAdoption2")
self.assertContains(response, "TestAdoption3")
def test_basic_view_logged_in(self):
self.client.login(username='testuser0', password='12345')
response = self.client.get(reverse('search'))
self.assertEqual(response.status_code, 200)
# Check our user is logged in
self.assertEqual(str(response.context['user']), 'testuser0')
self.assertContains(response, "TestAdoption1")
self.assertContains(response, "TestAdoption3")
self.assertNotContains(response, "TestAdoption2")
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)
# 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):
@classmethod
def setUpTestData(cls):
test_user0 = User.objects.create_user(username='testuser0',
first_name="Admin",
last_name="BOFH",
password='12345',
trust_level=TrustLevel.MODERATOR)
test_user0.is_superuser = True
test_user0.save()
# Location of Berlin: lat 52.5170365 lon 13.3888599 PLZ 10115 (Mitte)
cls.adoption1 = baker.make(AdoptionNotice, name="TestAdoption1")
adoption2 = baker.make(AdoptionNotice, name="TestAdoption2")
cls.adoption3 = baker.make(AdoptionNotice, name="TestAdoption3")
cls.adoption1.set_unchecked()
cls.adoption3.set_unchecked()
def test_login_required(self):
response = self.client.get(reverse('updatequeue'))
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, "/accounts/login/?next=/updatequeue/")
def test_set_updated(self):
self.client.login(username='testuser0', password='12345')
# First get the list
response = self.client.get(reverse('updatequeue'))
self.assertEqual(response.status_code, 200)
# Make sure Adoption1 is in response
self.assertContains(response, "TestAdoption1")
self.assertNotContains(response, "TestAdoption2")
self.assertFalse(self.adoption1.is_active)
# Mark as checked
response = self.client.post(reverse('updatequeue'), {"adoption_notice_id": self.adoption1.pk,
"action": "checked_active"})
self.assertEqual(response.status_code, 200)
self.adoption1.refresh_from_db()
self.assertTrue(self.adoption1.is_active)
def test_set_checked_inactive(self):
self.client.login(username='testuser0', password='12345')
# First get the list
response = self.client.get(reverse('updatequeue'))
self.assertEqual(response.status_code, 200)
# Make sure Adoption3 is in response
self.assertContains(response, "TestAdoption3")
self.assertNotContains(response, "TestAdoption2")
self.assertFalse(self.adoption3.is_active)
# Mark as checked
response = self.client.post(reverse('updatequeue'),
{"adoption_notice_id": self.adoption3.id, "action": "checked_inactive"})
self.assertEqual(response.status_code, 200)
self.adoption3.refresh_from_db()
# Make sure correct status is set and AN is not shown anymore
self.assertNotContains(response, "TestAdoption3")
self.assertFalse(self.adoption3.is_active)
self.assertEqual(self.adoption3.adoptionnoticestatus.major_status, AdoptionNoticeStatus.CLOSED)

View File

View File

@ -0,0 +1,395 @@
from django.test import TestCase
from django.contrib.auth.models import Permission
from django.urls import reverse
from model_bakery import baker
from fellchensammlung.models import Animal, Species, AdoptionNotice, User, Location, AdoptionNoticeStatus, TrustLevel, \
Animal, Subscriptions, Comment, CommentNotification, SearchSubscription
from fellchensammlung.tools.geo import LocationProxy
from fellchensammlung.views import add_adoption_notice
class AnimalAndAdoptionTest(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()
adoption1 = baker.make(AdoptionNotice, name="TestAdoption1", owner=test_user0)
rat = baker.make(Species, name="Farbratte")
rat1 = baker.make(Animal,
name="Rat1",
adoption_notice=adoption1,
species=rat,
description="Eine unglaublich süße Ratte")
def test_detail_animal(self):
self.client.login(username='testuser0', password='12345')
response = self.client.post(reverse('animal-detail', args="1"))
self.assertEqual(response.status_code, 200)
# Check our user is logged in
self.assertEqual(str(response.context['user']), 'testuser0')
self.assertContains(response, "Rat1")
def test_detail_animal_notice(self):
self.client.login(username='testuser0', password='12345')
response = self.client.post(reverse('adoption-notice-detail', args="1"))
self.assertEqual(response.status_code, 200)
# Check our user is logged in
self.assertEqual(str(response.context['user']), 'testuser0')
self.assertContains(response, "TestAdoption1")
self.assertContains(response, "Rat1")
def test_creating_AN_as_admin(self):
self.client.login(username='testuser0', password='12345')
form_data = {"name": "TestAdoption4",
"species": Species.objects.first().pk,
"num_animals": "2",
"date_of_birth": "2024-11-04",
"sex": "M",
"group_only": "on",
"searching_since": "2024-11-10",
"location_string": "Mannheim",
"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.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):
@classmethod
def setUpTestData(cls):
test_user0 = User.objects.create_user(username='testuser0',
first_name="Admin",
last_name="BOFH",
password='12345')
test_user0.save()
# Location of Berlin: lat 52.5170365 lon 13.3888599 PLZ 10115 (Mitte)
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_basic_view(self):
response = self.client.get(reverse('search'))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "TestAdoption1")
self.assertNotContains(response, "TestAdoption2")
self.assertContains(response, "TestAdoption3")
def test_basic_view_logged_in(self):
self.client.login(username='testuser0', password='12345')
response = self.client.get(reverse('search'))
self.assertEqual(response.status_code, 200)
# Check our user is logged in
self.assertEqual(str(response.context['user']), 'testuser0')
self.assertContains(response, "TestAdoption1")
self.assertContains(response, "TestAdoption3")
self.assertNotContains(response, "TestAdoption2")
def test_unauthenticated_subscribe(self):
response = self.client.post(reverse('search'), {"subscribe_to_search": ""})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, "/accounts/login/?next=/suchen/")
def test_unauthenticated_unsubscribe(self):
response = self.client.post(reverse('search'), {"unsubscribe_to_search": 1})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, "/accounts/login/?next=/suchen/")
def test_subscribe(self):
self.client.login(username='testuser0', password='12345')
response = self.client.post(reverse('search'), {"max_distance": 50, "location_string": "Berlin", "sex": "A",
"subscribe_to_search": ""})
self.assertEqual(response.status_code, 200)
self.assertTrue(SearchSubscription.objects.filter(owner=User.objects.get(username='testuser0'),
max_distance=50).exists())
def test_unsubscribe(self):
user0 = User.objects.get(username='testuser0')
self.client.login(username='testuser0', password='12345')
location = Location.get_location_from_string("München")
subscription = SearchSubscription.objects.create(owner=user0, max_distance=200, location=location, sex="A")
response = self.client.post(reverse('search'), {"max_distance": 200, "location_string": "München", "sex": "A",
"unsubscribe_to_search": subscription.pk})
self.assertEqual(response.status_code, 200)
self.assertFalse(SearchSubscription.objects.filter(owner=User.objects.get(username='testuser0'),
max_distance=200).exists())
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)
# 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):
@classmethod
def setUpTestData(cls):
test_user0 = User.objects.create_user(username='testuser0',
first_name="Admin",
last_name="BOFH",
password='12345',
trust_level=TrustLevel.MODERATOR)
test_user0.is_superuser = True
test_user0.save()
# Location of Berlin: lat 52.5170365 lon 13.3888599 PLZ 10115 (Mitte)
cls.adoption1 = baker.make(AdoptionNotice, name="TestAdoption1")
adoption2 = baker.make(AdoptionNotice, name="TestAdoption2")
cls.adoption3 = baker.make(AdoptionNotice, name="TestAdoption3")
cls.adoption1.set_unchecked()
cls.adoption3.set_unchecked()
def test_login_required(self):
response = self.client.get(reverse('updatequeue'))
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, "/accounts/login/?next=/updatequeue/")
def test_set_updated(self):
self.client.login(username='testuser0', password='12345')
# First get the list
response = self.client.get(reverse('updatequeue'))
self.assertEqual(response.status_code, 200)
# Make sure Adoption1 is in response
self.assertContains(response, "TestAdoption1")
self.assertNotContains(response, "TestAdoption2")
self.assertFalse(self.adoption1.is_active)
# Mark as checked
response = self.client.post(reverse('updatequeue'), {"adoption_notice_id": self.adoption1.pk,
"action": "checked_active"})
self.assertEqual(response.status_code, 200)
self.adoption1.refresh_from_db()
self.assertTrue(self.adoption1.is_active)
def test_set_checked_inactive(self):
self.client.login(username='testuser0', password='12345')
# First get the list
response = self.client.get(reverse('updatequeue'))
self.assertEqual(response.status_code, 200)
# Make sure Adoption3 is in response
self.assertContains(response, "TestAdoption3")
self.assertNotContains(response, "TestAdoption2")
self.assertFalse(self.adoption3.is_active)
# Mark as checked
response = self.client.post(reverse('updatequeue'),
{"adoption_notice_id": self.adoption3.id, "action": "checked_inactive"})
self.assertEqual(response.status_code, 200)
self.adoption3.refresh_from_db()
# Make sure correct status is set and AN is not shown anymore
self.assertNotContains(response, "TestAdoption3")
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()
cls.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_basic_view(self):
response = self.client.get(
reverse('adoption-notice-detail', args=str(AdoptionNotice.objects.get(name="TestAdoption1").pk)), )
self.assertEqual(response.status_code, 200)
def test_basic_view_logged_in(self):
self.client.login(username='testuser0', password='12345')
response = self.client.get(
reverse('adoption-notice-detail', args=str(AdoptionNotice.objects.get(name="TestAdoption1").pk)), )
self.assertEqual(response.status_code, 200)
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/")
def test_unauthenticated_comment(self):
response = self.client.post(
reverse('adoption-notice-detail', args=str(AdoptionNotice.objects.get(name="TestAdoption1").pk)),
data={"action": "comment"})
self.assertEqual(response.status_code, 403)
def test_comment(self):
an1 = AdoptionNotice.objects.get(name="TestAdoption1")
# Set up subscription
Subscriptions.objects.create(owner=self.test_user1, adoption_notice=an1)
self.client.login(username='testuser0', password='12345')
response = self.client.post(
reverse('adoption-notice-detail', args=str(an1.pk)),
data={"action": "comment", "text": "Test"})
self.assertTrue(Comment.objects.filter(user__username="testuser0").exists())
self.assertFalse(CommentNotification.objects.filter(user__username="testuser0").exists())
self.assertTrue(CommentNotification.objects.filter(user__username="testuser1").exists())
class AdoptionEditTest(TestCase):
@classmethod
def setUpTestData(cls):
test_user0 = User.objects.create_user(username='testuser0',
first_name="Admin",
last_name="BOFH",
password='12345')
test_user0.save()
cls.test_user1 = User.objects.create_user(username='testuser1',
first_name="Max",
last_name="Müller",
password='12345')
adoption1 = baker.make(AdoptionNotice, name="TestAdoption1", description="Test1", owner=test_user0)
adoption2 = baker.make(AdoptionNotice, name="TestAdoption2", description="Test2")
def test_basic_view(self):
response = self.client.get(
reverse('adoption-notice-edit', args=str(AdoptionNotice.objects.get(name="TestAdoption1").pk)), )
self.assertEqual(response.status_code, 302)
def test_basic_view_logged_in_unauthorized(self):
self.client.login(username='testuser1', password='12345')
response = self.client.get(
reverse('adoption-notice-edit', args=str(AdoptionNotice.objects.get(name="TestAdoption1").pk)), )
self.assertEqual(response.status_code, 403)
def test_basic_view_logged_in(self):
self.client.login(username='testuser0', password='12345')
response = self.client.get(
reverse('adoption-notice-edit', args=str(AdoptionNotice.objects.get(name="TestAdoption1").pk)), )
self.assertEqual(response.status_code, 200)
def test_edit(self):
data = {"name": "Mia",
"searching_since": "01.01.2025",
"location_string": "Paderborn",
"organization": "",
"description": "Test3",
"further_information": ""}
an = AdoptionNotice.objects.get(name="TestAdoption1")
assert self.client.login(username='testuser0', password='12345')
response = self.client.post(reverse("adoption-notice-edit", args=str(an.pk)), data=data, follow=True)
self.assertEqual(response.redirect_chain[0][1],
302) # See https://docs.djangoproject.com/en/5.1/topics/testing/tools/
self.assertEqual(response.status_code, 200) # Redirects to AN page
self.assertContains(response, "Test3")
self.assertContains(response, "Mia")
self.assertNotContains(response, "Test1")

View File

@ -0,0 +1,136 @@
from django.test import TestCase
from django.urls import reverse
from docs.conf import language
from fellchensammlung.models import User, TrustLevel, AdoptionNotice, Species, Rule, Language, Comment, ReportComment
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()
rule1 = Rule.objects.create(title="Rule 1", rule_text="Description of r1", rule_identifier="rule1",
language=Language.objects.get(name="English"))
an1 = AdoptionNotice.objects.get(name="TestAdoption0")
comment1 = Comment.objects.create(adoption_notice=an1, text="Comment1", user=test_user1)
comment2 = Comment.objects.create(adoption_notice=an1, text="Comment2", user=test_user1)
comment3 = Comment.objects.create(adoption_notice=an1, text="Comment3", user=test_user1)
report_comment1 = ReportComment.objects.create(reported_comment=comment1,
user_comment="ReportComment1")
report_comment1.save()
report_comment1.reported_broken_rules.set({rule1,})
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
def test_about_logged_in(self):
self.client.login(username='testuser0', password='12345')
response = self.client.get(reverse('about'))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Rule 1")
def test_about_anonymous(self):
response = self.client.get(reverse('about'))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Rule 1")
def test_report_adoption_logged_in(self):
self.client.login(username='testuser0', password='12345')
an = AdoptionNotice.objects.get(name="TestAdoption0")
response = self.client.get(reverse('report-adoption-notice', args=str(an.pk)))
self.assertEqual(response.status_code, 200)
data = {"reported_broken_rules": 1, "user_comment": "animal cruelty"}
response = self.client.post(reverse('report-adoption-notice', args=str(an.pk)), data=data)
self.assertEqual(response.status_code, 302)
def test_report_adoption_anonymous(self):
an = AdoptionNotice.objects.get(name="TestAdoption0")
response = self.client.get(reverse('report-adoption-notice', args=str(an.pk)))
self.assertEqual(response.status_code, 200)
data = {"reported_broken_rules": 1, "user_comment": "animal cruelty"}
response = self.client.post(reverse('report-adoption-notice', args=str(an.pk)), data=data)
self.assertEqual(response.status_code, 302)
def test_report_comment_logged_in(self):
self.client.login(username='testuser0', password='12345')
c = Comment.objects.get(text="Comment1")
response = self.client.get(reverse('report-comment', args=str(c.pk)))
self.assertEqual(response.status_code, 200)
data = {"reported_broken_rules": 1, "user_comment": "animal cruelty"}
response = self.client.post(reverse('report-comment', args=str(c.pk)), data=data)
self.assertEqual(response.status_code, 302)
self.assertTrue(ReportComment.objects.filter(reported_comment=c.pk).exists())
def test_report_comment_anonymous(self):
c = Comment.objects.get(text="Comment2")
response = self.client.get(reverse('report-comment', args=str(c.pk)))
self.assertEqual(response.status_code, 200)
data = {"reported_broken_rules": 1, "user_comment": "animal cruelty"}
response = self.client.post(reverse('report-comment', args=str(c.pk)), data=data)
self.assertEqual(response.status_code, 302)
self.assertTrue(ReportComment.objects.filter(reported_comment=c.pk).exists())
def test_show_report_details_logged_in(self):
self.client.login(username='testuser1', password='12345')
report = ReportComment.objects.get(user_comment="ReportComment1")
response = self.client.get(reverse('report-detail', args=(report.pk,)))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Rule 1")
self.assertContains(response, "ReportComment1")
self.assertNotContains(response, '<form action="allow" class="">')
def test_show_report_details_anonymous(self):
report = ReportComment.objects.get(user_comment="ReportComment1")
response = self.client.get(reverse('report-detail', args=(report.pk,)))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Rule 1")
self.assertContains(response, "ReportComment1")
self.assertNotContains(response, '<form action="allow" class="">')
def test_show_report_details_admin(self):
self.client.login(username='testuser0', password='12345')
report = ReportComment.objects.get(user_comment="ReportComment1")
response = self.client.get(reverse('report-detail', args=(report.pk,)))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Rule 1")
self.assertContains(response, "ReportComment1")
self.assertContains(response, '<form action="allow" class="">')