Compare commits

...

100 Commits
ci ... main

Author SHA1 Message Date
bf54bc5d51 test: fix test by setting trust level of admin user as admin
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2024-11-12 22:44:00 +01:00
93ae172431 test: fix name of AN 2024-11-12 22:39:07 +01:00
03d40a5092 test: Add test for creating a standard user 2024-11-12 22:38:31 +01:00
993f8f9cd2 feat: Allow export of users as CSV 2024-11-12 17:20:07 +01:00
8efc0aad21 feat: Show ANs in admin view of user 2024-11-12 17:19:30 +01:00
3a6e7f5344 feat: Add customizable external site warning to 2024-11-12 17:18:20 +01:00
dac9661d51 feat: Add comments to admin 2024-11-12 13:17:53 +01:00
b9bfa8e359 feat: Add mail to admins when new user registers 2024-11-12 13:12:45 +01:00
d07589464c test: Add basic form test 2024-11-11 13:01:08 +01:00
1880da5151 refactor: blank line 2024-11-11 13:00:57 +01:00
4e953c83ea feat: Add comment field to RescueOrg
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-11-09 10:09:46 +01:00
2212df4729 feat: add sex coding 2024-11-09 10:03:04 +01:00
98d67381c6 feat: mark location with fa icon 2024-11-08 08:49:27 +01:00
e02672c2bb feat: Make sure flag stays in line 2024-11-08 08:11:22 +01:00
c3dd9faa85 feat: Use truncated location 2024-11-08 07:42:34 +01:00
9f977e35c2 feat: Use minimal view for listing ANs on index 2024-11-08 07:35:37 +01:00
3269d5a39a feat: add search for text 2024-11-07 23:16:47 +01:00
d96a44bbdd feat: search case-insensitive 2024-11-07 22:13:55 +01:00
2641b2e7bf feat: add search in admin for ANs 2024-11-07 22:01:21 +01:00
50c1a4f2c6 fix: save timestamp 2024-11-07 21:58:12 +01:00
573630f9ee feat: allow filtering and searching rescue orgs 2024-11-07 21:57:58 +01:00
1a09b7859f feat: add e-mail to rescue organization 2024-11-07 21:41:33 +01:00
70b3ae4bbc docs: fix copyright
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-11-07 21:23:37 +01:00
5eaafe7646 docs: add link 2024-11-07 21:23:28 +01:00
5781b49c7c docs: translate
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-11-07 21:20:21 +01:00
e2f516d409 feat: make external site nicer 2024-11-07 13:02:30 +01:00
ca8996fff6 docs: Expand moderationskonzept
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-11-07 12:33:49 +01:00
eb734d2716 feat: Add about_us text 2024-11-07 07:58:17 +01:00
655e304c6c fix 2024-11-06 23:47:14 +01:00
8e34ed440e test: Test for correct behaviour for further information = None 2024-11-06 23:32:50 +01:00
0c7080f005 feat: Add admin tasks 2024-11-06 23:32:37 +01:00
0b93b5eccb fix: Correct behaviour for further information = None 2024-11-06 23:32:01 +01:00
f1d9f7ad22 test: Add test for deactivate_404_adoption_notices 2024-11-06 08:03:45 +01:00
4e71ac7866 fix: Use field value not html representation 2024-11-06 08:03:32 +01:00
1d0a42a7e1 feat: Add migration for log change 2024-11-06 08:02:59 +01:00
d384e75746 refactor: spacing 2024-11-06 07:52:38 +01:00
70154abd37 feat: Add function to deactivate AN when link returns 404 2024-11-05 07:47:31 +01:00
ab3437e61d test: test function to check if site is up 2024-11-05 07:38:13 +01:00
0ccbb18411 feat: Add function to check if site is up 2024-11-05 07:37:52 +01:00
e6f12ce5b1 test: fix assertion method (not a request return but a list is returned) 2024-11-05 07:35:58 +01:00
6325de17d9 feat: Add updated_at and created at where it makes sense 2024-11-03 21:08:15 +01:00
b9d6293546 test:Add tests for deactivation tasks 2024-11-03 20:36:13 +01:00
dbe52e4884 fix: Deactivate ANs OLDER than three weeks, not newer 2024-11-03 20:35:41 +01:00
3c286d84d8 feat: nicer AN name 2024-11-03 16:37:35 +01:00
227fa4d5a8 refactor: rename 2024-11-02 09:43:02 +01:00
d47f181e1d feat: Make updatequeue parted 2024-11-02 09:42:39 +01:00
272046142e refactor: Move card of AN check to partial 2024-10-30 17:57:58 +01:00
5c18832961 test: Test updatequeue further 2024-10-30 11:18:24 +01:00
d59cc0034a refactor: Adjust status changes 2024-10-30 11:17:57 +01:00
64024be833 feat: Add test case for setting AN as checked 2024-10-29 18:49:02 +01:00
5ef20bdce0 fix: Make sure AN is active 2024-10-29 18:04:42 +01:00
7ddd7b0c0c feat: Test distance calculation 2024-10-29 17:52:07 +01:00
cbd8700917 feat: Test search for location 2024-10-29 17:51:55 +01:00
6eb2f5000f feat: Use Stadt instead of postcode 2024-10-29 17:51:28 +01:00
1cd70228b9 fix: Use timezone data not native datetime 2024-10-29 17:50:53 +01:00
23d8e85031 fix: Use timezone data not native datetime 2024-10-29 17:50:18 +01:00
4fb92d8215 refactor: Remove unused import 2024-10-29 17:49:54 +01:00
6dfc92bf15 fix: Correct import 2024-10-29 17:49:39 +01:00
2015f8b332 feat: Use new shortcuts when creating ANs 2024-10-29 06:53:34 +01:00
66a0b42718 feat: Add basic tests for search 2024-10-29 06:53:02 +01:00
efecfc910d feat: Add shortcut for setting status 2024-10-29 06:52:43 +01:00
96bc44c508 feat: add pytest as development dependency 2024-10-27 17:55:21 +01:00
a2c8f469a7 refactor: format 2024-10-27 17:54:57 +01:00
a98b428614 refactor: delete unused tests.py 2024-10-27 17:54:45 +01:00
dfede77e98 docs: Add moderationskonzept 2024-10-27 17:54:30 +01:00
6702211c05 docs: various refactoring
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-10-27 06:43:10 +01:00
f97e682640 docs: Registrierungen 2024-10-26 08:55:31 +02:00
c1e3248cc8 feat: show all ANs to be checked, not only active ones 2024-10-26 08:52:59 +02:00
0e67b777b5 fix: adoption form 2024-10-26 08:52:38 +02:00
0435c427b3 fix: Detail Animal form 2024-10-26 08:52:23 +02:00
be2df6970a fix: search template 2024-10-26 07:43:04 +02:00
1f5e7856b1 fix: url name 2024-10-19 20:38:24 +02:00
793de1ec64 fix: Add migration 2024-10-19 20:37:32 +02:00
6844e771b5 feat: Add timestamps to instance health check 2024-10-19 19:46:11 +02:00
1282b6b201 feat: Log tasks 2024-10-19 19:45:42 +02:00
975de1a230 fix: make sure anonymous users can look at ans 2024-10-19 17:45:26 +02:00
fd3478600f feat: exchange docs picture
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-10-14 21:46:50 +02:00
4d2991ba2f feat: Add basic logs 2024-10-10 23:21:07 +02:00
5aaaf57dd4 docs: document healthchecks 2024-10-10 23:19:34 +02:00
766b19e7c2 fix: comment action 2024-10-10 23:19:17 +02:00
f660a6b49a fix: varname 2024-10-10 22:26:28 +02:00
ab0c1a5c46 refactor: make healthchekscheck hourly 2024-10-10 22:24:55 +02:00
74a6b5f2aa feat: Add healthcheck 2024-10-10 18:35:22 +02:00
e38234b736 docs: Add basics on Vermittlungen
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-10-10 17:10:17 +02:00
ce38002676 chore: bump version 2024-10-10 17:09:33 +02:00
314cdfdd7c fix: Add missing celery file 2024-10-10 17:07:58 +02:00
4504a18f60 feat: add celery task to deactivate unchecked 2024-10-10 17:07:11 +02:00
df41028e99 feat: Add unchecked AN cleanup to health check 2024-10-10 17:06:50 +02:00
f404cfa0a3 feat: Add unchecked as AN status 2024-10-10 17:06:29 +02:00
72dedb6b0c fix: Build psycopg2 from source, pin python minor version 2024-10-10 14:33:18 +02:00
17468097ec fix: Add tag 2024-10-10 07:39:53 +02:00
28331f105a feat: Use celery for location queries 2024-10-10 07:39:44 +02:00
39893c2185 feat: Add basic celery config 2024-10-09 21:54:31 +02:00
ab2b91735e feat: Set title per page 2024-10-09 20:46:50 +02:00
1b9574cca9 refactor: Reorder 2024-10-05 13:26:32 +02:00
0d52101f22 feat: Add basic redirect service 2024-10-05 11:22:10 +02:00
96c0c1218f refactor: identation 2024-10-05 11:05:50 +02:00
864c76bc21 feat: Add domain customtag 2024-10-05 11:05:33 +02:00
c3646e6334 fix: typo 2024-10-05 11:02:37 +02:00
83a219df0c refactor: Add staticmethod to get a number of texts 2024-10-05 11:02:26 +02:00
64 changed files with 1606 additions and 369 deletions

View File

@ -1,10 +1,12 @@
FROM python:3-slim FROM python:3.11-slim
# Use 3.11 to avoid django.core.exceptions.ImproperlyConfigured: Error loading psycopg2 or psycopg module
MAINTAINER Julian-Samuel Gebühr MAINTAINER Julian-Samuel Gebühr
ENV DOCKER_BUILD=true ENV DOCKER_BUILD=true
RUN apt update RUN apt update
RUN apt install gettext -y RUN apt install gettext -y
RUN apt install libpq-dev gcc -y
COPY . /app COPY . /app
WORKDIR /app WORKDIR /app
RUN mkdir /app/data RUN mkdir /app/data

View File

@ -45,12 +45,14 @@ There is a system for customizing texts in Notfellchen. Not every change of a te
Therefore, a solution is used where a number of predefined texts per site are supported. These markdown texts will then be included in the site, if defined. Therefore, a solution is used where a number of predefined texts per site are supported. These markdown texts will then be included in the site, if defined.
| Textcode | Location | | Textcode | Location |
|---------------------|----------| |-------------------------|-----------------------|
| `how_to` | Index | | `how_to` | Index |
| `introduction` | Index | | `introduction` | Index |
| `privacy_statement` | About | | `privacy_statement` | About |
| `terms_of_service` | About | | `terms_of_service` | About |
| `imprint` | About | | `imprint` | About |
| `about_us` | About |
| `external_site_warning` | External Site Warning |
| Any rule | About | | Any rule | About |
# Developer Notes # Developer Notes
@ -106,3 +108,20 @@ Use a program like `gtranslator` or `poedit` to start translations
| Edit adoption notice | User that created, Moderator, Admin | | Edit adoption notice | User that created, Moderator, Admin |
| Edit animal | User that created, Moderator, Admin | | Edit animal | User that created, Moderator, Admin |
| Add animal/photo to adoption notice | User that created, Moderator, Admin | | Add animal/photo to adoption notice | User that created, Moderator, Admin |
# Celery and KeyDB
Start KeyDB docker container
```zsh
docker run -d --name keydb -p 6379:6379 eqalpha/keydb
```
Start worker
```zsh
celery -A notfellchen.celery worker
```
Start beat
```zsh
celery -A notfellchen.celery beat
```

View File

@ -4,9 +4,5 @@ Administration
:maxdepth: 2 :maxdepth: 2
:caption: Contents: :caption: Contents:
create_user.rst GDPR.rst
lending.rst
returning.rst
opening_hours.rst
add_items.rst
monitoring.rst monitoring.rst

View File

@ -1,7 +1,7 @@
Monitoring Monitoring
========== ==========
ILMO should, like every other software, be easy to monitor. Therefore a basic metrics are exposed to `https://notfellchen.org/metrics`. Notfellchen should, like every other software, be easy to monitor. Therefore a basic metrics are exposed to `https://notfellchen.org/metrics`.
The data is encoded in JSON format and is therefore suitable to bea read by humans and it is easy to use it as data source for further processing. The data is encoded in JSON format and is therefore suitable to bea read by humans and it is easy to use it as data source for further processing.
@ -60,3 +60,12 @@ Now we can simply use the InfluxDB as data source in Grafana and configure until
beautiful plots! beautiful plots!
.. image:: monitoring_grafana.png .. image:: monitoring_grafana.png
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

@ -1,10 +0,0 @@
Opening hours
=============
The opening hours can be changed by selecting the page :guilabel:`Opening hours` in the navigation menu.
You can not change an entry, simply delete it and create a new one.
.. note::
It is advised to fill empty time cells with a "-".

View File

@ -1,8 +0,0 @@
Returning
=========
To return an item either visit the page :guilabel:`All loans` and search
for the loan there or you search for the item via :guilabel:`Search`.
If you found the loan, you can simply click on the button :guilabel:`Return` and
you are finished.

View File

@ -20,7 +20,7 @@
# -- Project information ----------------------------------------------------- # -- Project information -----------------------------------------------------
project = 'Notfellchen' project = 'Notfellchen'
copyright = 'Julian-Samuel Gebühr' copyright = 'CC-BY-SA Julian-Samuel Gebühr'
author = 'Julian-Samuel Gebühr' author = 'Julian-Samuel Gebühr'
# The short X.Y version # The short X.Y version

View File

