3 Commits

Author SHA1 Message Date
c9f46d7547 ci: fix?
All checks were successful
ci/woodpecker/push/docs Pipeline was successful
ci/woodpecker/push/test Pipeline was successful
2025-01-19 07:12:09 +01:00
9f23f5768c ci: restructure
Some checks failed
ci/woodpecker/push/docs Pipeline failed
ci/woodpecker/push/test Pipeline failed
2025-01-19 07:07:53 +01:00
19210f90cd ci: try teests 2025-01-19 07:04:22 +01:00
265 changed files with 5595 additions and 14755 deletions

10
.gitignore vendored
View File

@@ -2,18 +2,11 @@
# Database
notfellchen
*.sq3
# Geojson from imports
*.geojson
# Media storage
/static
static
media
# Compiled CSS
/src/fellchensammlung/static/fellchensammlung/css/main.css
/src/fellchensammlung/static/fellchensammlung/css/main.css.map
# Byte-compiled / optimized / DLL files
__pycache__/
@@ -168,4 +161,3 @@ dmypy.json
# Cython debug symbols
cython_debug/
/node_modules/

View File

@@ -6,6 +6,9 @@ steps:
commands:
- cd docs && make html
when:
event: [ tag, push ]
deploy:
image: appleboy/drone-scp
settings:
@@ -19,6 +22,8 @@ steps:
source: docs/_build/html/
key:
from_secret: ssh_key
when:
event: [ tag, push ]

14
.woodpecker/test.yml Normal file
View File

@@ -0,0 +1,14 @@
---
steps:
test:
image: python
commands:
- python -m pip install '.[develop]'
- coverage run --source='.' src/manage.py test src && coverage html
- coverage html
- cat htmlcov/index.html
when:
event: [tag, push]

View File

@@ -1,6 +1,6 @@
FROM python:3.11-slim
# Use 3.11 to avoid django.core.exceptions.ImproperlyConfigured: Error loading psycopg2 or psycopg module
LABEL org.opencontainers.image.authors="Julian-Samuel Gebühr"
MAINTAINER Julian-Samuel Gebühr
ENV DOCKER_BUILD=true

View File

