Compare commits

24 Commits

Author SHA1 Message Date
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
34 changed files with 581 additions and 191 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
ENV DOCKER_BUILD=true
RUN apt update
RUN apt install gettext -y
RUN apt install libpq-dev gcc -y
COPY . /app
WORKDIR /app
RUN mkdir /app/data

View File

@@ -106,3 +106,20 @@ Use a program like `gtranslator` or `poedit` to start translations
| Edit adoption notice | User that created, Moderator, Admin |
| Edit animal | 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

@@ -60,3 +60,12 @@ Now we can simply use the InfluxDB as data source in Grafana and configure until
beautiful plots!
.. 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

@@ -12,10 +12,7 @@ Notfellchen Plattform Dokumentation
API/index.rst
.. image:: rtfm.png
:name: RTFM by Elektroll
:scale: 50 %
:alt: Soviet style image of workers holding a sign with a gear and a screwdriver. Below is says "Read the manual"
:name: Ratte lesend
:alt: Zeichnung einer lesenden Ratte
: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

View File

@@ -1,3 +1,18 @@
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 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,10 +35,11 @@ dependencies = [
"markdown",
"Pillow",
"django-registration",
"psycopg2-binary",
"psycopg2",
"django-crispy-forms",
"crispy-bootstrap4",
"djangorestframework"
"djangorestframework",
"celery[redis]"
]
dynamic = ["version", "readme"]

View File

@@ -2,7 +2,7 @@ from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib import admin
from django.utils.html import format_html
from .models import User, Language, Text, ReportComment, ReportAdoptionNotice
from .models import User, Language, Text, ReportComment, ReportAdoptionNotice, Log
from .models import Animal, Species, RescueOrganization, AdoptionNotice, Location, Rule, Image, ModerationAction, \
Comment, Report, Announcement, AdoptionNoticeStatus, User, Subscriptions
@@ -62,3 +62,4 @@ admin.site.register(Text)
admin.site.register(Announcement)
admin.site.register(AdoptionNoticeStatus)
admin.site.register(Subscriptions)
admin.site.register(Log)

View File

@@ -6,7 +6,7 @@ from .models import AdoptionNotice, Animal, Image, ReportAdoptionNotice, ReportC
Comment
from django_registration.forms import RegistrationForm
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 notfellchen.settings import MEDIA_URL
@@ -142,8 +142,8 @@ class CommentForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_action = "comment"
self.helper.form_class = 'form-comments'
self.helper.add_input(Hidden('action', 'comment'))
self.helper.add_input(Submit('submit', _('Kommentieren'), css_class="btn2"))
class Meta:

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

@@ -72,7 +72,7 @@ class User(AbstractUser):
return self.get_absolute_url()
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 owner(self):
@@ -134,6 +134,13 @@ class Location(models.Model):
)
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):
def __str__(self):
@@ -155,7 +162,10 @@ class RescueOrganization(models.Model):
name = models.CharField(max_length=200)
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 = models.ForeignKey(Location, on_delete=models.PROTECT, blank=True, null=True)
instagram = models.URLField(null=True, blank=True, verbose_name=_('Instagram Profil'))
@@ -276,11 +286,11 @@ class AdoptionNotice(models.Model):
if not hasattr(self, 'adoptionnoticestatus'):
return False
return self.adoptionnoticestatus.is_active
def set_checked(self):
self.last_checked = datetime.now()
self.save()
def set_closed(self):
self.last_checked = datetime.now()
self.adoptionnoticestatus.set_closed()
@@ -324,6 +334,7 @@ class AdoptionNoticeStatus(models.Model):
"against_the_rules": "against_the_rules",
"missing_information": "missing_information",
"technical_error": "technical_error",
"unchecked": "unchecked",
"other": "other"
}
}
@@ -351,6 +362,11 @@ class AdoptionNoticeStatus(models.Model):
self.minor_status = self.MINOR_STATUS_CHOICES[self.CLOSED]["other"]
self.save()
def deactivate_unchecked(self):
self.major_status = self.MAJOR_STATUS_CHOICES[self.DISABLED]
self.minor_status = self.MINOR_STATUS_CHOICES[self.DISABLED]["unchecked"]
self.save()
class Animal(models.Model):
MALE_NEUTERED = "M_N"
@@ -516,6 +532,17 @@ class Text(models.Model):
def __str__(self):
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):
"""
@@ -523,7 +550,7 @@ class Announcement(Text):
"""
logged_in_only = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
publish_start_time = models.DateTimeField(verbose_name="Veröffentlichungszeitpunk")
publish_start_time = models.DateTimeField(verbose_name="Veröffentlichungszeitpunkt")
publish_end_time = models.DateTimeField(verbose_name="Veröffentlichungsende")
IMPORTANT = "important"
WARNING = "warning"
@@ -615,4 +642,17 @@ class Subscriptions(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
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"))
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)
def __str__(self):
return f"[{self.action}] - {self.user} - {self.created_at.strftime('%H:%M:%S %d-%m-%Y ')}"