@ -4,16 +4,16 @@
Deployment Deployment
********** **********
There are different ways to deploy ILMO. We support an ansible+docker based deployment and manual installation. There are different ways to deploy Notfellchen. We support an ansible+docker based deployment and manual installation.
Ansible deployment Ansible deployment
================== ==================
ILMO can be deployed with the `ilmo-ansible-role <https://github.com/moan0s/ansible-role-ilmo>`_ that is based on the Notfellchen can be deployed with the `notfellchen-ansible-role <https://github.com/moan0s/ansible-role-notfellchen>`_ that is based on the
official ILMO docker image. This role will only install ilmo itself. If you want a complete setup that includes a official Notfellchen docker image. This role will only install notfellchen itself. If you want a complete setup that includes a
database and a webserver with minimal configuration you can use the database and a webserver with minimal configuration you can use the
`mash-playbook <https://github.com/mother-of-all-self-hosting/mash-playbook>`_ by following `it's documentation `mash-playbook <https://github.com/mother-of-all-self-hosting/mash-playbook>`_ by following `it's documentation
on ILMO <https://github.com/mother-of-all-self-hosting/mash-playbook/blob/main/docs/services/ilmo.md>`_. on Notfellchen <https://github.com/mother-of-all-self-hosting/mash-playbook/blob/main/docs/services/notfellchen.md>`_.
@ -21,10 +21,10 @@ Manual Deployment
================= =================
This guide describes the installation of a installation of ILMO from source. It is inspired by this great guide from This guide describes the installation of a installation of Notfellchen from source. It is inspired by this great guide from
pretix_. pretix_.
.. warning:: Even though this guide tries to make it as straightforward to run ILMO, it still requires some Linux experience to .. warning:: Even though this guide tries to make it as straightforward to run Notfellchen, it still requires some Linux experience to
get it right. If you're not feeling comfortable managing a Linux server, check out a managed service_. get it right. If you're not feeling comfortable managing a Linux server, check out a managed service_.
This guide is tested on **Ubuntu20.04** but it should work very similar on other modern systemd based distributions. This guide is tested on **Ubuntu20.04** but it should work very similar on other modern systemd based distributions.
@ -39,18 +39,18 @@ installation guides):
* A HTTP reverse proxy, e.g. `nginx`_ or Traefik to allow HTTPS connections * A HTTP reverse proxy, e.g. `nginx`_ or Traefik to allow HTTPS connections
* A `PostgreSQL`_ database server * A `PostgreSQL`_ database server
Also recommended is, that you use a firewall, although this is not a ILMO-specific recommendation. If you're new to Also recommended is, that you use a firewall, although this is not a Notfellchen-specific recommendation. If you're new to
Linux and firewalls, it is recommended that you start with `ufw`_. Linux and firewalls, it is recommended that you start with `ufw`_.
.. note:: Please, do not run ILMO without HTTPS encryption. You'll handle user data and thanks to `Let's Encrypt`_ .. note:: Please, do not run Notfellchen without HTTPS encryption. You'll handle user data and thanks to `Let's Encrypt`_
SSL certificates can be obtained for free these days. SSL certificates can be obtained for free these days.
Unix user Unix user
--------- ---------
As we do not want to run ilmo as root, we first create a new unprivileged user:: As we do not want to run notfellchen as root, we first create a new unprivileged user::
# adduser ilmo --disabled-password --home /var/ilmo # adduser notfellchen --disabled-password --home /var/notfellchen
In this guide, all code lines prepended with a ``#`` symbol are commands that you need to execute on your server as In this guide, all code lines prepended with a ``#`` symbol are commands that you need to execute on your server as
``root`` user (e.g. using ``sudo``); all lines prepended with a ``$`` symbol should be run by the unprivileged user. ``root`` user (e.g. using ``sudo``); all lines prepended with a ``$`` symbol should be run by the unprivileged user.
@ -66,16 +66,16 @@ best compatibility. You can check this with the following command::
For PostgreSQL database creation, we would do:: For PostgreSQL database creation, we would do::
# sudo -u postgres createuser ilmo # sudo -u postgres createuser notfellchen
# sudo -u postgres createdb -O ilmo ilmo # sudo -u postgres createdb -O notfellchen notfellchen
# su ilmo # su notfellchen
$ psql $ psql
> ALTER USER ilmo PASSWORD 'strong_password'; > ALTER USER notfellchen PASSWORD 'strong_password';
Package dependencies Package dependencies
-------------------- --------------------
To build and run ilmo, you will need the following debian packages:: To build and run notfellchen, you will need the following debian packages::
# apt-get install git build-essential python-dev python3-venv python3 python3-pip \ # apt-get install git build-essential python-dev python3-venv python3 python3-pip \
python3-dev python3-dev
@ -83,32 +83,32 @@ To build and run ilmo, you will need the following debian packages::
Config file Config file
----------- -----------
We now create a config directory and config file for ilmo:: We now create a config directory and config file for notfellchen::
# mkdir /etc/ilmo # mkdir /etc/notfellchen
# touch /etc/ilmo/ilmo.cfg # touch /etc/notfellchen/notfellchen.cfg
# chown -R ilmo:ilmo /etc/ilmo/ # chown -R notfellchen:notfellchen /etc/notfellchen/
# chmod 0600 /etc/ilmo/ilmo.cfg # chmod 0600 /etc/notfellchen/notfellchen.cfg
Fill the configuration file ``/etc/ilmo/ilmo.cfg`` with the following content (adjusted to your environment):: Fill the configuration file ``/etc/notfellchen/notfellchen.cfg`` with the following content (adjusted to your environment)::
[ilmo] [notfellchen]
instance_name=My library instance_name=My library
url=https://ilmo.example.com url=https://notfellchen.example.com
[database] [database]
backend=postgresql backend=postgresql
name=ilmo name=notfellchen
user=ilmo user=notfellchen
[locations] [locations]
static=/var/ilmo/static static=/var/notfellchen/static
[mail] [mail]
; See config file documentation for more options ; See config file documentation for more options
; from=ilmo@example.com ; from=notfellchen@example.com
; host=127.0.0.1 ; host=127.0.0.1
; user=ilmo ; user=notfellchen
; password=foobar ; password=foobar
; port=587 ; port=587
@ -121,21 +121,21 @@ Fill the configuration file ``/etc/ilmo/ilmo.cfg`` with the following content (a
;Scope= ;Scope=
;Policy= ;Policy=
Install ilmo as package Install notfellchen as package
------------------------ ------------------------
Now we will install ilmo itself. The following steps are to be executed as the ``ilmo`` user. Before we Now we will install notfellchen itself. The following steps are to be executed as the ``notfellchen`` user. Before we
actually install ilmo, we will create a virtual environment to isolate the python packages from your global actually install notfellchen, we will create a virtual environment to isolate the python packages from your global
python installation:: python installation::
$ python3 -m venv /var/ilmo/venv $ python3 -m venv /var/notfellchen/venv
$ source /var/ilmo/venv/bin/activate $ source /var/notfellchen/venv/bin/activate
(venv)$ pip3 install -U pip setuptools wheel (venv)$ pip3 install -U pip setuptools wheel
We now clone and install ilmo, its direct dependencies and gunicorn:: We now clone and install notfellchen, its direct dependencies and gunicorn::
(venv)$ git clone https://github.com/moan0s/ILMO2 (venv)$ git clone https://github.com/moan0s/Notfellchen2
(venv)$ cd ILMO2/src/ (venv)$ cd Notfellchen2/src/
(venv)$ pip3 install -r requirements.txt (venv)$ pip3 install -r requirements.txt
(venv)$ pip3 install -e . (venv)$ pip3 install -e .
@ -148,26 +148,26 @@ Finally, we compile static files and create the database structure::
(venv)$ django-admin compilemessages --ignore venv (venv)$ django-admin compilemessages --ignore venv
Start ilmo as a service Start notfellchen as a service
------------------------- -------------------------
You should start ilmo using systemd to automatically start it after a reboot. Create a file You should start notfellchen using systemd to automatically start it after a reboot. Create a file
named ``/etc/systemd/system/ilmo-web.service`` with the following content:: named ``/etc/systemd/system/notfellchen-web.service`` with the following content::
[Unit] [Unit]
Description=ilmo web service Description=notfellchen web service
After=network.target After=network.target
[Service] [Service]
User=ilmo User=notfellchen
Group=ilmo Group=notfellchen
Environment="VIRTUAL_ENV=/var/ilmo/venv" Environment="VIRTUAL_ENV=/var/notfellchen/venv"
Environment="PATH=/var/ilmo/venv/bin:/usr/local/bin:/usr/bin:/bin" Environment="PATH=/var/notfellchen/venv/bin:/usr/local/bin:/usr/bin:/bin"
ExecStart=/var/ilmo/venv/bin/gunicorn ilmo.wsgi \ ExecStart=/var/notfellchen/venv/bin/gunicorn notfellchen.wsgi \
--name ilmo --workers 5 \ --name notfellchen --workers 5 \
--max-requests 1200 --max-requests-jitter 50 \ --max-requests 1200 --max-requests-jitter 50 \
--log-level=info --bind=127.0.0.1:8345 --log-level=info --bind=127.0.0.1:8345
WorkingDirectory=/var/ilmo WorkingDirectory=/var/notfellchen
Restart=on-failure Restart=on-failure
[Install] [Install]
@ -176,14 +176,14 @@ named ``/etc/systemd/system/ilmo-web.service`` with the following content::
You can now run the following commands to enable and start the services:: You can now run the following commands to enable and start the services::
# systemctl daemon-reload # systemctl daemon-reload
# systemctl enable ilmo-web # systemctl enable notfellchen-web
# systemctl start ilmo-web # systemctl start notfellchen-web
SSL SSL
--- ---
The following snippet is an example on how to configure a nginx proxy for ilmo:: The following snippet is an example on how to configure a nginx proxy for notfellchen::
server { server {
listen 80; listen 80;
@ -196,8 +196,8 @@ The following snippet is an example on how to configure a nginx proxy for ilmo::
# #
listen 443 ssl; listen 443 ssl;
listen [::]:443 ssl; listen [::]:443 ssl;
ssl_certificate /etc/letsencrypt/live/ilmo.example.com/cert.pem; ssl_certificate /etc/letsencrypt/live/notfellchen.example.com/cert.pem;
ssl_certificate_key /etc/letsencrypt/live/ilmo.example.com/privkey.pem; ssl_certificate_key /etc/letsencrypt/live/notfellchen.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3; ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5; ssl_ciphers HIGH:!aNULL:!MD5;
@ -208,7 +208,7 @@ The following snippet is an example on how to configure a nginx proxy for ilmo::
add_header Referrer-Policy same-origin; add_header Referrer-Policy same-origin;
add_header X-Content-Type-Options nosniff; add_header X-Content-Type-Options nosniff;
server_name ilmo.example.com; server_name notfellchen.example.com;
location / { location / {
proxy_pass http://localhost:8345; proxy_pass http://localhost:8345;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@ -217,7 +217,7 @@ The following snippet is an example on how to configure a nginx proxy for ilmo::
} }
location /static/ { location /static/ {
alias /var/ilmo/static/; alias /var/notfellchen/static/;
access_log off; access_log off;
expires 365d; expires 365d;
add_header Cache-Control "public"; add_header Cache-Control "public";
@ -230,22 +230,22 @@ We recommend reading about setting `strong encryption settings`_ for your web se
Next steps Next steps
---------- ----------
Yay, you are done! You should now be able to reach ilmo at https://ilmo.example.com/ Yay, you are done! You should now be able to reach notfellchen at https://notfellchen.example.com/
Updates Updates
------- -------
.. warning:: While we try hard not to break things, **please perform a backup before every upgrade**. .. warning:: While we try hard not to break things, **please perform a backup before every upgrade**.
To upgrade to a new ilmo release, pull the latest code changes and run the following commands:: To upgrade to a new notfellchen release, pull the latest code changes and run the following commands::
$ source /var/ilmo/venv/bin/activate $ source /var/notfellchen/venv/bin/activate
(venv)$ git pull (venv)$ git pull
(venv)$ pg_dump ilmo > ilmo.psql (venv)$ pg_dump notfellchen > notfellchen.psql
(venv)$ python manage.py migrate (venv)$ python manage.py migrate
(venv)$ django-admin compilemessages --ignore venv (venv)$ django-admin compilemessages --ignore venv
# systemctl restart ilmo-web # systemctl restart notfellchen-web
.. _Postfix: https://www.digitalocean.com/community/tutorials/how-to-install-and-configure-postfix-as-a-send-only-smtp-server-on-ubuntu-16-04 .. _Postfix: https://www.digitalocean.com/community/tutorials/how-to-install-and-configure-postfix-as-a-send-only-smtp-server-on-ubuntu-16-04

View File

@ -8,5 +8,6 @@ Installation, customization and contributing
deployment.rst deployment.rst
contributing.rst contributing.rst
translation.rst
release.rst release.rst
backup.rst backup.rst

View File

@ -12,10 +12,7 @@ Notfellchen Plattform Dokumentation
API/index.rst API/index.rst
.. image:: rtfm.png .. image:: rtfm.png
:name: RTFM by Elektroll :name: Ratte lesend
:scale: 50 % :alt: Zeichnung einer lesenden Ratte
:alt: Soviet style image of workers holding a sign with a gear and a screwdriver. Below is says "Read the manual"
:align: center :align: center
Read the manual, Image by `Mike Powell (CC-BY) <https://elektroll.art/>`_.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 815 KiB

After

Width:  |  Height:  |  Size: 485 KiB

BIN
docs/user/abonnieren.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View File

@ -1,9 +1,19 @@
Benachrichtigungen Benachrichtigungen
================== ==================
Ersteller*innen von Vermittlungen werden über neue Kommentare per Mail benachrichtigt, ebenso alle die die Vermittlung abonniert haben.
Jede Vermittlung kann abonniert werden. Dafür klickst du auf die Glocke neben dem Titel der Vermittlung.
.. image:: abonnieren.png
Auf der Website
+++++++++++++++
E-Mail E-Mail
++++++ ++++++
Mit während deiner :doc:`registrierung` gibst du eine E-Mail Addresse an. Mit während deiner :doc:`registrierung` gibst du eine E-Mail Addresse an.
Wir senden dir Benachrichtigungen an diese E-Mail. Du kannst das in deinen Profileinstellungen anpassen.
Benachrichtigungen senden wir per Mail - du kannst das jederzeit in den Einstellungen deaktivieren.

View File

@ -1,11 +1,11 @@
*********** ******************
Users guide User Dokumentation
*********** ******************
.. toctree:: .. toctree::
:maxdepth: 2 :maxdepth: 2
:caption: Contents: :caption: Inhalt:
registrierung.rst registrierung.rst
vermittlungen.rst
moderationskonzept.rst
benachrichtigungen.rst benachrichtigungen.rst
login.rst
email.rst

View File

@ -0,0 +1,29 @@
Moderationskonzept
==================
Vertrauen in notfellchen.org ist uns wichtig. Unser Kernziel ist es Tierschutz und Tierwohl zu fördern. Dafür sind drei
Grundkonzepte wichtig
* Aktualität: Informationen auf notfellchen.org müssen aktuell&richtig sein
* Tierschutz: Ausschließlich Ratten aus dem Tierschutz werden vermittelt
* Moderation: Vermittlungen und Kommentare können gemeldet werden und werden vom Team zügig moderiert.
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.
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.
Nach 3 Wochen ohne Prüfung werden Anzeigen automatisch von der Seite entfernt und nur dann wieder freigeschaltet, wenn eine manuelle Prüfung erfolgt.
Darüber hinaus werden einmal täglich die verlinkten Seiten automatisiert geprüft. Wenn eine Vermittlung auf der Website eines Tierheims oder einer Pflegestelle entfernt wird, wird die Anzeige sofort deaktiviert.
Vermittlungen können von allen Menschen, auch ohne Account gemeldet werden. Grund für eine Meldung kann sein, dass Informationen veraltet sind oder ein Verdacht von Tierwohlgefärdung. Gemeldete Vermittlungen werden vom Moderationsteam geprüft und ggf. entfernt.
Kommentare
++++++++++
Die Kommentarfunktion von Vermittlungen ermöglicht es angemeldeten Nutzer*innen zusätzliche Informationen hinzuzufügen oder Fragen zu stellen.
Kommentare können, wie Vermittlungen, gemeldet werden wenn sie nicht den Regeln entsprechen.

View File

@ -1,5 +1,10 @@
Registration Registrierung
================================ ================================
To register you have to visit the library. An librarian will then set up an account for you. Du kannst dich jederzeit selbst registrieren. Das geht unter https://notfellchen.org/accounts/register/
You will need to provide an valid E-Mail Address and a password.
Ein Account ermöglicht es dir
* Kommentare zu hinterlassen
* Vermittlungen hinzuzufügen
* Vermittlungen zu abonnieren

View File

@ -1,3 +1,17 @@
Vermittlungen 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.
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.
Nach 3 Wochen ohne Prüfung werden Anzeigen automatisch von der Seite entfernt und nur dann wieder freigeschaltet, wenn eine manuelle Prüfung erfolgt.
Darüber hinaus werden einmal täglich die verlinkten Seiten automatisiert geprüft. Wenn eine Vermittlungs-Seite bei einem Tierheim oder einer Pflegestelle entfernt wurde, wird die Anzeige ebenfalls deaktiviert.
Vermittlungen können von allen Menschen, auch ohne Account gemeldet werden. Grund dafür kann sein, dass Informationen veraltet sind oder ein Verdacht von Tierwohlgefärdung. Gemeldete Vermittlungen werden vom Moderationsteam geprüft und ggf. entfernt.
Die Kommentarfunktion von Vermittlungen ermöglicht es angemeldeten Nutzer*innen zusätzliche Informationen hinzuzufügen oder Fragen zu stellen.
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.

View File

@ -35,13 +35,20 @@ dependencies = [
"markdown", "markdown",
"Pillow", "Pillow",
"django-registration", "django-registration",
"psycopg2-binary", "psycopg2",
"django-crispy-forms", "django-crispy-forms",
"crispy-bootstrap4", "crispy-bootstrap4",
"djangorestframework" "djangorestframework",
"celery[redis]"
] ]
dynamic = ["version", "readme"] dynamic = ["version", "readme"]
[project.optional-dependencies]
develop = [
"pytest",
]
[project.urls] [project.urls]
homepage = "https://notfellchen.org" homepage = "https://notfellchen.org"
repository = "https://codeberg.org/moanos/notfellchen/" repository = "https://codeberg.org/moanos/notfellchen/"

View File

@ -1,11 +1,16 @@
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin import csv
from django.contrib import admin
from django.utils.html import format_html
from .models import User, Language, Text, ReportComment, ReportAdoptionNotice from django.contrib import admin
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 User, Language, Text, ReportComment, ReportAdoptionNotice, Log, Timestamp
from .models import Animal, Species, RescueOrganization, AdoptionNotice, Location, Rule, Image, ModerationAction, \ from .models import Animal, Species, RescueOrganization, AdoptionNotice, Location, Rule, Image, ModerationAction, \
Comment, Report, Announcement, AdoptionNoticeStatus, User, Subscriptions Comment, Report, Announcement, AdoptionNoticeStatus, User, Subscriptions
from django.utils.translation import gettext_lazy as _
class StatusInline(admin.StackedInline): class StatusInline(admin.StackedInline):
@ -14,14 +19,45 @@ class StatusInline(admin.StackedInline):
@admin.register(AdoptionNotice) @admin.register(AdoptionNotice)
class AdoptionNoticeAdmin(admin.ModelAdmin): class AdoptionNoticeAdmin(admin.ModelAdmin):
search_fields = ("name__icontains", "description__icontains")
list_filter = ("owner",)
inlines = [ inlines = [
StatusInline, StatusInline,
] ]
# Re-register UserAdmin # Re-register UserAdmin
admin.site.register(User) @admin.register(User)
class UserAdmin(admin.ModelAdmin):
search_fields = ("usernamname__icontains", "first_name__icontains", "last_name__icontains", "email__icontains")
list_display = ("username", "email", "trust_level", "is_active", "view_adoption_notices")
list_filter = ("is_active", "trust_level",)
actions = ("export_as_csv",)
def view_adoption_notices(self, obj):
count = obj.adoption_notices.count()
url = (
reverse("admin:fellchensammlung_adoptionnotice_changelist")
+ "?"
+ urlencode({"owner__id": f"{obj.id}"})
)
return format_html('<a href="{}">{} Adoption Notices</a>', url, count)
def export_as_csv(self, request, queryset):
meta = self.model._meta
field_names = [field.name for field in meta.fields]
response = HttpResponse(content_type='text/csv')
response['Content-Disposition'] = 'attachment; filename={}.csv'.format(meta)
writer = csv.writer(response)
writer.writerow(field_names)
for obj in queryset:
row = writer.writerow([getattr(obj, field) for field in field_names])
return response
export_as_csv.short_description = _("Ausgewählte User exportieren")
def _reported_content_link(obj): def _reported_content_link(obj):
reported_content = obj.reported_content reported_content = obj.reported_content
@ -50,15 +86,30 @@ class ReportAdoptionNoticeAdmin(admin.ModelAdmin):
reported_content_link.short_description = "Reported Content" reported_content_link.short_description = "Reported Content"
@admin.register(RescueOrganization)
class RescueOrganizationAdmin(admin.ModelAdmin):
search_fields = ("name__icontains",)
list_display = ("name", "trusted", "allows_using_materials", "website")
list_filter = ("allows_using_materials", "trusted",)
@admin.register(Text)
class TextAdmin(admin.ModelAdmin):
search_fields = ("title__icontains", "text_code__icontains",)
@admin.register(Comment)
class CommentAdmin(admin.ModelAdmin):
list_filter = ("user",)
admin.site.register(Animal) admin.site.register(Animal)
admin.site.register(Species) admin.site.register(Species)
admin.site.register(RescueOrganization)
admin.site.register(Location) admin.site.register(Location)
admin.site.register(Rule) admin.site.register(Rule)
admin.site.register(Image) admin.site.register(Image)
admin.site.register(ModerationAction) admin.site.register(ModerationAction)
admin.site.register(Language) admin.site.register(Language)
admin.site.register(Text)
admin.site.register(Announcement) admin.site.register(Announcement)
admin.site.register(AdoptionNoticeStatus) admin.site.register(AdoptionNoticeStatus)
admin.site.register(Subscriptions) admin.site.register(Subscriptions)
admin.site.register(Log)
admin.site.register(Timestamp)

View File

@ -1,12 +1,10 @@
import datetime
from django import forms from django import forms
from .models import AdoptionNotice, Animal, Image, ReportAdoptionNotice, ReportComment, ModerationAction, User, Species, \ from .models import AdoptionNotice, Animal, Image, ReportAdoptionNotice, ReportComment, ModerationAction, User, Species, \
Comment Comment
from django_registration.forms import RegistrationForm from django_registration.forms import RegistrationForm
from crispy_forms.helper import FormHelper from crispy_forms.helper import FormHelper
from crispy_forms.layout import Submit, Layout, Fieldset, HTML, Row, Column, Field from crispy_forms.layout import Submit, Layout, Fieldset, HTML, Row, Column, Field, Hidden
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from notfellchen.settings import MEDIA_URL from notfellchen.settings import MEDIA_URL
@ -142,8 +140,8 @@ class CommentForm(forms.ModelForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.helper = FormHelper() self.helper = FormHelper()
self.helper.form_action = "comment"
self.helper.form_class = 'form-comments' self.helper.form_class = 'form-comments'
self.helper.add_input(Hidden('action', 'comment'))
self.helper.add_input(Submit('submit', _('Kommentieren'), css_class="btn2")) self.helper.add_input(Submit('submit', _('Kommentieren'), css_class="btn2"))
class Meta: class Meta:
@ -175,5 +173,5 @@ def _get_distances():
class AdoptionNoticeSearchForm(forms.Form): class AdoptionNoticeSearchForm(forms.Form):
postcode = forms.CharField(max_length=20, label=_("Postleitzahl")) location = forms.CharField(max_length=20, label=_("Stadt"))
max_distance = forms.ChoiceField(choices=_get_distances, label=_("Max. Distanz")) max_distance = forms.ChoiceField(choices=_get_distances, label=_("Max. Distanz"))

View File

@ -1,4 +1,8 @@
from venv import create
import django.conf.global_settings import django.conf.global_settings
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.utils.translation import gettext from django.utils.translation import gettext
@ -10,6 +14,7 @@ from notfellchen.settings import host
NEWLINE = "\r\n" NEWLINE = "\r\n"
def mail_admins_new_report(report): def mail_admins_new_report(report):
subject = _("Neue Meldung") subject = _("Neue Meldung")
for moderator in User.objects.filter(trust_level__gt=User.TRUST_LEVEL[User.MODERATOR]): for moderator in User.objects.filter(trust_level__gt=User.TRUST_LEVEL[User.MODERATOR]):
@ -31,3 +36,21 @@ def mail_admins_new_report(report):
message = mail.EmailMessage(subject, body_text, settings.DEFAULT_FROM_EMAIL, [moderator.email]) message = mail.EmailMessage(subject, body_text, settings.DEFAULT_FROM_EMAIL, [moderator.email])
print("Sending email to ", moderator.email) print("Sending email to ", moderator.email)
message.send() message.send()
@receiver(post_save, sender=User)
def mail_admins_new_member(sender, instance: User, created: bool, **kwargs):
if not created:
return
subject = _("Neuer User") + f": {instance.username}"
for moderator in User.objects.filter(trust_level__gt=User.TRUST_LEVEL[User.MODERATOR]):
greeting = _("Moin,") + "{NEWLINE}"
new_report_text = _("es hat sich eine neue Person registriert.") + "{NEWLINE}"
user_detail_text = _("Username") + f": {instance.username}{NEWLINE}" + _(
"E-Mail") + f": {instance.email}{NEWLINE}"
user_url = "https://" + host + instance.get_absolute_url()
link_text = f"Um alle Details zu sehen, geh bitte auf: {user_url}"
body_text = greeting + new_report_text + user_detail_text + link_text
message = mail.EmailMessage(subject, body_text, settings.DEFAULT_FROM_EMAIL, [moderator.email])
print("Sending email to ", moderator.email)
message.send()

View File

@ -1,6 +1,6 @@
from django.core.management import BaseCommand from django.core.management import BaseCommand
from fellchensammlung.models import AdoptionNotice, Location from fellchensammlung.models import AdoptionNotice, Location
from fellchensammlung.tools.geo import clean_locations from fellchensammlung.tools.admin import clean_locations
class Command(BaseCommand): class Command(BaseCommand):

View File

@ -0,0 +1,23 @@
# Generated by Django 5.1.1 on 2024-10-10 14:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0007_alter_adoptionnotice_last_checked'),
]
operations = [
migrations.AlterField(
model_name='adoptionnoticestatus',
name='minor_status',
field=models.CharField(choices=[('searching', 'searching'), ('interested', 'interested'), ('waiting_for_review', 'waiting_for_review'), ('needs_additional_info', 'needs_additional_info'), ('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'), ('against_the_rules', 'against_the_rules'), ('missing_information', 'missing_information'), ('technical_error', 'technical_error'), ('unchecked', 'unchecked')], max_length=200),
),
migrations.AlterField(
model_name='announcement',
name='publish_start_time',
field=models.DateTimeField(verbose_name='Veröffentlichungszeitpunkt'),
),
]

View File

@ -0,0 +1,25 @@
# Generated by Django 5.1.1 on 2024-10-10 21:00
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0008_alter_adoptionnoticestatus_minor_status_and_more'),
]
operations = [
migrations.CreateModel(
name='Log',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('action', models.CharField(max_length=255, verbose_name='Aktion')),
('text', models.CharField(max_length=1000, verbose_name='Log text')),
('created_at', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Nutzer*in')),
],
),
]

View File

@ -0,0 +1,21 @@
# Generated by Django 5.1.1 on 2024-10-19 18:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0009_log'),
]
operations = [
migrations.CreateModel(
name='Timestamp',
fields=[
('key', models.CharField(max_length=255, primary_key=True, serialize=False, verbose_name='Schlüssel')),
('timestamp', models.DateTimeField(auto_now_add=True, verbose_name='Zeitstempel')),
('data', models.CharField(blank=True, max_length=2000, null=True)),
],
),
]

View File

@ -0,0 +1,24 @@
# Generated by Django 5.1.1 on 2024-10-29 10:44
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0010_timestamp'),
]
operations = [
migrations.AlterField(
model_name='adoptionnotice',
name='created_at',
field=models.DateField(default=django.utils.timezone.now, verbose_name='Erstellt am'),
),
migrations.AlterField(
model_name='adoptionnotice',
name='last_checked',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='Zuletzt überprüft am'),
),
]

