Compare commits

3 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
11 changed files with 28 additions and 365 deletions

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

@@ -1,97 +0,0 @@
import argparse
import json
import os
import requests
DEFAULT_OSM_DATA_FILE = "osm_data.geojson"
def parse_args():
"""Parse command-line arguments."""
parser = argparse.ArgumentParser(description="Upload animal shelter data to the Notfellchen API.")
parser.add_argument("--api-token", type=str, help="API token for authentication.")
parser.add_argument("--instance", type=str, help="API instance URL.")
parser.add_argument("--data-file", type=str, help="Path to the GeoJSON file containing (only) animal shelters.")
return parser.parse_args()
def get_config():
"""Get configuration from environment variables or command-line arguments."""
args = parse_args()
api_token = args.api_token or os.getenv("NOTFELLCHEN_API_TOKEN")
instance = args.instance or os.getenv("NOTFELLCHEN_INSTANCE")
data_file = args.data_file or os.getenv("NOTFELLCHEN_DATA_FILE", DEFAULT_OSM_DATA_FILE)
if not api_token or not instance:
raise ValueError("API token and instance URL must be provided via environment variables or CLI arguments.")
return api_token, instance, data_file
def load_osm_data(file_path):
"""Load OSM data from a GeoJSON file."""
with open(file_path, "r", encoding="utf-8") as file:
data = json.load(file)
return data
def load_osm_data(file_path):
#Load OSM data from a GeoJSON file.
with open(file_path, "r", encoding="utf-8") as file:
data = json.load(file)
return data
def transform_osm_data(feature):
#Transform a single OSM feature into the API payload format
prop = feature.get("properties", {})
geometry = feature.get("geometry", {})
return {
"name": prop.get("name", "Unnamed Shelter"),
"phone": prop.get("phone"),
"website": prop.get("website"),
"opening_hours": prop.get("opening_hours"),
"email": prop.get("email"),
"location_string": f'{prop.get("addr:street", "")} {prop.get("addr:housenumber", "")} {prop.get("addr:postcode", "")} {prop.get("addr:city", "")}',
"external_object_id": prop.get("@id"),
"external_source_id": "OSM"
}
def send_to_api(data, endpoint, headers):
# Send transformed data to the Notfellchen API.
response = requests.post(endpoint, headers=headers, json=data)
if response.status_code == 201:
print(f"Success: Shelter '{data['name']}' uploaded.")
elif response.status_code == 400:
print(f"Error: Shelter '{data['name']}' already exists or invalid data. {response.text}")
else:
print(f"Unexpected Error: {response.status_code} - {response.text}")
raise ConnectionError
def main():
# Get configuration
api_token, instance, data_file = get_config()
# Set headers and endpoint
endpoint = f"{instance}/api/organizations/"
headers = {
"Authorization": f"Token {api_token}",
"Content-Type": "application/json"
}
# Step 1: Load OSM data
osm_data = load_osm_data(data_file)
# Step 2: Process each shelter and send it to the API
for feature in osm_data.get("features", []):
shelter_data = transform_osm_data(feature)
send_to_api(shelter_data, endpoint, headers)
if __name__ == "__main__":
main()

View File

