Compare commits

...

39 Commits

Author SHA1 Message Date
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
26 changed files with 653 additions and 87 deletions

View File

@ -51,6 +51,7 @@ Therefore, a solution is used where a number of predefined texts per site are su
| `privacy_statement` | About |
| `terms_of_service` | About |
| `imprint` | About |
| `about_us` | About |
| Any rule | About |
# Developer Notes

View File

@ -1,6 +1,7 @@
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

View File

@ -6,5 +6,6 @@ Users guide
:caption: Contents:
registrierung.rst
benachrichtigungen.rst
vermittlungen.rst
moderationskonzept.rt
benachrichtigungen.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

@ -41,8 +41,14 @@ dependencies = [
"djangorestframework",
"celery[redis]"
]
dynamic = ["version", "readme"]
[project.optional-dependencies]
develop = [
"pytest",
]
[project.urls]
homepage = "https://notfellchen.org"
repository = "https://codeberg.org/moanos/notfellchen/"

View File

@ -1,5 +1,3 @@
import datetime
from django import forms
from .models import AdoptionNotice, Animal, Image, ReportAdoptionNotice, ReportComment, ModerationAction, User, Species, \
@ -175,5 +173,5 @@ def _get_distances():
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"))

View File

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

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

@ -3,7 +3,6 @@ import uuid
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from datetime import datetime
from django.utils import timezone
from django.dispatch import receiver
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,
verbose_name=_('Bevorzugte Sprache'))
trust_level = models.IntegerField(choices=TRUST_LEVEL, default=TRUST_LEVEL[MEMBER])
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = _('Nutzer*in')
@ -83,6 +83,8 @@ class Image(models.Model):
image = models.ImageField(upload_to='images')
alt_text = models.TextField(max_length=2000)
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):
return self.alt_text
@ -96,6 +98,8 @@ class Species(models.Model):
"""Model representing a species of animal."""
name = models.CharField(max_length=200, help_text=_('Name der Tierart'),
verbose_name=_('Name'))
updated_at = models.DateTimeField(auto_now=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
"""String for representing the Model object."""
@ -111,6 +115,8 @@ class Location(models.Model):
latitude = models.FloatField()
longitude = models.FloatField()
name = models.CharField(max_length=2000)
updated_at = models.DateTimeField(auto_now=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"{self.name} ({self.latitude:.5}, {self.longitude:.5})"
@ -172,6 +178,8 @@ class RescueOrganization(models.Model):
facebook = models.URLField(null=True, blank=True, verbose_name=_('Facebook Profil'))
fediverse_profile = models.URLField(null=True, blank=True, verbose_name=_('Fediverse Profil'))
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)
class AdoptionNotice(models.Model):
@ -181,10 +189,13 @@ class AdoptionNotice(models.Model):
]
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)
last_checked = models.DateTimeField(verbose_name=_('Zuletzt überprüft am'), default=datetime.now)
created_at = models.DateField(verbose_name=_('Erstellt am'), default=timezone.now)
updated_at = models.DateTimeField(auto_now=True)
last_checked = models.DateTimeField(verbose_name=_('Zuletzt überprüft am'), default=timezone.now)
searching_since = models.DateField(verbose_name=_('Sucht nach einem Zuhause seit'))
name = models.CharField(max_length=200)
description = models.TextField(null=True, blank=True, verbose_name=_('Beschreibung'))
@ -288,19 +299,27 @@ class AdoptionNotice(models.Model):
return self.adoptionnoticestatus.is_active
@property
def is_to_be_checked(self, include_active=False):
def is_disabled_unchecked(self):
if not hasattr(self, 'adoptionnoticestatus'):
return False
return self.adoptionnoticestatus.is_to_be_checked or (include_active and self.adoptionnoticestatus.is_active)
def set_checked(self):
self.last_checked = datetime.now()
self.save()
return self.adoptionnoticestatus.is_disabled_unchecked
def set_closed(self):
self.last_checked = datetime.now()
self.last_checked = timezone.now()
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):
"""
@ -351,32 +370,52 @@ class AdoptionNoticeStatus(models.Model):
minor_choices.update(MINOR_STATUS_CHOICES[key])
minor_status = models.CharField(choices=minor_choices, max_length=200)
adoption_notice = models.OneToOneField(AdoptionNotice, on_delete=models.CASCADE)
updated_at = models.DateTimeField(auto_now=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"{self.adoption_notice}: {self.major_status}, {self.minor_status}"
def as_string(self):
return f"{self.major_status}, {self.minor_status}"
@property
def is_active(self):
return self.major_status == self.ACTIVE
@property
def is_to_be_checked(self):
def is_disabled_unchecked(self):
return self.major_status == self.DISABLED and self.minor_status == "unchecked"
@staticmethod
def get_minor_choices(major_status):
return AdoptionNoticeStatus.MINOR_STATUS_CHOICES[major_status]
@staticmethod
def create_other(an_instance):
# Used as empty status to be changed immediately
major_status = AdoptionNoticeStatus.DISABLED
minor_status = AdoptionNoticeStatus.MINOR_STATUS_CHOICES[AdoptionNoticeStatus.DISABLED]["other"]
AdoptionNoticeStatus.objects.create(major_status=major_status,
minor_status=minor_status,
adoption_notice=an_instance)
def set_closed(self):
self.major_status = self.MAJOR_STATUS_CHOICES[self.CLOSED]
self.minor_status = self.MINOR_STATUS_CHOICES[self.CLOSED]["other"]
self.save()
def deactivate_unchecked(self):
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):
MALE_NEUTERED = "M_N"
@ -398,13 +437,15 @@ class Animal(models.Model):
sex = models.CharField(max_length=20, choices=SEX_CHOICES, )
adoption_notice = models.ForeignKey(AdoptionNotice, on_delete=models.CASCADE)
owner = models.ForeignKey(User, on_delete=models.CASCADE)
updated_at = models.DateTimeField(auto_now=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"{self.name}"
@property
def age(self):
return datetime.today().date() - self.date_of_birth
return timezone.now().today().date() - self.date_of_birth
@property
def hr_age(self):
@ -441,6 +482,8 @@ class Rule(models.Model):
language = models.ForeignKey(Language, on_delete=models.PROTECT)
# Rule identifier allows to translate rules with the same identifier
rule_identifier = models.CharField(max_length=24)
updated_at = models.DateTimeField(auto_now=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.title
@ -464,6 +507,7 @@ class Report(models.Model):
reported_broken_rules = models.ManyToManyField(Rule)
user_comment = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f"[{self.status}]: {self.user_comment:.20}"
@ -510,12 +554,12 @@ class ModerationAction(models.Model):
}
action = models.CharField(max_length=30, choices=ACTIONS.items())
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
public_comment = models.TextField(blank=True)
# Only visible to moderator
private_comment = models.TextField(blank=True)
report = models.ForeignKey(Report, on_delete=models.CASCADE)
# TODO: Needs field for moderator that performed the action
def __str__(self):
return f"[{self.action}]: {self.public_comment}"
@ -560,6 +604,7 @@ class Announcement(Text):
"""
logged_in_only = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
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")
IMPORTANT = "important"
@ -608,6 +653,7 @@ class Comment(models.Model):
"""
user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name=_('Nutzer*in'))
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
adoption_notice = models.ForeignKey(AdoptionNotice, on_delete=models.CASCADE, verbose_name=_('AdoptionNotice'))
text = models.TextField(verbose_name="Inhalt")
reply_to = models.ForeignKey("self", verbose_name="Antwort auf", blank=True, null=True, on_delete=models.CASCADE)
@ -625,6 +671,7 @@ class Comment(models.Model):
class BaseNotification(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
title = models.CharField(max_length=100)
text = models.TextField(verbose_name="Inhalt")
user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name=_('Nutzer*in'))
@ -650,6 +697,7 @@ class Subscriptions(models.Model):
owner = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name=_('Nutzer*in'))
adoption_notice = models.ForeignKey(AdoptionNotice, on_delete=models.CASCADE, verbose_name=_('AdoptionNotice'))
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f"{self.owner} - {self.adoption_notice}"
@ -659,10 +707,11 @@ 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"))
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 ')}"

View File

@ -115,6 +115,10 @@ textarea {
}
}
.spaced {
margin-bottom: 30px;
}
/*******************************/
/* PARTIAL SPECIFIC CONTAINERS */
/*******************************/
@ -163,6 +167,16 @@ textarea {
}
}
/*************/
/* Modifiers */
/*************/
/* Used to enlargen cards */
.full-width {
width: 100%;
flex: none;
}
/***********/
/* BUTTONS */
/***********/

View File

@ -1,6 +1,6 @@
from datetime import datetime
from django.utils import timezone
from notfellchen.celery import app as celery_app
from .tools.admin import clean_locations, deactivate_unchecked_adoption_notices
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
@ -8,9 +8,9 @@ from .models import Location, AdoptionNotice, Timestamp
def set_timestamp(key: str):
try:
ts = Timestamp.objects.get(key=key)
ts.timestamp = datetime.now()
ts.timestamp = timezone.now()
except Timestamp.DoesNotExist:
Timestamp.objects.create(key=key, timestamp=datetime.now())
Timestamp.objects.create(key=key, timestamp=timezone.now())
@celery_app.task(name="admin.clean_locations")
@ -19,10 +19,16 @@ def task_clean_locations():
set_timestamp("task_clean_locations")
@celery_app.task(name="admin.deactivate_unchecked")
@celery_app.task(name="admin.daily_unchecked_deactivation")
def task_deactivate_unchecked():
deactivate_unchecked_adoption_notices()
set_timestamp("task_deactivate_unchecked")
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")

View File

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

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

@ -3,32 +3,16 @@
{% block content %}
<h1>{% translate "Aktualitätscheck" %}</h1>
<p>{% translate "Überprüfe ob Vermittlungen noch aktuell sind" %}</p>
{% for adoption_notice in adoption_notices %}
<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>
<div class="container-cards spaced">
<h1>{% translate 'Deaktivierte Vermittlungen zur Überprüfung' %}</h1>
{% for adoption_notice in adoption_notices_disabled %}
{% include "fellchensammlung/partials/partial-check-adoption-notice.html" %}
{% 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 %}

View File

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

View File

@ -1,7 +1,10 @@
import logging
from django.utils import timezone
from datetime import timedelta
from fellchensammlung.models import AdoptionNotice, Location, RescueOrganization, AdoptionNoticeStatus
from fellchensammlung.models import AdoptionNotice, Location, RescueOrganization, AdoptionNoticeStatus, Log
from fellchensammlung.tools.misc import is_404
def clean_locations(quiet=True):
@ -54,12 +57,28 @@ def get_unchecked_adoption_notices(weeks=3):
# Query for active adoption notices that were checked in the last three weeks
unchecked_adoptions = AdoptionNotice.objects.filter(
last_checked__gte=three_weeks_ago
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).deactivate_unchecked()
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

@ -34,3 +34,11 @@ def healthcheck_ok():
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

@ -172,7 +172,7 @@ def search(request):
if max_distance == "":
max_distance = None
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:
place_not_found = True
adoption_notices_in_distance = active_adoptions
@ -205,16 +205,9 @@ def add_adoption_notice(request):
# 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"]
instance.set_active()
else:
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)
status.save()
instance.set_unchecked()
# Get the species and number of animals from the form
species = form.cleaned_data["species"]
@ -347,7 +340,7 @@ def about(request):
lang = Language.objects.get(languagecode=language_code)
legal = {}
for text_code in ["terms_of_service", "privacy_statement", "imprint"]:
for text_code in ["terms_of_service", "privacy_statement", "imprint", "about_us"]:
try:
legal[text_code] = Text.objects.get(text_code=text_code, language=lang, )
except Text.DoesNotExist:
@ -456,23 +449,21 @@ def modqueue(request):
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"))
action = request.POST.get("action")
print(f"Action: {action}")
if action == "checked_inactive":
adoption_notice.set_closed()
elif action == "checked_active":
print("set checked")
adoption_notice.set_checked()
if action == "checked_active":
adoption_notice.set_active()
if user_is_trust_level_or_above(request.user, User.MODERATOR):
last_checked_adoption_list = AdoptionNotice.objects.order_by("last_checked")
else:
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 or adoption.is_to_be_checked]
context = {"adoption_notices": adoption_notices}
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_disabled": adoption_notices_disabled,
"adoption_notices_active": adoption_notices_active}
return render(request, 'fellchensammlung/updatequeue.html', context=context)

View File

@ -1,4 +1,4 @@
__version__ = "0.3.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.

View File

@ -16,10 +16,14 @@ app.conf.beat_schedule = {
'task': 'admin.clean_locations',
'schedule': crontab(hour=2),
},
'daily-deactivation': {
'task': 'admin.deactivate_unchecked',
'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 != "":

View File

@ -76,6 +76,7 @@ DB_NAME = config.get("database", "name", fallback="notfellchen.sqlite3")
DB_USER = config.get("database", "user", fallback='')
DB_PASSWORD = config.get("database", "password", fallback='')
DB_HOST = config.get("database", "host", fallback='')
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
LOCALE_PATHS = [os.path.join(BASE_DIR, 'locale')]
@ -92,7 +93,6 @@ GEOCODING_API_URL = config.get("geocoding", "api_url", fallback="https://nominat
""" Tile Server """
MAP_TILE_SERVER = config.get("map", "tile_server", fallback="https://tiles.hyteck.de")
""" OxiTraffic"""
OXITRAFFIC_ENABLED = config.get("tracking", "oxitraffic_enabled", fallback=False)
OXITRAFFIC_BASE_URL = config.get("tracking", "oxitraffic_base_url", fallback="")
@ -187,6 +187,8 @@ MIDDLEWARE = [
ROOT_URLCONF = 'notfellchen.urls'
SETTINGS_PATH = os.path.dirname(os.path.dirname(__file__))
TEMPLATES = [
{
'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)

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,9 @@ from django.urls import reverse
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):
@classmethod
@ -47,3 +49,123 @@ class AnimalAndAdoptionTest(TestCase):
self.assertEqual(str(response.context['user']), 'testuser0')
self.assertContains(response, "TestAdoption1")
self.assertContains(response, "Rat1")
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)