View File

@ -0,0 +1,136 @@
# Generated by Django 5.1.1 on 2024-11-03 20:07
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0011_alter_adoptionnotice_created_at_and_more'),
]
operations = [
migrations.AddField(
model_name='adoptionnotice',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='adoptionnoticestatus',
name='created_at',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AddField(
model_name='adoptionnoticestatus',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='animal',
name='created_at',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AddField(
model_name='animal',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='announcement',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='basenotification',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='comment',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='image',
name='created_at',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AddField(
model_name='image',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='location',
name='created_at',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AddField(
model_name='location',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='log',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='moderationaction',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='report',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='rescueorganization',
name='created_at',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AddField(
model_name='rescueorganization',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='rule',
name='created_at',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AddField(
model_name='rule',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='species',
name='created_at',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AddField(
model_name='species',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='subscriptions',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='user',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
]

View File

@ -0,0 +1,20 @@
# Generated by Django 5.1.1 on 2024-11-06 07:02
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0012_adoptionnotice_updated_at_and_more'),
]
operations = [
migrations.AlterField(
model_name='log',
name='user',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Nutzer*in'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.1.1 on 2024-11-07 20:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0013_alter_log_user'),
]
operations = [
migrations.AddField(
model_name='rescueorganization',
name='email',
field=models.EmailField(blank=True, max_length=254, null=True, verbose_name='E-Mail'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.1.1 on 2024-11-09 09:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0014_rescueorganization_email'),
]
operations = [
migrations.AddField(
model_name='rescueorganization',
name='comment',
field=models.TextField(blank=True, null=True, verbose_name='Kommentar'),
),
]

View File

@ -3,7 +3,6 @@ import uuid
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from datetime import datetime
from django.utils import timezone from django.utils import timezone
from django.dispatch import receiver from django.dispatch import receiver
from django.db.models.signals import post_save from django.db.models.signals import post_save
@ -60,6 +59,7 @@ class User(AbstractUser):
preferred_language = models.ForeignKey(Language, on_delete=models.PROTECT, null=True, blank=True, preferred_language = models.ForeignKey(Language, on_delete=models.PROTECT, null=True, blank=True,
verbose_name=_('Bevorzugte Sprache')) verbose_name=_('Bevorzugte Sprache'))
trust_level = models.IntegerField(choices=TRUST_LEVEL, default=TRUST_LEVEL[MEMBER]) trust_level = models.IntegerField(choices=TRUST_LEVEL, default=TRUST_LEVEL[MEMBER])
updated_at = models.DateTimeField(auto_now=True)
class Meta: class Meta:
verbose_name = _('Nutzer*in') verbose_name = _('Nutzer*in')
@ -74,6 +74,10 @@ class User(AbstractUser):
def get_num_unread_notifications(self): def get_num_unread_notifications(self):
return BaseNotification.objects.filter(user=self, read=False).count() return BaseNotification.objects.filter(user=self, read=False).count()
@property
def adoption_notices(self):
return AdoptionNotice.objects.filter(owner=self)
@property @property
def owner(self): def owner(self):
return self return self
@ -83,6 +87,8 @@ class Image(models.Model):
image = models.ImageField(upload_to='images') image = models.ImageField(upload_to='images')
alt_text = models.TextField(max_length=2000) alt_text = models.TextField(max_length=2000)
owner = models.ForeignKey(User, on_delete=models.CASCADE) owner = models.ForeignKey(User, on_delete=models.CASCADE)
updated_at = models.DateTimeField(auto_now=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self): def __str__(self):
return self.alt_text return self.alt_text
@ -96,6 +102,8 @@ class Species(models.Model):
"""Model representing a species of animal.""" """Model representing a species of animal."""
name = models.CharField(max_length=200, help_text=_('Name der Tierart'), name = models.CharField(max_length=200, help_text=_('Name der Tierart'),
verbose_name=_('Name')) verbose_name=_('Name'))
updated_at = models.DateTimeField(auto_now=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self): def __str__(self):
"""String for representing the Model object.""" """String for representing the Model object."""
@ -111,10 +119,16 @@ class Location(models.Model):
latitude = models.FloatField() latitude = models.FloatField()
longitude = models.FloatField() longitude = models.FloatField()
name = models.CharField(max_length=2000) name = models.CharField(max_length=2000)
updated_at = models.DateTimeField(auto_now=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self): def __str__(self):
return f"{self.name} ({self.latitude:.5}, {self.longitude:.5})" return f"{self.name} ({self.latitude:.5}, {self.longitude:.5})"
@property
def str_hr(self):
return f"{self.name.split(',')[0]}"
@staticmethod @staticmethod
def get_location_from_string(location_string): def get_location_from_string(location_string):
geo_api = geo.GeoAPI() geo_api = geo.GeoAPI()
@ -134,6 +148,13 @@ class Location(models.Model):
) )
return location return location
@staticmethod
def add_location_to_object(instance):
"""Search the location given in the location string and add it to the object"""
location = Location.get_location_from_string(instance.location_string)
instance.location = location
instance.save()
class RescueOrganization(models.Model): class RescueOrganization(models.Model):
def __str__(self): def __str__(self):
@ -155,13 +176,20 @@ class RescueOrganization(models.Model):
name = models.CharField(max_length=200) name = models.CharField(max_length=200)
trusted = models.BooleanField(default=False, verbose_name=_('Vertrauenswürdig')) trusted = models.BooleanField(default=False, verbose_name=_('Vertrauenswürdig'))
allows_using_materials = models.CharField(max_length=200,default=ALLOW_USE_MATERIALS_CHOICE[USE_MATERIALS_NOT_ASKED], choices=ALLOW_USE_MATERIALS_CHOICE, verbose_name=_('Erlaubt Nutzung von Inhalten')) allows_using_materials = models.CharField(max_length=200,
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")) location_string = models.CharField(max_length=200, verbose_name=_("Ort der Organisation"))
location = models.ForeignKey(Location, on_delete=models.PROTECT, blank=True, null=True) location = models.ForeignKey(Location, on_delete=models.PROTECT, blank=True, null=True)
instagram = models.URLField(null=True, blank=True, verbose_name=_('Instagram Profil')) instagram = models.URLField(null=True, blank=True, verbose_name=_('Instagram Profil'))
facebook = models.URLField(null=True, blank=True, verbose_name=_('Facebook Profil')) facebook = models.URLField(null=True, blank=True, verbose_name=_('Facebook Profil'))
fediverse_profile = models.URLField(null=True, blank=True, verbose_name=_('Fediverse Profil')) fediverse_profile = models.URLField(null=True, blank=True, verbose_name=_('Fediverse Profil'))
email = models.EmailField(null=True, blank=True, verbose_name=_('E-Mail'))
website = models.URLField(null=True, blank=True, verbose_name=_('Website')) website = models.URLField(null=True, blank=True, verbose_name=_('Website'))
updated_at = models.DateTimeField(auto_now=True)
created_at = models.DateTimeField(auto_now_add=True)
comment = models.TextField(verbose_name=_("Kommentar"), null=True, blank=True,)
class AdoptionNotice(models.Model): class AdoptionNotice(models.Model):
@ -171,10 +199,13 @@ class AdoptionNotice(models.Model):
] ]
def __str__(self): def __str__(self):
return f"{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=datetime.now) created_at = models.DateField(verbose_name=_('Erstellt am'), default=timezone.now)
last_checked = models.DateTimeField(verbose_name=_('Zuletzt überprüft am'), default=datetime.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')) searching_since = models.DateField(verbose_name=_('Sucht nach einem Zuhause seit'))
name = models.CharField(max_length=200) name = models.CharField(max_length=200)
description = models.TextField(null=True, blank=True, verbose_name=_('Beschreibung')) description = models.TextField(null=True, blank=True, verbose_name=_('Beschreibung'))
@ -191,6 +222,24 @@ class AdoptionNotice(models.Model):
def animals(self): def animals(self):
return Animal.objects.filter(adoption_notice=self) return Animal.objects.filter(adoption_notice=self)
@property
def sexes(self):
sexes = set()
for animal in self.animals:
sexes.update(animal.sex)
return sexes
def sex_code(self):
if len(self.sexes) > 1:
return "mixed"
elif self.sexes.pop() == Animal.MALE:
return "male"
elif self.sexes.pop() == Animal.FEMALE:
return "female"
else:
return "mixed"
@property @property
def comments(self): def comments(self):
return Comment.objects.filter(adoption_notice=self) return Comment.objects.filter(adoption_notice=self)
@ -277,14 +326,28 @@ class AdoptionNotice(models.Model):
return False return False
return self.adoptionnoticestatus.is_active return self.adoptionnoticestatus.is_active
def set_checked(self): @property
self.last_checked = datetime.now() def is_disabled_unchecked(self):
self.save() if not hasattr(self, 'adoptionnoticestatus'):
return False
return self.adoptionnoticestatus.is_disabled_unchecked
def set_closed(self): def set_closed(self):
self.last_checked = datetime.now() self.last_checked = timezone.now()
self.adoptionnoticestatus.set_closed() self.adoptionnoticestatus.set_closed()
def set_active(self):
self.last_checked = timezone.now()
if not hasattr(self, 'adoptionnoticestatus'):
AdoptionNoticeStatus.create_other(self)
self.adoptionnoticestatus.set_active()
def set_unchecked(self):
self.last_checked = timezone.now()
if not hasattr(self, 'adoptionnoticestatus'):
AdoptionNoticeStatus.create_other(self)
self.adoptionnoticestatus.set_unchecked()
class AdoptionNoticeStatus(models.Model): class AdoptionNoticeStatus(models.Model):
""" """
@ -324,6 +387,7 @@ class AdoptionNoticeStatus(models.Model):
"against_the_rules": "against_the_rules", "against_the_rules": "against_the_rules",
"missing_information": "missing_information", "missing_information": "missing_information",
"technical_error": "technical_error", "technical_error": "technical_error",
"unchecked": "unchecked",
"other": "other" "other": "other"
} }
} }
@ -334,23 +398,51 @@ class AdoptionNoticeStatus(models.Model):
minor_choices.update(MINOR_STATUS_CHOICES[key]) minor_choices.update(MINOR_STATUS_CHOICES[key])
minor_status = models.CharField(choices=minor_choices, max_length=200) minor_status = models.CharField(choices=minor_choices, max_length=200)
adoption_notice = models.OneToOneField(AdoptionNotice, on_delete=models.CASCADE) 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): def __str__(self):
return f"{self.adoption_notice}: {self.major_status}, {self.minor_status}" return f"{self.adoption_notice}: {self.major_status}, {self.minor_status}"
def as_string(self):
return f"{self.major_status}, {self.minor_status}"
@property @property
def is_active(self): def is_active(self):
return self.major_status == self.ACTIVE return self.major_status == self.ACTIVE
@property
def is_disabled_unchecked(self):
return self.major_status == self.DISABLED and self.minor_status == "unchecked"
@staticmethod @staticmethod
def get_minor_choices(major_status): def get_minor_choices(major_status):
return AdoptionNoticeStatus.MINOR_STATUS_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): def set_closed(self):
self.major_status = self.MAJOR_STATUS_CHOICES[self.CLOSED] self.major_status = self.MAJOR_STATUS_CHOICES[self.CLOSED]
self.minor_status = self.MINOR_STATUS_CHOICES[self.CLOSED]["other"] self.minor_status = self.MINOR_STATUS_CHOICES[self.CLOSED]["other"]
self.save() 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 Animal(models.Model): class Animal(models.Model):
MALE_NEUTERED = "M_N" MALE_NEUTERED = "M_N"
@ -372,13 +464,15 @@ class Animal(models.Model):
sex = models.CharField(max_length=20, choices=SEX_CHOICES, ) sex = models.CharField(max_length=20, choices=SEX_CHOICES, )
adoption_notice = models.ForeignKey(AdoptionNotice, on_delete=models.CASCADE) adoption_notice = models.ForeignKey(AdoptionNotice, on_delete=models.CASCADE)
owner = models.ForeignKey(User, on_delete=models.CASCADE) owner = models.ForeignKey(User, on_delete=models.CASCADE)
updated_at = models.DateTimeField(auto_now=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self): def __str__(self):
return f"{self.name}" return f"{self.name}"
@property @property
def age(self): def age(self):
return datetime.today().date() - self.date_of_birth return timezone.now().today().date() - self.date_of_birth
@property @property
def hr_age(self): def hr_age(self):
@ -415,6 +509,8 @@ class Rule(models.Model):
language = models.ForeignKey(Language, on_delete=models.PROTECT) language = models.ForeignKey(Language, on_delete=models.PROTECT)
# Rule identifier allows to translate rules with the same identifier # Rule identifier allows to translate rules with the same identifier
rule_identifier = models.CharField(max_length=24) 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): def __str__(self):
return self.title return self.title
@ -438,6 +534,7 @@ class Report(models.Model):
reported_broken_rules = models.ManyToManyField(Rule) reported_broken_rules = models.ManyToManyField(Rule)
user_comment = models.TextField(blank=True) user_comment = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self): def __str__(self):
return f"[{self.status}]: {self.user_comment:.20}" return f"[{self.status}]: {self.user_comment:.20}"
@ -484,13 +581,12 @@ class ModerationAction(models.Model):
} }
action = models.CharField(max_length=30, choices=ACTIONS.items()) action = models.CharField(max_length=30, choices=ACTIONS.items())
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
public_comment = models.TextField(blank=True) public_comment = models.TextField(blank=True)
# Only visible to moderator # Only visible to moderator
private_comment = models.TextField(blank=True) private_comment = models.TextField(blank=True)
report = models.ForeignKey(Report, on_delete=models.CASCADE) report = models.ForeignKey(Report, on_delete=models.CASCADE)
# TODO: Needs field for moderator that performed the action
def __str__(self): def __str__(self):
return f"[{self.action}]: {self.public_comment}" return f"[{self.action}]: {self.public_comment}"
@ -516,6 +612,17 @@ class Text(models.Model):
def __str__(self): def __str__(self):
return f"{self.title} ({self.language})" return f"{self.title} ({self.language})"
@staticmethod
def get_texts(text_codes, language, expandable_dict=None):
if expandable_dict is None:
expandable_dict = {}
for text_code in text_codes:
try:
expandable_dict[text_code] = Text.objects.get(text_code=text_code, language=language, )
except Text.DoesNotExist:
expandable_dict[text_code] = None
return expandable_dict
class Announcement(Text): class Announcement(Text):
""" """
@ -523,7 +630,8 @@ class Announcement(Text):
""" """
logged_in_only = models.BooleanField(default=False) logged_in_only = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
publish_start_time = models.DateTimeField(verbose_name="Veröffentlichungszeitpunk") updated_at = models.DateTimeField(auto_now=True)
publish_start_time = models.DateTimeField(verbose_name="Veröffentlichungszeitpunkt")
publish_end_time = models.DateTimeField(verbose_name="Veröffentlichungsende") publish_end_time = models.DateTimeField(verbose_name="Veröffentlichungsende")
IMPORTANT = "important" IMPORTANT = "important"
WARNING = "warning" WARNING = "warning"
@ -571,6 +679,7 @@ class Comment(models.Model):
""" """
user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name=_('Nutzer*in')) user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name=_('Nutzer*in'))
created_at = models.DateTimeField(auto_now_add=True) 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=_('AdoptionNotice')) adoption_notice = models.ForeignKey(AdoptionNotice, on_delete=models.CASCADE, verbose_name=_('AdoptionNotice'))
text = models.TextField(verbose_name="Inhalt") text = models.TextField(verbose_name="Inhalt")
reply_to = models.ForeignKey("self", verbose_name="Antwort auf", blank=True, null=True, on_delete=models.CASCADE) reply_to = models.ForeignKey("self", verbose_name="Antwort auf", blank=True, null=True, on_delete=models.CASCADE)
@ -588,6 +697,7 @@ class Comment(models.Model):
class BaseNotification(models.Model): class BaseNotification(models.Model):
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
title = models.CharField(max_length=100) title = models.CharField(max_length=100)
text = models.TextField(verbose_name="Inhalt") text = models.TextField(verbose_name="Inhalt")
user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name=_('Nutzer*in')) user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name=_('Nutzer*in'))
@ -613,6 +723,33 @@ class Subscriptions(models.Model):
owner = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name=_('Nutzer*in')) owner = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name=_('Nutzer*in'))
adoption_notice = models.ForeignKey(AdoptionNotice, on_delete=models.CASCADE, verbose_name=_('AdoptionNotice')) adoption_notice = models.ForeignKey(AdoptionNotice, on_delete=models.CASCADE, verbose_name=_('AdoptionNotice'))
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self): def __str__(self):
return f"{self.owner} - {self.adoption_notice}" return f"{self.owner} - {self.adoption_notice}"
class Log(models.Model):
"""
Basic class that allows logging random entries for later inspection
"""
user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name=_("Nutzer*in"), blank=True, null=True)
action = models.CharField(max_length=255, verbose_name=_("Aktion"))
text = models.CharField(max_length=1000, verbose_name=_("Log text"))
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f"[{self.action}] - {self.user} - {self.created_at.strftime('%H:%M:%S %d-%m-%Y ')}"
class Timestamp(models.Model):
"""
Class to store timestamps based on keys
"""
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):
return f"[{self.key}] - {self.timestamp.strftime('%H:%M:%S %d-%m-%Y ')} - {self.data}"