@@ -2,7 +2,7 @@
[notfellchen.org](https://notfellchen.org) ist eine Sammelstelle für Tier-Vermittlungen. Die Idee entstand, da in der
deutschsprachigen Rattencommunity ein wilder Mix aus Websites, Foren und Facebookgruppen besteht die Ratten vermitteln.
Diese Website soll die bestehende Communitys NICHT ersetzten, jedoch ermöglichen, dass Menschen die Ratten aufnehmen
Diese Website soll die bestehende Communities NICHT ersetzten, jedoch ermöglichen, dass Menschen die Ratten aufnehmen
wollen Informationen einfach finden und nicht bereits in jeder Gruppe sein müssen.
Wir nehmen Angebote auf die
@@ -57,50 +57,10 @@ Therefore, a solution is used where a number of predefined texts per site are su
# Developer Notes
## Getting started
### Clone the project
```
git clone https://codeberg.org/moanos/notfellchen.git
```
### Install dependencies
```
pip install -e '.[all]'
```
### Create the database
```
nf migrate
```
Because of a wired bug the initial migrations must run two times as the first time the permissions
for `create_active_adoption_notice` are created but can not yet be accessed and on the second time this permission will
be added to groups.
### Start the server
```
nf runserver
```
### Build the docs
```
sphinx-autobuild ./docs ./docs/_build/html
```
## Styling
Bulma is used for styling, including related SCSS. All styles should eventually be migrated to SCSS.
Use `npm run build-bulma` to generate the css file from SCSS.
You can use `npm start` during development so that the file is re-generated upon change.
## Docker
Build latest image
@@ -117,7 +77,6 @@ docker push moanos/notfellchen:latest
docker run -p8000:7345 moanos/notfellchen:latest
```
## Testing
Tests can be run with
@@ -185,17 +144,17 @@ Start beat
# Contributing
This project is currently mainly developed by me, moanos. I'd like that to change and will be very happy for contributions
This project is currently solely developed by me, moanos. I'd like that to change and will be very happy for contributions
and shared responsibilities. Some ideas where you can look for contributing first
* UI improvements: Since a major redesign I'm much happier but the UI could use many, many little tweaks
* CSS structure: It's a hot mess right now, and I'm happy it somehow works. As you might see, there is much room for improvement. Refactoring this and streamlining the look across the app would be amazing.
* Docker: If you know how to build a docker container that is a) smaller or b) utilizes staged builds this would be amazing. Any improvement welcome
* Testing: Writing tests is always welcome, and it's likely you discover a few bugs
I'm also very happy for all other contributions. Before you do large refactoring efforts or features, best write a short
issue for it before you spend a lot of work.
Send PRs either to [codeberg](https://codeberg.org/moanos/notfellchen) (preferred) or [GitHub](https://github.com/moan0s/notfellchen).
CI (currently only for documentation) runs via [git.hyteck.de](https://git.hyteck.de), you can also ask moanos for an account there.
Send PRs either to [codeberg](https://codeberg.org/moanos/notfellchen) (preferred) or [Github](https://github.com/moan0s/notfellchen).
CI (currently only for dcumentation) runs via [git.hyteck.de](https://git.hyteck.de), you can also ask moanos for an account there.
Also welcome are new issues with suggestions or bugs and additions to the documentation.

View File

@@ -14,8 +14,7 @@ Via browser
-----------
When a user is logged in, they can easily access the API in their browser, authenticated by their session.
For example: You can check all current adoption notices here: https://notfellchen.org/api/adoption_notice
The API endpoint can be found at http://notfellchen.org/api/adoption_notices
Via token
---------
@@ -29,9 +28,9 @@ An application can then send this token in the request header for authorization.
.. warning::
Usage or creation of content still has to follow the terms of notfellchen.org.
Usage or creation of content still has to follow the terms of Notfellchen.org
Copyright of content is often held by rescue organizations, so you are not allowed to simply mirror content.
Talk to the notfellchen team if you want develop such things.
Talk to the Notfellchen-Team if you want develop such things.
Endpoints
@@ -46,8 +45,7 @@ Get Adoption Notices
++++++++++++++++++++
.. code-block::
curl --request GET \
curl --request GET \
--url https://notfellchen.org/api/adoption_notice \
--header 'Authorization: {{token}}'
@@ -55,8 +53,7 @@ Create Adoption Notice
++++++++++++++++++++++
.. code-block::
curl --request POST \
curl --request POST \
--url https://notfellchen.org/api/adoption_notice \
--header 'Authorization: {{token}}' \
--header 'content-type: multipart/form-data' \
@@ -71,7 +68,6 @@ Add Animal to Adoption Notice
+++++++++++++++++++++++++++++
.. code-block::
curl --request POST \
--url https://notfellchen.org/api/animals/ \
--header 'Authorization: {{token}}' \
@@ -87,7 +83,6 @@ Add picture to Animal or Adoption Notice
++++++++++++++++++++++++++++++++++++++++
.. code-block::
curl -X POST https://notfellchen.org/api/images/ \
-H "Authorization: Token {{token}}" \
-F "image=@256-256-crop.jpg" \
@@ -101,7 +96,6 @@ Species
Getting available species is mainly important when creating animals
.. code-block::
curl --request GET \
--url https://notfellchen.org/api/species \
--header 'Authorization: {{token}}'

View File

@@ -1,74 +0,0 @@
from __future__ import annotations
from pathlib import Path
from docutils import nodes
from sphinx.application import Sphinx
from sphinx.util.docutils import SphinxDirective
from sphinx.util.typing import ExtensionMetadata
class DrawioDirective(SphinxDirective):
"""A directive to show a drawio diagram!
Usage:
.. drawio::
example-diagram.drawio.html
example-diagram.drawio.png
:alt: Example of a Draw.io diagram
"""
has_content = False
required_arguments = 2 # html and png
optional_arguments = 1
final_argument_whitespace = True # indicating if the final argument may contain whitespace
option_spec = {
"alt": str,
}
def run(self) -> list[nodes.Node]:
env = self.state.document.settings.env
builder = env.app.builder
# Resolve paths relative to the document
docdir = Path(env.doc2path(env.docname)).parent
html_rel = Path(self.arguments[0])
png_rel = Path(self.arguments[1])
html_path = (docdir / html_rel).resolve()
png_path = (docdir / png_rel).resolve()
alt_text = self.options.get("alt", "")
container = nodes.container()
# HTML output -> raw HTML node
if builder.format == "html":
# Embed the HTML file contents directly
try:
html_content = html_path.read_text(encoding="utf-8")
except OSError as e:
msg = self.state_machine.reporter.error(f"Cannot read HTML file: {e}")
return [msg]
aria_attribute = f' aria-label="{alt_text}"' if alt_text else ""
raw_html_node = nodes.raw(
"",
f'<div class="drawio-diagram"{aria_attribute}>{html_content}</div>',
format="html",
)
container += raw_html_node
else:
# Other outputs -> PNG image node
image_node = nodes.image(uri=png_path)
container += image_node
return [container]
def setup(app: Sphinx) -> ExtensionMetadata:
app.add_directive("drawio", DrawioDirective)
return {
"version": "0.2",
"parallel_read_safe": True,
"parallel_write_safe": True,
}

View File

@@ -67,6 +67,5 @@ Healthchecks
You can configure notfellchen to give a hourly ping to a healthchecks server. If this ping is not received, you will get notified and cna check why the celery jobs are no running.
Add the following to your `notfellchen.cfg` and adjust the URL to match your check.
.. code::
[monitoring]
healthchecks_url=https://health.example.org/ping/5fa7c9b2-753a-4cb3-bcc9-f982f5bc68e8

View File

@@ -16,10 +16,6 @@
# import sys
# sys.path.insert(0, os.path.abspath('.'))
import sys
from pathlib import Path
sys.path.append(str(Path('_ext').resolve()))
# -- Project information -----------------------------------------------------
@@ -32,6 +28,7 @@ version = ''
# The full version, including alpha/beta/rc tags
release = '0.2.0'
# -- General configuration ---------------------------------------------------
# If your documentation needs a minimal Sphinx version, state it here.
@@ -43,7 +40,6 @@ release = '0.2.0'
# ones.
extensions = [
'sphinx.ext.ifconfig',
'drawio'
]
# Add any paths that contain templates here, relative to this directory.
@@ -73,6 +69,7 @@ exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
# The name of the Pygments (syntax highlighting) style to use.
pygments_style = None
# -- Options for HTML output -------------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for
@@ -107,6 +104,7 @@ html_static_path = ['_static']
# Output file base name for HTML help builder.
htmlhelp_basename = 'notfellchen'
# -- Options for LaTeX output ------------------------------------------------
latex_elements = {
@@ -135,6 +133,7 @@ latex_documents = [
'Julian-Samuel Gebühr', 'manual'),
]
# -- Options for manual page output ------------------------------------------
# One entry per manual page. List of tuples
@@ -144,6 +143,7 @@ man_pages = [
[author], 1)
]
# -- Options for Texinfo output ----------------------------------------------
# Grouping the document tree into Texinfo files. List of tuples
@@ -155,6 +155,7 @@ texinfo_documents = [
'Miscellaneous'),
]
# -- Options for Epub output -------------------------------------------------
# Bibliographic Dublin Core info.
@@ -172,4 +173,5 @@ epub_title = project
# A list of files that should not be packed into the epub file.
epub_exclude_files = ['search.html']
# -- Extension configuration -------------------------------------------------

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

File diff suppressed because one or more lines are too long

View File

@@ -6,27 +6,14 @@ Jede Vermittlung kann abonniert werden. Dafür klickst du auf die Glocke neben d
.. image:: abonnieren.png
Einstellungen
-------------
Du kannst E-Mail Benachrichtigungen in den Einstellungen deaktivieren.
.. image::
einstellungen-benachrichtigungen.png
:alt: Screenshot der Profileinstellungen in Notfellchen. Ein roter Pfeil zeigt auf einen Schalter "E-Mail Benachrichtigungen"
Auf der Website
+++++++++++++++
.. image::
screenshot-benachrichtigungen.png
:alt: Screenshot der Menüleiste von Notfellchen.org. Neben dem Symbol einer Glocke steht die Zahl 27.
E-Mail
++++++
Mit während deiner :doc:`registrierung` gibst du eine E-Mail Adresse an. An diese senden wir Benachrichtigungen, außer
du deaktiviert dies wie oben beschrieben.
Mit während deiner :doc:`registrierung` gibst du eine E-Mail Addresse an.
Benachrichtigungen senden wir per Mail - du kannst das jederzeit in den Einstellungen deaktivieren.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

View File

@@ -1,58 +0,0 @@
Erste Schritte
==============
Tiere zum Adoptieren suchen
---------------------------
Wenn du Tiere zum adoptieren suchst, brauchst du keinen Account. Du kannst bequem die `Suche <https://notfellchen.org/suchen/>`_ nutzen, um Tiere zur Adoption in deiner Nähe zu finden.
Wenn dich eine Vermittlung interessiert, kannst du folgendes tun
* die Vermittlung aufrufen um Details zu sehen
* den Link :guilabel:`Weitere Informationen` anklicken um auf der Tierheimwebsite mehr zu erfahren
* per Kommentar weitere Informationen erfragen oder hinzufügen
Wenn du die Tiere tatsächlich informieren willst, folge der Anleitung unter :guilabel:`Adoptionsprozess`.
Dieser kann sich je nach Tierschutzorganisation unterscheiden.
.. image::
screenshot-adoptionsprozess.png
:alt: Screenshot der Sektion "Adoptionsprozess" einer Vermittlungsanzeige. Der Prozess ist folgendermaßen: 1. Link zu "Weiteren Informationen" prüfen, 2. Organization kontaktieren, 3. Bei erfolgreicher Vermittlung: Vermittlung als geschlossen melden
Suchen abonnieren
+++++++++++++++++
Es kann sein, dass es in deiner Umgebung keine passenden Tiere für deine Suche gibt. Damit du nicht ständig wieder Suchen musst, gibt es die Funktion "Suche abonnieren".
Wenn du eine Suche abonnierst, wirst du für neue Vermittlungen, die den Kriterien der Suche entsprechen, benachrichtigt.
.. image::
screenshot-suche-abonnieren.png
:alt: Screenshot der Suchmaske auf Notfellchen.org . Ein roter Pfeil zeigt auf den Button "Suche abonnieren"
.. important::
Um Suchen zu abonnieren brauchst du einen Account. Wie du einen Account erstellst erfährst du hier: :doc:`registrierung`.
.. hint::
Mehr über Benachrichtigungen findest du hier: :doc:`benachrichtigungen`.
Vermittlungen hinzufügen
------------------------
Gehe zu `Vermittlung hinzufügen <https://notfellchen.org/vermitteln/>`_ um eine neue Vermittlung einzustellen.
Füge alle Informationen die du hast hinzu.
.. important::
Um Vermittlungen hinzuzufügen brauchst du einen Account.
Wie du einen Account erstellst erfährst du hier: :doc:`registrierung`.
.. important::
Vermittlungen die du einstellst müssen erst durch Moderator\*innen freigeschaltet werden. Das passiert normalerweise
innerhalb von 24 Stunden. Wenn deine Vermittlung dann noch nicht freigeschaltet ist, prüfe bitte dein E-Mail Postfach,
es könnte sein, dass die Moderator\*innen Rückfragen haben. Melde dich gerne unter info@notfellchen.org, wenn deine
Vermittlung nach 24 Stunden nicht freigeschaltet ist.

View File

@@ -1,17 +1,11 @@
****************
Benutzerhandbuch
****************
Im Benutzerhandbuch findest du Informationen zur Benutzung von `notfellchen.org <https://notfellchen.org>`_.
Solltest du darüber hinaus Fragen haben, komm gerne auf uns zu: info@notfellchen.org
******************
User Dokumentation
******************
.. toctree::
:maxdepth: 2
:caption: Inhalt:
erste-schritte.rst
registrierung.rst
vermittlungen.rst
moderationskonzept.rst
benachrichtigungen.rst
organisationen-pruefen.rst

View File

@@ -1,55 +0,0 @@
Tiere in Vermittlung systematisch entdecken & eintragen
=======================================================
Notfellchen hat eine Liste der meisten deutschen Tierheime und anderer Tierschutzorganisationen.
Die meisten dieser Organisationen nehmen Tiere auf die bei Notfellchen eingetragen werden können.
Es ist daher das Ziel, diese Organisationen alle zwei Wochen auf neue Tiere zu prüfen.
+-------------------------------------------------+---------+----------------------+
| Gruppe | Anzahl | Zuletzt aktualisiert |
+=================================================+=========+======================+
| Tierschutzorganisationen im Verzeichnis | 550 | Oktober 2025 |
+-------------------------------------------------+---------+----------------------+
| Tierschutzorganisationen in regelmäßigerPrüfung | 412 | Oktober 2025 |
+-------------------------------------------------+---------+----------------------+
.. warning::
Organisationen auf neue Tiere zu prüfen ist eine Funktion für Moderator\*innen. Falls du Lust hast mitzuhelfen,
meld dich unter info@notfellchen.org
Als Moderator\*in kannst du direkt auf den `Moderations-Check <https://notfellchen.org/organization-check/>`_ zugreifen
oder findest ihn in unter :menuselection:`Hilfreiche Links --> Moderationstools`:
.. image::
Screenshot-hilfreiche-Links.png
:alt: Screenshot der Hilfreichen Links. Zur Auswahl stehen "Tierheime in der Nähe","Moderationstools" und "Admin-Bereich"
.. image::
Screenshot-Moderationstools.png
:alt: Screenshot der Moderationstools. Zur Auswahl stehen "Moderationswarteschlange", "Up-to-Date Check", "Organisations-Check" und "Vermittlung ins Fediverse posten".
Arbeitsmodus
------------
.. drawio::
Tiere-in-Vermittlung-entdecken.drawio.html
Tiere-in-Vermittlung-entdecken.drawio.png
Shortcuts
---------
Um die Prüfung schneller zu gestalten, gibt es eine Reihe von Shortcuts die du nutzen kannst. Aus Gründen der
Übersichtlichkeit sind im Folgenden auch Shortcuts im Browser aufgeführt.
+------------------------------------------------------+---------------+
| Aktion | Shortcut |
+======================================================+===============+
| Website der ersten Tierschutzorganisation öffnen | :kbd:`O` |
+------------------------------------------------------+---------------+
| Tab schließen (Firefox/Chrome) | :kbd:`STRG+W` |
+------------------------------------------------------+---------------+
| Erste Tierschutzorganisationa als geprüft markieren | :kbd:`C` |
+------------------------------------------------------+---------------+

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -1,7 +1,7 @@
Vermittlungen
=============
Vermittlungen können von allen Nutzer\*innen mit Account erstellt werden. Vermittlungen normaler Nutzer*innen kommen dann in eine Warteschlange und werden vom Admin & Modertionsteam geprüft und sichtbar geschaltet.
Vermittlungen können von allen Nutzer*innen mit Account erstellt werden. Vermittlungen normaler Nutzer*innen kommen dann in eine Warteschlange und werden vom Admin & Modertionsteam geprüft und sichtbar geschaltet.
Tierheime und Pflegestellen können auf Anfrage einen Koordinations-Status bekommen, wodurch sie Vermittlungsanzeigen erstellen können die direkt öffentlich sichtbar sind.
Jede Vermittlung hat ein "Zuletzt-geprüft" Datum, das anzeigt, wann ein Mensch zuletzt überprüft hat, ob die Anzeige noch aktuell ist.
@@ -15,114 +15,3 @@ Die Kommentarfunktion von Vermittlungen ermöglicht es angemeldeten Nutzer*innen
Ersteller*innen von Vermittlungen werden über neue Kommentare per Mail benachrichtigt, ebenso alle die die Vermittlung abonniert haben.
Kommentare können, wie Vermittlungen, gemeldet werden.
.. drawio::
Vermittlung_Lifecycle.drawio.html
Vermittlung-Lifecycle.drawio.png
:alt: Diagramm das den Prozess der Vermittlungen zeigt.
Adoption Notice Status Choices
++++++++++++++++++++++++++++++
Aktiv
-----
Aktive Vermittlungen die über die Suche auffindbar sind.
.. list-table::
:header-rows: 1
:width: 100%
:widths: 1 1 2
* - Value
- Label
- Description
* - ``active_searching``
- Searching
-
* - ``active_interested``
- Interested
- Jemand hat bereits Interesse an den Tieren.
Warte auf Aktion
----------------
Vermittlungen in diesem Status warten darauf, dass ein Mensch sie überprüft. Sie können nicht über die Suche gefunden werden.
.. list-table::
:header-rows: 1
:width: 100%
:widths: 1 1 2
* - ``awaiting_action_waiting_for_review``
- Waiting for review
- Neue Vermittlung die deaktiviert ist bis Moderator*innen sie überprüfen.
* - ``awaiting_action_needs_additional_info``
- Needs additional info
- Deaktiviert bis Informationen nachgetragen werden.
* - ``disabled_unchecked``
- Unchecked
- Vermittlung deaktiviert bis sie vom Team auf Aktualität geprüft wurde.
Geschlossen
-----------
Geschlossene Vermittlungen tauchen in keiner Suche auf. Sie werden aber weiterhin angezeigt, wenn der Link zu ihnen direkt aufgerufen wird.
.. list-table::
:header-rows: 1
:width: 100%
:widths: 1 1 2
* - ``closed_successful_with_notfellchen``
- Successful (with Notfellchen)
- Vermittlung erfolgreich abgeschlossen.
* - ``closed_successful_without_notfellchen``
- Successful (without Notfellchen)
- Vermittlung erfolgreich abgeschlossen.
* - ``closed_animal_died``
- Animal died
- Die zu vermittelnden Tiere sind über die Regenbrücke gegangen.
* - ``closed_for_other_adoption_notice``
- Closed for other adoption notice
- Vermittlung wurde zugunsten einer anderen geschlossen.
* - ``closed_not_open_for_adoption_anymore``
- Not open for adoption anymore
- Tier(e) stehen nicht mehr zur Vermittlung bereit.
* - ``closed_link_to_more_info_not_reachable``
- Der Link zu weiteren Informationen ist nicht mehr erreichbar.
- Der Link zu weiteren Informationen ist nicht mehr erreichbar, die Vermittlung wurde daher automatisch deaktiviert.
* - ``closed_other``
- Other (closed)
- Vermittlung geschlossen.
Deaktiviert
-----------
Deaktivierte Vermittlungen werden nur noch Moderator\*innen und Administrator\*innen angezeigt.
.. list-table::
:header-rows: 1
:width: 100%
:widths: 1 1 2
* - ``disabled_against_the_rules``
- Against the rules
- Vermittlung deaktiviert da sie gegen die Regeln verstößt.
* - ``disabled_other``
- Other (disabled)
- Vermittlung deaktiviert.

View File

@@ -8,7 +8,6 @@ host=localhost
[django]
secret=CHANGE-ME
debug=True
internal_ips=["127.0.0.1"]
[database]
backend=sqlite3
@@ -19,7 +18,7 @@ media=./media
static=./static
[mail]
console_only=true
console-only=true
[logging]
app_log_level=INFO
@@ -29,6 +28,3 @@ django_log_level=INFO
api_url=https://photon.hyteck.de/api
api_format=photon
[security]
totp_issuer="NF Localhost"
webauth_allow_insecure_origin=True

497
package-lock.json generated
View File

@@ -1,497 +0,0 @@
{
"name": "notfellchen",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"bulma": "^1.0.4",
"sass": "^1.89.2"
}
},
"node_modules/@parcel/watcher": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz",
"integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"detect-libc": "^1.0.3",
"is-glob": "^4.0.3",
"micromatch": "^4.0.5",
"node-addon-api": "^7.0.0"
},
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"@parcel/watcher-android-arm64": "2.5.1",
"@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-darwin-x64": "2.5.1",
"@parcel/watcher-freebsd-x64": "2.5.1",
"@parcel/watcher-linux-arm-glibc": "2.5.1",
"@parcel/watcher-linux-arm-musl": "2.5.1",
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
"@parcel/watcher-linux-arm64-musl": "2.5.1",
"@parcel/watcher-linux-x64-glibc": "2.5.1",
"@parcel/watcher-linux-x64-musl": "2.5.1",
"@parcel/watcher-win32-arm64": "2.5.1",
"@parcel/watcher-win32-ia32": "2.5.1",
"@parcel/watcher-win32-x64": "2.5.1"
}
},
"node_modules/@parcel/watcher-android-arm64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz",
"integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-darwin-arm64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz",
"integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-darwin-x64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz",
"integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-freebsd-x64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz",
"integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm-glibc": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz",
"integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm-musl": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz",
"integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm64-glibc": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz",
"integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm64-musl": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz",
"integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-x64-glibc": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz",
"integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-x64-musl": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz",
"integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-arm64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz",
"integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-ia32": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz",
"integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-x64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz",
"integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"license": "MIT",
"optional": true,
"dependencies": {
"fill-range": "^7.1.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/bulma": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/bulma/-/bulma-1.0.4.tgz",
"integrity": "sha512-Ffb6YGXDiZYX3cqvSbHWqQ8+LkX6tVoTcZuVB3lm93sbAVXlO0D6QlOTMnV6g18gILpAXqkG2z9hf9z4hCjz2g==",
"license": "MIT"
},
"node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"license": "MIT",
"dependencies": {
"readdirp": "^4.0.1"
},
"engines": {
"node": ">= 14.16.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/detect-libc": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
"integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==",
"license": "Apache-2.0",
"optional": true,
"bin": {
"detect-libc": "bin/detect-libc.js"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"license": "MIT",
"optional": true,
"dependencies": {
"to-regex-range": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/immutable": {
"version": "5.1.3",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.3.tgz",
"integrity": "sha512-+chQdDfvscSF1SJqv2gn4SRO2ZyS3xL3r7IW/wWEEzrzLisnOlKiQu5ytC/BVNcS15C39WT2Hg/bjKjDMcu+zg==",
"license": "MIT"
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"license": "MIT",
"optional": true,
"dependencies": {
"is-extglob": "^2.1.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"license": "MIT",
"optional": true,
"dependencies": {
"braces": "^3.0.3",
"picomatch": "^2.3.1"
},
"engines": {
"node": ">=8.6"
}
},
"node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"license": "MIT",
"optional": true
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"license": "MIT",
"engines": {
"node": ">= 14.18.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/sass": {
"version": "1.89.2",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.89.2.tgz",
"integrity": "sha512-xCmtksBKd/jdJ9Bt9p7nPKiuqrlBMBuuGkQlkhZjjQk3Ty48lv93k5Dq6OPkKt4XwxDJ7tvlfrTa1MPA9bf+QA==",
"license": "MIT",
"dependencies": {
"chokidar": "^4.0.0",
"immutable": "^5.0.2",
"source-map-js": ">=0.6.2 <2.0.0"
},
"bin": {
"sass": "sass.js"
},
"engines": {
"node": ">=14.0.0"
},
"optionalDependencies": {
"@parcel/watcher": "^2.4.1"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"is-number": "^7.0.0"
},
"engines": {
"node": ">=8.0"
}
}
}
}

View File

@@ -1,10 +0,0 @@
{
"dependencies": {
"bulma": "^1.0.4",
"sass": "^1.89.2"
},
"scripts": {
"build-bulma": "sass --load-path=node_modules src/fellchensammlung/static/fellchensammlung/css/main.scss src/fellchensammlung/static/fellchensammlung/css/main.css --style compressed",
"start": "npm run build-bulma -- --watch"
}
}

View File

@@ -6,14 +6,14 @@ build-backend = "setuptools.build_meta"
[project]
name = "notfellchen"
description = "A website to help animals to find a loving home. It features organized input of adoption notices and related animals including automated lifecycle, location-based search, roles, and support for easy checking of rescue organizations."
description = "A tool to help."
authors = [
{ name = "moanos", email = "julian-samuel@gebuehr.net" },
]
maintainers = [
{ name = "moanos", email = "julian-samuel@gebuehr.net" },
]
keywords = ["animal", "adoption", "django", "rescue", "rats" ]
keywords = ["animal", "adoption", "django", "rescue", ]
license = { text = "AGPL-3.0-or-later" }
classifiers = [
"Environment :: Web",
@@ -25,6 +25,8 @@ classifiers = [
dependencies = [
"Django",
"codecov",
"sphinx",
"sphinx-rtd-theme",
"gunicorn",
"fontawesomefree",
"whitenoise",
@@ -36,10 +38,7 @@ dependencies = [
"crispy-bootstrap4",
"djangorestframework",
"celery[redis]",
"drf-spectacular[sidecar]",
"django-widget-tweaks",
"django-super-deduper",
"django-allauth[mfa]",
"drf-spectacular[sidecar]"
]
dynamic = ["version", "readme"]
@@ -49,12 +48,6 @@ develop = [
"pytest",
"coverage",
"model_bakery",
"debug_toolbar",
]
docs = [
"sphinx",
"sphinx-rtd-theme",
"sphinx-autobuild"
]
[project.urls]

View File

@@ -1,275 +0,0 @@
import argparse
import json
import logging
import os
from types import SimpleNamespace
import requests
# TODO: consider using OSMPythonTools instead of requests or overpass library
from osmtogeojson import osmtogeojson
from tqdm import tqdm
DEFAULT_OSM_DATA_FILE = "export.geojson"
# Search area must be the official name, e.g. "Germany" is not a valid area name in Overpass API
# Consider instead finding & using the code within the query itself, e.g. "ISO3166-1"="DE"
DEFAULT_OVERPASS_SEARCH_AREA = "Deutschland"
def parse_args():
"""Parse command-line arguments."""
parser = argparse.ArgumentParser(
description="Download animal shelter data from the Overpass API to the Notfellchen API.")
parser.add_argument("--api-token", type=str, help="API token for authentication.")
parser.add_argument("--area", type=str, help="Area to search for animal shelters (default: Deutschland).")
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.")
parser.add_argument("--use-cached", action='store_true', help="Use the stored GeoJSON file")
return parser.parse_args()
def get_config():
"""Get configuration from environment variables or command-line arguments."""
args = parse_args()
api_token = args.api_token or os.getenv("NOTFELLCHEN_API_TOKEN")
# TODO: document new environment variable NOTFELLCHEN_AREA
area = args.area or os.getenv("NOTFELLCHEN_AREA", DEFAULT_OVERPASS_SEARCH_AREA)
instance = args.instance or os.getenv("NOTFELLCHEN_INSTANCE")
data_file = args.data_file or os.getenv("NOTFELLCHEN_DATA_FILE", DEFAULT_OSM_DATA_FILE)
use_cached = args.use_cached or os.getenv("NOTFELLCHEN_USE_CACHED", False)
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, area, instance, data_file, use_cached
def get_or_none(data, key):
if key in data["properties"].keys():
return data["properties"][key]
else:
return None
def get_or_empty(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 calc_coordinate_center(coordinates):
"""
Calculates the center as the arithmetic mean of the list of coordinates.
Not perfect because earth is a sphere (citation needed) but good enough.
"""
if not coordinates:
return None, None
lon_sum = 0.0
lat_sum = 0.0
count = 0
for lon, lat in coordinates:
lon_sum += lon
lat_sum += lat
count += 1
return lon_sum / count, lat_sum / count
def get_center_coordinates(geometry):
"""
Given a GeoJSON geometry dict, return (longitude, latitude)
If a shape, calculate the center, else reurn the point
"""
geom_type = geometry["type"]
coordinates = geometry["coordinates"]
if geom_type == "Point":
return coordinates[0], coordinates[1]
elif geom_type == "LineString":
return calc_coordinate_center(coordinates)
elif geom_type == "Polygon":
outer_ring = coordinates[0]
return calc_coordinate_center(outer_ring)
else:
raise ValueError(f"Unsupported geometry type: {geom_type}")
# TODO: take note of new get_overpass_result function which does the bulk of the new overpass query work
def get_overpass_result(area, data_file):
"""Build the Overpass query for fetching animal shelters in the specified area."""
overpass_endpoint = "https://overpass-api.de/api/interpreter"
overpass_query = f"""
[out:json][timeout:25];
area[name="{area}"]->.searchArea;
nwr["amenity"="animal_shelter"](area.searchArea);
out body;
>;
out skel qt;
"""
r = requests.get(overpass_endpoint, params={'data': overpass_query})
if r.status_code == 200:
rjson = r.json()
result = osmtogeojson.process_osm_json(rjson)
with open(data_file, 'w', encoding='utf-8') as f:
json.dump(result, f, ensure_ascii=False)
return result
def add_if_available(base_data, keys, result):
# Loads the data into the org if available
for key in keys:
if getattr(base_data, key) is not None:
result[key] = getattr(base_data, key)
return result
def create_location(tierheim, instance, headers):
location_data = {
"place_id": tierheim["id"],
"longitude": get_center_coordinates(tierheim["geometry"])[0],
"latitude": get_center_coordinates(tierheim["geometry"])[1],
"name": tierheim["properties"]["name"],
"city": tierheim["properties"]["addr:city"],
"housenumber": get_or_empty(tierheim, "addr:housenumber"),
"postcode": get_or_empty(tierheim, "addr:postcode"),
"street": get_or_empty(tierheim, "addr:street"),
"countrycode": get_or_empty(tierheim, "addr:country"),
}
location_result = requests.post(f"{instance}/api/locations/", json=location_data, headers=headers)
if location_result.status_code != 201:
try:
print(
f"Location for {tierheim["properties"]["name"]}:{location_result.status_code} {location_result.json()} not created")
except requests.exceptions.JSONDecodeError:
print(f"Location for {tierheim["properties"]["name"]} could not be created")
exit()
return location_result.json()
def main():
api_token, area, instance, data_file, use_cached = get_config()
if not use_cached:
# Query shelters
overpass_result = get_overpass_result(area, data_file)
if overpass_result is None:
print("Error: get_overpass_result returned None")
return
else:
with open(data_file, 'r', encoding='utf-8') as f:
overpass_result = json.load(f)
# Set headers and endpoint
endpoint = f"{instance}/api/organizations/"
h = {'Authorization': f'Token {api_token}', "content-type": "application/json"}
tierheime = overpass_result["features"]
stats = {"num_updated_orgs": 0,
"num_inserted_orgs": 0}
for idx, tierheim in enumerate(tqdm(tierheime)):
# Check if data is low quality
if "name" not in tierheim["properties"].keys() or "addr:city" not in tierheim["properties"].keys():
continue
# Load TH data in for easier accessing
th_data = SimpleNamespace(
name=tierheim["properties"]["name"],
email=choose(("contact:email", "email"), tierheim["properties"]),
phone_number=choose(("contact:phone", "phone"), tierheim["properties"], replace=True),
fediverse_profile=get_or_none(tierheim, "contact:mastodon"),
facebook=https(add(get_or_empty(tierheim, "contact:facebook"), "facebook")),
instagram=https(add(get_or_empty(tierheim, "contact:instagram"), "instagram")),
website=https(choose(("contact:website", "website"), tierheim["properties"])),
description=get_or_none(tierheim, "opening_hours"),
external_object_identifier=tierheim["id"],
EXTERNAL_SOURCE_IDENTIFIER="OSM",
)
# Define here for later
optional_data = ["email", "phone_number", "website", "description", "fediverse_profile", "facebook",
"instagram"]
# Check if rescue organization exists
search_data = {"external_source_identifier": "OSM",
"external_object_identifier": f"{tierheim["id"]}"}
search_result = requests.get(f"{instance}/api/organizations", params=search_data, headers=h)
# Rescue organization exits
if search_result.status_code == 200:
stats["num_updated_orgs"] += 1
org_id = search_result.json()[0]["id"]
logging.debug(f"{th_data.name} already exists as ID {org_id}.")
org_patch_data = {"id": org_id,
"name": th_data.name}
if search_result.json()[0]["location"] is None:
location = create_location(tierheim, instance, h)
org_patch_data["location"] = location["id"]
org_patch_data = add_if_available(th_data, optional_data, org_patch_data)
result = requests.patch(endpoint, json=org_patch_data, headers=h)
if result.status_code != 200:
logging.warning(f"Updating {tierheim['properties']['name']} failed:{result.status_code} {result.json()}")
continue
# Rescue organization does not exist
else:
stats["num_inserted_orgs"] += 1
location = create_location(tierheim, instance, h)
org_data = {"name": tierheim["properties"]["name"],
"external_object_identifier": f"{tierheim["id"]}",
"external_source_identifier": "OSM",
"location": location["id"]
}
org_data = add_if_available(th_data, optional_data, org_data)
result = requests.post(endpoint, json=org_data, headers=h)
if result.status_code != 201:
print(f"{idx} {tierheim["properties"]["name"]}:{result.status_code} {result.json()}")
print(f"Upload finished. Inserted {stats['num_inserted_orgs']} new orgs and updated {stats['num_updated_orgs']} orgs.")
if __name__ == "__main__":
main()

View File

@@ -1,32 +1,35 @@
import csv
from django.contrib import admin
from django.contrib.admin import EmptyFieldListFilter
from django.http import HttpResponse
from django.utils.html import format_html
from django.urls import reverse
from django.utils.http import urlencode
from .models import Language, Text, ReportComment, ReportAdoptionNotice, Log, Timestamp, SearchSubscription, \
SpeciesSpecificURL, ImportantLocation, SocialMediaPost
from .models import User, Language, Text, ReportComment, ReportAdoptionNotice, Log, Timestamp, SearchSubscription, \
SpeciesSpecificURL
from .models import Animal, Species, RescueOrganization, AdoptionNotice, Location, Rule, Image, ModerationAction, \
Comment, Announcement, User, Subscriptions, Notification
Comment, Report, Announcement, AdoptionNoticeStatus, User, Subscriptions, BaseNotification
from django.utils.translation import gettext_lazy as _
from .tools.model_helpers import AdoptionNoticeStatusChoices
class StatusInline(admin.StackedInline):
model = AdoptionNoticeStatus
@admin.register(AdoptionNotice)
class AdoptionNoticeAdmin(admin.ModelAdmin):
search_fields = ("name__icontains", "description__icontains")
list_filter = ("owner",)
inlines = [
StatusInline,
]
actions = ("activate",)
def activate(self, request, queryset):
for obj in queryset:
obj.adoption_notice_status = AdoptionNoticeStatusChoices.Active.SEARCHING
obj.save()
obj.set_active()
activate.short_description = _("Ausgewählte Vermittlungen aktivieren")
@@ -91,16 +94,14 @@ class ReportAdoptionNoticeAdmin(admin.ModelAdmin):
reported_content_link.short_description = "Reported Content"
class SpeciesSpecificURLInline(admin.StackedInline):
model = SpeciesSpecificURL
@admin.register(RescueOrganization)
class RescueOrganizationAdmin(admin.ModelAdmin):
search_fields = ("name", "description", "internal_comment", "location_string", "location__city")
search_fields = ("name","description", "internal_comment", "location_string")
list_display = ("name", "trusted", "allows_using_materials", "website")
list_filter = ("allows_using_materials", "trusted", ("external_source_identifier", EmptyFieldListFilter))
list_filter = ("allows_using_materials", "trusted",)
inlines = [
SpeciesSpecificURLInline,
@@ -117,66 +118,24 @@ class CommentAdmin(admin.ModelAdmin):
list_filter = ("user",)
@admin.register(Notification)
@admin.register(BaseNotification)
class BaseNotificationAdmin(admin.ModelAdmin):
list_filter = ("user_to_notify", "read")
list_filter = ("user", "read")
@admin.register(SearchSubscription)
class SearchSubscriptionAdmin(admin.ModelAdmin):
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.register(SocialMediaPost)
class SocialMediaPostAdmin(admin.ModelAdmin):
list_filter = ("platform",)
@admin.register(Log)
class LogAdmin(admin.ModelAdmin):
ordering = ["-created_at"]
list_filter = ("action",)
list_display = ("action", "user", "created_at")
admin.site.register(Animal)
admin.site.register(Species)
admin.site.register(Location)
admin.site.register(Rule)
admin.site.register(Image)
admin.site.register(ModerationAction)
admin.site.register(Language)
admin.site.register(Announcement)
admin.site.register(AdoptionNoticeStatus)
admin.site.register(Subscriptions)
admin.site.register(Log)
admin.site.register(Timestamp)

View File

@@ -1,33 +0,0 @@
from rest_framework.renderers import BaseRenderer
import json
class GeoJSONRenderer(BaseRenderer):
media_type = 'application/json'
format = 'geojson'
charset = 'utf-8'
def render(self, data, accepted_media_type=None, renderer_context=None):
features = []
for item in data:
coords = item["coordinates"]
if coords:
feature = {
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": coords
},
"properties": {
k: v for k, v in item.items()
},
"id": f"{item['id']}"
}
features.append(feature)
geojson = {
"type": "FeatureCollection",
"generator": "notfellchen",
"features": features
}
return json.dumps(geojson)

View File

@@ -1,135 +1,12 @@
from ..models import Animal, RescueOrganization, AdoptionNotice, Species, Image, Location
from ..models import Animal, RescueOrganization, AdoptionNotice, Species, Image
from rest_framework import serializers
import math
class ImageSerializer(serializers.ModelSerializer):
width = serializers.SerializerMethodField()
height = serializers.SerializerMethodField()
class Meta:
model = Image
fields = ['id', 'image', 'alt_text', 'width', 'height']
def get_width(self, obj):
return obj.image.width
def get_height(self, obj):
return obj.image.height
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
)
url = serializers.SerializerMethodField()
photos = ImageSerializer(many=True, read_only=True)
def get_url(self, obj):
return obj.get_full_url()
class Meta:
model = AdoptionNotice
fields = ['created_at', 'last_checked', "searching_since", "name", "description", "further_information",
"group_only", "location", "location_details", "organization", "photos", "adoption_notice_status",
"url"]
class AdoptionNoticeGeoJSONSerializer(serializers.ModelSerializer):
species = serializers.SerializerMethodField()
title = serializers.CharField(source='name')
url = serializers.SerializerMethodField()
location_hr = serializers.SerializerMethodField()
coordinates = serializers.SerializerMethodField()
image_url = serializers.SerializerMethodField()
image_alt = serializers.SerializerMethodField()
class Meta:
model = AdoptionNotice
fields = ('id', 'species', 'title', 'description', 'url', 'location_hr', 'coordinates', 'image_url',
'image_alt')
def get_species(self, obj):
return "rat"
def get_url(self, obj):
return obj.get_absolute_url()
def get_image_url(self, obj):
photo = obj.get_photo()
if photo is not None:
return obj.get_photo().image.url
return None
def get_image_alt(self, obj):
photo = obj.get_photo()
if photo is not None:
return obj.get_photo().alt_text
return None
def get_coordinates(self, obj):
"""
Coordinates are randomly moved around real location, roughly in a circle. The object id is used as angle so that
points are always displayed at the same location (as if they were a seed for a random function).
It's not exactly a circle, because the earth is round.
"""
if obj.location:
longitude_addition = math.sin(obj.id) / 2000
latitude_addition = math.cos(obj.id) / 2000
return [obj.location.longitude + longitude_addition, obj.location.latitude + latitude_addition]
return None
def get_location_hr(self, obj):
if obj.location:
return f"{obj.location}"
return None
class RescueOrgeGeoJSONSerializer(serializers.ModelSerializer):
name = serializers.CharField()
url = serializers.SerializerMethodField()
location_hr = serializers.SerializerMethodField()
coordinates = serializers.SerializerMethodField()
class Meta:
model = AdoptionNotice
fields = ('id', 'name', 'description', 'url', 'location_hr', 'coordinates')
def get_url(self, obj):
return obj.get_absolute_url()
def get_coordinates(self, obj):
"""
Coordinates are randomly moved around real location, roughly in a circle. The object id is used as angle so that
points are always displayed at the same location (as if they were a seed for a random function).
It's not exactly a circle, because the earth is round.
"""
if obj.location:
return [obj.location.longitude, obj.location.latitude]
return None
def get_location_hr(self, obj):
if obj.location.city:
return f"{obj.location.city}"
elif obj.location:
return f"{obj.location}"
return None
"group_only"]
class AnimalCreateSerializer(serializers.ModelSerializer):
@@ -137,6 +14,11 @@ class AnimalCreateSerializer(serializers.ModelSerializer):
model = Animal
fields = ["name", "date_of_birth", "description", "species", "sex", "adoption_notice"]
class RescueOrgSerializer(serializers.ModelSerializer):
class Meta:
model = RescueOrganization
fields = ["name", "location_string", "instagram", "facebook", "fediverse_profile", "email", "phone_number",
"website", "description", "external_object_identifier", "external_source_identifier"]
class AnimalGetSerializer(serializers.ModelSerializer):
class Meta:
@@ -169,9 +51,3 @@ class SpeciesSerializer(serializers.ModelSerializer):
class Meta:
model = Species
fields = "__all__"
class LocationSerializer(serializers.ModelSerializer):
class Meta:
model = Location
fields = "__all__"

View File

@@ -1,21 +1,16 @@
from django.urls import path
from .views import (
AdoptionNoticeApiView,
AnimalApiView, RescueOrganizationApiView, AddImageApiView, SpeciesApiView, LocationApiView,
AdoptionNoticeGeoJSONView, RescueOrgGeoJSONView, AdoptionNoticePerOrgApiView
AnimalApiView, RescueOrganizationApiView, AddImageApiView, SpeciesApiView
)
urlpatterns = [
path("adoption_notice", AdoptionNoticeApiView.as_view(), name="api-adoption-notice-list"),
path("adoption_notice.geojson", AdoptionNoticeGeoJSONView.as_view(), name="api-adoption-notice-list-geojson"),
path("adoption_notice/<int:id>/", AdoptionNoticeApiView.as_view(), name="api-adoption-notice-detail"),
path("animals/", AnimalApiView.as_view(), name="api-animal-list"),
path("animals/<int:id>/", AnimalApiView.as_view(), name="api-animal-detail"),
path("organizations/", RescueOrganizationApiView.as_view(), name="api-organization-list"),
path("organizations.geojson", RescueOrgGeoJSONView.as_view(), name="api-organization-list-geojson"),
path("organizations/<int:id>/", RescueOrganizationApiView.as_view(), name="api-organization-detail"),
path("organizations/<int:id>/adoption-notices", AdoptionNoticePerOrgApiView.as_view(), name="api-organization-adoption-notices"),
path("images/", AddImageApiView.as_view(), name="api-add-image"),
path("species/", SpeciesApiView.as_view(), name="api-species-list"),
path("locations/", LocationApiView.as_view(), name="api-locations-list"),
]

View File

@@ -1,28 +1,20 @@
from django.db.models import Q
from drf_spectacular.types import OpenApiTypes
from rest_framework.generics import ListAPIView
from fellchensammlung.api.serializers import LocationSerializer, AdoptionNoticeGeoJSONSerializer
from rest_framework.views import APIView
from rest_framework.response import Response
from django.db import transaction
from fellchensammlung.models import Log, TrustLevel, Location, AdoptionNoticeStatusChoices
from fellchensammlung.tasks import post_adoption_notice_save, post_rescue_org_save
from rest_framework import status, serializers
from fellchensammlung.models import AdoptionNotice, Animal, Log, TrustLevel
from fellchensammlung.tasks import post_adoption_notice_save
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from .renderers import GeoJSONRenderer
from .serializers import (
AnimalGetSerializer,
AnimalCreateSerializer,
RescueOrgeGeoJSONSerializer,
RescueOrganizationSerializer,
AdoptionNoticeSerializer,
ImageCreateSerializer,
SpeciesSerializer, RescueOrganizationSerializer,
SpeciesSerializer, RescueOrgSerializer,
)
from fellchensammlung.models import Animal, RescueOrganization, AdoptionNotice, Species, Image
from drf_spectacular.utils import extend_schema, inline_serializer, OpenApiParameter
from drf_spectacular.utils import extend_schema
class AdoptionNoticeApiView(APIView):
permission_classes = [IsAuthenticated]
@@ -67,22 +59,22 @@ class AdoptionNoticeApiView(APIView):
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
adoption_notice = serializer.save(owner=request.user_to_notify)
adoption_notice = serializer.save(owner=request.user)
# Add the location
post_adoption_notice_save.delay_on_commit(adoption_notice.pk)
# Only set active when user has trust level moderator or higher
if request.user_to_notify.trust_level >= TrustLevel.MODERATOR:
adoption_notice.adoption_notice_status = AdoptionNoticeStatusChoices.Active.SEARCHING
if request.user.trust_level >= TrustLevel.MODERATOR:
adoption_notice.set_active()
else:
adoption_notice.adoption_notice_status = AdoptionNoticeStatusChoices.AwaitingAction.WAITING_FOR_REVIEW
adoption_notice.set_unchecked()
# Log the action
Log.objects.create(
user=request.user_to_notify,
user=request.user,
action="add_adoption_notice",
text=f"{request.user_to_notify} added adoption notice {adoption_notice.pk} via API",
text=f"{request.user} added adoption notice {adoption_notice.pk} via API",
)
# Return success response with new adoption notice details
@@ -92,12 +84,10 @@ class AdoptionNoticeApiView(APIView):
)
class AnimalApiView(APIView):
permission_classes = [IsAuthenticated]
@extend_schema(
responses=AnimalGetSerializer
)
def get(self, request, *args, **kwargs):
"""
Get list of animals or a specific animal by ID.
@@ -114,16 +104,6 @@ class AnimalApiView(APIView):
serializer = AnimalGetSerializer(animals, many=True, context={"request": request})
return Response(serializer.data, status=status.HTTP_200_OK)
@transaction.atomic
@extend_schema(
request=AnimalCreateSerializer,
responses={201: inline_serializer(
name='Animal',
fields={
'id': serializers.IntegerField(),
"message": serializers.Field()}),
400: "json"}
)
@transaction.atomic
def post(self, request, *args, **kwargs):
"""
@@ -131,14 +111,13 @@ class AnimalApiView(APIView):
"""
serializer = AnimalCreateSerializer(data=request.data, context={"request": request})
if serializer.is_valid():
animal = serializer.save(owner=request.user_to_notify)
animal = serializer.save(owner=request.user)
return Response(
{"message": "Animal created successfully!", "id": animal.id},
status=status.HTTP_201_CREATED,
)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class RescueOrganizationApiView(APIView):
permission_classes = [IsAuthenticated]
@@ -150,44 +129,14 @@ class RescueOrganizationApiView(APIView):
'description': 'ID of the rescue organization to retrieve.',
'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)}
)
def get(self, request, *args, **kwargs):
"""
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
Get list of rescue organizations or a specific organization by 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")
org_id = kwargs.get("id")
if org_id:
try:
organization = RescueOrganization.objects.get(pk=org_id)
@@ -195,79 +144,28 @@ class RescueOrganizationApiView(APIView):
return Response(serializer.data, status=status.HTTP_200_OK)
except RescueOrganization.DoesNotExist:
return Response({"error": "Organization not found."}, status=status.HTTP_404_NOT_FOUND)
organizations = RescueOrganization.objects.all()
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)
)
if organizations.count() == 0:
return Response({"error": "No organizations found."}, status=status.HTTP_404_NOT_FOUND)
serializer = RescueOrganizationSerializer(organizations, many=True, context={"request": request})
return Response(serializer.data, status=status.HTTP_200_OK)
@transaction.atomic
@extend_schema(
request=RescueOrganizationSerializer,
responses={201: 'Rescue organization created successfully!'}
request=RescueOrgSerializer, # Document the request body
responses={201: 'Rescue organization created/updated successfully!'}
)
def post(self, request, *args, **kwargs):
"""
Create or update a rescue organization.
"""
serializer = RescueOrganizationSerializer(data=request.data, context={"request": request})
serializer = RescueOrgSerializer(data=request.data, context={"request": request})
if serializer.is_valid():
rescue_org = serializer.save()
if rescue_org.location is None:
# Add the location
post_rescue_org_save.delay_on_commit(rescue_org.pk)
rescue_org = serializer.save(owner=request.user)
return Response(
{"message": "Rescue organization created successfully!", "id": rescue_org.id},
{"message": "Rescue organization created/updated successfully!", "id": rescue_org.id},
status=status.HTTP_201_CREATED,
)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@transaction.atomic
@extend_schema(
request=RescueOrganizationSerializer,
responses={200: 'Rescue organization updated successfully!'}
)
def patch(self, request, *args, **kwargs):
"""
Partially update a rescue organization.
"""
org_id = request.data.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 = RescueOrganizationSerializer(organization, data=request.data, partial=True)
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)
class AddImageApiView(APIView):
permission_classes = [IsAuthenticated]
@@ -290,7 +188,7 @@ class AddImageApiView(APIView):
raise ValueError("Unknown attach_to_type given, should not happen. Check serializer")
serializer.validated_data.pop('attach_to_type', None)
serializer.validated_data.pop('attach_to', None)
image = serializer.save(owner=request.user_to_notify)
image = serializer.save(owner=request.user)
object_to_attach_to.photos.add(image)
return Response(
{"message": "Image added successfully!", "id": image.id},
@@ -312,141 +210,3 @@ class SpeciesApiView(APIView):
species = Species.objects.all()
serializer = SpeciesSerializer(species, many=True, context={"request": request})
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,
)
class AdoptionNoticeGeoJSONView(ListAPIView):
queryset = AdoptionNotice.objects.select_related('location').filter(location__isnull=False).filter(
adoption_notice_status__in=AdoptionNoticeStatusChoices.Active.values)
serializer_class = AdoptionNoticeGeoJSONSerializer
renderer_classes = [GeoJSONRenderer]
class RescueOrgGeoJSONView(ListAPIView):
queryset = RescueOrganization.objects.select_related('location').filter(location__isnull=False)
serializer_class = RescueOrgeGeoJSONSerializer
renderer_classes = [GeoJSONRenderer]
class AdoptionNoticePerOrgApiView(APIView):
permission_classes = [IsAuthenticated]
@extend_schema(
parameters=[
OpenApiParameter(
name='id',
required=False,
description='ID of the rescue organization from which to retrieve adoption notices.',
type=OpenApiTypes.INT
),
OpenApiParameter(
name='in_hierarchy',
type=OpenApiTypes.BOOL,
required=False,
description='Show all Adoption Notices in hierarchy.',
),
OpenApiParameter(
name='status',
type=OpenApiTypes.STR,
required=False,
description='Show all Adoption Notices in a certain status. Comma separated list of values e.g. '
'"active,closed"',
),
],
responses={200: AdoptionNoticeSerializer(many=True)}
)
def get(self, request, *args, **kwargs):
"""
Retrieve adoption notices with their related animals and images.
"""
org_id = kwargs.get("id")
in_hierarchy = request.query_params.get("in_hierarchy")
an_status = request.query_params.get("status")
try:
org = RescueOrganization.objects.get(id=org_id)
except RescueOrganization.DoesNotExist:
return Response({"error": "Rescue Organization notice not found."}, status=status.HTTP_404_NOT_FOUND)
if in_hierarchy:
adoption_notices = org.adoption_notices_in_hierarchy
else:
adoption_notices = AdoptionNotice.objects.filter(organization=org)
if an_status:
status_list = an_status.lower().strip().split(",")
temporary_an_storage = []
if "active" in status_list:
active_ans = [adoption_notice for adoption_notice in adoption_notices if
adoption_notice.adoption_notice_status in AdoptionNoticeStatusChoices.Active.values]
temporary_an_storage.extend(active_ans)
if "closed" in status_list:
closed_ans = [adoption_notice for adoption_notice in adoption_notices if
adoption_notice.adoption_notice_status in AdoptionNoticeStatusChoices.Closed.values]
temporary_an_storage.extend(closed_ans)
if "disabled" in status_list:
disabled_ans = [adoption_notice for adoption_notice in adoption_notices if
adoption_notice.adoption_notice_status in AdoptionNoticeStatusChoices.Disabled.values]
temporary_an_storage.extend(disabled_ans)
if "awaiting_action" in status_list:
awaiting_action_ans = [adoption_notice for adoption_notice in adoption_notices if
adoption_notice.adoption_notice_status in AdoptionNoticeStatusChoices.AwaitingAction.values]
temporary_an_storage.extend(awaiting_action_ans)
adoption_notices = temporary_an_storage
serializer = AdoptionNoticeSerializer(adoption_notices, many=True, context={"request": request})
return Response(serializer.data, status=status.HTTP_200_OK)