View File

@@ -1,3 +1,7 @@
/***************/
/* MAIN COLORS */
/***************/
:root {
--primary-light-one: #5daa68;
--primary-light-two: #4a9455;
@@ -18,7 +22,9 @@
--text-three: var(--primary-light-one);
--shadow-three: var(--primary-dark-one);
}
/**************************/
/* TAG SETTINGS (GENERAL) */
/**************************/
html, body {
margin: 0;
height: 100%;
@@ -30,10 +36,6 @@ body {
}
.content-box {
margin: 20px;
}
table {
width: 100%;
}
@@ -79,6 +81,127 @@ h1, h2 {
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;
}
}
/*******************************/
/* 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);
}
}
/***********/
/* 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;
}
.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 {
overflow: hidden;
background-color: var(--background-two);
@@ -110,14 +233,7 @@ h1, h2 {
color: white;
}
select, .button {
width: 100%;
border: none;
border-radius: 4px;
opacity: 1;
background-color: var(--secondary-light-one);
}
.header-right select.option {
color: #000;
@@ -135,26 +251,6 @@ select, .button {
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) {
.header a {
@@ -172,25 +268,6 @@ select, .button {
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 {
background-color: #4ba3cd;
color: white;
@@ -203,14 +280,6 @@ select, .button {
border: none;
}
.container-edit-buttons {
display: flex;
flex-wrap: wrap;
.btn {
margin: 5px;
}
}
.form-button:hover, .link-button a:hover, .link-button a:active {
background-color: #4090b6;
@@ -326,18 +395,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 {
border: black 1px solid;
@@ -355,10 +412,7 @@ select, .button {
}
.container-cards {
display: flex;
flex-wrap: wrap;
}
.photos {
@@ -376,14 +430,6 @@ select, .button {
border-radius: 10%;
}
.card {
flex: 1 25%;
margin: 10px;
border-radius: 8px;
padding: 5px;
background: var(--background-three);
color: var(--text-two);
}
.card h1 {
color: var(--text-three);
@@ -514,20 +560,7 @@ select, .button {
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 {
.btn {
@@ -552,6 +585,21 @@ textarea {
}
}
.form-search {
select, input {
background-color: var(--primary-light-one);
color: var(--text-one);
border-radius: 3px;
border: none;
}
}
/************************/
/* GENERAL HIGHLIGHTING */
/************************/
.important {
border: #e01137 4px solid;
}
@@ -564,15 +612,10 @@ textarea {
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 {
background-image: url('../img/logo_transparent.png');
@@ -594,4 +637,4 @@ textarea {
.map-in-content #map {
height: 500px;
width: 90%;
}
}

View File

@@ -0,0 +1,24 @@
from notfellchen.celery import app as celery_app
from .tools.admin import clean_locations, deactivate_unchecked_adoption_notices
from .tools.misc import healthcheck_ok
from .models import Location, AdoptionNotice
@celery_app.task(name="admin.clean_locations")
def task_clean_locations():
clean_locations()
@celery_app.task(name="admin.deactivate_unchecked")
def task_deactivate_unchecked():
deactivate_unchecked_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)
@celery_app.task(name="tools.healthcheck")
def task_healthcheck():
healthcheck_ok()