View File

@ -1,3 +1,7 @@
/***************/
/* MAIN COLORS */
/***************/
:root { :root {
--primary-light-one: #5daa68; --primary-light-one: #5daa68;
--primary-light-two: #4a9455; --primary-light-two: #4a9455;
@ -18,7 +22,9 @@
--text-three: var(--primary-light-one); --text-three: var(--primary-light-one);
--shadow-three: var(--primary-dark-one); --shadow-three: var(--primary-dark-one);
} }
/**************************/
/* TAG SETTINGS (GENERAL) */
/**************************/
html, body { html, body {
margin: 0; margin: 0;
height: 100%; height: 100%;
@ -30,10 +36,6 @@ body {
} }
.content-box {
margin: 20px;
}
table { table {
width: 100%; width: 100%;
} }
@ -79,6 +81,145 @@ h1, h2 {
box-sizing: border-box; box-sizing: border-box;
} }
textarea {
border-radius: 10px;
width: 100%;
margin: 5px;
}
/**************/
/* CONTAINERS */
/**************/
.container-cards {
display: flex;
flex-wrap: wrap;
}
.card {
flex: 1 25%;
margin: 10px;
border-radius: 8px;
padding: 5px;
background: var(--background-three);
color: var(--text-two);
}
.container-edit-buttons {
display: flex;
flex-wrap: wrap;
.btn {
margin: 5px;
}
}
.spaced {
margin-bottom: 30px;
}
/*******************************/
/* PARTIAL SPECIFIC CONTAINERS */
/*******************************/
.detail-animal-header {
border-radius: 5px;
display: flex;
align-items: center;
justify-content: space-between;
}
@media screen and (max-width: 800px) {
.detail-animal-header {
display: block;
}
}
.profile-card {
display: flex;
border-radius: 0px 0px 8px 8px;
background-color: var(--highlight-two);
color: var(--highlight-one-text);
.btn2 {
height: 40px;
}
.button_darken:hover {
background-color: var(--highlight-one);
color: var(--highlight-one-text);
}
button {
background: inherit;
color: inherit;
}
}
.container-comment-form {
width: 80%;
color: var(--text-one);
b {
text-shadow: 2px 2px var(--shadow-one);
}
}
/*************/
/* Modifiers */
/*************/
/* Used to enlargen cards */
.full-width {
width: 100%;
flex: none;
}
/***********/
/* BUTTONS */
/***********/
select, .button {
width: 100%;
border: none;
border-radius: 4px;
opacity: 1;
background-color: var(--secondary-light-one);
}
.btn {
background-color: var(--primary-light-one);
color: var(--secondary-light-one);
padding: 16px;
border-radius: 8px;
border: none;
font-weight: bold;
display: block;
}
.btn2 {
background-color: var(--secondary-light-one);
color: var(--primary-dark-one);
padding: 8px;
border-radius: 4px;
border: none;
margin: 5px;
}
/*********************/
/* UNIQUE COMPONENTS */
/*********************/
.content-box {
margin: 20px;
}
.header { .header {
overflow: hidden; overflow: hidden;
background-color: var(--background-two); background-color: var(--background-two);
@ -110,14 +251,7 @@ h1, h2 {
color: white; color: white;
} }
select, .button {
width: 100%;
border: none;
border-radius: 4px;
opacity: 1;
background-color: var(--secondary-light-one);
}
.header-right select.option { .header-right select.option {
color: #000; color: #000;
@ -135,26 +269,6 @@ select, .button {
height: 67px; height: 67px;
} }
.profile-card {
display: flex;
border-radius: 0px 0px 8px 8px;
background-color: var(--highlight-two);
color: var(--highlight-one-text);
.btn2 {
height: 40px;
}
.button_darken:hover {
background-color: var(--highlight-one);
color: var(--highlight-one-text);
}
button {
background: inherit;
color: inherit;
}
}
@media screen and (max-width: 500px) { @media screen and (max-width: 500px) {
.header a { .header a {
@ -172,25 +286,6 @@ select, .button {
height: 40px; height: 40px;
} }
.btn {
background-color: var(--primary-light-one);
color: var(--secondary-light-one);
padding: 16px;
border-radius: 8px;
border: none;
font-weight: bold;
}
.btn2 {
background-color: var(--secondary-light-one);
color: var(--primary-dark-one);
padding: 8px;
border-radius: 4px;
border: none;
margin: 5px;
}
.form-button, .link-button a:link, .link-button a:visited { .form-button, .link-button a:link, .link-button a:visited {
background-color: #4ba3cd; background-color: #4ba3cd;
color: white; color: white;
@ -203,14 +298,6 @@ select, .button {
border: none; border: none;
} }
.container-edit-buttons {
display: flex;
flex-wrap: wrap;
.btn {
margin: 5px;
}
}
.form-button:hover, .link-button a:hover, .link-button a:active { .form-button:hover, .link-button a:hover, .link-button a:active {
background-color: #4090b6; background-color: #4090b6;
@ -326,18 +413,6 @@ select, .button {
} }
} }
.detail-animal-header {
border-radius: 5px;
display: flex;
align-items: center;
justify-content: space-between;
}
@media screen and (max-width: 800px) {
.detail-animal-header {
display: block;
}
}
.tag { .tag {
border: black 1px solid; border: black 1px solid;
@ -355,10 +430,7 @@ select, .button {
} }
.container-cards {
display: flex;
flex-wrap: wrap;
}
.photos { .photos {
@ -376,23 +448,17 @@ select, .button {
border-radius: 10%; border-radius: 10%;
} }
.card {
flex: 1 25%;
margin: 10px;
border-radius: 8px;
padding: 5px;
background: var(--background-three);
color: var(--text-two);
}
.card h1 { .card h1 {
color: var(--text-three); color: var(--text-three);
text-shadow: 1px 1px var(--shadow-three); text-shadow: 1px 1px var(--shadow-three);
width: 85%;
} }
.card h2 { .card h2 {
color: var(--text-three); color: var(--text-three);
text-shadow: 1px 1px var(--shadow-three); text-shadow: 1px 1px var(--shadow-three);
width: 85%;
} }
.card img { .card img {
@ -514,20 +580,7 @@ select, .button {
color: var(--text-two); color: var(--text-two);
} }
.container-comment-form {
width: 80%;
color: var(--text-one);
b {
text-shadow: 2px 2px var(--shadow-one);
}
}
textarea {
border-radius: 10px;
width: 100%;
margin: 5px;
}
.form-comments { .form-comments {
.btn { .btn {
@ -552,6 +605,21 @@ textarea {
} }
} }
.form-search {
select, input {
background-color: var(--primary-light-one);
color: var(--text-one);
border-radius: 3px;
border: none;
}
}
/************************/
/* GENERAL HIGHLIGHTING */
/************************/
.important { .important {
border: #e01137 4px solid; border: #e01137 4px solid;
} }
@ -564,15 +632,10 @@ textarea {
border: rgba(17, 58, 224, 0.51) 4px solid; border: rgba(17, 58, 224, 0.51) 4px solid;
} }
.form-search {
select, input {
background-color: var(--primary-light-one);
color: var(--text-one);
border-radius: 3px;
border: none;
}
} /*******/
/* MAP */
/*******/
.marker { .marker {
background-image: url('../img/logo_transparent.png'); background-image: url('../img/logo_transparent.png');

View File

@ -0,0 +1,45 @@
from django.utils import timezone
from notfellchen.celery import app as celery_app
from .tools.admin import clean_locations, deactivate_unchecked_adoption_notices, deactivate_404_adoption_notices
from .tools.misc import healthcheck_ok
from .models import Location, AdoptionNotice, Timestamp
def set_timestamp(key: str):
try:
ts = Timestamp.objects.get(key=key)
ts.timestamp = timezone.now()
ts.save()
except Timestamp.DoesNotExist:
Timestamp.objects.create(key=key, timestamp=timezone.now())
@celery_app.task(name="admin.clean_locations")
def task_clean_locations():
clean_locations()
set_timestamp("task_clean_locations")
@celery_app.task(name="admin.daily_unchecked_deactivation")
def task_deactivate_unchecked():
deactivate_unchecked_adoption_notices()
set_timestamp("task_daily_unchecked_deactivation")
@celery_app.task(name="admin.deactivate_404_adoption_notices")
def task_deactivate_unchecked():
deactivate_404_adoption_notices()
set_timestamp("task_deactivate_404_adoption_notices")
@celery_app.task(name="commit.add_location")
def add_adoption_notice_location(pk):
instance = AdoptionNotice.objects.get(pk=pk)
Location.add_location_to_object(instance)
set_timestamp("add_adoption_notice_location")
@celery_app.task(name="tools.healthcheck")
def task_healthcheck():
healthcheck_ok()
set_timestamp("task_healthcheck")

View File

@ -2,7 +2,14 @@
{% load i18n %} {% load i18n %}
{% load custom_tags %} {% load custom_tags %}
{% block title %}<title>{% translate "Über uns und Regeln" %}</title>{% endblock %}
{% block content %} {% block content %}
{% if about_us %}
<h1>{{ about_us.title }}</h1>
{{ about_us.content | render_markdown }}
{% endif %}
<h1>{% translate "Regeln" %}</h1> <h1>{% translate "Regeln" %}</h1>
{% include "fellchensammlung/lists/list-rules.html" %} {% include "fellchensammlung/lists/list-rules.html" %}

View File

@ -1,10 +1,12 @@
{% load custom_tags %} {% load custom_tags %}
{% load i18n %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
{% block title %}<title>Notfellchen</title>{% endblock %} {% block title %}{% endblock %}
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="{% translate "Farbratten aus dem Tierschutz finden und adoptieren" %}">
<!-- Add additional CSS in static file --> <!-- Add additional CSS in static file -->
{% load static %} {% load static %}
<link rel="stylesheet" href="{% static 'fellchensammlung/css/styles.css' %}"> <link rel="stylesheet" href="{% static 'fellchensammlung/css/styles.css' %}">

View File

@ -2,6 +2,8 @@
{% load custom_tags %} {% load custom_tags %}
{% load i18n %} {% load i18n %}
{% block title %}<title>{{adoption_notice.name }}</title>{% endblock %}
{% block content %} {% block content %}
<div class="detail-adoption-notice-header"> <div class="detail-adoption-notice-header">
<h1 class="detail-adoption-notice-header">{{ adoption_notice.name }} <h1 class="detail-adoption-notice-header">{{ adoption_notice.name }}
@ -48,7 +50,14 @@
<td>{{ adoption_notice.searching_since }}</td> <td>{{ adoption_notice.searching_since }}</td>
<td>{{ adoption_notice.last_checked | date:'d. F Y' }}</td> <td>{{ adoption_notice.last_checked | date:'d. F Y' }}</td>
{% if adoption_notice.further_information %} {% if adoption_notice.further_information %}
<td>{{ adoption_notice.link_to_more_information | safe }}</td> <td>
<form method="get" action="{% url 'external-site' %}">
<input type="hidden" name="url" value="{{ adoption_notice.further_information }}">
<button class="btn" type="submit" id="submit">
{{ adoption_notice.further_information | domain }} <i class="fa-solid fa-arrow-up-right-from-square"></i>
</button>
</form>
</td>
{% else %} {% else %}
<td>-</td> <td>-</td>
{% endif %} {% endif %}

View File

@ -2,6 +2,8 @@
{% load custom_tags %} {% load custom_tags %}
{% load i18n %} {% load i18n %}
{% block title %}<title>{{ animal.name }}</title>{% endblock %}
{% block content %} {% block content %}
{% include "fellchensammlung/details/detail-animal-partial.html" %} {% include "fellchensammlung/details/detail-animal-partial.html" %}
{% endblock %} {% endblock %}

View File

@ -0,0 +1,15 @@
{% extends "fellchensammlung/base_generic.html" %}
{% load i18n %}
{% load custom_tags %}
{% block content %}
<div class="card">
{% if external_site_warning %}
{{ external_site_warning.content | render_markdown }}
{% else %}
{% blocktranslate %}
<p>Achtung du verlässt notfellchen.org</p>
{% endblocktranslate %}
{% endif %}
<a href="{{ url }}" class="btn button">{% translate "Weiter" %}</a>
</div>
{% endblock content %}

View File

@ -2,6 +2,8 @@
{% load i18n %} {% load i18n %}
{% load crispy_forms_tags %} {% load crispy_forms_tags %}
{% block title %}<title>{% translate "Vermittlung hinzufügen" %}</title>{% endblock %}
{% block content %} {% block content %}
<h1>{% translate "Vermitteln" %}</h1> <h1>{% translate "Vermitteln" %}</h1>
<p> <p>

View File

@ -2,6 +2,8 @@
{% load i18n %} {% load i18n %}
{% load custom_tags %} {% load custom_tags %}
{% block title %}<title>{% translate "Notfellchen - Farbratten aus dem Tierschutz adoptieren" %}</title>{% endblock %}
{% block content %} {% block content %}
{% for announcement in announcements %} {% for announcement in announcements %}
{% include "fellchensammlung/partials/partial-announcement.html" %} {% include "fellchensammlung/partials/partial-announcement.html" %}

View File

@ -1,5 +1,6 @@
{% extends "fellchensammlung/base_generic.html" %} {% extends "fellchensammlung/base_generic.html" %}
{% load i18n %} {% load i18n %}
{% block title %}<title>{% translate "Instanz-Check" %}</title> {% endblock %}
{% block content %} {% block content %}
<div class="card"> <div class="card">
<h1>{% translate "Instanz-Check" %}</h1> <h1>{% translate "Instanz-Check" %}</h1>
@ -23,6 +24,28 @@
<p>{% translate "Texte scheinen vollständig" %}</p> <p>{% translate "Texte scheinen vollständig" %}</p>
{% endif %} {% endif %}
<h2>{% trans "Zeitstempel" %}</h2>
{% if timestamps|length > 0 %}
<p>
<table>
<tr>
<th>{% translate "Key" %}</th>
<th>{% translate "Zeitstempel" %}</th>
<th>{% translate "Daten" %}</th>
</tr>
{% for timestamp in timestamps %}
<tr>
<td>{{ timestamp.key }}</td>
<td>{{ timestamp.timestamp }}</td>
<td>{{ timestamp.data }}</td>
</tr>
{% endfor %}
</table>
</p>
{% else %}
<p>{% translate "Keine Zeitstempel geloggt." %}</p>
{% endif %}
<h2>{% translate "Nicht-lokalisierte Vermittlungen" %}</h2> <h2>{% translate "Nicht-lokalisierte Vermittlungen" %}</h2>
{% if number_not_geocoded_adoption_notices > 0 %} {% if number_not_geocoded_adoption_notices > 0 %}
<details> <details>
@ -55,6 +78,22 @@
<p>{{ number_not_geocoded_rescue_orgs }}/{{ number_of_rescue_orgs }}</p> <p>{{ number_not_geocoded_rescue_orgs }}/{{ number_of_rescue_orgs }}</p>
{% endif %} {% endif %}
<h2>{% translate "Nicht-geprüfte Vermittlungen" %}</h2>
{% if number_unchecked_ans > 0 %}
<details>
<summary>{{ number_unchecked_ans }}</summary>
<ul>
{% for unchecked_an in unchecked_ans %}
<li>
<a href="{{ unchecked_an.get_absolute_url }}">{{ unchecked_an.name }}</a>
</li>
{% endfor %}
</ul>
</details>
{% else %}
<p>{{ number_unchecked_ans }}</p>
{% endif %}
<form class="notification-card-mark-read" method="post"> <form class="notification-card-mark-read" method="post">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="action" value="clean_locations"> <input type="hidden" name="action" value="clean_locations">
@ -62,5 +101,13 @@
<i class="fa-solid fa-broom"></i> {% translate "Erneut lokalisieren" %} <i class="fa-solid fa-broom"></i> {% translate "Erneut lokalisieren" %}
</button> </button>
</form> </form>
<form class="notification-card-mark-read" method="post">
{% csrf_token %}
<input type="hidden" name="action" value="deactivate_unchecked_adoption_notices">
<button class="btn" type="submit" id="submit">
<i class="fa-solid fa-broom"></i> {% translate "Deaktivire ungeprüfte Vermittlungen" %}
</button>
</form>
</div> </div>
{% endblock content %} {% endblock content %}

View File

@ -2,7 +2,7 @@
<div class="container-cards"> <div class="container-cards">
{% if adoption_notices %} {% if adoption_notices %}
{% for adoption_notice in adoption_notices %} {% for adoption_notice in adoption_notices %}
{% include "fellchensammlung/partials/partial-adoption-notice.html" %} {% include "fellchensammlung/partials/partial-adoption-notice-minimal.html" %}
{% endfor %} {% endfor %}
{% else %} {% else %}
<p>{% translate "Keine Vermittlungen gefunden." %}</p> <p>{% translate "Keine Vermittlungen gefunden." %}</p>

View File

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

View File

@ -1,5 +1,6 @@
{% extends "fellchensammlung/base_generic.html" %} {% extends "fellchensammlung/base_generic.html" %}
{% load i18n %} {% load i18n %}
{% block title %}<title>{% translate "Modqueue" %}</title> %}{% endblock %}
{% block content %} {% block content %}
<h1>{% translate "Modqueue" %}</h1> <h1>{% translate "Modqueue" %}</h1>

View File

@ -10,9 +10,9 @@
class="fa-solid fa-flag"></i></a> class="fa-solid fa-flag"></i></a>
</div> </div>
<p> <p>
<b>Ort</b> <b><i class="fa-solid fa-location-dot"></i></b>
{% if adoption_notice.location %} {% if adoption_notice.location %}
{{ adoption_notice.location }} {{ adoption_notice.location.str_hr }}
{% else %} {% else %}
{{ adoption_notice.location_string }} {{ adoption_notice.location_string }}
{% endif %} {% endif %}

View File

@ -14,7 +14,7 @@
<p> <p>
<b>Ort</b> <b>Ort</b>
{% if adoption_notice.location %} {% if adoption_notice.location %}
{{ adoption_notice.location }} {{ adoption_notice.location.str_hr }}
{% else %} {% else %}
{{ adoption_notice.location_string }} {{ adoption_notice.location_string }}
{% endif %} {% endif %}

View File

@ -0,0 +1,28 @@
{% load i18n %}
{% load custom_tags %}
<div class="card">
<h1>
<a href="{{ adoption_notice.get_absolute_url }}">{{ adoption_notice.name }}</a>
</h1>
{% if adoption_notice.further_information %}
<p>{% translate "Externe Quelle" %}: {{ adoption_notice.link_to_more_information | safe }}</p>
{% endif %}
<div>
<form method="post">
{% csrf_token %}
<input type="hidden"
name="adoption_notice_id"
value="{{ adoption_notice.pk }}">
<input type="hidden" name="action" value="checked_active">
<button class="btn" type="submit">{% translate "Vermittlung noch aktuell" %}</button>
</form>
<form method="post">
{% csrf_token %}
<input type="hidden"
name="adoption_notice_id"
value="{{ adoption_notice.pk }}">
<input type="hidden" name="action" value="checked_inactive">
<button class="btn" type="submit">{% translate "Vermittlung inaktiv" %}</button>
</form>
</div>
</div>

View File

@ -1,5 +1,8 @@
{% extends "fellchensammlung/base_generic.html" %} {% extends "fellchensammlung/base_generic.html" %}
{% load i18n %} {% load i18n %}
{% block title %}<title>{% translate "Suche" %}</title>{% endblock %}
{% block content %} {% block content %}
<form class="form-search card" method="post"> <form class="form-search card" method="post">
{% csrf_token %} {% csrf_token %}

View File

@ -3,32 +3,16 @@
{% block content %} {% block content %}
<h1>{% translate "Aktualitätscheck" %}</h1> <h1>{% translate "Aktualitätscheck" %}</h1>
<p>{% translate "Überprüfe ob Vermittlungen noch aktuell sind" %}</p> <p>{% translate "Überprüfe ob Vermittlungen noch aktuell sind" %}</p>
{% for adoption_notice in adoption_notices %} <div class="container-cards spaced">
<div class="card"> <h1>{% translate 'Deaktivierte Vermittlungen zur Überprüfung' %}</h1>
<h1> {% for adoption_notice in adoption_notices_disabled %}
<a href="{{ adoption_notice.get_absolute_url }}">{{ adoption_notice.name }}</a> {% include "fellchensammlung/partials/partial-check-adoption-notice.html" %}
</h1>
{% if adoption_notice.further_information %}
<p>{% translate "Externe Quelle" %}: {{ adoption_notice.link_to_more_information | safe }}</p>
{% endif %}
<div>
<form method="post">
{% csrf_token %}
<input type="hidden"
name="adoption_notice_id"
value="{{ adoption_notice.pk }}">
<input type="hidden" name="action" value="checked_active">
<button class="btn" type="submit">{% translate "Vermittlung noch aktuell" %}</button>
</form>
<form method="post">
{% csrf_token %}
<input type="hidden"
name="adoption_notice_id"
value="{{ adoption_notice.pk }}">
<input type="hidden" name="action" value="checked_inactive">
<button class="btn" type="submit">{% translate "Vermittlung inaktiv" %}</button>
</form>
</div>
</div>
{% endfor %} {% endfor %}
</div>
<div class="container-cards spaced">
<h1>{% translate 'Aktive Vermittlungen zur Überprüfung' %}</h1>
{% for adoption_notice in adoption_notices_active %}
{% include "fellchensammlung/partials/partial-check-adoption-notice.html" %}
{% endfor %}
</div>
{% endblock %} {% endblock %}

View File

@ -4,6 +4,7 @@ from django import template
from django.template.defaultfilters import stringfilter from django.template.defaultfilters import stringfilter
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from notfellchen import settings from notfellchen import settings
from urllib.parse import urlparse
register = template.Library() register = template.Library()
@ -56,6 +57,17 @@ def pointdecimal(value):
except ValueError: except ValueError:
return value return value
@register.filter
@stringfilter
def domain(url):
try:
domain = urlparse(url).netloc
if domain.startswith("www."):
return domain[4:]
return domain
except ValueError:
return url
@register.simple_tag @register.simple_tag
def settings_value(name): def settings_value(name):
return getattr(settings, name) return getattr(settings, name)

View File

@ -1,3 +0,0 @@
from django.test import TestCase
# Create your tests here.

View File

@ -1,4 +1,11 @@
from fellchensammlung.models import AdoptionNotice, Location, RescueOrganization import logging
from django.utils import timezone
from datetime import timedelta
from fellchensammlung.models import AdoptionNotice, Location, RescueOrganization, AdoptionNoticeStatus, Log
from fellchensammlung.tools.misc import is_404
def clean_locations(quiet=True): def clean_locations(quiet=True):
# ADOPTION NOTICES # ADOPTION NOTICES
@ -42,3 +49,36 @@ def clean_locations(quiet=True):
num_new = num_without_location - num_without_location_new num_new = num_without_location - num_without_location_new
if not quiet: if not quiet:
print(f"Added {num_new} new locations") print(f"Added {num_new} new locations")
def get_unchecked_adoption_notices(weeks=3):
now = timezone.now()
three_weeks_ago = now - timedelta(weeks=weeks)
# Query for active adoption notices that were checked in the last three weeks
unchecked_adoptions = AdoptionNotice.objects.filter(
last_checked__lte=three_weeks_ago
)
active_unchecked_adoptions = [adoption for adoption in unchecked_adoptions if adoption.is_active]
return active_unchecked_adoptions
def get_active_adoption_notices():
ans = AdoptionNotice.objects.all()
active_adoptions = [adoption for adoption in ans if adoption.is_active]
return active_adoptions
def deactivate_unchecked_adoption_notices():
for adoption_notice in get_unchecked_adoption_notices(weeks=3):
AdoptionNoticeStatus.objects.get(adoption_notice=adoption_notice).set_unchecked()
def deactivate_404_adoption_notices():
for adoption_notice in get_active_adoption_notices():
if adoption_notice.further_information and adoption_notice.further_information != "":
if is_404(adoption_notice.further_information):
adoption_notice.set_closed()
logging_msg = f"Automatically set Adoption Notice {adoption_notice.id} closed as link to more information returened 404"
logging.info(logging_msg)
Log.objects.create(action="automated", text=logging_msg)

View File

@ -7,7 +7,6 @@ from notfellchen import __version__ as nf_version
from notfellchen import settings from notfellchen import settings
def calculate_distance_between_coordinates(position1, position2): def calculate_distance_between_coordinates(position1, position2):
""" """
Calculate the distance between two points identified by coordinates Calculate the distance between two points identified by coordinates
@ -65,7 +64,8 @@ class GeoAPI:
def get_coordinates_from_query(self, location_string): def get_coordinates_from_query(self, location_string):
try: try:
result = self.requests.get(self.api_url, {"q": location_string, "format": "jsonv2"}, headers=self.headers).json()[0] result = \
self.requests.get(self.api_url, {"q": location_string, "format": "jsonv2"}, headers=self.headers).json()[0]
except IndexError: except IndexError:
return None return None
return result["lat"], result["lon"] return result["lat"], result["lon"]

View File

@ -1,4 +1,8 @@
import datetime as datetime import datetime as datetime
import logging
from notfellchen import settings
import requests
def pluralize(number, letter="e"): def pluralize(number, letter="e"):
@ -23,3 +27,18 @@ def age_as_hr_string(age: datetime.timedelta) -> str:
return f'{weeks:.0f} Woche{pluralize(weeks, "n")}' return f'{weeks:.0f} Woche{pluralize(weeks, "n")}'
else: else:
return f'{days:.0f} Tag{pluralize(days)}' return f'{days:.0f} Tag{pluralize(days)}'
def healthcheck_ok():
try:
requests.get(settings.HEALTHCHECKS_URL, timeout=10)
except requests.RequestException as e:
logging.error("Ping to healthcheck-server failed: %s" % e)
def is_404(url):
try:
result = requests.get(url, timeout=10)
return result.status_code == 404
except requests.RequestException as e:
logging.warning(f"Request to {url} failed: {e}")

View File

@ -79,5 +79,9 @@ urlpatterns = [
######### #########
path('api/', include('fellchensammlung.api.urls')), path('api/', include('fellchensammlung.api.urls')),
###################
## External Site ##
###################
path('external-site/', views.external_site_warning, name="external-site"),
] ]

View File

@ -13,14 +13,16 @@ from notfellchen import settings
from fellchensammlung import logger from fellchensammlung import logger
from .models import AdoptionNotice, Text, Animal, Rule, Image, Report, ModerationAction, \ from .models import AdoptionNotice, Text, Animal, Rule, Image, Report, ModerationAction, \
User, Location, AdoptionNoticeStatus, Subscriptions, CommentNotification, BaseNotification, RescueOrganization, Species User, Location, AdoptionNoticeStatus, Subscriptions, CommentNotification, BaseNotification, RescueOrganization, \
Species, Log, Timestamp
from .forms import AdoptionNoticeForm, AdoptionNoticeFormWithDateWidget, ImageForm, ReportAdoptionNoticeForm, \ from .forms import AdoptionNoticeForm, AdoptionNoticeFormWithDateWidget, ImageForm, ReportAdoptionNoticeForm, \
CommentForm, ReportCommentForm, AnimalForm, \ CommentForm, ReportCommentForm, AnimalForm, \
AdoptionNoticeSearchForm, AnimalFormWithDateWidget, AdoptionNoticeFormWithDateWidgetAutoAnimal AdoptionNoticeSearchForm, AnimalFormWithDateWidget, AdoptionNoticeFormWithDateWidgetAutoAnimal
from .models import Language, Announcement from .models import Language, Announcement
from .tools.geo import GeoAPI from .tools.geo import GeoAPI
from .tools.metrics import gather_metrics_data from .tools.metrics import gather_metrics_data
from .tools.admin import clean_locations from .tools.admin import clean_locations, get_unchecked_adoption_notices, deactivate_unchecked_adoption_notices
from .tasks import add_adoption_notice_location
def user_is_trust_level_or_above(user, trust_level=User.MODERATOR): def user_is_trust_level_or_above(user, trust_level=User.MODERATOR):
@ -46,12 +48,9 @@ def index(request):
lang = Language.objects.get(languagecode=language_code) lang = Language.objects.get(languagecode=language_code)
active_announcements = Announcement.get_active_announcements(lang) active_announcements = Announcement.get_active_announcements(lang)
context = {"adoption_notices": active_adoptions[:5], "adoption_notices_map": active_adoptions, "announcements": active_announcements} context = {"adoption_notices": active_adoptions[:5], "adoption_notices_map": active_adoptions,
for text_code in ["how_to", "introduction"]: "announcements": active_announcements}
try: Text.get_texts(["how_to", "introduction"], lang, context)
context[text_code] = Text.objects.get(text_code=text_code, language=lang, )
except Text.DoesNotExist:
context[text_code] = None
return render(request, 'fellchensammlung/index.html', context=context) return render(request, 'fellchensammlung/index.html', context=context)
@ -81,6 +80,8 @@ def adoption_notice_detail(request, adoption_notice_id):
is_subscribed = True is_subscribed = True
except Subscriptions.DoesNotExist: except Subscriptions.DoesNotExist:
is_subscribed = False is_subscribed = False
else:
is_subscribed = False
has_edit_permission = user_is_owner_or_trust_level(request.user, adoption_notice) has_edit_permission = user_is_owner_or_trust_level(request.user, adoption_notice)
if request.method == 'POST': if request.method == 'POST':
action = request.POST.get("action") action = request.POST.get("action")
@ -94,6 +95,10 @@ def adoption_notice_detail(request, adoption_notice_id):
comment_instance.user = request.user comment_instance.user = request.user
comment_instance.save() comment_instance.save()
"""Log"""
Log.objects.create(user=request.user, action="comment",
text=f"{request.user} hat Kommentar {comment_instance.pk} zur Vermittlung {adoption_notice_id} hinzugefügt")
# Auto-subscribe user to adoption notice # Auto-subscribe user to adoption notice
subscription, created = Subscriptions.objects.get_or_create(adoption_notice=adoption_notice, subscription, created = Subscriptions.objects.get_or_create(adoption_notice=adoption_notice,
owner=request.user) owner=request.user)
@ -141,6 +146,9 @@ def adoption_notice_edit(request, adoption_notice_id):
location = Location.get_location_from_string(adoption_notice_instance.location_string) location = Location.get_location_from_string(adoption_notice_instance.location_string)
adoption_notice_instance.location = location adoption_notice_instance.location = location
adoption_notice_instance.save() adoption_notice_instance.save()
"""Log"""
Log.objects.create(user=request.user, action="adoption_notice_edit", text=f"{request.user} hat Vermittlung {adoption_notice.pk} geändert")
return redirect(reverse("adoption-notice-detail", args=[adoption_notice_instance.pk], )) return redirect(reverse("adoption-notice-detail", args=[adoption_notice_instance.pk], ))
else: else:
form = AdoptionNoticeForm(instance=adoption_notice) form = AdoptionNoticeForm(instance=adoption_notice)
@ -164,14 +172,15 @@ def search(request):
if max_distance == "": if max_distance == "":
max_distance = None max_distance = None
geo_api = GeoAPI() geo_api = GeoAPI()
search_position = geo_api.get_coordinates_from_query(request.POST['postcode']) search_position = geo_api.get_coordinates_from_query(request.POST['location'])
if search_position is None: if search_position is None:
place_not_found = True place_not_found = True
adoption_notices_in_distance = active_adoptions adoption_notices_in_distance = active_adoptions
else: else:
adoption_notices_in_distance = [a for a in active_adoptions if a.in_distance(search_position, max_distance)] adoption_notices_in_distance = [a for a in active_adoptions if a.in_distance(search_position, max_distance)]
context = {"adoption_notices": adoption_notices_in_distance, "search_form": search_form, "place_not_found": place_not_found} context = {"adoption_notices": adoption_notices_in_distance, "search_form": search_form,
"place_not_found": place_not_found}
else: else:
latest_adoption_list = AdoptionNotice.objects.order_by("-created_at") latest_adoption_list = AdoptionNotice.objects.order_by("-created_at")
active_adoptions = [adoption for adoption in latest_adoption_list if adoption.is_active] active_adoptions = [adoption for adoption in latest_adoption_list if adoption.is_active]
@ -183,27 +192,22 @@ def search(request):
@login_required @login_required
def add_adoption_notice(request): def add_adoption_notice(request):
if request.method == 'POST': if request.method == 'POST':
form = AdoptionNoticeFormWithDateWidgetAutoAnimal(request.POST, request.FILES, in_adoption_notice_creation_flow=True) form = AdoptionNoticeFormWithDateWidgetAutoAnimal(request.POST, request.FILES,
in_adoption_notice_creation_flow=True)
if form.is_valid(): if form.is_valid():
instance = form.save(commit=False) instance = form.save(commit=False)
instance.owner = request.user instance.owner = request.user
"""Search the location given in the location string and add it to the adoption notice"""
location = Location.get_location_from_string(instance.location_string)
instance.location = location
instance.save() instance.save()
"""Spin up a task that adds the location"""
add_adoption_notice_location.delay_on_commit(instance.pk)
# Set correct status # Set correct status
if request.user.trust_level >= User.TRUST_LEVEL[User.COORDINATOR]: if request.user.trust_level >= User.TRUST_LEVEL[User.COORDINATOR]:
major_status = AdoptionNoticeStatus.ACTIVE instance.set_active()
minor_status = AdoptionNoticeStatus.MINOR_STATUS_CHOICES[AdoptionNoticeStatus.ACTIVE]["searching"]
else: else:
major_status=AdoptionNoticeStatus.AWAITING_ACTION instance.set_unchecked()
minor_status=AdoptionNoticeStatus.MINOR_STATUS_CHOICES[AdoptionNoticeStatus.AWAITING_ACTION]["waiting_for_review"]
status = AdoptionNoticeStatus.objects.create(major_status=major_status,
minor_status=minor_status,
adoption_notice=instance)
status.save()
# Get the species and number of animals from the form # Get the species and number of animals from the form
species = form.cleaned_data["species"] species = form.cleaned_data["species"]
@ -212,7 +216,12 @@ def add_adoption_notice(request):
date_of_birth = form.cleaned_data["date_of_birth"] date_of_birth = form.cleaned_data["date_of_birth"]
for i in range(0, num_animals): for i in range(0, num_animals):
Animal.objects.create(owner=request.user, Animal.objects.create(owner=request.user,
name=f"{species} {i+1}", adoption_notice=instance, species=species, sex=sex, date_of_birth=date_of_birth) name=f"{species} {i + 1}", adoption_notice=instance, species=species, sex=sex,
date_of_birth=date_of_birth)
"""Log"""
Log.objects.create(user=request.user, action="add_adoption_notice",
text=f"{request.user} hat Vermittlung {instance.pk} hinzugefügt")
return redirect(reverse("adoption-notice-detail", args=[instance.pk])) return redirect(reverse("adoption-notice-detail", args=[instance.pk]))
else: else:
form = AdoptionNoticeFormWithDateWidgetAutoAnimal(in_adoption_notice_creation_flow=True) form = AdoptionNoticeFormWithDateWidgetAutoAnimal(in_adoption_notice_creation_flow=True)
@ -257,6 +266,11 @@ def add_photo_to_animal(request, animal_id):
instance.save() instance.save()
animal.photos.add(instance) animal.photos.add(instance)
"""Log"""
Log.objects.create(user=request.user, action="add_photo_to_animal",
text=f"{request.user} hat Foto {instance.pk} zum Tier {animal.pk} hinzugefügt")
if "save-and-add-another" in request.POST: if "save-and-add-another" in request.POST:
form = ImageForm(in_flow=True) form = ImageForm(in_flow=True)
return render(request, 'fellchensammlung/forms/form-image.html', {'form': form}) return render(request, 'fellchensammlung/forms/form-image.html', {'form': form})
@ -280,6 +294,9 @@ def add_photo_to_adoption_notice(request, adoption_notice_id):
instance.owner = request.user instance.owner = request.user
instance.save() instance.save()
adoption_notice.photos.add(instance) adoption_notice.photos.add(instance)
"""Log"""
Log.objects.create(user=request.user, action="add_photo_to_animal",
text=f"{request.user} hat Foto {instance.pk} zur Vermittlung {adoption_notice.pk} hinzugefügt")
if "save-and-add-another" in request.POST: if "save-and-add-another" in request.POST:
form = ImageForm(in_flow=True) form = ImageForm(in_flow=True)
return render(request, 'fellchensammlung/forms/form-image.html', {'form': form}) return render(request, 'fellchensammlung/forms/form-image.html', {'form': form})
@ -306,6 +323,10 @@ def animal_edit(request, animal_id):
if form.is_valid(): if form.is_valid():
animal = form.save() animal = form.save()
"""Log"""
Log.objects.create(user=request.user, action="add_photo_to_animal",
text=f"{request.user} hat Tier {animal.pk} zum Tier geändert")
return redirect(reverse("animal-detail", args=[animal.pk], )) return redirect(reverse("animal-detail", args=[animal.pk], ))
else: else:
form = AnimalForm(instance=animal) form = AnimalForm(instance=animal)
@ -319,7 +340,7 @@ def about(request):
lang = Language.objects.get(languagecode=language_code) lang = Language.objects.get(languagecode=language_code)
legal = {} legal = {}
for text_code in ["terms_of_service", "privacy_statement", "imprint"]: for text_code in ["terms_of_service", "privacy_statement", "imprint", "about_us"]:
try: try:
legal[text_code] = Text.objects.get(text_code=text_code, language=lang, ) legal[text_code] = Text.objects.get(text_code=text_code, language=lang, )
except Text.DoesNotExist: except Text.DoesNotExist:
@ -423,26 +444,26 @@ def modqueue(request):
context = {"reports": open_reports} context = {"reports": open_reports}
return render(request, 'fellchensammlung/modqueue.html', context=context) return render(request, 'fellchensammlung/modqueue.html', context=context)
@login_required @login_required
def updatequeue(request): def updatequeue(request):
#TODO: Make sure update can only be done for instances with permission
if request.method == "POST": if request.method == "POST":
print(request.POST.get("adoption_notice_id"))
adoption_notice = AdoptionNotice.objects.get(id=request.POST.get("adoption_notice_id")) adoption_notice = AdoptionNotice.objects.get(id=request.POST.get("adoption_notice_id"))
action = request.POST.get("action") action = request.POST.get("action")
print(f"Action: {action}")
if action == "checked_inactive": if action == "checked_inactive":
adoption_notice.set_closed() adoption_notice.set_closed()
elif action == "checked_active": if action == "checked_active":
print("set checked") adoption_notice.set_active()
adoption_notice.set_checked()
if user_is_trust_level_or_above(request.user, User.MODERATOR): if user_is_trust_level_or_above(request.user, User.MODERATOR):
last_checked_adoption_list = AdoptionNotice.objects.order_by("last_checked") last_checked_adoption_list = AdoptionNotice.objects.order_by("last_checked")
else: else:
last_checked_adoption_list = AdoptionNotice.objects.filter(owner=request.user).order_by("last_checked") last_checked_adoption_list = AdoptionNotice.objects.filter(owner=request.user).order_by("last_checked")
adoption_notices = [adoption for adoption in last_checked_adoption_list if adoption.is_active] adoption_notices_active = [adoption for adoption in last_checked_adoption_list if adoption.is_active]
adoption_notices_disabled = [adoption for adoption in last_checked_adoption_list if adoption.is_disabled_unchecked]
context = {"adoption_notices": adoption_notices} context = {"adoption_notices_disabled": adoption_notices_disabled,
"adoption_notices_active": adoption_notices_active}
return render(request, 'fellchensammlung/updatequeue.html', context=context) return render(request, 'fellchensammlung/updatequeue.html', context=context)
@ -456,6 +477,7 @@ def metrics(request):
data = gather_metrics_data() data = gather_metrics_data()
return JsonResponse(data) return JsonResponse(data)
@login_required @login_required
def instance_health_check(request): def instance_health_check(request):
""" """
@ -465,16 +487,20 @@ def instance_health_check(request):
action = request.POST.get("action") action = request.POST.get("action")
if action == "clean_locations": if action == "clean_locations":
clean_locations(quiet=False) clean_locations(quiet=False)
elif action == "deactivate_unchecked_adoption_notices":
deactivate_unchecked_adoption_notices()
number_of_adoption_notices = AdoptionNotice.objects.all().count() number_of_adoption_notices = AdoptionNotice.objects.all().count()
none_geocoded_adoption_notices = AdoptionNotice.objects.filter(location__isnull=True) none_geocoded_adoption_notices = AdoptionNotice.objects.filter(location__isnull=True)
number_not_geocoded_adoption_notices = len(none_geocoded_adoption_notices) number_not_geocoded_adoption_notices = len(none_geocoded_adoption_notices)
number_of_rescue_orgs = RescueOrganization.objects.all().count() number_of_rescue_orgs = RescueOrganization.objects.all().count()
none_geocoded_rescue_orgs = RescueOrganization.objects.filter(location__isnull=True) none_geocoded_rescue_orgs = RescueOrganization.objects.filter(location__isnull=True)
number_not_geocoded_rescue_orgs = len(none_geocoded_rescue_orgs) number_not_geocoded_rescue_orgs = len(none_geocoded_rescue_orgs)
unchecked_ans = get_unchecked_adoption_notices()
number_unchecked_ans = len(unchecked_ans)
# CHECK FOR MISSING TEXTS # CHECK FOR MISSING TEXTS
languages = Language.objects.all() languages = Language.objects.all()
texts = Text.objects.all() texts = Text.objects.all()
@ -487,6 +513,9 @@ def instance_health_check(request):
except Text.DoesNotExist: except Text.DoesNotExist:
missing_texts.append((text_code, language)) missing_texts.append((text_code, language))
# Timestamps
timestamps = Timestamp.objects.all()
context = { context = {
"number_of_adoption_notices": number_of_adoption_notices, "number_of_adoption_notices": number_of_adoption_notices,
"number_not_geocoded_adoption_notices": number_not_geocoded_adoption_notices, "number_not_geocoded_adoption_notices": number_not_geocoded_adoption_notices,
@ -494,10 +523,21 @@ def instance_health_check(request):
"number_of_rescue_orgs": number_of_rescue_orgs, "number_of_rescue_orgs": number_of_rescue_orgs,
"number_not_geocoded_rescue_orgs": number_not_geocoded_rescue_orgs, "number_not_geocoded_rescue_orgs": number_not_geocoded_rescue_orgs,
"none_geocoded_rescue_orgs": none_geocoded_rescue_orgs, "none_geocoded_rescue_orgs": none_geocoded_rescue_orgs,
"missing_texts": missing_texts "missing_texts": missing_texts,
"number_unchecked_ans": number_unchecked_ans,
"unchecked_ans": unchecked_ans,
"timestamps": timestamps
} }
return render(request, 'fellchensammlung/instance-health-check.html', context=context) return render(request, 'fellchensammlung/instance-health-check.html', context=context)
def external_site_warning(request):
url = request.GET.get("url")
context = {"url": url}
language_code = translation.get_language()
lang = Language.objects.get(languagecode=language_code)
texts = Text.get_texts(["external_site_warning", "good_adoption_practices"], language=lang)
context.update(texts)
return render(request, 'fellchensammlung/external_site_warning.html', context=context)

View File

@ -1 +1,8 @@
__version__ = "0.2.0" __version__ = "0.3.1"
# This will make sure the app is always imported when
# Django starts so that shared_task will use this app.
from .celery import app as celery_app
__all__ = ('celery_app',)

33
src/notfellchen/celery.py Normal file
View File

@ -0,0 +1,33 @@
import os
from celery import Celery
from celery.schedules import crontab
from notfellchen import settings
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'notfellchen.settings')
app = Celery('notfellchen')
# Load task modules from all registered Django app configs.
app.config_from_object('django.conf:settings', namespace='CELERY')
app.autodiscover_tasks()
app.conf.beat_schedule = {
'daily-cleanup': {
'task': 'admin.clean_locations',
'schedule': crontab(hour=2),
},
'daily-unchecked-deactivation': {
'task': 'admin.daily_unchecked_deactivation',
'schedule': crontab(hour=1),
},
'daily-404-deactivation': {
'task': 'admin.deactivate_404_adoption_notices',
'schedule': crontab(hour=3),
},
}
if settings.HEALTHCHECKS_URL is not None and settings.HEALTHCHECKS_URL != "":
# If a healthcheck is configured, this will send an hourly ping to the healthchecks server
app.conf.beat_schedule['hourly-healthcheck'] = {'task': 'tools.healthcheck',
'schedule': crontab(minute=43),
}

View File

@ -14,6 +14,7 @@ from pathlib import Path
import os import os
import configparser import configparser
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from celery import Celery
"""CONFIG PARSER """ """CONFIG PARSER """
config = configparser.RawConfigParser() config = configparser.RawConfigParser()
@ -75,16 +76,23 @@ DB_NAME = config.get("database", "name", fallback="notfellchen.sqlite3")
DB_USER = config.get("database", "user", fallback='') DB_USER = config.get("database", "user", fallback='')
DB_PASSWORD = config.get("database", "password", fallback='') DB_PASSWORD = config.get("database", "password", fallback='')
DB_HOST = config.get("database", "host", fallback='') DB_HOST = config.get("database", "host", fallback='')
# Build paths inside the project like this: BASE_DIR / 'subdir'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
LOCALE_PATHS = [os.path.join(BASE_DIR, 'locale')] LOCALE_PATHS = [os.path.join(BASE_DIR, 'locale')]
""" CELERY + KEYDB """
CELERY_BROKER_URL = config.get("celery", "broker", fallback="redis://localhost:6379/0")
CELERY_RESULT_BACKEND = config.get("celery", "backend", fallback="redis://localhost:6379/0")
""" MONITORING """
HEALTHCHECKS_URL = config.get("monitoring", "healthchecks_url", fallback=None)
""" GEOCODING """ """ GEOCODING """
GEOCODING_API_URL = config.get("geocoding", "api_url", fallback="https://nominatim.hyteck.de/search") GEOCODING_API_URL = config.get("geocoding", "api_url", fallback="https://nominatim.hyteck.de/search")
""" Tile Server """ """ Tile Server """
MAP_TILE_SERVER = config.get("map", "tile_server", fallback="https://tiles.hyteck.de") MAP_TILE_SERVER = config.get("map", "tile_server", fallback="https://tiles.hyteck.de")
""" OxiTraffic""" """ OxiTraffic"""
OXITRAFFIC_ENABLED = config.get("tracking", "oxitraffic_enabled", fallback=False) OXITRAFFIC_ENABLED = config.get("tracking", "oxitraffic_enabled", fallback=False)
OXITRAFFIC_BASE_URL = config.get("tracking", "oxitraffic_base_url", fallback="") OXITRAFFIC_BASE_URL = config.get("tracking", "oxitraffic_base_url", fallback="")
@ -179,6 +187,8 @@ MIDDLEWARE = [
ROOT_URLCONF = 'notfellchen.urls' ROOT_URLCONF = 'notfellchen.urls'
SETTINGS_PATH = os.path.dirname(os.path.dirname(__file__))
TEMPLATES = [ TEMPLATES = [
{ {
'BACKEND': 'django.template.backends.django.DjangoTemplates', 'BACKEND': 'django.template.backends.django.DjangoTemplates',

View File

@ -0,0 +1,97 @@
from datetime import timedelta
from django.utils import timezone
from fellchensammlung.tools.admin import get_unchecked_adoption_notices, deactivate_unchecked_adoption_notices, \
deactivate_404_adoption_notices
from fellchensammlung.tools.misc import is_404
from django.test import TestCase
from model_bakery import baker
from fellchensammlung.models import AdoptionNotice
class DeactiviationTest(TestCase):
@classmethod
def setUpTestData(cls):
now = timezone.now()
more_than_three_weeks_ago = now - timedelta(weeks=3, days=2)
less_than_three_weeks_ago = now - timedelta(weeks=1, days=2)
cls.adoption1 = baker.make(AdoptionNotice,
name="TestAdoption1",
created_at=more_than_three_weeks_ago,
last_checked=more_than_three_weeks_ago)
cls.adoption2 = baker.make(AdoptionNotice, name="TestAdoption2")
cls.adoption3 = baker.make(AdoptionNotice,
name="TestAdoption3",
created_at=less_than_three_weeks_ago,
last_checked=less_than_three_weeks_ago)
cls.adoption1.set_active()
cls.adoption3.set_active()
def test_get_unchecked_adoption_notices(self):
result = get_unchecked_adoption_notices()
self.assertIn(self.adoption1, result)
self.assertNotIn(self.adoption2, result)
self.assertNotIn(self.adoption3, result)
def test_deactivate_unchecked_adoption_notices(self):
self.assertTrue(self.adoption1.is_active)
self.assertFalse(self.adoption2.is_active)
self.assertTrue(self.adoption3.is_active)
deactivate_unchecked_adoption_notices()
self.adoption1.refresh_from_db()
self.adoption2.refresh_from_db()
self.adoption3.refresh_from_db()
self.assertFalse(self.adoption1.is_active)
self.assertFalse(self.adoption2.is_active)
self.assertTrue(self.adoption3.is_active)
class PingTest(TestCase):
@classmethod
def setUpTestData(cls):
link_active = "https://hyteck.de/"
link_inactive = "https://hyteck.de/maxwell"
now = timezone.now()
less_than_three_weeks_ago = now - timedelta(weeks=1, days=2)
cls.adoption1 = baker.make(AdoptionNotice,
name="TestAdoption1",
created_at=less_than_three_weeks_ago,
last_checked=less_than_three_weeks_ago,
further_information=link_active)
cls.adoption2 = baker.make(AdoptionNotice,
name="TestAdoption2",
created_at=less_than_three_weeks_ago,
last_checked=less_than_three_weeks_ago,
further_information=link_inactive)
cls.adoption3 = baker.make(AdoptionNotice,
name="TestAdoption3",
created_at=less_than_three_weeks_ago,
last_checked=less_than_three_weeks_ago,
further_information=None)
cls.adoption1.set_active()
cls.adoption2.set_active()
cls.adoption3.set_active()
def test_is_404(self):
urls = [("https://hyteck.de/maxwell", True),
("https://hyteck.de", False)]
for url, expected_result in urls:
self.assertEqual(is_404(url), expected_result)
def test_deactivate_404_adoption_notices(self):
self.assertTrue(self.adoption1.is_active)
self.assertTrue(self.adoption2.is_active)
deactivate_404_adoption_notices()
self.adoption1.refresh_from_db()
self.adoption2.refresh_from_db()
self.assertTrue(self.adoption1.is_active)
self.assertFalse(self.adoption2.is_active)

25
src/tests/test_forms.py Normal file
View File

@ -0,0 +1,25 @@
from django.test import TestCase
from fellchensammlung.forms import AdoptionNoticeFormWithDateWidgetAutoAnimal
from fellchensammlung.models import Species
from model_bakery import baker
class TestAdoptionNoticeFormWithDateWidgetAutoAnimal(TestCase):
@classmethod
def setUpTestData(cls):
rat = baker.make(Species, name="Farbratte")
def test_forms(self):
form_data = {"name": "TestAdoption3",
"species": Species.objects.first(),
"num_animals": "2",
"date_of_birth": "2024-11-04",
"sex": "M",
"group_only": "on",
"searching_since": "2024-11-10",
"location_string": "Mannheim",
"description": "Blaaaa",
"further_information": "https://notfellchen.org",
"save-and-add-another-animal": "Speichern"}
form = AdoptionNoticeFormWithDateWidgetAutoAnimal(data=form_data)
self.assertTrue(form.is_valid())

24
src/tests/test_geo.py Normal file
View File

@ -0,0 +1,24 @@
from fellchensammlung.tools.geo import calculate_distance_between_coordinates
from django.test import TestCase
class DistanceTest(TestCase):
accuracy = 1.05 # 5% off is ok
def test_calculate_distance_between_coordinates(self):
coordinates_berlin = (52.50327,13.41238)
coordinates_stuttgart = (48.77753579028781, 9.185250111016634)
coordinates_weil_im_dorf = (48.813691653929276, 9.112217733791029)
coordinates_with_distance = {"berlin_stuttgart": (coordinates_berlin, coordinates_stuttgart, 510),
"stuttgart_berlin": (coordinates_stuttgart, coordinates_berlin, 510),
"stuttgart_weil": (coordinates_stuttgart, coordinates_weil_im_dorf, 6.7),
}
for key in coordinates_with_distance:
(a, b, distance) = coordinates_with_distance[key]
result = calculate_distance_between_coordinates(a, b)
try:
self.assertLess(result, distance * self.accuracy)
self.assertGreater(result, distance / self.accuracy)
except AssertionError as e:
print(f"Distance calculation failed. Expected {distance}, got {result}")
raise e

View File

@ -4,7 +4,15 @@ from django.utils import timezone
from django.test import TestCase from django.test import TestCase
from model_bakery import baker from model_bakery import baker
from fellchensammlung.models import Announcement, Language from fellchensammlung.models import Announcement, Language, User
class UserTest(TestCase):
def test_creating_user(self):
test_user_1 = User.objects.create(username="Testuser1", password="SUPERSECRET", email="test@example.org")
self.assertTrue(test_user_1.trust_level == 1)
self.assertTrue(test_user_1.trust_level == User.TRUST_LEVEL[User.MEMBER])
class AnnouncementTest(TestCase): class AnnouncementTest(TestCase):
@ -69,4 +77,3 @@ class AnnouncementTest(TestCase):
self.assertTrue(self.announcement2 not in active_announcements) self.assertTrue(self.announcement2 not in active_announcements)
self.assertTrue(self.announcement4 not in active_announcements) self.assertTrue(self.announcement4 not in active_announcements)
self.assertTrue(self.announcement5 in active_announcements) self.assertTrue(self.announcement5 in active_announcements)

View File

@ -4,7 +4,9 @@ from django.urls import reverse
from model_bakery import baker from model_bakery import baker
from fellchensammlung.models import Animal, Species, AdoptionNotice, User from fellchensammlung.models import Animal, Species, AdoptionNotice, User, Location, AdoptionNoticeStatus
from fellchensammlung.views import add_adoption_notice
class AnimalAndAdoptionTest(TestCase): class AnimalAndAdoptionTest(TestCase):
@classmethod @classmethod
@ -18,7 +20,8 @@ class AnimalAndAdoptionTest(TestCase):
first_name="Max", first_name="Max",
last_name="Müller", last_name="Müller",
password='12345') password='12345')
test_user1.save() test_user0.trust_level = User.TRUST_LEVEL[User.ADMIN]
test_user0.save()
adoption1 = baker.make(AdoptionNotice, name="TestAdoption1") adoption1 = baker.make(AdoptionNotice, name="TestAdoption1")
rat = baker.make(Species, name="Farbratte") rat = baker.make(Species, name="Farbratte")
@ -47,3 +50,146 @@ class AnimalAndAdoptionTest(TestCase):
self.assertEqual(str(response.context['user']), 'testuser0') self.assertEqual(str(response.context['user']), 'testuser0')
self.assertContains(response, "TestAdoption1") self.assertContains(response, "TestAdoption1")
self.assertContains(response, "Rat1") self.assertContains(response, "Rat1")
def test_creating_AN_as_admin(self):
self.client.login(username='testuser0', password='12345')
form_data = {"name": "TestAdoption4",
"species": Species.objects.first().pk,
"num_animals": "2",
"date_of_birth": "2024-11-04",
"sex": "M",
"group_only": "on",
"searching_since": "2024-11-10",
"location_string": "Mannheim",
"description": "Blaaaa",
"further_information": "https://notfellchen.org",
"save-and-add-another-animal": "Speichern"}
response = self.client.post(reverse('add-adoption'), data=form_data)
print(response.content)
self.assertTrue(response.status_code < 400)
self.assertTrue(AdoptionNotice.objects.get(name="TestAdoption4").is_active)
class SearchTest(TestCase):
@classmethod
def setUpTestData(cls):
test_user0 = User.objects.create_user(username='testuser0',
first_name="Admin",
last_name="BOFH",
password='12345')
test_user0.save()
# Location of Berlin: lat 52.5170365 lon 13.3888599 PLZ 10115 (Mitte)
adoption1 = baker.make(AdoptionNotice, name="TestAdoption1")
adoption2 = baker.make(AdoptionNotice, name="TestAdoption2")
adoption3 = baker.make(AdoptionNotice, name="TestAdoption3")
berlin = Location.get_location_from_string("Berlin")
adoption1.location = berlin
adoption1.save()
stuttgart = Location.get_location_from_string("Tübingen")
adoption3.location = stuttgart
adoption3.save()
adoption1.set_active()
adoption3.set_active()
adoption2.set_unchecked()
def test_basic_view(self):
response = self.client.get(reverse('search'))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "TestAdoption1")
self.assertNotContains(response, "TestAdoption2")
self.assertContains(response, "TestAdoption3")
def test_basic_view_logged_in(self):
self.client.login(username='testuser0', password='12345')
response = self.client.get(reverse('search'))
self.assertEqual(response.status_code, 200)
# Check our user is logged in
self.assertEqual(str(response.context['user']), 'testuser0')
self.assertContains(response, "TestAdoption1")
self.assertContains(response, "TestAdoption3")
self.assertNotContains(response, "TestAdoption2")
def test_plz_search(self):
response = self.client.post(reverse('search'), {"max_distance": 100, "location": "Berlin"})
self.assertEqual(response.status_code, 200)
self.assertContains(response, "TestAdoption1")
self.assertNotContains(response, "TestAdoption3")
class UpdateQueueTest(TestCase):
@classmethod
def setUpTestData(cls):
test_user0 = User.objects.create_user(username='testuser0',
first_name="Admin",
last_name="BOFH",
password='12345',
trust_level=User.TRUST_LEVEL[User.MODERATOR])
test_user0.is_superuser = True
test_user0.save()
# Location of Berlin: lat 52.5170365 lon 13.3888599 PLZ 10115 (Mitte)
cls.adoption1 = baker.make(AdoptionNotice, name="TestAdoption1")
adoption2 = baker.make(AdoptionNotice, name="TestAdoption2")
cls.adoption3 = baker.make(AdoptionNotice, name="TestAdoption3")
cls.adoption1.set_unchecked()
cls.adoption3.set_unchecked()
def test_login_required(self):
response = self.client.get(reverse('updatequeue'))
self.assertEqual(response.status_code, 302)
self.assertEquals(response.url, "/accounts/login/?next=/updatequeue/")
def test_set_updated(self):
self.client.login(username='testuser0', password='12345')
# First get the list
response = self.client.get(reverse('updatequeue'))
self.assertEqual(response.status_code, 200)
# Make sure Adoption1 is in response
self.assertContains(response, "TestAdoption1")
self.assertNotContains(response, "TestAdoption2")
self.assertFalse(self.adoption1.is_active)
# Mark as checked
response = self.client.post(reverse('updatequeue'), {"adoption_notice_id": self.adoption1.pk,
"action": "checked_active"})
self.assertEqual(response.status_code, 200)
self.adoption1.refresh_from_db()
self.assertTrue(self.adoption1.is_active)
def test_set_checked_inactive(self):
self.client.login(username='testuser0', password='12345')
# First get the list
response = self.client.get(reverse('updatequeue'))
self.assertEqual(response.status_code, 200)
# Make sure Adoption3 is in response
self.assertContains(response, "TestAdoption3")
self.assertNotContains(response, "TestAdoption2")
self.assertFalse(self.adoption3.is_active)
# Mark as checked
response = self.client.post(reverse('updatequeue'),
{"adoption_notice_id": self.adoption3.id, "action": "checked_inactive"})
self.assertEqual(response.status_code, 200)
self.adoption3.refresh_from_db()
# Make sure correct status is set and AN is not shown anymore
self.assertNotContains(response, "TestAdoption3")
self.assertFalse(self.adoption3.is_active)
self.assertEqual(self.adoption3.adoptionnoticestatus.major_status, AdoptionNoticeStatus.CLOSED)