View File

@@ -1,37 +0,0 @@
from django.shortcuts import get_object_or_404, render
from django.views.decorators.clickjacking import xframe_options_exempt
from fellchensammlung.aviews.helpers import headers
from fellchensammlung.models import RescueOrganization, AdoptionNotice, Species
@xframe_options_exempt
@headers({"X-Robots-Tag": "noindex"})
def list_ans_per_rescue_organization(request, rescue_organization_id, species_slug=None, active=True):
expand = request.GET.get("expand")
background_color = request.GET.get("background_color")
if expand is not None:
expand = True
else:
expand = False
org = get_object_or_404(RescueOrganization, pk=rescue_organization_id)
# Get only active adoption notices or all
if active:
adoption_notices_of_org = org.adoption_notices_in_hierarchy_divided_by_status[0]
else:
adoption_notices_of_org = org.adoption_notices
# Filter for Species if necessary
if species_slug is None:
adoption_notices = adoption_notices_of_org
else:
species = get_object_or_404(Species, slug=species_slug)
adoption_notices = [adoption_notice for adoption_notice in adoption_notices_of_org if
species in adoption_notice.species]
template = 'fellchensammlung/embeddables/list-adoption-notices.html'
return render(request, template,
context={"adoption_notices": adoption_notices,
"expand": expand,
"background_color": background_color})