@@ -16,7 +16,6 @@ from .serializers import (
from fellchensammlung.models import Animal, RescueOrganization, AdoptionNotice, Species, Image
from drf_spectacular.utils import extend_schema
class AdoptionNoticeApiView(APIView):
permission_classes = [IsAuthenticated]
@@ -85,6 +84,7 @@ class AdoptionNoticeApiView(APIView):
)
class AnimalApiView(APIView):
permission_classes = [IsAuthenticated]
@@ -118,7 +118,6 @@ class AnimalApiView(APIView):
)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class RescueOrganizationApiView(APIView):
permission_classes = [IsAuthenticated]
@@ -160,14 +159,13 @@ class RescueOrganizationApiView(APIView):
"""
serializer = RescueOrgSerializer(data=request.data, context={"request": request})
if serializer.is_valid():
rescue_org = serializer.save()
rescue_org = serializer.save(owner=request.user)
return Response(
{"message": "Rescue organization created/updated successfully!", "id": rescue_org.id},
status=status.HTTP_201_CREATED,
)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class AddImageApiView(APIView):
permission_classes = [IsAuthenticated]

View File

@@ -1,47 +0,0 @@
from django.contrib.sitemaps import Sitemap
from django.urls import reverse
from .models import AdoptionNotice, RescueOrganization
class StaticViewSitemap(Sitemap):
priority = 0.8
changefreq = "weekly"
def items(self):
return ["index", "search", "map", "about", "rescue-organizations"]
def location(self, item):
return reverse(item)
class AdoptionNoticeSitemap(Sitemap):
priority = 0.5
changefreq = "daily"
def items(self):
return AdoptionNotice.get_active_ANs()
def lastmod(self, obj):
return obj.updated_at
class AnimalSitemap(Sitemap):
priority = 0.2
changefreq = "daily"
def items(self):
return AdoptionNotice.objects.all()
def lastmod(self, obj):
return obj.updated_at
class RescueOrganizationSitemap(Sitemap):
priority = 0.3
changefreq = "weekly"
def items(self):
return RescueOrganization.objects.all()
def lastmod(self, obj):
return obj.updated_at

View File

@@ -1,7 +0,0 @@
User-agent: *
Disallow: /admin/
User-agent: OpenAI
Disallow: /
Sitemap: https://notfellchen.org/sitemap.xml

View File

@@ -1,102 +0,0 @@
{% extends "fellchensammlung/base_generic.html" %}
{% load i18n %}
{% load static %}
{% block title %}<title>{% translate "Styleguide" %}</title>{% endblock %}
{% block content %}
<h1>This is a heading</h1>
<p>And this is a short paragraph below</p>
<div class="container-cards">
<h2>Card Containers</h2>
<div class="card">
<h3>I am a card</h3>
<p>Cards are responsive. Use them to display multiple items of the same category</p>
</div>
<div class="card">
<h3>Photos</h3>
<p>Cards are responsive. Use them to display multiple items of the same category</p>
<img src="{% static 'fellchensammlung/img/example_rat_single.png' %}" alt="A rat sitting on a wooden house">
</div>
</div>
<div class="container-cards">
<form class="form-search card half" method="post">
<label for="inputA">Input Alpha</label>
<input name="inputA" maxlength="200" id="inputA">
<label for="inputB">Beta</label>
<input name="inputB" maxlength="200" id="inputB">
<label for="id_location_string">Ort</label>
<input name="location_string" id="id_location_string">
<ul id="results"></ul>
<div class="container-edit-buttons">
<button class="btn" type="submit" value="search" name="search">
<i class="fas fa-search"></i> {% trans 'Suchen' %}
</button>
{% if searched %}
{% if subscribed_search %}
<button class="btn" type="submit" value="{{ subscribed_search.pk }}"
name="unsubscribe_to_search">
<i class="fas fa-bell-slash"></i> {% trans 'Suche nicht mehr abonnieren' %}
</button>
{% else %}
<button class="btn" type="submit" name="subscribe_to_search">
<i class="fas fa-bell"></i> {% trans 'Suche abonnieren' %}
</button>
{% endif %}
{% endif %}
</div>
{% if place_not_found %}
<p class="error">
{% trans 'Ort nicht gefunden' %}
</p>
{% endif %}
</form>
<div class="card half">
{% include "fellchensammlung/partials/partial-map.html" %}
</div>
</div>
{% include "fellchensammlung/lists/list-adoption-notices.html" %}
<script>
const locationInput = document.getElementById('id_location_string');
const resultsList = document.getElementById('results');
const placeIdInput = document.getElementById('place_id');
locationInput.addEventListener('input', async function () {
const query = locationInput.value.trim();
if (query.length < 3) {
resultsList.innerHTML = ''; // Don't search for or show results if input is less than 3 characters
return;
}
try {
const response = await fetch(`{{ geocoding_api_url }}/?q=${encodeURIComponent(query)}&limit=5&lang=de`);
const data = await response.json();
if (data && data.features) {
resultsList.innerHTML = ''; // Clear previous results
const locations = data.features.slice(0, 5); // Show only the first 5 results
locations.forEach(location => {
const listItem = document.createElement('li');
listItem.classList.add('result-item');
listItem.textContent = geojson_to_summary(location);
// Add event when user clicks on a result location
listItem.addEventListener('click', () => {
locationInput.value = geojson_to_searchable_string(location); // Set input field to selected location
resultsList.innerHTML = ''; // Clear the results after selecting a location
});
resultsList.appendChild(listItem);
});
}
} catch (error) {
console.error('Error fetching location data:', error);
resultsList.innerHTML = '<li class="result-item">Error fetching data. Please try again.</li>';
}
});
</script>
{% endblock %}

View File

@@ -7,15 +7,6 @@ from .feeds import LatestAdoptionNoticesFeed
from . import views
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView
from django.contrib.sitemaps.views import sitemap
from .sitemap import StaticViewSitemap, AdoptionNoticeSitemap, AnimalSitemap
sitemaps = {
"static": StaticViewSitemap,
"vermittlungen": AdoptionNoticeSitemap,
"tiere": AnimalSitemap,
}
urlpatterns = [
path("", views.index, name="index"),
path("rss/", LatestAdoptionNoticesFeed(), name="rss"),
@@ -104,10 +95,4 @@ urlpatterns = [
###################
path('external-site/', views.external_site_warning, name="external-site"),
###############
## TECHNICAL ##
###############
path("sitemap.xml", sitemap, {"sitemaps": sitemaps}, name="django.contrib.sitemaps.views.sitemap"),
path("styleguide", views.styleguide, name="styleguide"),
]

View File

@@ -2,7 +2,6 @@ import logging
from django.contrib.auth.views import redirect_to_login
from django.http import HttpResponseRedirect, JsonResponse, HttpResponse
from django.http.response import HttpResponseForbidden
from django.shortcuts import render, redirect
from django.urls import reverse
from django.contrib.auth.decorators import login_required
@@ -134,7 +133,7 @@ def adoption_notice_detail(request, adoption_notice_id):
elif action == "subscribe":
return redirect_to_login(next=request.path)
else:
return HttpResponseForbidden()
raise PermissionDenied
else:
comment_form = CommentForm(instance=adoption_notice)
context = {"adoption_notice": adoption_notice, "comment_form": comment_form, "user": request.user,
@@ -633,9 +632,3 @@ def export_own_profile(request):
ANs_as_json = serialize('json', ANs)
full_json = f"{user_as_json}, {ANs_as_json}"
return HttpResponse(full_json, content_type="application/json")
def styleguide(request):
context = {"geocoding_api_url": settings.GEOCODING_API_URL, }
return render(request, 'fellchensammlung/styleguide.html', context=context)

View File

@@ -168,7 +168,6 @@ INSTALLED_APPS = [
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
"django.contrib.sitemaps",
'fontawesomefree',
'crispy_forms',
"crispy_bootstrap4",

View File

@@ -5,7 +5,7 @@ from django.urls import reverse
from model_bakery import baker
from fellchensammlung.models import Animal, Species, AdoptionNotice, User, Location, AdoptionNoticeStatus, TrustLevel, \
Animal, Subscriptions, Comment, CommentNotification
Animal, Subscriptions
from fellchensammlung.views import add_adoption_notice
@@ -233,10 +233,10 @@ class AdoptionDetailTest(TestCase):
password='12345')
test_user0.save()
cls.test_user1 = User.objects.create_user(username='testuser1',
first_name="Max",
last_name="Müller",
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()
@@ -256,16 +256,6 @@ class AdoptionDetailTest(TestCase):
adoption3.set_active()
adoption2.set_unchecked()
def test_basic_view(self):
response = self.client.get(
reverse('adoption-notice-detail', args=str(AdoptionNotice.objects.get(name="TestAdoption1").pk)), )
self.assertEqual(response.status_code, 200)
def test_basic_view_logged_in(self):
self.client.login(username='testuser0', password='12345')
response = self.client.get(
reverse('adoption-notice-detail', args=str(AdoptionNotice.objects.get(name="TestAdoption1").pk)), )
self.assertEqual(response.status_code, 200)
def test_subscribe(self):
self.client.login(username='testuser0', password='12345')
@@ -274,6 +264,7 @@ class AdoptionDetailTest(TestCase):
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")
@@ -293,72 +284,3 @@ class AdoptionDetailTest(TestCase):
data={"action": "subscribe"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, "/accounts/login/?next=/vermittlung/1/")
def test_unauthenticated_comment(self):
response = self.client.post(
reverse('adoption-notice-detail', args=str(AdoptionNotice.objects.get(name="TestAdoption1").pk)),
data={"action": "comment"})
self.assertEqual(response.status_code, 403)
def test_comment(self):
an1 = AdoptionNotice.objects.get(name="TestAdoption1")
# Set up subscription
Subscriptions.objects.create(owner=self.test_user1, adoption_notice=an1)
self.client.login(username='testuser0', password='12345')
response = self.client.post(
reverse('adoption-notice-detail', args=str(an1.pk)),
data={"action": "comment", "text": "Test"})
self.assertTrue(Comment.objects.filter(user__username="testuser0").exists())
self.assertFalse(CommentNotification.objects.filter(user__username="testuser0").exists())
self.assertTrue(CommentNotification.objects.filter(user__username="testuser1").exists())
class AdoptionEditTest(TestCase):
@classmethod
def setUpTestData(cls):
test_user0 = User.objects.create_user(username='testuser0',
first_name="Admin",
last_name="BOFH",
password='12345')
test_user0.save()
cls.test_user1 = User.objects.create_user(username='testuser1',
first_name="Max",
last_name="Müller",
password='12345')
adoption1 = baker.make(AdoptionNotice, name="TestAdoption1", description="Test1", owner=test_user0)
adoption2 = baker.make(AdoptionNotice, name="TestAdoption2", description="Test2")
def test_basic_view(self):
response = self.client.get(
reverse('adoption-notice-edit', args=str(AdoptionNotice.objects.get(name="TestAdoption1").pk)), )
self.assertEqual(response.status_code, 302)
def test_basic_view_logged_in_unauthorized(self):
self.client.login(username='testuser1', password='12345')
response = self.client.get(
reverse('adoption-notice-edit', args=str(AdoptionNotice.objects.get(name="TestAdoption1").pk)), )
self.assertEqual(response.status_code, 403)
def test_basic_view_logged_in(self):
self.client.login(username='testuser0', password='12345')
response = self.client.get(
reverse('adoption-notice-edit', args=str(AdoptionNotice.objects.get(name="TestAdoption1").pk)), )
self.assertEqual(response.status_code, 200)
def test_edit(self):
data = {"name": "Mia",
"searching_since": "01.01.2025",
"location_string": "Paderborn",
"organization": "",
"description": "Test3",
"further_information": ""}
an = AdoptionNotice.objects.get(name="TestAdoption1")
assert self.client.login(username='testuser0', password='12345')
response = self.client.post(reverse("adoption-notice-edit", args=str(an.pk)), data=data, follow=True)
self.assertEqual(response.redirect_chain[0][1], 302) # See https://docs.djangoproject.com/en/5.1/topics/testing/tools/
self.assertEqual(response.status_code, 200) # Redirects to AN page
self.assertContains(response, "Test3")
self.assertContains(response, "Mia")
self.assertNotContains(response, "Test1")