Compare commits

..

17 Commits

Author SHA1 Message Date
c9f46d7547 ci: fix?
All checks were successful
ci/woodpecker/push/docs Pipeline was successful
ci/woodpecker/push/test Pipeline was successful
2025-01-19 07:12:09 +01:00
9f23f5768c ci: restructure
Some checks failed
ci/woodpecker/push/docs Pipeline failed
ci/woodpecker/push/test Pipeline failed
2025-01-19 07:07:53 +01:00
19210f90cd ci: try teests 2025-01-19 07:04:22 +01:00
462bb8f485 refactor: formatting 2025-01-18 21:43:37 +01:00
ea4d15b99a tests: Add test for index view 2025-01-18 21:43:00 +01:00
de30dfcb8b tests: Add test for unsubscribe 2025-01-18 21:39:45 +01:00
36a979954c feat: make sure owner also gets notified, add test 2025-01-18 18:53:55 +01:00
71ef17dc97 feat: Move pytest, coverage and model bakery to develop dependencies 2025-01-18 18:30:02 +01:00
206cd282e6 feat: Add coverage report instructions 2025-01-18 18:29:04 +01:00
e399346c3e feat: Add tests for subscription functionality 2025-01-18 16:17:22 +01:00
929c6dfff0 feat: Add registration button 2025-01-18 15:47:07 +01:00
841b57fea2 feat: Make sure subscribe leads to login flow 2025-01-18 15:25:27 +01:00
9e5446ff1d feat: Test adding a adoption as user 2025-01-18 15:22:08 +01:00
3b79809b8c docs: various 2025-01-18 09:07:33 +01:00
53e6db3655 refactor: Remove print 2025-01-14 07:31:00 +01:00
424f91e919 feat: Add a timestamp for when a notification is read 2025-01-14 07:30:36 +01:00
84ce5f54b2 refactor: Remove unnecessary print 2025-01-14 07:27:03 +01:00
23 changed files with 332 additions and 48 deletions

4
.coveragerc Normal file
View File