View File

@@ -1,23 +0,0 @@
def headers(headers):
"""Decorator adding arbitrary HTTP headers to the response.
This decorator adds HTTP headers specified in the argument (map), to the
HTTPResponse returned by the function being decorated.
Example:
@headers({'Refresh': '10', 'X-Bender': 'Bite my shiny, metal ass!'})
def index(request):
....
Source: https://djangosnippets.org/snippets/275/
"""
def headers_wrapper(fun):
def wrapped_function(*args, **kwargs):
response = fun(*args, **kwargs)
for key in headers:
response[key] = headers[key]
return response
return wrapped_function
return headers_wrapper

View File

@@ -1,12 +0,0 @@
from django.urls import path
from . import embeddables
urlpatterns = [
path("tierschutzorganisationen/<int:rescue_organization_id>/vermittlungen/",
embeddables.list_ans_per_rescue_organization,
name="list-adoption-notices-for-rescue-organization"),
path("tierschutzorganisationen/<int:rescue_organization_id>/vermittlungen/<slug:species_slug>/",
embeddables.list_ans_per_rescue_organization,
name="list-adoption-notices-for-rescue-organization-species"),
]

View File

@@ -1,8 +1,7 @@
from django import forms
from django.forms.widgets import Textarea
from .models import AdoptionNotice, Animal, Image, ReportAdoptionNotice, ReportComment, ModerationAction, User, Species, \
Comment, SexChoicesWithAll, DistanceChoices, SpeciesSpecificURL, RescueOrganization
Comment, SexChoicesWithAll, DistanceChoices
from django_registration.forms import RegistrationForm
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Submit, Layout, Fieldset, HTML, Row, Column, Field, Hidden
@@ -10,8 +9,6 @@ from django.utils.translation import gettext_lazy as _
from notfellchen.settings import MEDIA_URL
from crispy_forms.layout import Div
from .tools.model_helpers import reason_for_signup_label, reason_for_signup_help_text
def animal_validator(value: str):
value = value.lower()
@@ -26,46 +23,95 @@ class DateInput(forms.DateInput):
class AdoptionNoticeForm(forms.ModelForm):
template_name = "fellchensammlung/forms/form_snippets.html"
def __init__(self, *args, **kwargs):
if 'in_adoption_notice_creation_flow' in kwargs:
in_flow = kwargs.pop('in_adoption_notice_creation_flow')
else:
in_flow = False
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_id = 'form-adoption-notice'
self.helper.form_class = 'card'
self.helper.form_method = 'post'
if in_flow:
submit = Submit('save-and-add-another-animal', _('Speichern'))
else:
submit = Submit('submit', _('Speichern'))
self.helper.layout = Layout(
Fieldset(
_('Vermittlungsdetails'),
'name',
'species',
'num_animals',
'date_of_birth',
'sex',
'group_only',
'searching_since',
'location_string',
'organization',
'description',
'further_information',
),
submit)
class Meta:
model = AdoptionNotice
fields = ['name', "group_only", "further_information", "description", "searching_since", "location_string",
"organization"]
class AdoptionNoticeFormWithDateWidget(AdoptionNoticeForm):
class Meta:
model = AdoptionNotice
fields = ['name', "group_only", "further_information", "description", "searching_since", "location_string",
"organization"]
widgets = {
'searching_since': DateInput(format=('%Y-%m-%d')),
'searching_since': DateInput(),
}
class AdoptionNoticeFormAutoAnimal(AdoptionNoticeForm):
def __init__(self, *args, **kwargs):
super(AdoptionNoticeFormAutoAnimal, self).__init__(*args, **kwargs)
self.fields["num_animals"] = forms.fields.IntegerField(min_value=1, max_value=30, label=_("Zahl der Tiere"))
animal_form = AnimalForm()
self.fields["species"] = animal_form.fields["species"]
self.fields["sex"] = animal_form.fields["sex"]
self.fields["date_of_birth"] = animal_form.fields["date_of_birth"]
self.fields["date_of_birth"].widget = DateInput(format=('%Y-%m-%d'))
class AnimalForm(forms.ModelForm):
template_name = "fellchensammlung/forms/form_snippets.html"
def __init__(self, *args, **kwargs):
if 'in_adoption_notice_creation_flow' in kwargs:
adding = kwargs.pop('in_adoption_notice_creation_flow')
else:
adding = False
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_class = 'form-animal card'
if adding:
self.helper.add_input(Submit('save-and-add-another-animal', _('Speichern und weiteres Tier hinzufügen')))
self.helper.add_input(Submit('save-and-finish', _('Speichern und beenden')))
else:
self.helper.add_input(Submit('submit', _('Speichern'), css_class="btn"))
class Meta:
model = Animal
fields = ["name", "date_of_birth", "species", "sex", "description"]
class AnimalFormWithDateWidget(AnimalForm):
class Meta:
model = Animal
fields = ["name", "date_of_birth", "species", "sex", "description"]
widgets = {
'date_of_birth': DateInput(format=('%Y-%m-%d'))
'date_of_birth': DateInput(),
}
class UpdateRescueOrgRegularCheckStatus(forms.ModelForm):
template_name = "fellchensammlung/forms/form_snippets.html"
class Meta:
model = RescueOrganization
fields = ["regular_check_status"]
class AdoptionNoticeFormWithDateWidgetAutoAnimal(AdoptionNoticeFormWithDateWidget):
def __init__(self, *args, **kwargs):
super(AdoptionNoticeFormWithDateWidgetAutoAnimal, self).__init__(*args, **kwargs)
self.fields["num_animals"] = forms.fields.IntegerField(min_value=1, max_value=30, label=_("Zahl der Tiere"))
animal_form = AnimalForm()
self.fields["species"] = animal_form.fields["species"]
self.fields["sex"] = animal_form.fields["sex"]
self.fields["date_of_birth"] = animal_form.fields["date_of_birth"]
self.fields["date_of_birth"].widget = DateInput()
class ImageForm(forms.ModelForm):
@@ -81,9 +127,8 @@ class ImageForm(forms.ModelForm):
self.helper.form_method = 'post'
if in_flow:
submits = Div(Submit('submit', _('Speichern')),
Submit('save-and-add-another', _('Speichern und weiteres Foto hinzufügen')),
css_class="container-edit-buttons")
submits= Div(Submit('submit', _('Speichern')),
Submit('save-and-add-another', _('Speichern und weiteres Foto hinzufügen')), css_class="container-edit-buttons")
else:
submits = Fieldset(Submit('submit', _('Speichern')), css_class="container-edit-buttons")
self.helper.layout = Layout(
@@ -95,86 +140,60 @@ class ImageForm(forms.ModelForm):
submits
)
class Meta:
model = Image
fields = ('image', 'alt_text')
class ReportAdoptionNoticeForm(forms.ModelForm):
template_name = "fellchensammlung/forms/form_snippets.html"
class Meta:
model = ReportAdoptionNotice
fields = ('reported_broken_rules', 'user_comment')
class ReportCommentForm(forms.ModelForm):
template_name = "fellchensammlung/forms/form_snippets.html"
class Meta:
model = ReportComment
fields = ('reported_broken_rules', 'user_comment')
class CommentForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_class = 'form-comments'
self.helper.add_input(Hidden('action', 'comment'))
self.helper.add_input(Submit('submit', _('Kommentieren'), css_class="btn2"))
class Meta:
model = Comment
fields = ('text',)
class SpeciesURLForm(forms.ModelForm):
class Meta:
model = SpeciesSpecificURL
fields = ('species', 'url')
class RescueOrgInternalComment(forms.ModelForm):
class Meta:
model = RescueOrganization
fields = ('internal_comment',)
class ModerationActionForm(forms.ModelForm):
class Meta:
model = ModerationAction
fields = ('action', 'public_comment', 'private_comment')
class AddedRegistrationForm(forms.Form):
reason_for_signup = forms.CharField(label=reason_for_signup_label,
help_text=reason_for_signup_help_text,
widget=Textarea)
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 signup(self, request, user):
pass
class CustomRegistrationForm(RegistrationForm):
class Meta(RegistrationForm.Meta):
model = User
template_name = "fellchensammlung/forms/form_snippets.html"
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):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_id = 'form-registration'
self.helper.form_class = 'card'
self.helper.add_input(Submit('submit', _('Registrieren'), css_class="btn"))
class AdoptionNoticeSearchForm(forms.Form):
template_name = "fellchensammlung/forms/form_snippets.html"
sex = forms.ChoiceField(choices=SexChoicesWithAll, label=_("Geschlecht"), required=False,
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)
class RescueOrgSearchForm(forms.Form):
template_name = "fellchensammlung/forms/form_snippets.html"
location_string = forms.CharField(max_length=100, label=_("Stadt"), required=False)
max_distance = forms.ChoiceField(choices=DistanceChoices, initial=DistanceChoices.TWENTY,
label=_("Suchradius"))

View File

@@ -1,43 +1,43 @@
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from django.utils.translation import gettext_lazy as _
from django.conf import settings
from django.core import mail
from fellchensammlung.models import User, Notification, TrustLevel, NotificationTypeChoices
from fellchensammlung.tools.model_helpers import ndm
from fellchensammlung.models import User, CommentNotification, BaseNotification, TrustLevel
from notfellchen.settings import host
NEWLINE = "\r\n"
def notify_mods_new_report(report, notification_type):
"""
Sends an e-mail to all users that should handle the report.
"""
def mail_admins_new_report(report):
subject = _("Neue Meldung")
for moderator in User.objects.filter(trust_level__gt=TrustLevel.MODERATOR):
if notification_type == NotificationTypeChoices.NEW_REPORT_AN:
title = _("Vermittlung gemeldet")
elif notification_type == NotificationTypeChoices.NEW_REPORT_COMMENT:
title = _("Kommentar gemeldet")
greeting = _("Moin,") + "{NEWLINE}"
new_report_text = _("es wurde ein Regelverstoß gemeldet.") + "{NEWLINE}"
if len(report.reported_broken_rules.all()) > 0:
reported_rules_text = (f"Ein Verstoß gegen die folgenden Regeln wurde gemeldet:{NEWLINE}"
f"- {f'{NEWLINE} - '.join([str(r) for r in report.reported_broken_rules.all()])}{NEWLINE}")
else:
raise NotImplementedError
notification = Notification.objects.create(
notification_type=notification_type,
user_to_notify=moderator,
report=report,
title=title,
)
notification.save()
reported_rules_text = f"Es wurden keine Regeln angegeben gegen die Verstoßen wurde.{NEWLINE}"
if report.user_comment:
comment_text = f'Kommentar zum Report: "{report.user_comment}"{NEWLINE}'
else:
comment_text = f"Es wurde kein Kommentar hinzugefügt.{NEWLINE}"
report_url = "https://" + host + report.get_absolute_url()
link_text = f"Um alle Details zu sehen, geh bitte auf: {report_url}"
body_text = greeting + new_report_text + reported_rules_text + comment_text + link_text
message = mail.EmailMessage(subject, body_text, settings.DEFAULT_FROM_EMAIL, [moderator.email])
message.send()
def send_notification_email(notification_pk):
notification = Notification.objects.get(pk=notification_pk)
subject = f"{notification.title}"
context = {"notification": notification, }
html_message = render_to_string(ndm[notification.notification_type].email_html_template, context)
plain_message = render_to_string(ndm[notification.notification_type].email_plain_template, context)
mail.send_mail(subject, plain_message, settings.DEFAULT_FROM_EMAIL,
[notification.user_to_notify.email],
html_message=html_message)
try:
notification = CommentNotification.objects.get(pk=notification_pk)
except CommentNotification.DoesNotExist:
notification = BaseNotification.objects.get(pk=notification_pk)
subject = f"🔔 {notification.title}"
body_text = notification.text
message = mail.EmailMessage(subject, body_text, settings.DEFAULT_FROM_EMAIL, [notification.user.email])
message.send()

View File

@@ -1,10 +0,0 @@
from django.core.management import BaseCommand
from fellchensammlung.tools.admin import dedup_locations
class Command(BaseCommand):
help = 'Deduplicate locations based on place_id'
def handle(self, *args, **options):
dedup_locations()

View File

@@ -1,11 +0,0 @@
from django.core.management import BaseCommand
from fellchensammlung.tools.admin import export_orgs_as_vcf
class Command(BaseCommand):
help = 'Export organizations with phone number as contacts in vcf format'
def handle(self, *args, **options):
export_orgs_as_vcf()

View File

@@ -1,13 +0,0 @@
from django.core.management import BaseCommand
from fellchensammlung.tools.admin import mask_organization_contact_data
class Command(BaseCommand):
help = 'Mask e-mail addresses and phone numbers of organizations for testing purposes.'
def add_arguments(self, parser):
parser.add_argument("domain", type=str)
def handle(self, *args, **options):
domain = options["domain"]
mask_organization_contact_data(domain)

View File

@@ -1,19 +0,0 @@
from django.core.management import BaseCommand
from tqdm import tqdm
from fellchensammlung.models import RescueOrganization
from fellchensammlung.tools.twenty import sync_rescue_org_to_twenty
class Command(BaseCommand):
help = 'Send rescue organizations as companies to twenty'
def add_arguments(self, parser):
parser.add_argument("base_url", type=str)
parser.add_argument("token", type=str)
def handle(self, *args, **options):
base_url = options["base_url"]
token = options["token"]
for rescue_org in tqdm(RescueOrganization.objects.all()):
sync_rescue_org_to_twenty(rescue_org, base_url, token)

View File

@@ -1,20 +0,0 @@
# 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

@@ -1,18 +0,0 @@
# 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

@@ -1,18 +0,0 @@
# 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

@@ -1,42 +0,0 @@
# 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

@@ -1,18 +0,0 @@
# 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

@@ -1,18 +0,0 @@
# 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

@@ -1,18 +0,0 @@
# 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

@@ -1,23 +0,0 @@
# 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

@@ -1,19 +0,0 @@
# 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

@@ -1,28 +0,0 @@
# Generated by Django 5.2.1 on 2025-05-23 16:07
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0046_alter_importantlocation_location'),
]
operations = [
migrations.AlterField(
model_name='adoptionnotice',
name='further_information',
field=models.URLField(blank=True, help_text='Verlinke hier die Quelle der Vermittlung (z.B. die Website des Tierheims', null=True, verbose_name='Link zu mehr Informationen'),
),
migrations.AlterField(
model_name='adoptionnotice',
name='name',
field=models.CharField(max_length=200, verbose_name='Titel der Vermittlung'),
),
migrations.AlterField(
model_name='rescueorganization',
name='location_string',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Ort der Organisation'),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.2.1 on 2025-06-19 15:51
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0047_alter_adoptionnotice_further_information_and_more'),
]
operations = [
migrations.AlterField(
model_name='adoptionnotice',
name='further_information',
field=models.URLField(blank=True, help_text='Verlinke hier die Quelle der Vermittlung (z.B. die Website des Tierheims)', null=True, verbose_name='Link zu mehr Informationen'),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.2.1 on 2025-06-19 21:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0048_alter_adoptionnotice_further_information'),
]
operations = [
migrations.AddField(
model_name='rescueorganization',
name='exclude_from_check',
field=models.BooleanField(default=False, help_text='Organisation von der manuellen Überprüfung ausschließen, z.B. weil Tiere nicht online geführt werden', verbose_name='Von Prüfung ausschließen'),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.2.1 on 2025-06-20 16:52
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0049_rescueorganization_exclude_from_check'),
]
operations = [
migrations.RenameField(
model_name='speciesspecificurl',
old_name='rescues_organization',
new_name='rescue_organization',
),
]

View File

@@ -1,27 +0,0 @@
# Generated by Django 5.2.1 on 2025-07-03 09:28
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0050_rename_rescues_organization_speciesspecificurl_rescue_organization'),
]
operations = [
migrations.AddField(
model_name='rescueorganization',
name='parent_org',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='fellchensammlung.rescueorganization'),
),
migrations.CreateModel(
name='SpeciesSpecialization',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('rescue_organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='fellchensammlung.rescueorganization', verbose_name='Tierschutzorganisation')),
('species', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='fellchensammlung.species', verbose_name='Tierart')),
],
),
]