View File

@@ -2,6 +2,8 @@
{% load i18n %}
{% load custom_tags %}
{% block title %}<title>{% translate "Über uns und Regeln" %}</title> %}{% endblock %}
{% block content %}
<h1>{% translate "Regeln" %}</h1>
{% include "fellchensammlung/lists/list-rules.html" %}

View File

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

View File

@@ -2,23 +2,25 @@
{% load custom_tags %}
{% load i18n %}
{% block title %}<title>{{adoption_notice.name }}</title>{% endblock %}
{% block content %}
<div class="detail-adoption-notice-header">
<h1 class="detail-adoption-notice-header">{{ adoption_notice.name }}
{% if not is_subscribed %}
<form class="notification-card-mark-read" method="post">
{% csrf_token %}
<input type="hidden" name="action" value="subscribe">
<input type="hidden" name="adoption_notice_id" value="{{ adoption_notice.pk }}">
<button class="btn2" type="submit" id="submit"><i class="fa-solid fa-bell"></i></button>
</form>
<form class="notification-card-mark-read" method="post">
{% csrf_token %}
<input type="hidden" name="action" value="subscribe">
<input type="hidden" name="adoption_notice_id" value="{{ adoption_notice.pk }}">
<button class="btn2" type="submit" id="submit"><i class="fa-solid fa-bell"></i></button>
</form>
{% else %}
<form class="notification-card-mark-read" method="post">
{% csrf_token %}
<input type="hidden" name="action" value="unsubscribe">
<input type="hidden" name="adoption_notice_id" value="{{ adoption_notice.pk }}">
<button class="btn2" type="submit" id="submit"><i class="fa-solid fa-bell-slash"></i></button>
</form>
<form class="notification-card-mark-read" method="post">
{% csrf_token %}
<input type="hidden" name="action" value="unsubscribe">
<input type="hidden" name="adoption_notice_id" value="{{ adoption_notice.pk }}">
<button class="btn2" type="submit" id="submit"><i class="fa-solid fa-bell-slash"></i></button>
</form>
{% endif %}
</h1>
{% if has_edit_permission %}
@@ -48,7 +50,14 @@
<td>{{ adoption_notice.searching_since }}</td>
<td>{{ adoption_notice.last_checked | date:'d. F Y' }}</td>
{% 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 %}
<td>-</td>
{% endif %}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,23 +1,24 @@
{% extends "fellchensammlung/base_generic.html" %}
{% load i18n %}
{% block title %}<title>{% translate "Instanz-Check" %}</title> {% endblock %}
{% block content %}
<div class="card">
<h1>{% translate "Instanz-Check" %}</h1>
{% if missing_texts|length > 0 %}
<h2>{% trans "Fehlende Texte" %}</h2>
<p>
<table>
<table>
<tr>
<th>{% translate "Text Code" %}</th>
<th>{% translate "Sprache" %}</th>
</tr>
{% for missing_text in missing_texts %}
<tr>
<th>{% translate "Text Code" %}</th>
<th>{% translate "Sprache" %}</th>
<td>{{ missing_text.0 }}</td>
<td>{{ missing_text.1 }}</td>
</tr>
{% for missing_text in missing_texts %}
<tr>
<td>{{ missing_text.0 }}</td>
<td>{{ missing_text.1 }}</td>
</tr>
{% endfor %}
</table>
{% endfor %}
</table>
</p>
{% else %}
<p>{% translate "Texte scheinen vollständig" %}</p>
@@ -55,6 +56,22 @@
<p>{{ number_not_geocoded_rescue_orgs }}/{{ number_of_rescue_orgs }}</p>
{% 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">
{% csrf_token %}
<input type="hidden" name="action" value="clean_locations">
@@ -62,5 +79,13 @@
<i class="fa-solid fa-broom"></i> {% translate "Erneut lokalisieren" %}
</button>
</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>
{% endblock content %}

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ from django import template
from django.template.defaultfilters import stringfilter
from django.utils.safestring import mark_safe
from notfellchen import settings
from urllib.parse import urlparse
register = template.Library()
@@ -56,6 +57,17 @@ def pointdecimal(value):
except ValueError:
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
def settings_value(name):
return getattr(settings, name)

View File

@@ -1,4 +1,8 @@
from fellchensammlung.models import AdoptionNotice, Location, RescueOrganization
from django.utils import timezone
from datetime import timedelta
from fellchensammlung.models import AdoptionNotice, Location, RescueOrganization, AdoptionNoticeStatus
def clean_locations(quiet=True):
# ADOPTION NOTICES
@@ -7,7 +11,7 @@ def clean_locations(quiet=True):
num_without_location = adoption_notices_without_location.count()
if not quiet:
print(f"From {num_of_all} there are {num_without_location} adoption notices without location "
f"({num_without_location/num_of_all*100:.2f}%)")
f"({num_without_location / num_of_all * 100:.2f}%)")
for adoption_notice in adoption_notices_without_location:
if not quiet:
print(f"Searching {adoption_notice.location_string} in Nominatim")
@@ -28,7 +32,7 @@ def clean_locations(quiet=True):
num_without_location = rescue_orgs_without_location.count()
if not quiet:
print(f"From {num_of_all} there are {num_without_location} adoption notices without location "
f"({num_without_location/num_of_all*100:.2f}%)")
f"({num_without_location / num_of_all * 100:.2f}%)")
for rescue_org in rescue_orgs_without_location:
if not quiet:
print(f"Searching {rescue_org.location_string} in Nominatim")
@@ -41,4 +45,21 @@ def clean_locations(quiet=True):
num_without_location_new = rescue_orgs_without_location_new.count()
num_new = num_without_location - num_without_location_new
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__gte=three_weeks_ago
)
active_unchecked_adoptions = [adoption for adoption in unchecked_adoptions if adoption.is_active]
return active_unchecked_adoptions
def deactivate_unchecked_adoption_notices():
for adoption_notice in get_unchecked_adoption_notices(weeks=3):
AdoptionNoticeStatus.objects.get(adoption_notice=adoption_notice).deactivate_unchecked()

