Compare commits
39 Commits
6702211c05
...
ca8996fff6
Author | SHA1 | Date | |
---|---|---|---|
ca8996fff6 | |||
eb734d2716 | |||
655e304c6c | |||
8e34ed440e | |||
0c7080f005 | |||
0b93b5eccb | |||
f1d9f7ad22 | |||
4e71ac7866 | |||
1d0a42a7e1 | |||
d384e75746 | |||
70154abd37 | |||
ab3437e61d | |||
0ccbb18411 | |||
e6f12ce5b1 | |||
6325de17d9 | |||
b9d6293546 | |||
dbe52e4884 | |||
3c286d84d8 | |||
227fa4d5a8 | |||
d47f181e1d | |||
272046142e | |||
5c18832961 | |||
d59cc0034a | |||
64024be833 | |||
5ef20bdce0 | |||
7ddd7b0c0c | |||
cbd8700917 | |||
6eb2f5000f | |||
1cd70228b9 | |||
23d8e85031 | |||
4fb92d8215 | |||
6dfc92bf15 | |||
2015f8b332 | |||
66a0b42718 | |||
efecfc910d | |||
96bc44c508 | |||
a2c8f469a7 | |||
a98b428614 | |||
dfede77e98 |
@ -51,6 +51,7 @@ Therefore, a solution is used where a number of predefined texts per site are su
|
|||||||
| `privacy_statement` | About |
|
| `privacy_statement` | About |
|
||||||
| `terms_of_service` | About |
|
| `terms_of_service` | About |
|
||||||
| `imprint` | About |
|
| `imprint` | About |
|
||||||
|
| `about_us` | About |
|
||||||
| Any rule | About |
|
| Any rule | About |
|
||||||
|
|
||||||
# Developer Notes
|
# Developer Notes
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
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.
|
Jede Vermittlung kann abonniert werden. Dafür klickst du auf die Glocke neben dem Titel der Vermittlung.
|
||||||
|
|
||||||
.. image:: abonnieren.png
|
.. image:: abonnieren.png
|
||||||
|
@ -6,5 +6,6 @@ Users guide
|
|||||||
:caption: Contents:
|
:caption: Contents:
|
||||||
|
|
||||||
registrierung.rst
|
registrierung.rst
|
||||||
benachrichtigungen.rst
|
|
||||||
vermittlungen.rst
|
vermittlungen.rst
|
||||||
|
moderationskonzept.rt
|
||||||
|
benachrichtigungen.rst
|
||||||
|
29
docs/user/moderationskonzept.rst
Normal file
29
docs/user/moderationskonzept.rst
Normal 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.
|
@ -41,8 +41,14 @@ dependencies = [
|
|||||||
"djangorestframework",
|
"djangorestframework",
|
||||||
"celery[redis]"
|
"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/"
|
||||||
|
@ -1,5 +1,3 @@
|
|||||||
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, \
|
||||||
@ -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"))
|
||||||
|
@ -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):
|
||||||
|
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
20
src/fellchensammlung/migrations/0013_alter_log_user.py
Normal file
20
src/fellchensammlung/migrations/0013_alter_log_user.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
@ -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')
|
||||||
@ -83,6 +83,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 +98,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,6 +115,8 @@ 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})"
|
||||||
@ -172,6 +178,8 @@ class RescueOrganization(models.Model):
|
|||||||
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'))
|
||||||
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)
|
||||||
|
|
||||||
|
|
||||||
class AdoptionNotice(models.Model):
|
class AdoptionNotice(models.Model):
|
||||||
@ -181,10 +189,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'))
|
||||||
@ -288,19 +299,27 @@ class AdoptionNotice(models.Model):
|
|||||||
return self.adoptionnoticestatus.is_active
|
return self.adoptionnoticestatus.is_active
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_to_be_checked(self, include_active=False):
|
def is_disabled_unchecked(self):
|
||||||
if not hasattr(self, 'adoptionnoticestatus'):
|
if not hasattr(self, 'adoptionnoticestatus'):
|
||||||
return False
|
return False
|
||||||
return self.adoptionnoticestatus.is_to_be_checked or (include_active and self.adoptionnoticestatus.is_active)
|
return self.adoptionnoticestatus.is_disabled_unchecked
|
||||||
|
|
||||||
def set_checked(self):
|
|
||||||
self.last_checked = datetime.now()
|
|
||||||
self.save()
|
|
||||||
|
|
||||||
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):
|
||||||
"""
|
"""
|
||||||
@ -351,32 +370,52 @@ 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
|
@property
|
||||||
def is_to_be_checked(self):
|
def is_disabled_unchecked(self):
|
||||||
return self.major_status == self.DISABLED and self.minor_status == "unchecked"
|
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 deactivate_unchecked(self):
|
def set_unchecked(self):
|
||||||
self.major_status = self.MAJOR_STATUS_CHOICES[self.DISABLED]
|
self.major_status = self.MAJOR_STATUS_CHOICES[self.DISABLED]
|
||||||
self.minor_status = self.MINOR_STATUS_CHOICES[self.DISABLED]["unchecked"]
|
self.minor_status = self.MINOR_STATUS_CHOICES[self.DISABLED]["unchecked"]
|
||||||
self.save()
|
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"
|
||||||
@ -398,13 +437,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):
|
||||||
@ -441,6 +482,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
|
||||||
@ -464,6 +507,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}"
|
||||||
@ -510,12 +554,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}"
|
||||||
@ -560,6 +604,7 @@ 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)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
publish_start_time = models.DateTimeField(verbose_name="Veröffentlichungszeitpunkt")
|
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"
|
||||||
@ -608,6 +653,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)
|
||||||
@ -625,6 +671,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'))
|
||||||
@ -650,6 +697,7 @@ 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}"
|
||||||
@ -659,10 +707,11 @@ class Log(models.Model):
|
|||||||
"""
|
"""
|
||||||
Basic class that allows logging random entries for later inspection
|
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"))
|
action = models.CharField(max_length=255, verbose_name=_("Aktion"))
|
||||||
text = models.CharField(max_length=1000, verbose_name=_("Log text"))
|
text = models.CharField(max_length=1000, verbose_name=_("Log text"))
|
||||||
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.action}] - {self.user} - {self.created_at.strftime('%H:%M:%S %d-%m-%Y ')}"
|
return f"[{self.action}] - {self.user} - {self.created_at.strftime('%H:%M:%S %d-%m-%Y ')}"
|
||||||
|
@ -115,6 +115,10 @@ textarea {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.spaced {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
/*******************************/
|
/*******************************/
|
||||||
/* PARTIAL SPECIFIC CONTAINERS */
|
/* PARTIAL SPECIFIC CONTAINERS */
|
||||||
/*******************************/
|
/*******************************/
|
||||||
@ -163,6 +167,16 @@ textarea {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*************/
|
||||||
|
/* Modifiers */
|
||||||
|
/*************/
|
||||||
|
|
||||||
|
/* Used to enlargen cards */
|
||||||
|
.full-width {
|
||||||
|
width: 100%;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
/***********/
|
/***********/
|
||||||
/* BUTTONS */
|
/* BUTTONS */
|
||||||
/***********/
|
/***********/
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
from datetime import datetime
|
from django.utils import timezone
|
||||||
from notfellchen.celery import app as celery_app
|
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 .tools.misc import healthcheck_ok
|
||||||
from .models import Location, AdoptionNotice, Timestamp
|
from .models import Location, AdoptionNotice, Timestamp
|
||||||
|
|
||||||
@ -8,9 +8,9 @@ from .models import Location, AdoptionNotice, Timestamp
|
|||||||
def set_timestamp(key: str):
|
def set_timestamp(key: str):
|
||||||
try:
|
try:
|
||||||
ts = Timestamp.objects.get(key=key)
|
ts = Timestamp.objects.get(key=key)
|
||||||
ts.timestamp = datetime.now()
|
ts.timestamp = timezone.now()
|
||||||
except Timestamp.DoesNotExist:
|
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")
|
@celery_app.task(name="admin.clean_locations")
|
||||||
@ -19,10 +19,16 @@ def task_clean_locations():
|
|||||||
set_timestamp("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():
|
def task_deactivate_unchecked():
|
||||||
deactivate_unchecked_adoption_notices()
|
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")
|
@celery_app.task(name="commit.add_location")
|
||||||
|
@ -2,9 +2,14 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load custom_tags %}
|
{% 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 %}
|
{% 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" %}
|
||||||
|
|
||||||
|
@ -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>
|
@ -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>
|
{% endfor %}
|
||||||
{% if adoption_notice.further_information %}
|
</div>
|
||||||
<p>{% translate "Externe Quelle" %}: {{ adoption_notice.link_to_more_information | safe }}</p>
|
<div class="container-cards spaced">
|
||||||
{% endif %}
|
<h1>{% translate 'Aktive Vermittlungen zur Überprüfung' %}</h1>
|
||||||
<div>
|
{% for adoption_notice in adoption_notices_active %}
|
||||||
<form method="post">
|
{% include "fellchensammlung/partials/partial-check-adoption-notice.html" %}
|
||||||
{% csrf_token %}
|
{% endfor %}
|
||||||
<input type="hidden"
|
</div>
|
||||||
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 %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -1,3 +0,0 @@
|
|||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
# Create your tests here.
|
|
@ -1,7 +1,10 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from datetime import timedelta
|
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):
|
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
|
# Query for active adoption notices that were checked in the last three weeks
|
||||||
unchecked_adoptions = AdoptionNotice.objects.filter(
|
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]
|
active_unchecked_adoptions = [adoption for adoption in unchecked_adoptions if adoption.is_active]
|
||||||
return active_unchecked_adoptions
|
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():
|
def deactivate_unchecked_adoption_notices():
|
||||||
for adoption_notice in get_unchecked_adoption_notices(weeks=3):
|
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)
|
||||||
|
@ -34,3 +34,11 @@ def healthcheck_ok():
|
|||||||
requests.get(settings.HEALTHCHECKS_URL, timeout=10)
|
requests.get(settings.HEALTHCHECKS_URL, timeout=10)
|
||||||
except requests.RequestException as e:
|
except requests.RequestException as e:
|
||||||
logging.error("Ping to healthcheck-server failed: %s" % 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}")
|
||||||
|
@ -172,7 +172,7 @@ 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
|
||||||
@ -205,16 +205,9 @@ def add_adoption_notice(request):
|
|||||||
|
|
||||||
# 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"]
|
||||||
@ -347,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:
|
||||||
@ -456,23 +449,21 @@ def modqueue(request):
|
|||||||
def updatequeue(request):
|
def updatequeue(request):
|
||||||
#TODO: Make sure update can only be done for instances with permission
|
#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 or adoption.is_to_be_checked]
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
__version__ = "0.3.0"
|
__version__ = "0.3.1"
|
||||||
|
|
||||||
# This will make sure the app is always imported when
|
# This will make sure the app is always imported when
|
||||||
# Django starts so that shared_task will use this app.
|
# Django starts so that shared_task will use this app.
|
||||||
|
@ -16,10 +16,14 @@ app.conf.beat_schedule = {
|
|||||||
'task': 'admin.clean_locations',
|
'task': 'admin.clean_locations',
|
||||||
'schedule': crontab(hour=2),
|
'schedule': crontab(hour=2),
|
||||||
},
|
},
|
||||||
'daily-deactivation': {
|
'daily-unchecked-deactivation': {
|
||||||
'task': 'admin.deactivate_unchecked',
|
'task': 'admin.daily_unchecked_deactivation',
|
||||||
'schedule': crontab(hour=1),
|
'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 settings.HEALTHCHECKS_URL is not None and settings.HEALTHCHECKS_URL != "":
|
||||||
|
@ -76,6 +76,7 @@ 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')]
|
||||||
@ -92,7 +93,6 @@ GEOCODING_API_URL = config.get("geocoding", "api_url", fallback="https://nominat
|
|||||||
""" 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="")
|
||||||
@ -187,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',
|
||||||
|
97
src/tests/test_admin_tasks.py
Normal file
97
src/tests/test_admin_tasks.py
Normal 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
24
src/tests/test_geo.py
Normal 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
|
@ -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
|
||||||
@ -47,3 +49,123 @@ 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")
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
Loading…
Reference in New Issue
Block a user