@ -0,0 +1,4 @@
[run]
omit =
*/migrations/*
*/tests/*

View File

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

14
.woodpecker/test.yml Normal file
View File

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

View File

@ -77,6 +77,26 @@ docker push moanos/notfellchen:latest
docker run -p8000:7345 moanos/notfellchen:latest
```
## Testing
Tests can be run with
```zsh
nf test src
```
If you want to report on code coverage run
```zsh
coverage run --source='.' src/manage.py test src
```
and
```
coverage report
```
## Geocoding
Geocoding services (search map data by name, address or postcode) are provided via the

View File

@ -36,6 +36,11 @@ An application can then send this token in the request header for authorization.
Endpoints
---------
All Endpoints are documented at https://notfellchen.org/api/schema/swagger-ui/ or at https://notfellchen.org/api/schema/redoc/ if you prefer redoc.
The OpenAI schema can be downloaded at https://notfellchen.org/api/schema/
Examples are documented here.
Get Adoption Notices
++++++++++++++++++++

View File

@ -5,7 +5,7 @@ Report a bug
^^^^^^^^^^^^
To report a bug, file an issue on `Github
<https://codeberg.org/moanos/notfellchen/issues>`_
<https://github.com/moan0s/notfellchen/issues>`_
Try to include the following information:
@ -29,7 +29,7 @@ To contribute simply clone the directory, make your changes and file a
pull request.
If you want to know what can be done, have a look at the current `Github
<https://codeberg.org/moanos/notfellchen/issues>`_.
<https://github.com/moan0s/notfellchen/issues>`_.
Get in touch!
^^^^^^^^^^^^^

View File

@ -5,8 +5,7 @@ What qualifies as release?
^^^^^^^^^^^^^^^^^^^^^^^^^^
A new release should be announced when a significant number functions, bugfixes or other improvements to the software
is made. Usually this indicates a minor release.
Major releases are yet to be determined.
is made. Notfellchen follows `Semantic Versioning <https://semver.org/>`_.
What should be done before a release?
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
@ -14,7 +13,7 @@ What should be done before a release?
Tested basic functions
######################
Run :command:`pytest`
Run :command:`nf test src`
Test upgrade on a copy of a production database
###############################################
@ -38,4 +37,4 @@ Do a final commit on this change, and tag the commit as release with appropriate
git tag -a v1.0.0 -m "Releasing version v1.0.0"
git push origin v1.0.0
Make sure the tag is visible on Codeberg and celebrate 🥳
Make sure the tag is visible on GitHub/Codeberg and celebrate 🥳

View File

@ -24,14 +24,12 @@ classifiers = [
]
dependencies = [
"Django",
"coverage",
"codecov",
"sphinx",
"sphinx-rtd-theme",
"gunicorn",
"fontawesomefree",
"whitenoise",
"model_bakery",
"markdown",
"Pillow",
"django-registration",
@ -48,6 +46,8 @@ dynamic = ["version", "readme"]
[project.optional-dependencies]
develop = [
"pytest",
"coverage",
"model_bakery",
]
[project.urls]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.1.4 on 2025-01-14 06:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0035_alter_image_alt_text_and_more'),
]
operations = [
migrations.AddField(
model_name='basenotification',
name='read_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Gelesen am'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.1.4 on 2025-01-14 06:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0036_basenotification_read_at'),
]
operations = [
migrations.AlterField(
model_name='basenotification',
name='title',
field=models.CharField(max_length=100, verbose_name='Titel'),
),
]

View File

@ -818,7 +818,8 @@ 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)
read_at = models.DateTimeField(blank=True, null=True, verbose_name=_("Gelesen am"))
title = models.CharField(max_length=100, verbose_name=_("Titel"))
text = models.TextField(verbose_name="Inhalt")
user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name=_('Nutzer*in'))
read = models.BooleanField(default=False)
@ -829,6 +830,11 @@ class BaseNotification(models.Model):
def get_absolute_url(self):
self.user.get_notifications_url()
def mark_read(self):
self.read = True
self.read_at = timezone.now()
self.save()
class CommentNotification(BaseNotification):
comment = models.ForeignKey(Comment, on_delete=models.CASCADE, verbose_name=_('Antwort'))

View File

@ -259,6 +259,11 @@ a.btn, a.btn2, a.nav-link {
border: 1px solid black;
}
.btn-small {
font-size: medium;
padding: 6px;
}
.checkmark {
display: inline-block;
position: relative;

View File

@ -7,7 +7,7 @@ from .mail import send_notification_email
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
from .tools.notifications import notify_moderators_of_AN_to_be_checked
from .tools.notifications import notify_of_AN_to_be_checked
from .tools.search import notify_search_subscribers
@ -46,7 +46,7 @@ def post_adoption_notice_save(pk):
logging.info(f"Location was added to Adoption notice {pk}")
notify_search_subscribers(instance, only_if_active=True)
notify_moderators_of_AN_to_be_checked(instance)
notify_of_AN_to_be_checked(instance)
@celery_app.task(name="tools.healthcheck")
def task_healthcheck():

View File

@ -137,7 +137,6 @@ class GeoAPI:
result = self.requests.get(self.api_url,
{"q": location_string, "lang": language},
headers=self.headers).json()
logging.warning(result)
geofeatures = GeoFeature.geofeatures_from_photon_result(result)
else:
raise NotImplementedError

View File

@ -1,11 +1,13 @@
from fellchensammlung.models import User, AdoptionNoticeNotification, TrustLevel
def notify_moderators_of_AN_to_be_checked(adoption_notice):
def notify_of_AN_to_be_checked(adoption_notice):
if adoption_notice.is_disabled_unchecked:
for moderator in User.objects.filter(trust_level__gt=TrustLevel.MODERATOR):
users_to_notify = set(User.objects.filter(trust_level__gt=TrustLevel.MODERATOR))
users_to_notify.add(adoption_notice.owner)
for user in users_to_notify:
AdoptionNoticeNotification.objects.create(adoption_notice=adoption_notice,
user=moderator,
user=user,
title=f" Prüfe Vermittlung {adoption_notice}",
text=f"{adoption_notice} muss geprüft werden bevor sie veröffentlicht wird.",
)

View File

@ -130,6 +130,8 @@ def adoption_notice_detail(request, adoption_notice_id):
if action == "unsubscribe":
subscription.delete()
is_subscribed = False
elif action == "subscribe":
return redirect_to_login(next=request.path)
else:
raise PermissionDenied
else:
@ -481,13 +483,11 @@ def my_profile(request):
notification = CommentNotification.objects.get(pk=notification_id)
except CommentNotification.DoesNotExist:
notification = BaseNotification.objects.get(pk=notification_id)
notification.read = True
notification.save()
notification.mark_read()
elif action == "notification_mark_all_read":
notifications = CommentNotification.objects.filter(user=request.user, mark_read=False)
for notification in notifications:
notification.read = True
notification.save()
notification.mark_read()
elif action == "search_subscription_delete":
search_subscription_id = request.POST.get("search_subscription_id")
SearchSubscription.objects.get(pk=search_subscription_id).delete()

View File

@ -10,18 +10,25 @@
{% if user.is_authenticated %}
<p>{% translate "Du bist bereits eingeloggt." %}</p>
{% else %} {% if next %}
<p>{% translate "Bitte log dich ein um diese Seite sehen zu können." %}</p>
<p class="card">{% translate "Bitte log dich ein um diese Seite sehen zu können." %}</p>
{% endif %}
{% endif %}
{% if not user.is_authenticated %}
<form class="card" method="post" action="{% url 'login' %}">
<div class="card">
<div class="container-edit-buttons">
<form method="post" action="{% url 'login' %}">
{% csrf_token %}
{{ form.as_p }}
<input class="btn" type="submit" value={% translate "Einloggen" %} />
<input class="btn" type="submit" value="{% translate 'Einloggen' %}"/>
<input type="hidden" name="next" value="{{ next }}"/>
</form>
</div>
<p><a class="btn2" href="{% url 'password_reset' %}">{% translate "Passwort vergessen?" %}</a></p>
<div class="container-edit-buttons">
<a class="btn btn-small" href="{% url 'password_reset' %}">{% translate "Passwort vergessen?" %}</a>
<a class="btn btn-small" href="{% url 'django_registration_register' %}">{% translate "Registrieren" %}</a>
</div>
</div>
{% endif %}
{% endblock %}

View File

@ -27,7 +27,6 @@ class DistanceTest(TestCase):
l_stuttgart = LocationProxy("Stuttgart")
l_tue = LocationProxy("Tübingen")
# Should be 30km
print(f"{l_stuttgart.position} -> {l_tue.position}")
distance_tue_stuttgart = calculate_distance_between_coordinates(l_stuttgart.position, l_tue.position)
self.assertLess(distance_tue_stuttgart, 50)
self.assertGreater(distance_tue_stuttgart, 20)

View File

@ -4,7 +4,7 @@ from django.utils import timezone
from django.test import TestCase
from model_bakery import baker
from fellchensammlung.models import Announcement, Language, User, TrustLevel
from fellchensammlung.models import Announcement, Language, User, TrustLevel, BaseNotification
class UserTest(TestCase):
@ -77,3 +77,21 @@ class AnnouncementTest(TestCase):
self.assertTrue(self.announcement2 not in active_announcements)
self.assertTrue(self.announcement4 not in active_announcements)
self.assertTrue(self.announcement5 in active_announcements)
class TestNotifications(TestCase):
@classmethod
def setUpTestData(cls):
cls.test_user_1 = User.objects.create(username="Testuser1", password="SUPERSECRET", email="test@example.org")
def test_mark_read(self):
not1 = BaseNotification.objects.create(user=self.test_user_1, text="New rats to adopt", title="🔔 New Rat alert")
not2 = BaseNotification.objects.create(user=self.test_user_1,
text="New wombat to adopt", title="🔔 New Wombat alert")
not1.mark_read()
self.assertTrue(not1.read)
self.assertFalse(not2.read)
self.assertTrue((timezone.now() - timedelta(hours=1)) < not1.read_at < timezone.now())
self.assertIsNone(not2.read_at)

View File

@ -0,0 +1,34 @@
from django.test import TestCase
from model_bakery import baker
from fellchensammlung.models import User, TrustLevel, Species, Location, AdoptionNotice, AdoptionNoticeNotification
from fellchensammlung.tools.notifications import notify_of_AN_to_be_checked
class TestNotifications(TestCase):
@classmethod
def setUpTestData(cls):
cls.test_user0 = User.objects.create_user(username='testuser0',
first_name="Admin",
last_name="BOFH",
password='12345')
cls.test_user1 = User.objects.create_user(username='testuser1',
first_name="Max",
last_name="Müller",
password='12345')
cls.test_user2 = User.objects.create_user(username='testuser2',
first_name="Miriam",
last_name="Müller",
password='12345')
cls.test_user0.trust_level = TrustLevel.ADMIN
cls.test_user0.save()
cls.adoption1 = baker.make(AdoptionNotice, name="TestAdoption1", owner=cls.test_user1,)
cls.adoption1.set_unchecked() # Could also emit notification
def test_notify_of_AN_to_be_checked(self):
notify_of_AN_to_be_checked(self.adoption1)
self.assertTrue(AdoptionNoticeNotification.objects.filter(user=self.test_user0).exists())
self.assertTrue(AdoptionNoticeNotification.objects.filter(user=self.test_user1).exists())
self.assertFalse(AdoptionNoticeNotification.objects.filter(user=self.test_user2).exists())

View File

View File

@ -4,7 +4,8 @@ from django.urls import reverse
from model_bakery import baker
from fellchensammlung.models import Animal, Species, AdoptionNotice, User, Location, AdoptionNoticeStatus, TrustLevel
from fellchensammlung.models import Animal, Species, AdoptionNotice, User, Location, AdoptionNoticeStatus, TrustLevel, \
Animal, Subscriptions
from fellchensammlung.views import add_adoption_notice
@ -70,7 +71,33 @@ class AnimalAndAdoptionTest(TestCase):
self.assertTrue(response.status_code < 400)
self.assertTrue(AdoptionNotice.objects.get(name="TestAdoption4").is_active)
an = AdoptionNotice.objects.get(name="TestAdoption4")
animals = Animal.objects.filter(adoption_notice=an)
self.assertTrue(len(animals) == 2)
def test_creating_AN_as_user(self):
self.client.login(username='testuser1', password='12345')
form_data = {"name": "TestAdoption5",
"species": Species.objects.first().pk,
"num_animals": "3",
"date_of_birth": "2024-12-04",
"sex": "M",
"group_only": "on",
"searching_since": "2024-11-10",
"location_string": "München",
"description": "Blaaaa",
"further_information": "https://notfellchen.org/",
"save-and-add-another-animal": "Speichern"}
response = self.client.post(reverse('add-adoption'), data=form_data)
self.assertTrue(response.status_code < 400)
self.assertFalse(AdoptionNotice.objects.get(name="TestAdoption5").is_active)
an = AdoptionNotice.objects.get(name="TestAdoption5")
animals = Animal.objects.filter(adoption_notice=an)
self.assertTrue(len(animals) == 3)
self.assertTrue(an.sexes == set("M", ))
class SearchTest(TestCase):
@ -196,3 +223,64 @@ class UpdateQueueTest(TestCase):
self.assertFalse(self.adoption3.is_active)
self.assertEqual(self.adoption3.adoptionnoticestatus.major_status, AdoptionNoticeStatus.CLOSED)
class AdoptionDetailTest(TestCase):
@classmethod
def setUpTestData(cls):
test_user0 = User.objects.create_user(username='testuser0',
first_name="Admin",
last_name="BOFH",
password='12345')
test_user0.save()
test_user1 = User.objects.create_user(username='testuser1',
first_name="Max",
last_name="Müller",
password='12345')
test_user0.trust_level = TrustLevel.ADMIN
test_user0.save()
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("Stuttgart")
adoption3.location = stuttgart
adoption3.save()
adoption1.set_active()
adoption3.set_active()
adoption2.set_unchecked()
def test_subscribe(self):
self.client.login(username='testuser0', password='12345')
response = self.client.post(
reverse('adoption-notice-detail', args=str(AdoptionNotice.objects.get(name="TestAdoption1").pk)),
data={"action": "subscribe"})
self.assertTrue(Subscriptions.objects.filter(owner__username="testuser0").exists())
def test_unsubscribe(self):
# Make sure subscription exists
an = AdoptionNotice.objects.get(name="TestAdoption1")
user = User.objects.get(username="testuser0")
subscription = Subscriptions.objects.get_or_create(owner=user, adoption_notice=an)
# Unsubscribe
self.client.login(username='testuser0', password='12345')
response = self.client.post(
reverse('adoption-notice-detail', args=str(an.pk)),
data={"action": "unsubscribe"})
self.assertFalse(Subscriptions.objects.filter(owner__username="testuser0").exists())
def test_login_required(self):
response = self.client.post(
reverse('adoption-notice-detail', args=str(AdoptionNotice.objects.get(name="TestAdoption1").pk)),
data={"action": "subscribe"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, "/accounts/login/?next=/vermittlung/1/")

View File

@ -0,0 +1,43 @@
from django.test import TestCase
from django.urls import reverse
from fellchensammlung.models import User, TrustLevel, AdoptionNotice, Species
from model_bakery import baker
class BasicViewTest(TestCase):
@classmethod
def setUpTestData(cls):
test_user0 = User.objects.create_user(username='testuser0',
first_name="Admin",
last_name="BOFH",
password='12345')
test_user1 = User.objects.create_user(username='testuser1',
first_name="Max",
last_name="Müller",
password='12345')
test_user0.trust_level = TrustLevel.ADMIN
test_user0.save()
ans = []
for i in range(0, 8):
ans.append(baker.make(AdoptionNotice, name=f"TestAdoption{i}"))
for i in range(0, 4):
AdoptionNotice.objects.get(name=f"TestAdoption{i}").set_active()
def test_index_logged_in(self):
self.client.login(username='testuser0', password='12345')
response = self.client.get(reverse('index'))
self.assertEqual(response.status_code, 200)
# Check our user is logged in
self.assertEqual(str(response.context['user']), 'testuser0')
self.assertContains(response, "TestAdoption0")
self.assertNotContains(response, "TestAdoption5") # Should not be active, therefore not shown
def test_index_anonymous(self):
response = self.client.get(reverse('index'))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "TestAdoption1")
self.assertNotContains(response, "TestAdoption4") # Should not be active, therefore not shown