View File

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

View File

@@ -1,4 +1,8 @@
import datetime as datetime
import logging
from notfellchen import settings
import requests
def pluralize(number, letter="e"):
@@ -11,11 +15,11 @@ def pluralize(number, letter="e"):
def age_as_hr_string(age: datetime.timedelta) -> str:
days = age.days
weeks = age.days/7
months = age.days/30
years = age.days/365
weeks = age.days / 7
months = age.days / 30
years = age.days / 365
if years >= 1:
months = months - 12*years
months = months - 12 * years
return f'{years:.0f} Jahr{pluralize(years)} und {months:.0f} Monat{pluralize(months)}'
elif months >= 3:
return f'{months:.0f} Monat{pluralize(months)}'
@@ -23,3 +27,10 @@ def age_as_hr_string(age: datetime.timedelta) -> str:
return f'{weeks:.0f} Woche{pluralize(weeks, "n")}'
else:
return f'{days:.0f} Tag{pluralize(days)}'
def healthcheck_ok():
try:
requests.get(settings.HEALTHCHECK_URL, timeout=10)
except requests.RequestException as e:
logging.error("Ping to healthcheck-server failed: %s" % e)

View File

@@ -79,5 +79,9 @@ urlpatterns = [
#########
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 .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
from .forms import AdoptionNoticeForm, AdoptionNoticeFormWithDateWidget, ImageForm, ReportAdoptionNoticeForm, \
CommentForm, ReportCommentForm, AnimalForm, \
AdoptionNoticeSearchForm, AnimalFormWithDateWidget, AdoptionNoticeFormWithDateWidgetAutoAnimal
from .models import Language, Announcement
from .tools.geo import GeoAPI
from .tools.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):
@@ -46,12 +48,9 @@ def index(request):
lang = Language.objects.get(languagecode=language_code)
active_announcements = Announcement.get_active_announcements(lang)
context = {"adoption_notices": active_adoptions[:5], "adoption_notices_map": active_adoptions, "announcements": active_announcements}
for text_code in ["how_to", "introduction"]:
try:
context[text_code] = Text.objects.get(text_code=text_code, language=lang, )
except Text.DoesNotExist:
context[text_code] = None
context = {"adoption_notices": active_adoptions[:5], "adoption_notices_map": active_adoptions,
"announcements": active_announcements}
Text.get_texts(["how_to", "introduction"], lang, context)
return render(request, 'fellchensammlung/index.html', context=context)
@@ -76,7 +75,7 @@ def change_language(request):
def adoption_notice_detail(request, adoption_notice_id):
adoption_notice = AdoptionNotice.objects.get(id=adoption_notice_id)
if request.user.is_authenticated:
try:
try:
subscription = Subscriptions.objects.get(owner=request.user, adoption_notice=adoption_notice)
is_subscribed = True
except Subscriptions.DoesNotExist:
@@ -94,6 +93,10 @@ def adoption_notice_detail(request, adoption_notice_id):
comment_instance.user = request.user
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
subscription, created = Subscriptions.objects.get_or_create(adoption_notice=adoption_notice,
owner=request.user)
@@ -104,9 +107,9 @@ def adoption_notice_detail(request, adoption_notice_id):
# Create a notification but only if the user is not the one that posted the comment
if subscription.owner != request.user:
notification = CommentNotification(user=subscription.owner,
title=f"{adoption_notice.name} - Neuer Kommentar",
text=f"{request.user}: {comment_instance.text}",
comment=comment_instance)
title=f"{adoption_notice.name} - Neuer Kommentar",
text=f"{request.user}: {comment_instance.text}",
comment=comment_instance)
notification.save()
else:
comment_form = CommentForm(instance=adoption_notice)
@@ -114,13 +117,13 @@ def adoption_notice_detail(request, adoption_notice_id):
Subscriptions.objects.create(owner=request.user, adoption_notice=adoption_notice)
is_subscribed = True
if action == "unsubscribe":
subscription.delete()
is_subscribed = False
subscription.delete()
is_subscribed = False
else:
raise PermissionDenied
else:
comment_form = CommentForm(instance=adoption_notice)
context = {"adoption_notice": adoption_notice,"comment_form": comment_form, "user": request.user,
context = {"adoption_notice": adoption_notice, "comment_form": comment_form, "user": request.user,
"has_edit_permission": has_edit_permission, "is_subscribed": is_subscribed}
return render(request, 'fellchensammlung/details/detail_adoption_notice.html', context=context)
@@ -141,6 +144,9 @@ def adoption_notice_edit(request, adoption_notice_id):
location = Location.get_location_from_string(adoption_notice_instance.location_string)
adoption_notice_instance.location = location
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], ))
else:
form = AdoptionNoticeForm(instance=adoption_notice)
@@ -170,8 +176,9 @@ def search(request):
adoption_notices_in_distance = active_adoptions
else:
adoption_notices_in_distance = [a for a in active_adoptions if a.in_distance(search_position, max_distance)]
context = {"adoption_notices": adoption_notices_in_distance, "search_form": search_form, "place_not_found": place_not_found}
context = {"adoption_notices": adoption_notices_in_distance, "search_form": search_form,
"place_not_found": place_not_found}
else:
latest_adoption_list = AdoptionNotice.objects.order_by("-created_at")
active_adoptions = [adoption for adoption in latest_adoption_list if adoption.is_active]
@@ -183,36 +190,43 @@ def search(request):
@login_required
def add_adoption_notice(request):
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():
instance = form.save(commit=False)
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()
"""Spin up a task that adds the location"""
add_adoption_notice_location.delay_on_commit(instance.pk)
# Set correct status
if request.user.trust_level >= User.TRUST_LEVEL[User.COORDINATOR]:
major_status = AdoptionNoticeStatus.ACTIVE
minor_status = AdoptionNoticeStatus.MINOR_STATUS_CHOICES[AdoptionNoticeStatus.ACTIVE]["searching"]
else:
major_status=AdoptionNoticeStatus.AWAITING_ACTION
minor_status=AdoptionNoticeStatus.MINOR_STATUS_CHOICES[AdoptionNoticeStatus.AWAITING_ACTION]["waiting_for_review"]
major_status = AdoptionNoticeStatus.AWAITING_ACTION
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)
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"]
sex = form.cleaned_data["sex"]
num_animals = form.cleaned_data["num_animals"]
date_of_birth = form.cleaned_data["date_of_birth"]
for i in range(0, num_animals):
Animal.objects.create(owner=request.user,
name=f"{species} {i+1}", adoption_notice=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]))
else:
form = AdoptionNoticeFormWithDateWidgetAutoAnimal(in_adoption_notice_creation_flow=True)
@@ -257,6 +271,11 @@ def add_photo_to_animal(request, animal_id):
instance.save()
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:
form = ImageForm(in_flow=True)
return render(request, 'fellchensammlung/forms/form-image.html', {'form': form})
@@ -280,6 +299,9 @@ def add_photo_to_adoption_notice(request, adoption_notice_id):
instance.owner = request.user
instance.save()
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:
form = ImageForm(in_flow=True)
return render(request, 'fellchensammlung/forms/form-image.html', {'form': form})
@@ -306,6 +328,10 @@ def animal_edit(request, animal_id):
if form.is_valid():
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], ))
else:
form = AnimalForm(instance=animal)
@@ -423,8 +449,10 @@ def modqueue(request):
context = {"reports": open_reports}
return render(request, 'fellchensammlung/modqueue.html', context=context)
@login_required
def updatequeue(request):
#TODO: Make sure update can only be done for instances with permission
if request.method == "POST":
print(request.POST.get("adoption_notice_id"))
adoption_notice = AdoptionNotice.objects.get(id=request.POST.get("adoption_notice_id"))
@@ -447,7 +475,7 @@ def updatequeue(request):
def map(request):
adoption_notices = AdoptionNotice.objects.all() #TODO: Filter to active
adoption_notices = AdoptionNotice.objects.all() #TODO: Filter to active
context = {"adoption_notices_map": adoption_notices}
return render(request, 'fellchensammlung/map.html', context=context)
@@ -456,6 +484,7 @@ def metrics(request):
data = gather_metrics_data()
return JsonResponse(data)
@login_required
def instance_health_check(request):
"""
@@ -465,16 +494,20 @@ def instance_health_check(request):
action = request.POST.get("action")
if action == "clean_locations":
clean_locations(quiet=False)
elif action == "deactivate_unchecked_adoption_notices":
deactivate_unchecked_adoption_notices()
number_of_adoption_notices = AdoptionNotice.objects.all().count()
none_geocoded_adoption_notices = AdoptionNotice.objects.filter(location__isnull=True)
number_not_geocoded_adoption_notices = len(none_geocoded_adoption_notices)
number_of_rescue_orgs = RescueOrganization.objects.all().count()
none_geocoded_rescue_orgs = RescueOrganization.objects.filter(location__isnull=True)
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
languages = Language.objects.all()
texts = Text.objects.all()
@@ -494,10 +527,19 @@ def instance_health_check(request):
"number_of_rescue_orgs": number_of_rescue_orgs,
"number_not_geocoded_rescue_orgs": number_not_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
}
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)
Text.get_texts(["external_site_warning", "good_adoption_practices"], language=lang)
return render(request, 'fellchensammlung/external_site_warning.html', context=context)

View File

@@ -1 +1,8 @@
__version__ = "0.2.0"
__version__ = "0.3.0"
# 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',)

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

@@ -0,0 +1,29 @@
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-deactivation': {
'task': 'admin.deactivate_unchecked',
'schedule': crontab(hour=1),
},
}
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=32),
}

View File

@@ -14,6 +14,7 @@ from pathlib import Path
import os
import configparser
from django.utils.translation import gettext_lazy as _
from celery import Celery
"""CONFIG PARSER """
config = configparser.RawConfigParser()
@@ -79,6 +80,13 @@ DB_HOST = config.get("database", "host", fallback='')
BASE_DIR = Path(__file__).resolve().parent.parent
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_API_URL = config.get("geocoding", "api_url", fallback="https://nominatim.hyteck.de/search")
""" Tile Server """