View File

@@ -1,54 +0,0 @@
# Generated by Django 5.2.1 on 2025-07-11 09:01
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0051_rescueorganization_parent_org_speciesspecialization'),
]
operations = [
migrations.RemoveField(
model_name='basenotification',
name='user',
),
migrations.RemoveField(
model_name='commentnotification',
name='basenotification_ptr',
),
migrations.RemoveField(
model_name='commentnotification',
name='comment',
),
migrations.CreateModel(
name='Notification',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('read_at', models.DateTimeField(blank=True, null=True, verbose_name='Gelesen am')),
('notification_type', models.CharField(choices=[('new_user', 'Useraccount wurde erstellt'), ('new_report_an', 'Vermittlung wurde gemeldet'), ('new_report_comment', 'Kommentar wurde gemeldet'), ('an_is_to_be_checked', 'Vermittlung muss überprüft werden'), ('an_was_deactivated', 'Vermittlung wurde deaktiviert'), ('an_for_search_found', 'Vermittlung für Suche gefunden')], max_length=200, verbose_name='Benachrichtigungsgrund')),
('title', models.CharField(max_length=100, verbose_name='Titel')),
('text', models.TextField(verbose_name='Inhalt')),
('read', models.BooleanField(default=False)),
('adoption_notice', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='fellchensammlung.adoptionnotice', verbose_name='Vermittlung')),
('comment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='fellchensammlung.comment', verbose_name='Antwort')),
('report', models.ForeignKey(help_text='Report auf den sich die Benachrichtigung bezieht.', on_delete=django.db.models.deletion.CASCADE, to='fellchensammlung.report', verbose_name='Report')),
('user_related', models.ForeignKey(help_text='Useraccount auf den sich die Benachrichtigung bezieht.', on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Verwandter Useraccount')),
('user_to_notify', models.ForeignKey(help_text='Useraccount der Benachrichtigt wird', on_delete=django.db.models.deletion.CASCADE, related_name='user', to=settings.AUTH_USER_MODEL, verbose_name='Nutzer*in')),
],
),
migrations.DeleteModel(
name='AdoptionNoticeNotification',
),
migrations.DeleteModel(
name='BaseNotification',
),
migrations.DeleteModel(
name='CommentNotification',
),
]

View File

@@ -1,40 +0,0 @@
# Generated by Django 5.2.1 on 2025-07-11 09:10
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0052_remove_basenotification_user_and_more'),
]
operations = [
migrations.AlterField(
model_name='notification',
name='adoption_notice',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='fellchensammlung.adoptionnotice', verbose_name='Vermittlung'),
),
migrations.AlterField(
model_name='notification',
name='notification_type',
field=models.CharField(choices=[('new_user', 'Useraccount wurde erstellt'), ('new_report_an', 'Vermittlung wurde gemeldet'), ('new_report_comment', 'Kommentar wurde gemeldet'), ('an_is_to_be_checked', 'Vermittlung muss überprüft werden'), ('an_was_deactivated', 'Vermittlung wurde deaktiviert'), ('an_for_search_found', 'Vermittlung für Suche gefunden'), ('new_comment', 'Neuer Kommentar')], max_length=200, verbose_name='Benachrichtigungsgrund'),
),
migrations.AlterField(
model_name='notification',
name='report',
field=models.ForeignKey(blank=True, help_text='Report auf den sich die Benachrichtigung bezieht.', null=True, on_delete=django.db.models.deletion.CASCADE, to='fellchensammlung.report', verbose_name='Report'),
),
migrations.AlterField(
model_name='notification',
name='user_related',
field=models.ForeignKey(blank=True, help_text='Useraccount auf den sich die Benachrichtigung bezieht.', null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Verwandter Useraccount'),
),
migrations.AlterField(
model_name='notification',
name='user_to_notify',
field=models.ForeignKey(help_text='Useraccount der Benachrichtigt wird', on_delete=django.db.models.deletion.CASCADE, related_name='user', to=settings.AUTH_USER_MODEL, verbose_name='Nutzer*in'),
),
]

View File

@@ -1,19 +0,0 @@
# Generated by Django 5.2.1 on 2025-07-11 11:47
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0053_alter_notification_adoption_notice_and_more'),
]
operations = [
migrations.AlterField(
model_name='notification',
name='comment',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='fellchensammlung.comment', verbose_name='Antwort'),
),
]

View File

@@ -1,25 +0,0 @@
# Generated by Django 5.2.1 on 2025-07-13 10:54
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0054_alter_notification_comment'),
]
operations = [
migrations.AddField(
model_name='rescueorganization',
name='ongoing_communication',
field=models.BooleanField(default=False, help_text='Es findet gerade Kommunikation zwischen Notfellchen und der Organisation statt.', verbose_name='In aktiver Kommunikation'),
),
migrations.AlterField(
model_name='notification',
name='user_to_notify',
field=models.ForeignKey(help_text='Useraccount der Benachrichtigt wird', on_delete=django.db.models.deletion.CASCADE, related_name='user', to=settings.AUTH_USER_MODEL, verbose_name='Empfänger*in'),
),
]

View File

@@ -1,22 +0,0 @@
# Generated by Django 5.2.1 on 2025-07-14 05:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0055_rescueorganization_ongoing_communication_and_more'),
]
operations = [
migrations.AlterModelOptions(
name='rescueorganization',
options={'ordering': ['name']},
),
migrations.AddField(
model_name='rescueorganization',
name='specializations',
field=models.ManyToManyField(to='fellchensammlung.species'),
),
]

View File

@@ -1,16 +0,0 @@
# Generated by Django 5.2.1 on 2025-07-14 05:15
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0056_alter_rescueorganization_options_and_more'),
]
operations = [
migrations.DeleteModel(
name='SpeciesSpecialization',
),
]

View File

@@ -1,25 +0,0 @@
# Generated by Django 5.2.1 on 2025-07-19 17:48
import django.db.models.deletion
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0057_delete_speciesspecialization'),
]
operations = [
migrations.CreateModel(
name='SocialMediaPost',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateField(default=django.utils.timezone.now, verbose_name='Erstellt am')),
('platform', models.CharField(choices=[('fediverse', 'Fediverse')], max_length=255, verbose_name='Social Media Platform')),
('url', models.URLField(verbose_name='URL')),
('adoption_notice', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='fellchensammlung.adoptionnotice', verbose_name='Vermittlung')),
],
),
]

View File

@@ -1,23 +0,0 @@
# Generated by Django 5.2.1 on 2025-08-02 09:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0058_socialmediapost'),
]
operations = [
migrations.AddField(
model_name='rescueorganization',
name='twenty_id',
field=models.UUIDField(blank=True, help_text='ID der der Organisation in Twenty', null=True, verbose_name='Twenty-ID'),
),
migrations.AlterField(
model_name='rescueorganization',
name='specializations',
field=models.ManyToManyField(blank=True, to='fellchensammlung.species'),
),
]

View File

@@ -1,87 +0,0 @@
# Generated by Django 5.2.1 on 2025-08-30 21:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0059_rescueorganization_twenty_id_and_more'),
]
operations = [
migrations.AlterModelOptions(
name='adoptionnotice',
options={'permissions': [('create_active_adoption_notice', 'Can create an active adoption notice')], 'verbose_name': 'Vermittlung', 'verbose_name_plural': 'Vermittlungen'},
),
migrations.AlterModelOptions(
name='adoptionnoticestatus',
options={'verbose_name': 'Vermittlungsstatus', 'verbose_name_plural': 'Vermittlungsstati'},
),
migrations.AlterModelOptions(
name='animal',
options={'verbose_name': 'Tier', 'verbose_name_plural': 'Tiere'},
),
migrations.AlterModelOptions(
name='announcement',
options={'verbose_name': 'Banner', 'verbose_name_plural': 'Banner'},
),
migrations.AlterModelOptions(
name='comment',
options={'verbose_name': 'Kommentar', 'verbose_name_plural': 'Kommentare'},
),
migrations.AlterModelOptions(
name='image',
options={'verbose_name': 'Bild', 'verbose_name_plural': 'Bilder'},
),
migrations.AlterModelOptions(
name='importantlocation',
options={'verbose_name': 'Wichtiger Standort', 'verbose_name_plural': 'Wichtige Standorte'},
),
migrations.AlterModelOptions(
name='location',
options={'verbose_name': 'Standort', 'verbose_name_plural': 'Standorte'},
),
migrations.AlterModelOptions(
name='moderationaction',
options={'verbose_name': 'Moderationsaktion', 'verbose_name_plural': 'Moderationsaktionen'},
),
migrations.AlterModelOptions(
name='notification',
options={'verbose_name': 'Benachrichtigung', 'verbose_name_plural': 'Benachrichtigungen'},
),
migrations.AlterModelOptions(
name='report',
options={'verbose_name': 'Meldung', 'verbose_name_plural': 'Meldungen'},
),
migrations.AlterModelOptions(
name='rescueorganization',
options={'ordering': ['name'], 'verbose_name': 'Tierschutzorganisation', 'verbose_name_plural': 'Tierschutzorganisationen'},
),
migrations.AlterModelOptions(
name='rule',
options={'verbose_name': 'Regel', 'verbose_name_plural': 'Regeln'},
),
migrations.AlterModelOptions(
name='searchsubscription',
options={'verbose_name': 'Abonnierte Suche', 'verbose_name_plural': 'Abonnierte Suchen'},
),
migrations.AlterModelOptions(
name='speciesspecificurl',
options={'verbose_name': 'Tierartspezifische URL', 'verbose_name_plural': 'Tierartspezifische URLs'},
),
migrations.AlterModelOptions(
name='subscriptions',
options={'verbose_name': 'Abonnement', 'verbose_name_plural': 'Abonnements'},
),
migrations.AlterModelOptions(
name='timestamp',
options={'verbose_name': 'Zeitstempel', 'verbose_name_plural': 'Zeitstempel'},
),
migrations.AddField(
model_name='adoptionnotice',
name='adoption_notice_status',
field=models.TextField(choices=[('active_searching', 'Searching'), ('active_interested', 'Interested'), ('awaiting_action_waiting_for_review', 'Waiting for review'), ('awaiting_action_needs_additional_info', 'Needs additional info'), ('closed_successful_with_notfellchen', 'Successful (with Notfellchen)'), ('closed_successful_without_notfellchen', 'Successful (without Notfellchen)'), ('closed_animal_died', 'Animal died'), ('closed_for_other_adoption_notice', 'Closed for other adoption notice'), ('closed_not_open_for_adoption_anymore', 'Not open for adoption anymore'), ('closed_other', 'Other (closed)'), ('disabled_against_the_rules', 'Against the rules'), ('disabled_unchecked', 'Unchecked'), ('disabled_other', 'Other (disabled)')], default='disabled_other', max_length=64, verbose_name='Status'),
preserve_default=False,
),
]

View File

@@ -1,63 +0,0 @@
# Generated by Django 5.2.1 on 2025-08-30 21:51
import logging
from django.db import migrations
def map_status(adoption_notice_status):
minor = adoption_notice_status.minor_status
if minor == "searching":
return "active_searching"
if minor == "interested":
return "active_interested"
if minor == "waiting_for_review":
return "awaiting_action_waiting_for_review"
if minor == "needs_additional_info":
return "awaiting_action_needs_additional_info"
if minor == "successful_with_notfellchen":
return "closed_successful_with_notfellchen"
if minor == "successful_without_notfellchen":
return "closed_successful_without_notfellchen"
if minor == "animal_died":
return "closed_animal_died"
if minor == "closed_for_other_adoption_notice":
return "closed_for_other_adoption_notice"
if minor == "not_open_for_adoption_anymore":
return "closed_not_open_for_adoption_anymore"
if minor == "other":
return "closed_other"
if minor == "against_the_rules":
return "disabled_against_the_rules"
if minor == "unchecked":
return "disabled_unchecked"
if minor in ["missing_information", "technical_error"]:
return "disabled_other"
return None
def migrate_status(apps, schema_editor):
# We can't import the model directly as it may be a newer
# version than this migration expects. We use the historical version.
AdoptionNoticeStatus = apps.get_model("fellchensammlung", "AdoptionNoticeStatus")
AdoptionNotice = apps.get_model("fellchensammlung", "AdoptionNotice")
for ans in AdoptionNoticeStatus.objects.all():
adoption_notice = AdoptionNotice.objects.get(id=ans.adoption_notice.id)
new_status = map_status(ans)
logging.debug(f"{ans.minor_status} -> {new_status}")
adoption_notice.adoption_notice_status = map_status(ans)
adoption_notice.save()
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0060_alter_adoptionnotice_options_and_more'),
]
operations = [
migrations.RunPython(migrate_status),
]

View File

@@ -1,21 +0,0 @@
# Generated by Django 5.2.1 on 2025-08-30 22:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0061_datamigration_status_model_to_field'),
]
operations = [
migrations.AlterField(
model_name='adoptionnotice',
name='adoption_notice_status',
field=models.TextField(choices=[('active_searching', 'Searching'), ('active_interested', 'Interested'), ('awaiting_action_waiting_for_review', 'Waiting for review'), ('awaiting_action_needs_additional_info', 'Needs additional info'), ('closed_successful_with_notfellchen', 'Successful (with Notfellchen)'), ('closed_successful_without_notfellchen', 'Successful (without Notfellchen)'), ('closed_animal_died', 'Animal died'), ('closed_for_other_adoption_notice', 'Closed for other adoption notice'), ('closed_not_open_for_adoption_anymore', 'Not open for adoption anymore'), ('closed_link_to_more_info_not_reachable', 'Der Link zu weiteren Informationen ist nicht mehr erreichbar.'), ('closed_other', 'Other (closed)'), ('disabled_against_the_rules', 'Against the rules'), ('disabled_unchecked', 'Unchecked'), ('disabled_other', 'Other (disabled)')], max_length=64, verbose_name='Status'),
),
migrations.DeleteModel(
name='AdoptionNoticeStatus',
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.2.1 on 2025-09-05 14:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0062_alter_adoptionnotice_adoption_notice_status_and_more'),
]
operations = [
migrations.AddField(
model_name='adoptionnotice',
name='adoption_process',
field=models.TextField(blank=True, choices=[('contact_person_in_an', 'Kontaktiere die Person im Vermittlungstext')], max_length=64, null=True, verbose_name='Adoptionsprozess'),
),
]

View File

@@ -1,140 +0,0 @@
# Generated by Django 5.2.1 on 2025-09-06 11:11
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0063_adoptionnotice_adoption_process'),
]
operations = [
migrations.AlterField(
model_name='animal',
name='name',
field=models.CharField(max_length=200, verbose_name='Name'),
),
migrations.AlterField(
model_name='animal',
name='photos',
field=models.ManyToManyField(blank=True, to='fellchensammlung.image', verbose_name='Fotos'),
),
migrations.AlterField(
model_name='animal',
name='sex',
field=models.CharField(choices=[('F', 'Weiblich'), ('M', 'Männlich'), ('M_N', 'Männlich, kastriert'), ('F_N', 'Weiblich, kastriert'), ('I', 'Intergeschlechtlich')], max_length=20, verbose_name='Geschlecht'),
),
migrations.AlterField(
model_name='comment',
name='adoption_notice',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='fellchensammlung.adoptionnotice', verbose_name='Vermittlung'),
),
migrations.AlterField(
model_name='image',
name='alt_text',
field=models.TextField(help_text='Beschreibe das Bild für blinde und sehbehinderte Menschen', max_length=2000, verbose_name='Alternativtext'),
),
migrations.AlterField(
model_name='location',
name='city',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Stadt'),
),
migrations.AlterField(
model_name='location',
name='county',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Landkreis'),
),
migrations.AlterField(
model_name='location',
name='housenumber',
field=models.CharField(blank=True, max_length=20, null=True, verbose_name='Hausnummer'),
),
migrations.AlterField(
model_name='location',
name='latitude',
field=models.FloatField(verbose_name='Breitengrad'),
),
migrations.AlterField(
model_name='location',
name='longitude',
field=models.FloatField(verbose_name='Längengrad'),
),
migrations.AlterField(
model_name='location',
name='postcode',
field=models.CharField(blank=True, max_length=20, null=True, verbose_name='Postleitzahl'),
),
migrations.AlterField(
model_name='location',
name='street',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Straße'),
),
migrations.AlterField(
model_name='notification',
name='user_to_notify',
field=models.ForeignKey(help_text='Useraccount der benachrichtigt wird', on_delete=django.db.models.deletion.CASCADE, related_name='user', to=settings.AUTH_USER_MODEL, verbose_name='Empfänger*in'),
),
migrations.AlterField(
model_name='report',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='Erstellt am'),
),
migrations.AlterField(
model_name='report',
name='updated_at',
field=models.DateTimeField(auto_now=True, verbose_name='Zuletzt geändert am'),
),
migrations.AlterField(
model_name='rule',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='Erstellt am'),
),
migrations.AlterField(
model_name='rule',
name='language',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fellchensammlung.language', verbose_name='Sprache'),
),
migrations.AlterField(
model_name='rule',
name='rule_identifier',
field=models.CharField(help_text='Ein eindeutiger Identifikator der Regel. Ein Regelobjekt derselben Regel in einer anderen Sprache muss den gleichen Identifikator haben', max_length=24, verbose_name='Regel-ID'),
),
migrations.AlterField(
model_name='rule',
name='rule_text',
field=models.TextField(verbose_name='Regeltext'),
),
migrations.AlterField(
model_name='rule',
name='updated_at',
field=models.DateTimeField(auto_now=True, verbose_name='Zuletzt geändert am'),
),
migrations.AlterField(
model_name='searchsubscription',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='Erstellt am'),
),
migrations.AlterField(
model_name='searchsubscription',
name='sex',
field=models.CharField(choices=[('F', 'Weiblich'), ('M', 'Männlich'), ('M_N', 'Männlich, kastriert'), ('F_N', 'Weiblich Kastriert'), ('I', 'Intergeschlechtlich'), ('A', 'Alle')], max_length=20, verbose_name='Geschlecht'),
),
migrations.AlterField(
model_name='searchsubscription',
name='updated_at',
field=models.DateTimeField(auto_now=True, verbose_name='Zuletzt geändert am'),
),
migrations.AlterField(
model_name='subscriptions',
name='adoption_notice',
field=models.ForeignKey(help_text='Vermittlung die abonniert wurde', on_delete=django.db.models.deletion.CASCADE, to='fellchensammlung.adoptionnotice', verbose_name='Vermittlung'),
),
migrations.AlterField(
model_name='text',
name='title',
field=models.CharField(max_length=100, verbose_name='Titel'),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.2.1 on 2025-09-06 13:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0064_alter_animal_name_alter_animal_photos_and_more'),
]
operations = [
migrations.AddField(
model_name='species',
name='slug',
field=models.SlugField(null=True, unique=True, verbose_name='Slug'),
),
]

View File

@@ -1,20 +0,0 @@
# Generated by Django 5.2.1 on 2025-09-06 13:05
from django.db import migrations
def migrate_slug(apps, schema_editor):
Species = apps.get_model("fellchensammlung", "Species")
for species in Species.objects.all():
species.slug = f"species-{species.id}"
species.save()
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0065_species_slug'),
]
operations = [
migrations.RunPython(migrate_slug),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.2.1 on 2025-09-06 13:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0066_add_slug_to_species'),
]
operations = [
migrations.AlterField(
model_name='species',
name='slug',
field=models.SlugField(unique=True, verbose_name='Slug'),
),
]

View File

@@ -1,23 +0,0 @@
# Generated by Django 5.2.1 on 2025-09-29 15:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0067_alter_species_slug'),
]
operations = [
migrations.AlterField(
model_name='adoptionnotice',
name='adoption_notice_status',
field=models.TextField(choices=[('active_searching', 'Searching'), ('active_interested', 'Interested'), ('awaiting_action_waiting_for_review', 'Waiting for review'), ('awaiting_action_needs_additional_info', 'Needs additional info'), ('awaiting_action_unchecked', 'Unchecked'), ('closed_successful_with_notfellchen', 'Successful (with Notfellchen)'), ('closed_successful_without_notfellchen', 'Successful (without Notfellchen)'), ('closed_animal_died', 'Animal died'), ('closed_for_other_adoption_notice', 'Closed for other adoption notice'), ('closed_not_open_for_adoption_anymore', 'Not open for adoption anymore'), ('closed_link_to_more_info_not_reachable', 'Der Link zu weiteren Informationen ist nicht mehr erreichbar.'), ('closed_other', 'Other (closed)'), ('disabled_against_the_rules', 'Against the rules'), ('disabled_other', 'Other (disabled)')], max_length=64, verbose_name='Status'),
),
migrations.AlterField(
model_name='image',
name='image',
field=models.ImageField(help_text='Wähle ein Bild aus', upload_to='images', verbose_name='Bild'),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.2.1 on 2025-10-20 08:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0068_alter_adoptionnotice_adoption_notice_status_and_more'),
]
operations = [
migrations.AddField(
model_name='rescueorganization',
name='regular_check_status',
field=models.CharField(choices=[('regular_check', 'Wird regelmäßig geprüft'), ('excluded_no_online_listing', 'Exkludiert: Tiere werden nicht online gelistet'), ('excluded_other_org', 'Exkludiert: Andere Organisation wird geprüft'), ('excluded_scope', 'Exkludiert: Organisation hat nie Notfellchen-relevanten Vermittlungen'), ('excluded_other', 'Exkludiert: Anderer Grund')], default='regular_check', help_text='Organisationen können, durch ändern dieser Einstellung, von der regelmäßigen Prüfung ausgeschlossen werden.', max_length=30, verbose_name='Status der regelmäßigen Prüfung'),
),
]

View File

@@ -1,21 +1,20 @@
import uuid
from random import choices
from tabnanny import verbose
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.utils import timezone
from django.dispatch import receiver
from django.db.models.signals import post_save
from django.contrib.auth.models import Group
from django.contrib.auth.models import AbstractUser
from django.core.exceptions import ValidationError
import base64
from .tools import misc, geo
from notfellchen.settings import MEDIA_URL, base_url
from notfellchen.settings import MEDIA_URL
from .tools.geo import LocationProxy, Position
from .tools.misc import time_since_as_hr_string
from .tools.model_helpers import NotificationTypeChoices, AdoptionNoticeStatusChoices, AdoptionProcess, \
AdoptionNoticeStatusChoicesDescriptions, RegularCheckStatusChoices, reason_for_signup_label, \
reason_for_signup_help_text
from .tools.model_helpers import ndm as NotificationDisplayMapping
from .tools.misc import age_as_hr_string, time_since_as_hr_string
class Language(models.Model):
@@ -40,37 +39,24 @@ class Language(models.Model):
class Location(models.Model):
place_id = models.CharField(max_length=200) # OSM id
latitude = models.FloatField(verbose_name=_("Breitengrad"))
longitude = models.FloatField(verbose_name=_("Längengrad"))
place_id = models.IntegerField() # OSM id
latitude = models.FloatField()
longitude = models.FloatField()
name = models.CharField(max_length=2000)
city = models.CharField(max_length=200, blank=True, null=True, verbose_name=_('Stadt'))
housenumber = models.CharField(max_length=20, blank=True, null=True, verbose_name=_("Hausnummer"))
postcode = models.CharField(max_length=20, blank=True, null=True, verbose_name=_("Postleitzahl"))
street = models.CharField(max_length=200, blank=True, null=True, verbose_name=_("Straße"))
county = models.CharField(max_length=200, blank=True, null=True, verbose_name=_("Landkreis"))
# 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)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = _("Standort")
verbose_name_plural = _("Standorte")
def __str__(self):
if self.city and self.postcode:
return f"{self.city} ({self.postcode})"
else:
return f"{self.name}"
return f"{self.name} ({self.latitude:.5}, {self.longitude:.5})"
@property
def position(self):
return (self.latitude, self.longitude)
@property
def str_hr(self):
return f"{self.name.split(',')[0]}"
@staticmethod
def get_location_from_string(location_string):
try:
@@ -87,11 +73,6 @@ class Location(models.Model):
latitude=proxy.latitude,
longitude=proxy.longitude,
name=proxy.name,
postcode=proxy.postcode,
city=proxy.city,
street=proxy.street,
county=proxy.county,
countrycode=proxy.countrycode,
)
return location
@@ -103,56 +84,35 @@ class Location(models.Model):
instance.save()
class ImportantLocation(models.Model):
class Meta:
verbose_name = _("Wichtiger Standort")
verbose_name_plural = _("Wichtige Standorte")
location = models.OneToOneField(Location, on_delete=models.CASCADE)
slug = models.SlugField(unique=True)
name = models.CharField(max_length=200)
def get_absolute_url(self):
return reverse('search-by-location', kwargs={'important_location_slug': self.slug})
class ExternalSourceChoices(models.TextChoices):
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 Species(models.Model):
"""Model representing a species of animal."""
name = models.CharField(max_length=200, help_text=_('Name der Tierart'),
verbose_name=_('Name'))
slug = models.SlugField(unique=True, verbose_name=_('Slug'))
updated_at = models.DateTimeField(auto_now=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
"""String for representing the Model object."""
return self.name
class Meta:
verbose_name = _('Tierart')
verbose_name_plural = _('Tierarten')
class RescueOrganization(models.Model):
def __str__(self):
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)
trusted = models.BooleanField(default=False, verbose_name=_('Vertrauenswürdig'))
allows_using_materials = models.CharField(max_length=200,
default=AllowUseOfMaterialsChices.USE_MATERIALS_NOT_ASKED,
choices=AllowUseOfMaterialsChices.choices,
default=ALLOW_USE_MATERIALS_CHOICE[USE_MATERIALS_NOT_ASKED],
choices=ALLOW_USE_MATERIALS_CHOICE,
verbose_name=_('Erlaubt Nutzung von Inhalten'))
location_string = models.CharField(max_length=200, verbose_name=_("Ort der Organisation"), null=True, blank=True, )
location_string = models.CharField(max_length=200, verbose_name=_("Ort der Organisation"))
location = models.ForeignKey(Location, on_delete=models.PROTECT, blank=True, null=True)
instagram = models.URLField(null=True, blank=True, verbose_name=_('Instagram Profil'))
facebook = models.URLField(null=True, blank=True, verbose_name=_('Facebook Profil'))
@@ -162,7 +122,6 @@ class RescueOrganization(models.Model):
website = models.URLField(null=True, blank=True, verbose_name=_('Website'))
updated_at = models.DateTimeField(auto_now=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, )
description = models.TextField(null=True, blank=True, verbose_name=_('Beschreibung')) # Markdown allowed
external_object_identifier = models.CharField(max_length=200, null=True, blank=True,
@@ -170,37 +129,6 @@ class RescueOrganization(models.Model):
external_source_identifier = models.CharField(max_length=200, null=True, blank=True,
choices=ExternalSourceChoices.choices,
verbose_name=_('External Source Identifier'))
exclude_from_check = models.BooleanField(default=False, verbose_name=_('Von Prüfung ausschließen'),
help_text=_("Organisation von der manuellen Überprüfung ausschließen, "
"z.B. weil Tiere nicht online geführt werden"))
regular_check_status = models.CharField(max_length=30, choices=RegularCheckStatusChoices.choices,
default=RegularCheckStatusChoices.REGULAR_CHECK,
verbose_name=_('Status der regelmäßigen Prüfung'),
help_text=_(
"Organisationen können, durch ändern dieser Einstellung, von der "
"regelmäßigen Prüfung ausgeschlossen werden."))
ongoing_communication = models.BooleanField(default=False, verbose_name=_('In aktiver Kommunikation'),
help_text=_(
"Es findet gerade Kommunikation zwischen Notfellchen und der Organisation statt."))
parent_org = models.ForeignKey("RescueOrganization", on_delete=models.PROTECT, blank=True, null=True)
# allows to specify if a rescue organization has a specialization for dedicated species
specializations = models.ManyToManyField(Species, blank=True)
twenty_id = models.UUIDField(verbose_name=_("Twenty-ID"), null=True, blank=True,
help_text=_("ID der der Organisation in Twenty"))
class Meta:
unique_together = ('external_object_identifier', 'external_source_identifier',)
ordering = ['name']
verbose_name = _("Tierschutzorganisation")
verbose_name_plural = _("Tierschutzorganisationen")
def __str__(self):
return f"{self.name}"
def clean(self):
super().clean()
if self.location is None and self.location_string is None:
raise ValidationError(_('Location or Location String must be set'))
def get_absolute_url(self):
return reverse("rescue-organization-detail", args=[str(self.pk)])
@@ -209,29 +137,6 @@ class RescueOrganization(models.Model):
def adoption_notices(self):
return AdoptionNotice.objects.filter(organization=self)
@property
def adoption_notices_in_hierarchy(self):
"""
Shows all adoption notices of this rescue organization and all child organizations.
"""
adoption_notices_discovered = list(self.adoption_notices)
if self.child_organizations:
for child in self.child_organizations:
adoption_notices_discovered.extend(child.adoption_notices_in_hierarchy)
return adoption_notices_discovered
@property
def adoption_notices_in_hierarchy_divided_by_status(self):
"""Returns two lists of adoption notices, the first active, the other inactive."""
active_adoption_notices = []
inactive_adoption_notices = []
for an in self.adoption_notices_in_hierarchy:
if an.is_active:
active_adoption_notices.append(an)
else:
inactive_adoption_notices.append(an)
return active_adoption_notices, inactive_adoption_notices
@property
def position(self):
if self.location:
@@ -244,41 +149,7 @@ class RescueOrganization(models.Model):
if self.description is None:
return ""
if len(self.description) > 200:
return self.description[:200] + _(f" ... [weiterlesen]({self.get_absolute_url()})")
else:
return self.description
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(rescue_organization=self)
@property
def has_contact_data(self):
"""
Returns true if at least one type of contact data is available.
"""
return self.instagram or self.facebook or self.website or self.phone_number or self.email or self.fediverse_profile
@property
def child_organizations(self):
return RescueOrganization.objects.filter(parent_org=self)
def in_distance(self, position, max_distance, unknown_true=True):
"""
Returns a boolean indicating if the Location of the adoption notice is within a given distance to the position
If the location is none, we by default return that the location is within the given distance
"""
return geo.object_in_distance(self, position, max_distance, unknown_true)
return self.description[:200] + f" ... [weiterlesen]({self.get_absolute_url()})"
# Admins can perform all actions and have the highest trust associated with them
@@ -308,7 +179,8 @@ class User(AbstractUser):
updated_at = models.DateTimeField(auto_now=True)
organization_affiliation = models.ForeignKey(RescueOrganization, on_delete=models.PROTECT, null=True, blank=True,
verbose_name=_('Organisation'))
reason_for_signup = models.TextField(verbose_name=reason_for_signup_label, help_text=reason_for_signup_help_text)
reason_for_signup = models.TextField(verbose_name=_("Grund für die Registrierung"), help_text=_(
"Wir würden gerne wissen warum du dich registriertst, ob du dich z.B. Tiere eines bestimmten Tierheim einstellen willst 'nur mal gucken' willst. Beides ist toll! Wenn du für ein Tierheim/eine Pflegestelle arbeitest kontaktieren wir dich ggf. um dir erweiterte Rechte zu geben."))
email_notifications = models.BooleanField(verbose_name=_("Benachrichtigung per E-Mail"), default=True)
REQUIRED_FIELDS = ["reason_for_signup", "email"]
@@ -325,17 +197,14 @@ class User(AbstractUser):
def get_absolute_url(self):
return reverse("user-detail", args=[str(self.pk)])
def get_full_url(self):
return f"{base_url}{self.get_absolute_url()}"
def get_notifications_url(self):
return self.get_absolute_url()
def get_unread_notifications(self):
return Notification.objects.filter(user_to_notify=self, read=False)
return BaseNotification.objects.filter(user=self, read=False)
def get_num_unread_notifications(self):
return Notification.objects.filter(user_to_notify=self, read=False).count()
return BaseNotification.objects.filter(user=self, read=False).count()
@property
def adoption_notices(self):
@@ -347,9 +216,8 @@ class User(AbstractUser):
class Image(models.Model):
image = models.ImageField(upload_to='images', verbose_name=_("Bild"), help_text=_("Wähle ein Bild aus"))
alt_text = models.TextField(max_length=2000, verbose_name=_('Alternativtext'),
help_text=_("Beschreibe das Bild für blinde und sehbehinderte Menschen"))
image = models.ImageField(upload_to='images')
alt_text = models.TextField(max_length=2000, verbose_name=_('Alternativtext'))
owner = models.ForeignKey(User, on_delete=models.CASCADE)
updated_at = models.DateTimeField(auto_now=True)
created_at = models.DateTimeField(auto_now_add=True)
@@ -357,18 +225,25 @@ class Image(models.Model):
def __str__(self):
return self.alt_text
class Meta:
verbose_name = _("Bild")
verbose_name_plural = _("Bilder")
@property
def as_html(self):
return f'<img src="{MEDIA_URL}/{self.image}" alt="{self.alt_text}">'
@property
def as_base64(self):
encoded_string = base64.b64encode(self.image.file.read())
return encoded_string.decode("utf-8")
class Species(models.Model):
"""Model representing a species of animal."""
name = models.CharField(max_length=200, help_text=_('Name der Tierart'),
verbose_name=_('Name'))
updated_at = models.DateTimeField(auto_now=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
"""String for representing the Model object."""
return self.name
class Meta:
verbose_name = _('Tierart')
verbose_name_plural = _('Tierarten')
class AdoptionNotice(models.Model):
@@ -376,35 +251,26 @@ class AdoptionNotice(models.Model):
permissions = [
("create_active_adoption_notice", "Can create an active adoption notice"),
]
verbose_name = _("Vermittlung")
verbose_name_plural = _("Vermittlungen")
def __str__(self):
return self.name
if not hasattr(self, 'adoptionnoticestatus'):
return self.name
return f"[{self.adoptionnoticestatus.as_string()}] {self.name}"
created_at = models.DateField(verbose_name=_('Erstellt am'), default=timezone.now)
updated_at = models.DateTimeField(auto_now=True)
last_checked = models.DateTimeField(verbose_name=_('Zuletzt überprüft am'), default=timezone.now)
searching_since = models.DateField(verbose_name=_('Sucht nach einem Zuhause seit'))
name = models.CharField(max_length=200, verbose_name=_('Titel der Vermittlung'))
name = models.CharField(max_length=200)
description = models.TextField(null=True, blank=True, verbose_name=_('Beschreibung'))
organization = models.ForeignKey(RescueOrganization, blank=True, null=True, on_delete=models.SET_NULL,
verbose_name=_('Organisation'))
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)"))
further_information = models.URLField(null=True, blank=True, verbose_name=_('Link zu mehr Informationen'))
group_only = models.BooleanField(default=False, verbose_name=_('Ausschließlich Gruppenadoption'))
photos = models.ManyToManyField(Image, blank=True)
location_string = models.CharField(max_length=200, verbose_name=_("Ortsangabe"))
location = models.ForeignKey(Location, blank=True, null=True, on_delete=models.SET_NULL, )
owner = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name=_('Creator'))
adoption_notice_status = models.TextField(max_length=64, verbose_name=_('Status'),
choices=AdoptionNoticeStatusChoices.all_choices())
adoption_process = models.TextField(null=True, blank=True,
max_length=64, verbose_name=_('Adoptionsprozess'),
choices=AdoptionProcess)
@property
def animals(self):
@@ -417,21 +283,6 @@ class AdoptionNotice(models.Model):
sexes.add(animal.sex)
return sexes
@property
def num_per_sex(self):
print(f"{self.pk} x")
num_per_sex = dict()
for sex in SexChoices:
num_per_sex[sex] = len([animal for animal in self.animals if animal.sex == sex])
return num_per_sex
@property
def species(self):
species = set()
for animal in self.animals:
species.add(animal.species)
return species
@property
def last_checked_hr(self):
time_since_last_checked = timezone.now() - self.last_checked
@@ -461,30 +312,17 @@ class AdoptionNotice(models.Model):
else:
return self.location.latitude, self.location.longitude
def _get_short_description(self, length: int) -> str:
if self.description is None:
return ""
elif len(self.description) > length:
return self.description[:length] + f" ... [weiterlesen]({self.get_absolute_url()})"
else:
return self.description
@property
def description_short(self):
return self._get_short_description(200)
@property
def description_100_short(self):
return self._get_short_description(90)
if self.description is None:
return ""
if len(self.description) > 200:
return self.description[:200] + f" ... [weiterlesen]({self.get_absolute_url()})"
def get_absolute_url(self):
"""Returns the url to access a detailed page for the adoption notice."""
return reverse('adoption-notice-detail', args=[str(self.id)])
def get_full_url(self):
"""Returns the url including protocol and domain"""
return f"{base_url}{self.get_absolute_url()}"
def get_report_url(self):
"""Returns the url to report an adoption notice."""
return reverse('report-adoption-notice', args=[str(self.id)])
@@ -512,7 +350,6 @@ class AdoptionNotice(models.Model):
photos.extend(animal.photos.all())
if len(photos) > 0:
return photos
return None
def get_photo(self):
"""
@@ -536,53 +373,149 @@ class AdoptionNotice(models.Model):
If the location is none, we by default return that the location is within the given distance
"""
return geo.object_in_distance(self, position, max_distance, unknown_true)
if unknown_true and self.position is None:
return True
@staticmethod
def _values_of(list_of_enums):
return list(map(lambda x: x[0], list_of_enums))
distance = geo.calculate_distance_between_coordinates(self.position, position)
return distance < max_distance
@property
def link_to_more_information(self):
from urllib.parse import urlparse
domain = urlparse(self.further_information).netloc
return f"<a href='{self.further_information}'>{domain}</a>"
@property
def is_active(self):
return self.adoption_notice_status in self._values_of(AdoptionNoticeStatusChoices.Active.choices)
if not hasattr(self, 'adoptionnoticestatus'):
return False
return self.adoptionnoticestatus.is_active
@property
def is_disabled(self):
return self.adoption_notice_status in self._values_of(AdoptionNoticeStatusChoices.Disabled.choices)
def is_disabled_unchecked(self):
if not hasattr(self, 'adoptionnoticestatus'):
return False
return self.adoptionnoticestatus.is_disabled_unchecked
@property
def is_closed(self):
return self.adoption_notice_status in self._values_of(AdoptionNoticeStatusChoices.Closed.choices)
def set_closed(self):
self.last_checked = timezone.now()
self.save()
self.adoptionnoticestatus.set_closed()
@property
def is_awaiting_action(self):
return self.adoption_notice_status in self._values_of(AdoptionNoticeStatusChoices.AwaitingAction.choices)
@property
def status_description(self):
return AdoptionNoticeStatusChoicesDescriptions.mapping[self.adoption_notice_status]
def set_active(self):
self.last_checked = timezone.now()
self.save()
if not hasattr(self, 'adoptionnoticestatus'):
AdoptionNoticeStatus.create_other(self)
self.adoptionnoticestatus.set_active()
def set_unchecked(self):
self.last_checked = timezone.now()
self.adoption_notice_status = AdoptionNoticeStatusChoices.AwaitingAction.UNCHECKED
self.save()
if not hasattr(self, 'adoptionnoticestatus'):
AdoptionNoticeStatus.create_other(self)
self.adoptionnoticestatus.set_unchecked()
for subscription in self.get_subscriptions():
notification_title = _("Vermittlung deaktiviert:") + f" {self.name}"
text = _("Die folgende Vermittlung wurde deaktiviert: ") + f"[{self.name}]({self.get_absolute_url()})"
Notification.objects.create(user_to_notify=subscription.owner,
notification_type=NotificationTypeChoices.AN_WAS_DEACTIVATED,
adoption_notice=self,
text=text,
title=notification_title)
BaseNotification.objects.create(user=subscription.owner, text=text, title=notification_title)
def last_posted(self, platform=None):
if platform is None:
last_post = SocialMediaPost.objects.filter(adoption_notice=self).order_by('-created_at').first()
else:
last_post = SocialMediaPost.objects.filter(adoption_notice=self, platform=platform).order_by(
'-created_at').first()
return last_post.created_at
class AdoptionNoticeStatus(models.Model):
"""
The major status indicates a general state of an adoption notice
whereas the minor status is used for reporting
"""
ACTIVE = "active"
AWAITING_ACTION = "awaiting_action"
CLOSED = "closed"
DISABLED = "disabled"
MAJOR_STATUS_CHOICES = {
ACTIVE: "active",
AWAITING_ACTION: "in review",
CLOSED: "closed",
DISABLED: "disabled",
}
MINOR_STATUS_CHOICES = {
ACTIVE: {
"searching": "searching",
"interested": "interested",
},
AWAITING_ACTION: {
"waiting_for_review": "waiting_for_review",
"needs_additional_info": "needs_additional_info",
},
CLOSED: {
"successful_with_notfellchen": "successful_with_notfellchen",
"successful_without_notfellchen": "successful_without_notfellchen",
"animal_died": "animal_died",
"closed_for_other_adoption_notice": "closed_for_other_adoption_notice",
"not_open_for_adoption_anymore": "not_open_for_adoption_anymore",
"other": "other"
},
DISABLED: {
"against_the_rules": "against_the_rules",
"missing_information": "missing_information",
"technical_error": "technical_error",
"unchecked": "unchecked",
"other": "other"
}
}
major_status = models.CharField(choices=MAJOR_STATUS_CHOICES, max_length=200)
minor_choices = {}
for key in MINOR_STATUS_CHOICES:
minor_choices.update(MINOR_STATUS_CHOICES[key])
minor_status = models.CharField(choices=minor_choices, max_length=200)
adoption_notice = models.OneToOneField(AdoptionNotice, on_delete=models.CASCADE)
updated_at = models.DateTimeField(auto_now=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"{self.adoption_notice}: {self.major_status}, {self.minor_status}"
def as_string(self):
return f"{self.major_status}, {self.minor_status}"
@property
def is_active(self):
return self.major_status == self.ACTIVE
@property
def is_disabled_unchecked(self):
return self.major_status == self.DISABLED and self.minor_status == "unchecked"
@staticmethod
def get_minor_choices(major_status):
return AdoptionNoticeStatus.MINOR_STATUS_CHOICES[major_status]
@staticmethod
def create_other(an_instance):
# Used as empty status to be changed immediately
major_status = AdoptionNoticeStatus.DISABLED
minor_status = AdoptionNoticeStatus.MINOR_STATUS_CHOICES[AdoptionNoticeStatus.DISABLED]["other"]
AdoptionNoticeStatus.objects.create(major_status=major_status,
minor_status=minor_status,
adoption_notice=an_instance)
def set_closed(self):
self.major_status = self.MAJOR_STATUS_CHOICES[self.CLOSED]
self.minor_status = self.MINOR_STATUS_CHOICES[self.CLOSED]["other"]
self.save()
def set_unchecked(self):
self.major_status = self.MAJOR_STATUS_CHOICES[self.DISABLED]
self.minor_status = self.MINOR_STATUS_CHOICES[self.DISABLED]["unchecked"]
self.save()
def set_active(self):
self.major_status = self.MAJOR_STATUS_CHOICES[self.ACTIVE]
self.minor_status = self.MINOR_STATUS_CHOICES[self.ACTIVE]["searching"]
self.save()
class SexChoices(models.TextChoices):
@@ -603,19 +536,14 @@ class SexChoicesWithAll(models.TextChoices):
class Animal(models.Model):
class Meta:
verbose_name = _('Tier')
verbose_name_plural = _('Tiere')
date_of_birth = models.DateField(verbose_name=_('Geburtsdatum'))
name = models.CharField(max_length=200, verbose_name=_('Name'))
name = models.CharField(max_length=200)
description = models.TextField(null=True, blank=True, verbose_name=_('Beschreibung'))
species = models.ForeignKey(Species, on_delete=models.PROTECT, verbose_name=_("Tierart"))
photos = models.ManyToManyField(Image, blank=True, verbose_name=_("Fotos"))
photos = models.ManyToManyField(Image, blank=True)
sex = models.CharField(
max_length=20,
choices=SexChoices.choices,
verbose_name=_("Geschlecht")
)
adoption_notice = models.ForeignKey(AdoptionNotice, on_delete=models.CASCADE)
owner = models.ForeignKey(User, on_delete=models.CASCADE)
@@ -673,17 +601,12 @@ class SearchSubscription(models.Model):
- On new AdoptionNotice: Check all existing SearchSubscriptions for matches
- For matches: Send notification to user of the SearchSubscription
"""
class Meta:
verbose_name = _("Abonnierte Suche")
verbose_name_plural = _("Abonnierte Suchen")
owner = models.ForeignKey(User, on_delete=models.CASCADE)
location = models.ForeignKey(Location, on_delete=models.PROTECT, null=True)
sex = models.CharField(max_length=20, choices=SexChoicesWithAll.choices, verbose_name=_("Geschlecht"))
sex = models.CharField(max_length=20, choices=SexChoicesWithAll.choices)
max_distance = models.IntegerField(choices=DistanceChoices.choices, null=True)
updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Zuletzt geändert am"))
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Erstellt am"))
updated_at = models.DateTimeField(auto_now=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
if self.location and self.max_distance:
@@ -696,24 +619,15 @@ class Rule(models.Model):
"""
Class to store rules
"""
class Meta:
verbose_name = _("Regel")
verbose_name_plural = _("Regeln")
title = models.CharField(max_length=200)
# Markdown is allowed in rule text
rule_text = models.TextField(verbose_name=_("Regeltext"))
language = models.ForeignKey(Language, on_delete=models.PROTECT, verbose_name=_("Sprache"))
rule_text = models.TextField()
language = models.ForeignKey(Language, on_delete=models.PROTECT)
# Rule identifier allows to translate rules with the same identifier
rule_identifier = models.CharField(max_length=24,
verbose_name=_("Regel-ID"),
help_text=_("Ein eindeutiger Identifikator der Regel. Ein Regelobjekt "
"derselben Regel in einer anderen Sprache muss den gleichen "
"Identifikator haben"))
updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Zuletzt geändert am"))
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Erstellt am"))
rule_identifier = models.CharField(max_length=24)
updated_at = models.DateTimeField(auto_now=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.title
@@ -721,8 +635,7 @@ class Rule(models.Model):
class Report(models.Model):
class Meta:
verbose_name = _("Meldung")
verbose_name_plural = _("Meldungen")
permissions = []
ACTION_TAKEN = "action taken"
NO_ACTION_TAKEN = "no action taken"
@@ -737,8 +650,8 @@ class Report(models.Model):
status = models.CharField(max_length=30, choices=STATES)
reported_broken_rules = models.ManyToManyField(Rule, verbose_name=_("Regeln gegen die verstoßen wurde"))
user_comment = models.TextField(blank=True, verbose_name=_("Kommentar/Zusätzliche Information"))
updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Zuletzt geändert am"))
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Erstellt am"))
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f"[{self.status}]: {self.user_comment:.20}"
@@ -747,40 +660,12 @@ class Report(models.Model):
"""Returns the url to access a detailed page for the report."""
return reverse('report-detail', args=[str(self.id)])
def get_full_url(self):
return f"{base_url}{self.get_absolute_url()}"
def get_reported_rules(self):
return self.reported_broken_rules.all()
def get_moderation_actions(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):
adoption_notice = models.ForeignKey("AdoptionNotice", on_delete=models.CASCADE)
@@ -789,9 +674,6 @@ class ReportAdoptionNotice(Report):
def reported_content(self):
return self.adoption_notice
def __str__(self):
return f"Report der Vermittlung {self.adoption_notice}"
class ReportComment(Report):
reported_comment = models.ForeignKey("Comment", on_delete=models.CASCADE)
@@ -802,10 +684,6 @@ class ReportComment(Report):
class ModerationAction(models.Model):
class Meta:
verbose_name = _("Moderationsaktion")
verbose_name_plural = _("Moderationsaktionen")
BAN = "user_banned"
DELETE = "content_deleted"
COMMENT = "comment"
@@ -842,7 +720,7 @@ class Text(models.Model):
"""
Base class to store markdown content
"""
title = models.CharField(max_length=100, verbose_name=_("Titel"))
title = models.CharField(max_length=100)
content = models.TextField(verbose_name="Inhalt")
language = models.ForeignKey(Language, verbose_name="Sprache", on_delete=models.PROTECT)
text_code = models.CharField(max_length=24, verbose_name="Text code", blank=True)
@@ -870,11 +748,6 @@ class Announcement(Text):
"""
Class to store announcements that should be displayed for all users
"""
class Meta:
verbose_name = _("Banner")
verbose_name_plural = _("Banner")
logged_in_only = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@@ -924,15 +797,10 @@ class Comment(models.Model):
"""
Class to store comments in markdown content
"""
class Meta:
verbose_name = _("Kommentar")
verbose_name_plural = _("Kommentare")
user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name=_('Nutzer*in'))
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
adoption_notice = models.ForeignKey(AdoptionNotice, on_delete=models.CASCADE, verbose_name=_('Vermittlung'))
adoption_notice = models.ForeignKey(AdoptionNotice, on_delete=models.CASCADE, verbose_name=_('AdoptionNotice'))
text = models.TextField(verbose_name="Inhalt")
reply_to = models.ForeignKey("self", verbose_name="Antwort auf", blank=True, null=True, on_delete=models.CASCADE)
@@ -947,63 +815,46 @@ class Comment(models.Model):
return self.adoption_notice.get_absolute_url()
class Notification(models.Model):
class Meta:
verbose_name = _("Benachrichtigung")
verbose_name_plural = _("Benachrichtigungen")
class BaseNotification(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
notification_type = models.CharField(max_length=200,
choices=NotificationTypeChoices.choices,
verbose_name=_('Benachrichtigungsgrund'))
user_to_notify = models.ForeignKey(User,
on_delete=models.CASCADE,
verbose_name=_('Empfänger*in'),
help_text=_("Useraccount der benachrichtigt wird"),
related_name='user')
read_at = models.DateTimeField(blank=True, null=True, verbose_name=_("Gelesen am"))
title = models.CharField(max_length=100, verbose_name=_("Titel"))
text = models.TextField(verbose_name="Inhalt")
user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name=_('Nutzer*in'))
read = models.BooleanField(default=False)
read_at = models.DateTimeField(blank=True, null=True, verbose_name=_("Gelesen am"))
comment = models.ForeignKey(Comment, blank=True, null=True, on_delete=models.CASCADE, verbose_name=_('Antwort'))
adoption_notice = models.ForeignKey(AdoptionNotice, blank=True, null=True, on_delete=models.CASCADE,
verbose_name=_('Vermittlung'))
user_related = models.ForeignKey(User,
blank=True, null=True,
on_delete=models.CASCADE, verbose_name=_('Verwandter Useraccount'),
help_text=_("Useraccount auf den sich die Benachrichtigung bezieht."))
report = models.ForeignKey(Report,
blank=True, null=True,
on_delete=models.CASCADE,
verbose_name=_('Report'),
help_text=_("Report auf den sich die Benachrichtigung bezieht."))
def __str__(self):
return f"[{self.user_to_notify}] {self.title} ({self.created_at})"
return f"[{self.user}] {self.title} ({self.created_at})"
def get_absolute_url(self):
self.user_to_notify.get_notifications_url()
self.user.get_notifications_url()
def mark_read(self):
self.read = True
self.read_at = timezone.now()
self.save()
def get_body_part(self):
return NotificationDisplayMapping[self.notification_type].web_partial
class CommentNotification(BaseNotification):
comment = models.ForeignKey(Comment, on_delete=models.CASCADE, verbose_name=_('Antwort'))
@property
def url(self):
return self.comment.get_absolute_url
class AdoptionNoticeNotification(BaseNotification):
adoption_notice = models.ForeignKey(AdoptionNotice, on_delete=models.CASCADE, verbose_name=_('Vermittlung'))
@property
def url(self):
return self.adoption_notice.get_absolute_url
class Subscriptions(models.Model):
"""Subscription to a AdoptionNotice"""
class Meta:
verbose_name = _("Abonnement")
verbose_name_plural = _("Abonnements")
owner = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name=_('Nutzer*in'))
adoption_notice = models.ForeignKey(AdoptionNotice, on_delete=models.CASCADE, verbose_name=_('Vermittlung'),
help_text=_("Vermittlung die abonniert wurde"))
adoption_notice = models.ForeignKey(AdoptionNotice, on_delete=models.CASCADE, verbose_name=_('AdoptionNotice'))
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@@ -1029,16 +880,11 @@ class Timestamp(models.Model):
"""
Class to store timestamps based on keys
"""
class Meta:
verbose_name = _("Zeitstempel")
verbose_name_plural = _("Zeitstempel")
key = models.CharField(max_length=255, verbose_name=_("Schlüssel"), primary_key=True)
timestamp = models.DateTimeField(auto_now_add=True, verbose_name=_("Zeitstempel"))
data = models.CharField(max_length=2000, blank=True, null=True)
def __str__(self):
def ___str__(self):
return f"[{self.key}] - {self.timestamp.strftime('%H:%M:%S %d-%m-%Y ')} - {self.data}"
@@ -1046,33 +892,7 @@ class SpeciesSpecificURL(models.Model):
"""
Model that allows to specify a URL for a rescue organization where a certain species can be found
"""
class Meta:
verbose_name = _("Tierartspezifische URL")
verbose_name_plural = _("Tierartspezifische URLs")
species = models.ForeignKey(Species, on_delete=models.CASCADE, verbose_name=_("Tierart"))
rescue_organization = models.ForeignKey(RescueOrganization, on_delete=models.CASCADE,
verbose_name=_("Tierschutzorganisation"))
rescues_organization = models.ForeignKey(RescueOrganization, on_delete=models.CASCADE,
verbose_name=_("Tierschutzorganisation"))
url = models.URLField(verbose_name=_("Tierartspezifische URL"))
class PlatformChoices(models.TextChoices):
FEDIVERSE = "fediverse", _("Fediverse")
class SocialMediaPost(models.Model):
created_at = models.DateField(verbose_name=_('Erstellt am'), default=timezone.now)
platform = models.CharField(max_length=255, verbose_name=_("Social Media Platform"),
choices=PlatformChoices.choices)
adoption_notice = models.ForeignKey(AdoptionNotice, on_delete=models.CASCADE, verbose_name=_('Vermittlung'))
url = models.URLField(verbose_name=_("URL"))
@staticmethod
def get_an_to_post():
adoption_notices_without_post = AdoptionNotice.objects.filter(socialmediapost__isnull=True,
adoption_notice_status__in=AdoptionNoticeStatusChoices.Active.values)
return adoption_notices_without_post.first()
def __str__(self):
return f"{self.platform} - {self.adoption_notice}"

View File

@@ -1,23 +1,19 @@
from django.db.models.signals import post_save
from django.dispatch import receiver
from fellchensammlung.models import Notification, User, TrustLevel, RescueOrganization, \
NotificationTypeChoices
from fellchensammlung.models import BaseNotification, CommentNotification, User, TrustLevel
from .tasks import task_send_notification_email
from notfellchen.settings import host
from django.utils.translation import gettext_lazy as _
@receiver(post_save, sender=Notification)
def base_notification_receiver(sender, instance: Notification, created: bool, **kwargs):
if not created or not instance.user_to_notify.email_notifications:
return
else:
task_send_notification_email.delay(instance.pk)
@receiver(post_save, sender=CommentNotification)
def comment_notification_receiver(sender, instance: BaseNotification, created: bool, **kwargs):
base_notification_receiver(sender, instance, created, **kwargs)
@receiver(post_save, sender=RescueOrganization)
def rescue_org_receiver(sender, instance: RescueOrganization, created: bool, **kwargs):
if instance.location:
@receiver(post_save, sender=BaseNotification)
def base_notification_receiver(sender, instance: BaseNotification, created: bool, **kwargs):
if not created or not instance.user.email_notifications:
return
else:
task_send_notification_email.delay(instance.pk)
@@ -37,9 +33,5 @@ def notification_new_user(sender, instance: User, created: bool, **kwargs):
link_text = f"Um alle Details zu sehen, geh bitte auf: {user_url}"
body_text = new_user_text + user_detail_text + link_text
for moderator in User.objects.filter(trust_level__gt=TrustLevel.MODERATOR):
notification = Notification.objects.create(title=subject,
text=body_text,
notification_type=NotificationTypeChoices.NEW_USER,
user_to_notify=moderator,
user_related=instance)
notification = BaseNotification.objects.create(title=subject, text=body_text, user=moderator)
notification.save()

View File

@@ -1,29 +0,0 @@
from django.utils.html import strip_tags
from django_registration.backends.activation.views import RegistrationView
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from django.conf import settings
class HTMLMailRegistrationView(RegistrationView):
def send_activation_email(self, user):
"""
overwrites the function in django registration
"""
activation_key = self.get_activation_key(user)
context = self.get_email_context(activation_key)
context["user"] = user
subject = render_to_string(
template_name=self.email_subject_template,
context=context,
request=self.request,
)
# Force subject to a single line to avoid header-injection issues.
subject = "".join(subject.splitlines())
message = render_to_string(
template_name=self.email_body_template,
context=context,
request=self.request,
)
plain_message = strip_tags(message)
user.email_user(subject, plain_message, settings.DEFAULT_FROM_EMAIL, html_message=message)

View File

@@ -1,45 +0,0 @@
from django.contrib.sitemaps import Sitemap
from django.urls import reverse
from .models import AdoptionNotice, RescueOrganization, ImportantLocation, Animal
class StaticViewSitemap(Sitemap):
priority = 0.8
changefreq = "weekly"
def items(self):
return ["index", "search", "map", "about", "rescue-organizations", "buying", "imprint", "terms-of-service",
"privacy"]
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 RescueOrganizationSitemap(Sitemap):
priority = 0.3
changefreq = "weekly"
def items(self):
return RescueOrganization.objects.all()
def lastmod(self, obj):
return obj.updated_at
class SearchSitemap(Sitemap):
priority = 0.5
chanfreq = "daily"
def items(self):
return ImportantLocation.objects.all()

View File

@@ -1,363 +0,0 @@
$primary: #6CD4FF;
$link: #292a2c;
$grey-light: #c4c6ce;
$grey-dark: #262728;
$confirm: hsl(133deg, 100%, calc(41% + 0%));
// Path to Bulma's sass folder
@use "bulma/sass" with (
$family-primary: '"Nunito", sans-serif',
$grey-dark: $grey-dark,
$grey-light: $grey-light,
$primary: $primary,
$link: $link,
$control-border-width: 2px,
$input-shadow: none
);
@use "bulma/sass/utilities/css-variables" as cv;
@include cv.system-theme($name: "dark") {
.navbar-item > img {
background-color: $grey-light !important;
border-radius: 5px;
}
.card-header {
background-color: $grey-dark;
}
a.card-footer-item.is-danger {
color: black;
}
.tag {
color: $grey-dark;
background-color: $grey-light;
}
}
// General Styles
.main-content {
margin: auto;
max-width: 80em;
padding: 10px;
}
p > a {
text-decoration: underline;
word-break: break-all;
}
p > a.button {
text-decoration: none;
}
// Cards
.card-header {
background-color: $primary;
}
// Search form suggestion dropdown
#location-result-list {
display: inline; //ensures that the dropdown is not restricted in width WTF
}
.result-item {
cursor: pointer;
}
.result-item:hover {
background-color: #b2aaaa;
}
// Toggle switch
.toggle-switch {
display: inline-block;
background: #ccc;
border-radius: 16px;
width: 58px;
height: 32px;
position: relative;
vertical-align: middle;
transition: background 0.25s;
}
.toggle-switch:before, .toggle-switch:after {
content: "";
}
.toggle-switch:before {
display: block;
background: linear-gradient(to bottom, #fff 0%, #eee 100%);
border-radius: 50%;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.25);
width: 24px;
height: 24px;
position: absolute;
top: 4px;
left: 4px;
transition: left 0.25s;
}
.toggle:hover .toggle-switch:before {
background: linear-gradient(to bottom, #fff 0%, #fff 100%);
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.5);
}
.checked + .toggle-switch {
background: #56c080;
}
.checked + .toggle-switch:before {
left: 30px;
}
.toggle-checkbox {
position: absolute;
visibility: hidden;
}
.slider-label {
margin-left: 5px;
position: relative;
top: 2px;
}
// Button in card footer
.card-footer {
overflow: hidden;
}
.card-footer .card-footer-item.is-confirm {
background-color: $confirm;
}
.card-footer .card-footer-item.is-confirm:hover {
filter: brightness(0.9);
}
.card-footer .card-footer-item.is-danger {
background-color: sass.$danger;
}
.card-footer .card-footer-item.is-danger:hover {
filter: brightness(0.9);
}
.card-footer .card-footer-item.is-warning {
background-color: sass.$warning;
}
.card-footer .card-footer-item.is-warning:hover {
filter: brightness(0.9);
}
/*******/
/* 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');
}
.map-in-content #map {
max-height: 500px;
width: 90%;
}
@media only screen and (min-width: 768px) {
.maplibregl-popup {
max-width: 280px !important;
}
}
@media only screen and (max-width: 768px) {
.maplibregl-popup {
max-width: 150px !important;
}
}
.maplibregl-popup-close-button {
all: unset; /* Remove all inherited styles */
font-size: 1.2rem;
background: none;
border: none;
color: black;
cursor: pointer;
position: absolute;
top: 0.25rem;
right: 0.5rem;
padding: 0.25rem;
}
.popup-content {
line-height: 1.4;
}
/*****
IMAGES
*****/
.gallery .main-photo img {
width: 100%;
height: 150px;
object-fit: cover; /* Crops the images */
border-radius: 6px;
}
.thumbnail-row {
display: flex;
gap: 10px;
margin-top: 10px;
}
.thumbnail img {
width: 100%;
height: 70px;
object-fit: cover; /* Crops the images */
border-radius: 4px;
}
/* Ensure each thumbnail takes equal width */
.thumbnail {
flex: 1;
}
/**
AN Cards
*/
.an-card {
width: 100%;
height: 100%;
}
// Fonts
@font-face {
font-family: 'Nunito';
font-style: normal;
font-weight: 400;
src: url(../fonts/nunito.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
.new-animal-ad fieldset {
border-top: 4px solid var(--bulma-text-weak);
margin-top: 2em;
padding-top: 1em;
}
.new-animal-ad * {
transition: all ease 0.5s;
}
.new-animal-ad .open {
display: block;
}
.new-animal-ad .closed {
display: none;
overflow: hidden;
}
.new-animal-ad legend {
font-weight: bold;
padding-right: 0.2em;
color: var(--bulma-label-color);
font-size: 130%;
}
.feedback-backdrop {
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
background-color: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
}
.feedback-add-new {
width: 40ch;
min-height: 40ch;
padding: 1.5em;
background-color: var(--bulma-info-on-scheme);
color: black;
}
.feedback-add-new.error {
background-color: var(--bulma-danger-on-scheme);
}
.feedback-add-new.success {
background-color: var(--bulma-success-on-scheme);
}
.notification-container {
display: inline-block;
position: relative;
padding: 0;
}
.notification-label {
padding: 2px 5px;
}
/* Make the badge float in the top right corner of the button */
.notification-badge {
background-color: #fa3e3e;
border-radius: 2px;
color: white;
padding: 1px 3px;
font-size: 8px;
position: absolute; /* Position the badge within the relatively positioned button */
top: 0;
right: 0;
}
/* Embedding Specifics */
.embed-main-content {
padding: 20px 10px 20px 10px;
}
// FLOATING BUTTON
.floating {
position: fixed;
border-radius: 0.3rem;
bottom: 4.5rem;
right: 1rem;
}

View File

@@ -1,420 +0,0 @@
/*! 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

@@ -1,15 +1,27 @@
:root {
--primary: #6CD4FF;
--link: #292a2c;
--grey-light: #c4c6ce;
--grey-dark: #262728;
--primary-light-one: #5daa68;
--primary-light-two: #4a9455;
--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);
--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);
}
body {
display: flex;
flex-direction: column;
background-color: hsl(221, 14%, 100%)r;
color: #000000;
background-color: var(--background-one);
color: var(--text-one);
margin: 0;
height: 100%;
}
@@ -20,22 +32,24 @@ body {
alert-box {
color: var(--highlight-one);
display: block;
margin: 3rem 0;
padding: 2rem 3rem;
border: 1px solid var(--highlight-one);
border-left-width: .5rem;
border-radius: .4rem;
background-color: var(--primary);
background-color: var(--background-three);
a {
text-decoration: underline;
color: var(--text-three);
text-decoration: none;
}
}
a {
color: inherit;
text-decoration: none;
color: var(--link);
}
@@ -51,7 +65,7 @@ a {
margin: 1rem;
padding: 5px;
border-radius: .4rem;
border: 3px solid var(--primary);
background-color: var(--background-one);
}
.post-summary h1 {
@@ -65,7 +79,8 @@ a {
}
.navigation-sticky {
background-color: var(--primary);
background-color: var(--secondary-light-one);
color: var(--primary-light-one);
padding: 16px;
margin: 0;
border-bottom-right-radius: 8px;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 546 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

View File

@@ -19,6 +19,3 @@ function geojson_to_searchable_string(location) {
return ifdef(location.properties.name, "", ", ") + ifdef(location.properties.street, "", ifdef(location.properties.housenumber, " ",", ")) + ifdef(location.properties.city, "", ", ") + ifdef(location.properties.country, "", "")
}
function truncate(str, n, url){
return (str.length > n) ? str.slice(0, n-1) + '<a href="' + url + '">&hellip;</a>' : str;
};

File diff suppressed because one or more lines are too long

View File

@@ -1,11 +0,0 @@
/* mousetrap v1.6.5 craig.is/killing/mice */
(function(q,u,c){function v(a,b,g){a.addEventListener?a.addEventListener(b,g,!1):a.attachEvent("on"+b,g)}function z(a){if("keypress"==a.type){var b=String.fromCharCode(a.which);a.shiftKey||(b=b.toLowerCase());return b}return n[a.which]?n[a.which]:r[a.which]?r[a.which]:String.fromCharCode(a.which).toLowerCase()}function F(a){var b=[];a.shiftKey&&b.push("shift");a.altKey&&b.push("alt");a.ctrlKey&&b.push("ctrl");a.metaKey&&b.push("meta");return b}function w(a){return"shift"==a||"ctrl"==a||"alt"==a||
"meta"==a}function A(a,b){var g,d=[];var e=a;"+"===e?e=["+"]:(e=e.replace(/\+{2}/g,"+plus"),e=e.split("+"));for(g=0;g<e.length;++g){var m=e[g];B[m]&&(m=B[m]);b&&"keypress"!=b&&C[m]&&(m=C[m],d.push("shift"));w(m)&&d.push(m)}e=m;g=b;if(!g){if(!p){p={};for(var c in n)95<c&&112>c||n.hasOwnProperty(c)&&(p[n[c]]=c)}g=p[e]?"keydown":"keypress"}"keypress"==g&&d.length&&(g="keydown");return{key:m,modifiers:d,action:g}}function D(a,b){return null===a||a===u?!1:a===b?!0:D(a.parentNode,b)}function d(a){function b(a){a=
a||{};var b=!1,l;for(l in p)a[l]?b=!0:p[l]=0;b||(x=!1)}function g(a,b,t,f,g,d){var l,E=[],h=t.type;if(!k._callbacks[a])return[];"keyup"==h&&w(a)&&(b=[a]);for(l=0;l<k._callbacks[a].length;++l){var c=k._callbacks[a][l];if((f||!c.seq||p[c.seq]==c.level)&&h==c.action){var e;(e="keypress"==h&&!t.metaKey&&!t.ctrlKey)||(e=c.modifiers,e=b.sort().join(",")===e.sort().join(","));e&&(e=f&&c.seq==f&&c.level==d,(!f&&c.combo==g||e)&&k._callbacks[a].splice(l,1),E.push(c))}}return E}function c(a,b,c,f){k.stopCallback(b,
b.target||b.srcElement,c,f)||!1!==a(b,c)||(b.preventDefault?b.preventDefault():b.returnValue=!1,b.stopPropagation?b.stopPropagation():b.cancelBubble=!0)}function e(a){"number"!==typeof a.which&&(a.which=a.keyCode);var b=z(a);b&&("keyup"==a.type&&y===b?y=!1:k.handleKey(b,F(a),a))}function m(a,g,t,f){function h(c){return function(){x=c;++p[a];clearTimeout(q);q=setTimeout(b,1E3)}}function l(g){c(t,g,a);"keyup"!==f&&(y=z(g));setTimeout(b,10)}for(var d=p[a]=0;d<g.length;++d){var e=d+1===g.length?l:h(f||
A(g[d+1]).action);n(g[d],e,f,a,d)}}function n(a,b,c,f,d){k._directMap[a+":"+c]=b;a=a.replace(/\s+/g," ");var e=a.split(" ");1<e.length?m(a,e,b,c):(c=A(a,c),k._callbacks[c.key]=k._callbacks[c.key]||[],g(c.key,c.modifiers,{type:c.action},f,a,d),k._callbacks[c.key][f?"unshift":"push"]({callback:b,modifiers:c.modifiers,action:c.action,seq:f,level:d,combo:a}))}var k=this;a=a||u;if(!(k instanceof d))return new d(a);k.target=a;k._callbacks={};k._directMap={};var p={},q,y=!1,r=!1,x=!1;k._handleKey=function(a,
d,e){var f=g(a,d,e),h;d={};var k=0,l=!1;for(h=0;h<f.length;++h)f[h].seq&&(k=Math.max(k,f[h].level));for(h=0;h<f.length;++h)f[h].seq?f[h].level==k&&(l=!0,d[f[h].seq]=1,c(f[h].callback,e,f[h].combo,f[h].seq)):l||c(f[h].callback,e,f[h].combo);f="keypress"==e.type&&r;e.type!=x||w(a)||f||b(d);r=l&&"keydown"==e.type};k._bindMultiple=function(a,b,c){for(var d=0;d<a.length;++d)n(a[d],b,c)};v(a,"keypress",e);v(a,"keydown",e);v(a,"keyup",e)}if(q){var n={8:"backspace",9:"tab",13:"enter",16:"shift",17:"ctrl",
18:"alt",20:"capslock",27:"esc",32:"space",33:"pageup",34:"pagedown",35:"end",36:"home",37:"left",38:"up",39:"right",40:"down",45:"ins",46:"del",91:"meta",93:"meta",224:"meta"},r={106:"*",107:"+",109:"-",110:".",111:"/",186:";",187:"=",188:",",189:"-",190:".",191:"/",192:"`",219:"[",220:"\\",221:"]",222:"'"},C={"~":"`","!":"1","@":"2","#":"3",$:"4","%":"5","^":"6","&":"7","*":"8","(":"9",")":"0",_:"-","+":"=",":":";",'"':"'","<":",",">":".","?":"/","|":"\\"},B={option:"alt",command:"meta","return":"enter",
escape:"esc",plus:"+",mod:/Mac|iPod|iPhone|iPad/.test(navigator.platform)?"meta":"ctrl"},p;for(c=1;20>c;++c)n[111+c]="f"+c;for(c=0;9>=c;++c)n[c+96]=c.toString();d.prototype.bind=function(a,b,c){a=a instanceof Array?a:[a];this._bindMultiple.call(this,a,b,c);return this};d.prototype.unbind=function(a,b){return this.bind.call(this,a,function(){},b)};d.prototype.trigger=function(a,b){if(this._directMap[a+":"+b])this._directMap[a+":"+b]({},a);return this};d.prototype.reset=function(){this._callbacks={};
this._directMap={};return this};d.prototype.stopCallback=function(a,b){if(-1<(" "+b.className+" ").indexOf(" mousetrap ")||D(b,this.target))return!1;if("composedPath"in a&&"function"===typeof a.composedPath){var c=a.composedPath()[0];c!==a.target&&(b=c)}return"INPUT"==b.tagName||"SELECT"==b.tagName||"TEXTAREA"==b.tagName||b.isContentEditable};d.prototype.handleKey=function(){return this._handleKey.apply(this,arguments)};d.addKeycodes=function(a){for(var b in a)a.hasOwnProperty(b)&&(n[b]=a[b]);p=null};
d.init=function(){var a=d(u),b;for(b in a)"_"!==b.charAt(0)&&(d[b]=function(b){return function(){return a[b].apply(a,arguments)}}(b))};d.init();q.Mousetrap=d;"undefined"!==typeof module&&module.exports&&(module.exports=d);"function"===typeof define&&define.amd&&define(function(){return d})}})("undefined"!==typeof window?window:null,"undefined"!==typeof window?document:null);

View File

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

Some files were not shown because too many files have changed in this diff Show More