Compare commits
47 Commits
b47ff31d43
...
main
Author | SHA1 | Date | |
---|---|---|---|
b557376ef2 | |||
b0e58f1737 | |||
27968f6ae5 | |||
2388e78c5f | |||
3fb5a5d4fc | |||
dc6e60970a | |||
a6df0a82ae | |||
90e8d15af7 | |||
2641955a36 | |||
4e4d825283 | |||
fb31dedf4d | |||
cc9b0733dc | |||
b90ecbadc4 | |||
698d648263 | |||
74e5f3d970 | |||
60306d1abc | |||
7dc9fc525a | |||
e805946fab | |||
2bb1ea9182 | |||
f860ac4e3c | |||
595e8b2b35 | |||
d873506c71 | |||
46c4def4c5 | |||
60072ffd54 | |||
294a067d5f | |||
ebc5718318 | |||
22156b404e | |||
2ee702a151 | |||
03237d6ea2 | |||
5fd99b7e5c | |||
1a75fcc147 | |||
e58eebd8af | |||
2097d68829 | |||
efb10729fe | |||
06426db458 | |||
21dd908466 | |||
881371262d | |||
0be5ad8b28 | |||
cbd90cdfb1 | |||
0b9ee1bae3 | |||
f0c8dda93e | |||
767549318f | |||
b6f302f822 | |||
20a189c9e9 | |||
f77af5e85f | |||
a29ac5481c | |||
238bde7d6f |
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
||||
# Hugo default output directory
|
||||
/public
|
||||
*.lock
|
||||
|
@@ -1,10 +1,13 @@
|
||||
---
|
||||
|
||||
pipeline:
|
||||
steps:
|
||||
build:
|
||||
image: klakegg/hugo
|
||||
commands:
|
||||
- hugo
|
||||
when:
|
||||
- event: push
|
||||
- event: manual
|
||||
|
||||
deploy:
|
||||
image: appleboy/drone-scp
|
||||
@@ -17,3 +20,6 @@ pipeline:
|
||||
source: public/
|
||||
key:
|
||||
from_secret: ssh_key
|
||||
when:
|
||||
- event: push
|
||||
- event: manual
|
||||
|
35
config.toml
@@ -19,6 +19,11 @@ paginate = 5 #frontpage pagination
|
||||
respectDoNotTrack = true
|
||||
useSessionStorage = true
|
||||
|
||||
[tracking]
|
||||
[tracking.oxitraffic]
|
||||
enabled = true
|
||||
hostname = "traffic.hyteck.de"
|
||||
|
||||
[params]
|
||||
# Unmark to use post folder for images. Default is static folder.
|
||||
#usepostimgfolder = true
|
||||
@@ -28,26 +33,14 @@ paginate = 5 #frontpage pagination
|
||||
author = "Julian-Samuel Gebühr"
|
||||
authorLink = "https://hyteck.de/"
|
||||
bio = [
|
||||
"Student of Medical Informatics, Developer, He/Him"
|
||||
"Business Analyst for work, Developer for fun. Activist because it's necessary. He/Him"
|
||||
]
|
||||
copyright = [
|
||||
'© 2023 CC-BY Julian-Samuel Gebühr</a> '
|
||||
'© 2025 CC-BY Julian-Samuel Gebühr</a> '
|
||||
]
|
||||
|
||||
|
||||
# Contact page
|
||||
# Since this template is static, the contact form uses www.formspree.io as a
|
||||
# proxy. The form makes a POST request to their servers to send the actual
|
||||
# email. Visitors can send up to a 1000 emails each month for free.
|
||||
#
|
||||
# What you need to do for the setup?
|
||||
#
|
||||
# - set your email address under 'email' below (it is also used in Gravatar for the bio).
|
||||
# - upload the generated site to your server
|
||||
# - send a dummy email yourself to confirm your account
|
||||
# - click the confirm link in the email from www.formspree.io
|
||||
# - you're done. Happy mailing!
|
||||
email = "julian-samuel@gebuehr.net"
|
||||
oxitraffic_url = "https://traffic.hyteck.de/count.js"
|
||||
|
||||
# Nav links in the side bar
|
||||
[[menu.main]]
|
||||
@@ -71,7 +64,7 @@ paginate = 5 #frontpage pagination
|
||||
weight = 4
|
||||
|
||||
# this will also be in author bio if there is no writer.
|
||||
[params.social]
|
||||
[params.social]
|
||||
github = "https://github.com/moan0s"
|
||||
email = "julian-samuel@gebuehr.net"
|
||||
twitter = "https://twitter.com/jgebuehr"
|
||||
@@ -84,13 +77,11 @@ paginate = 5 #frontpage pagination
|
||||
[[params.fediverse]]
|
||||
url = "https://chaos.social/@moanos"
|
||||
name = "Personal mastodon profile"
|
||||
[[params.fediverse]]
|
||||
url = "https://lediver.se/@moanos_foss"
|
||||
name= "FOSS profile"
|
||||
[[params.fediverse]]
|
||||
url = "https://pixelfed.social/@moanos"
|
||||
name= "Pixelfed"
|
||||
|
||||
[params.hosted_on]
|
||||
src = '/img/uberspace_badge_dark.png'
|
||||
alt = 'Badge saying: Proudly hosted on asteroids'
|
||||
target = 'https://uberspace.de'
|
||||
|
||||
[outputs]
|
||||
home = ["HTML", "RSS"]
|
||||
|
@@ -1,6 +1,7 @@
|
||||
---
|
||||
title: "About"
|
||||
date: 2019-08-20T19:56:10+02:00
|
||||
lastmod: 2024-10-05T12:56:10+02:00
|
||||
draft: False
|
||||
image: ""
|
||||
categories: [english, me]
|
||||
@@ -14,15 +15,32 @@ Currently available are the [services listed here]({{< ref "/services" >}} "Serv
|
||||
|
||||
# About me
|
||||
|
||||
To put it short I do something with computers and have a history in neuroscience.
|
||||
I work at [DKMS](https://www.dkms.de/), a nonprofit that fights blood cancer by registering potential blood stem cell donors and raising awareness and funds. My role is "Business Analyst" in our Salesforce and WebApps team. That means I spend my day trying to figure out the business departments need, sketching solutions and translating between product and business teams.
|
||||
|
||||
After work I spend my time with programming, activism and my pet rats.
|
||||
|
||||
**My backgrond**
|
||||
|
||||
After finishing school, I studied Medical Engineering in a joint course at University Stuttgart and University Tübingen.
|
||||
|
||||
In March 2020 I finished my bachelor thesis *"Real-time EEG analysis - Phase dependent effects of TMS on MEP"* at the Institute for Neuromodulation and Neurotechnology in the University Hospital Tübingen led by Prof. Gharabaghi. After that I was working there as a researcher.
|
||||
|
||||
In November 2020 I started studying Medical Informatics Tübingen.
|
||||
In November 2020 I started studying Medical Informatics Tübingen and finished in April 2024 with my master thesis *"Development and Validation of a Software Platform for Classification and Correction of Pathological Movement in Daily Activities by Multi-modal Sensor Analysis"*. This work focused on helping Ataxia and Parkinson's disease as part of a larger project in the Section for Computational Sensomotorics at the Hertie Institute for Clinical Brain Research (HIH).
|
||||
My advisor for this work was Winfried Ilg and it was examined by Prof. Dr. habil. Michael Menth and Prof. Dr. Martin Giese.
|
||||
|
||||
After work I spend my time with climbing, sailing and political activism.
|
||||
# Open-sourcs work & Freelancing
|
||||
|
||||
I work for various Open-Source projects
|
||||
|
||||
| Project | Description |
|
||||
| --- | --- |
|
||||
| [ILMO](https://hyteck.de/post/ilmo/) | A library management tool, available as SaaS |
|
||||
| [Notfellchen](https://notfellchen.org) | An app for helping fancy rats get adopted from rescues |
|
||||
| [mash-playbook](https://github.com/mother-of-all-self-hosting/mash-playbook) | An Ansible playbook which helps you host a large catalog of FOSS services as Docker containers on your own server |
|
||||
| https://github.com/spantaleev/matrix-docker-ansible-deploy | Matrix (An open network for secure, decentralized communication) server setup using Ansible and Docker |
|
||||
|
||||
and many more you can find on [GitHub](https://github.com/moan0s), [Codeberg](https://codeberg.org/moanos/) or [my own Gitea server](https://git.hyteck.de/).
|
||||
|
||||
Also I do a lot of programming, my biggest project so far is [ILMO](https://hyteck.de/post/ilmo/), an library management tool.
|
||||
Starting with Java, most of my real world projects have been done in Python and PHP.
|
||||
In 2019 I came in contact with programming of real time applications for medical devices and learned [Structured Text](https://en.wikipedia.org/wiki/Structured_text) (a programming language based on pascal focused on programming [PLCs](https://en.wikipedia.org/wiki/Programmable_logic_controller)) and C.
|
||||
Since then I worked in clinical research, especially phase- and power dependency of brain stimulation.
|
||||
|
131
content/post/about-html-mails/index.md
Normal file
@@ -0,0 +1,131 @@
|
||||
---
|
||||
title: "Thoughts on HTML mails"
|
||||
date: 2025-07-12T12:05:10+02:00
|
||||
lastmod: 2025-07-13T00:00:04+02:00
|
||||
draft: false
|
||||
image: "uploads/html-e-mails.png"
|
||||
categories: ['English']
|
||||
tags: ['email', 'html', 'plaintext', 'django', 'notfellchen']
|
||||
---
|
||||
|
||||
Lately I worked on notification e-mails for [notfellchen.org](https://notfellchen.org). Initially I just sent text
|
||||
notifications without links to the site. Terrible idea! An E-Mail notification I send always has Call-to-Action or at
|
||||
minimum a link to more information.
|
||||
|
||||
I left the system like this for half a year because it kinda worked for me (didn't suck enough for me to care), and I was the main receiver of these notifications.
|
||||
However, as the platform is developed further and more users join I need to think about more user-centric notifications.
|
||||
|
||||
So what do I imagine is important to a user?
|
||||
*
|
||||
* **Information benefit**: An e-mail has the purpose to inform a user. This information should be immediately visible & understandable.
|
||||
* **Actionables**: Users should be able to act on the information received. This is the bright red button "DO SOMETHING NOW!" you see so often.
|
||||
* **Unsubscribing**: Informing e-mails stop is not only a legal requirement and morally the right thing to do but it also gives users agency and - I hope - increases the User Experience
|
||||
|
||||
With these I naturally came to the next question: Plaintext or HTML?
|
||||
|
||||
Some people would say [Plaintext is inherently better](https://useplaintext.email/) than HTML e-mails. Many of these reasons resonate with me including:
|
||||
* Privacy invasion and tracking
|
||||
* HTML emails are less accessible
|
||||
* Some clients can't display HTML emails at all
|
||||
* Mail client vulnerabilities
|
||||
|
||||
These are all valid points and are a reason I generally enjoy plaintext e-mails when I receive them.
|
||||
But this is not about me but users. And there are some real benefits of HTML e-mails:
|
||||
* Visually appealing: This is subjective but generally most users seem to agree on that
|
||||
* User guidance: Rich text provides a real benefit when searching for the relevant information
|
||||
|
||||
Be honest: Do you read automated e-mails you receive completely? Or do you just skim for important information?
|
||||
|
||||
And here HTML-mails shine: **Information can easily be highlighted** and big button can lead the user to do the right action.
|
||||
Some might argue that you can also a highlight a link in plaintext but that nearly always will worsen accessibility for screen-reader user.
|
||||
|
||||
# The result
|
||||
|
||||
In the end, I decided that providing plaintext-only e-mails was not enough. I set up html mails, mostly using
|
||||
[djangos send_mail](https://docs.djangoproject.com/en/5.2/topics/email/#send-mail) function where I can pass the html message and attattching it correctly is done for me.
|
||||
|
||||

|
||||
|
||||
|
||||
For anyone that is interested, here is how most my notifications are sent
|
||||
```python
|
||||
def send_notification_email(notification_pk):
|
||||
notification = Notification.objects.get(pk=notification_pk)
|
||||
|
||||
subject = f"{notification.title}"
|
||||
context = {"notification": notification, }
|
||||
if notification.notification_type == NotificationTypeChoices.NEW_REPORT_COMMENT or notification.notification_type == NotificationTypeChoices.NEW_REPORT_AN:
|
||||
html_message = render_to_string('fellchensammlung/mail/notifications/report.html', context)
|
||||
plain_message = render_to_string('fellchensammlung/mail/notifications/report.txt', context)
|
||||
[...]
|
||||
elif notification.notification_type == NotificationTypeChoices.NEW_COMMENT:
|
||||
html_message = render_to_string('fellchensammlung/mail/notifications/new-comment.html', context)
|
||||
plain_message = render_to_string('fellchensammlung/mail/notifications/new-comment.txt', context)
|
||||
else:
|
||||
raise NotImplementedError("Unknown notification type")
|
||||
|
||||
if "plain_message" not in locals():
|
||||
plain_message = strip_tags(html_message)
|
||||
mail.send_mail(subject, plain_message, settings.DEFAULT_FROM_EMAIL,
|
||||
[notification.user_to_notify.email],
|
||||
html_message=html_message)
|
||||
```
|
||||
|
||||
Yes this could be made more efficient - for now it works. I made the notification framework too complicated initially, so I'm still tyring out what works and what doesn't.
|
||||
|
||||
Here is the html template
|
||||
```html
|
||||
{% extends "fellchensammlung/mail/base.html" %}
|
||||
{% load i18n %}
|
||||
{% block title %}
|
||||
{% translate 'Neuer User' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p>Moin,</p>
|
||||
<p>
|
||||
es wurde ein neuer Useraccount erstellt.
|
||||
|
||||
</p>
|
||||
<p>
|
||||
Details findest du hier
|
||||
</p>
|
||||
<p>
|
||||
<a href="{{ notification.user_related.get_full_url }}" class="cta-button">{% translate 'User anzeigen' %}</a>
|
||||
</p>
|
||||
{% endblock %}
|
||||
```
|
||||
|
||||
and here the plaintext
|
||||
```
|
||||
{% extends "fellchensammlung/mail/base.txt" %}
|
||||
{% load i18n %}
|
||||
{% block content %}{% blocktranslate %}Moin,
|
||||
|
||||
es wurde ein neuer Useraccount erstellt.
|
||||
|
||||
User anzeigen: {{ new_user_url }}
|
||||
{% endblocktranslate %}{% endblock %}
|
||||
```
|
||||
|
||||
|
||||
|
||||
Works pretty well for now. People that prefer plaintext will get these and most users will have skimmable html e-mail where the
|
||||
styling will help them recognize where it's from and what to do. Accessibility-wise this seems like the best option.
|
||||
|
||||
And while adding a new notification will force me to create
|
||||
* a new notification type,
|
||||
* two new e-mail templates and
|
||||
* a proper rendering on the website
|
||||
|
||||
this seems okay. Notifications are useful, but I don't want to shove them everywhere. I'm not running facebook or linkedin after all.
|
||||
|
||||
So for now I'm pretty happy with the new shiny e-mails and will roll out the changes soon (if I don't find any more wired bugs).
|
||||
|
||||
PS: I wrote this post after reading [blog & website in the age of containerized socials](https://blog.avas.space/blog-website-eval/) by ava.
|
||||
Maybe this "Thoughts on" format will stay and I will post these in addition to more structured deep dives.
|
||||
|
||||
# Update
|
||||
|
||||
I did a rework of the notification function and it's now much cleaner now. However, it's less readable so this blogpost will stay as-is.
|
||||
If you want to check out the new code have a look [on Codeberg](https://codeberg.org/moanos/notfellchen/src/commit/a4b8486bd489dacf8867b49d04f70f091556dc9d/src/fellchensammlung/mail.py).
|
BIN
content/post/about-html-mails/mail_screenshot.png
Normal file
After Width: | Height: | Size: 51 KiB |
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: "Cryptpad"
|
||||
date: 2021-05-7T22:08:55+02:00
|
||||
date: 2021-05-07T22:08:55+02:00
|
||||
draft: true
|
||||
image: "uploads/ILMO_bordered.png"
|
||||
tags: [FOSS]
|
||||
|
@@ -3,6 +3,7 @@ title: "Deploying a django app with docker, ansible and traefik"
|
||||
date: 2023-07-24T22:10:10+02:00
|
||||
draft: false
|
||||
image: "uploads/docker-ansible-django-traefik/django_docker_ansible_traefik.png"
|
||||
image_alt: "Graphic showing the Django, Docker, Ansible and Traefik logo"
|
||||
categrories: ['English']
|
||||
tags: ['MASH', 'django', 'ilmo', 'ansible', 'traefik', 'docker']
|
||||
---
|
||||
|
153
content/post/django-geocoding/index.md
Normal file
@@ -0,0 +1,153 @@
|
||||
---
|
||||
title: "Where are you? - Part 2 - Geocoding with Django to empower area search "
|
||||
date: 2024-10-04T14:05:10+02:00
|
||||
draft: false
|
||||
image: "uploads/django_geocoding2.png"
|
||||
categrories: ['English']
|
||||
tags: ['django', 'geocoding', 'nominatim', 'OpenStreetMap', 'osm', 'traefik', 'mash-playbook', 'docker', 'docker-compose']
|
||||
---
|
||||
|
||||
# Introduction
|
||||
|
||||
In the [previous post](geocoding-with-django/) I outlined how to set up a Nominatim server that allows us to find a geolocation for any address on the planet. Now let's use our newfound power in Django. Again, all code snippets are [CC0](https://creativecommons.org/public-domain/cc0/) so make free use of them. But I'd be very happy if you tell me if you use them for something cool!
|
||||
|
||||
## Prerquisites
|
||||
|
||||
* You have a working geocoding server or use a public one
|
||||
* You have a working django app
|
||||
|
||||
If you want to do geocoding in a different environment you will still be able to use a lot of the the following examples, just skip the Django-specifics and configure the `GEOCODING_API_URL` according to your needs.
|
||||
|
||||
# Using the Geocoding API
|
||||
|
||||
First of all, let's define the geocoding API URL in our settings. This enables us to switch easily if a service is not available. Add the following to you `settings.py`
|
||||
|
||||
```python
|
||||
# appname/settings.py
|
||||
""" GEOCODING """
|
||||
GEOCODING_API_URL = config.get("geocoding", "api_url", fallback="https://nominatim.hyteck.de/search") # Adjust if needed
|
||||
```
|
||||
|
||||
We can then add a class that interacts with the API.
|
||||
```python
|
||||
import logging
|
||||
|
||||
import requests
|
||||
import json
|
||||
from APPNAME import __version__ as app_version
|
||||
from APPNAME import settings
|
||||
|
||||
|
||||
class GeoAPI:
|
||||
api_url = settings.GEOCODING_API_URL
|
||||
# Set User-Agent headers as required by most usage policies (and it's the nice thing to do)
|
||||
headers = {
|
||||
'User-Agent': f"APPNAME {app_version}",
|
||||
'From': 'info@example.org'
|
||||
}
|
||||
|
||||
def __init__(self, debug=False):
|
||||
self.requests = requests # ignore why we do this for now
|
||||
|
||||
def get_coordinates_from_query(self, location_string):
|
||||
result = self.requests.get(self.api_url, {"q": location_string, "format": "jsonv2"}, headers=self.headers).json()[0]
|
||||
return result["lat"], result["lon"]
|
||||
|
||||
def _get_raw_response(self, location_string):
|
||||
result = self.requests.get(self.api_url, {"q": location_string, "format": "jsonv2"}, headers=self.headers)
|
||||
return result.content
|
||||
|
||||
def get_geojson_for_query(self, location_string):
|
||||
try:
|
||||
result = self.requests.get(self.api_url,
|
||||
{"q": location_string,
|
||||
"format": "jsonv2"},
|
||||
headers=self.headers).json()
|
||||
except Exception as e:
|
||||
logging.warning(f"Exception {e} when querying Nominatim")
|
||||
return None
|
||||
if len(result) == 0:
|
||||
logging.warning(f"Couldn't find a result for {location_string} when querying Nominatim")
|
||||
return None
|
||||
return result
|
||||
```
|
||||
|
||||
The wrapper is a synchronous interface to our geocoding server and will wait until the server returns a response or times out. This impacts the user experienc, as a site will take longer to load. But it's much easier to code, so here we are. If anyone wants to write a async interface for this I'll not stop them!
|
||||
|
||||
Fornow, let's start by adding `Location` to our `models.py`
|
||||
|
||||
```python
|
||||
class Location(models.Model):
|
||||
place_id = models.IntegerField()
|
||||
latitude = models.FloatField()
|
||||
longitude = models.FloatField()
|
||||
name = models.CharField(max_length=2000)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.latitude:.5}, {self.longitude:.5})"
|
||||
|
||||
@staticmethod
|
||||
def get_location_from_string(location_string):
|
||||
geo_api = geo.GeoAPI()
|
||||
geojson = geo_api.get_geojson_for_query(location_string)
|
||||
if geojson is None:
|
||||
return None
|
||||
result = geojson[0]
|
||||
if "name" in result:
|
||||
name = result["name"]
|
||||
else:
|
||||
name = result["display_name"]
|
||||
location = Location.objects.create(
|
||||
place_id=result["place_id"],
|
||||
latitude=result["lat"],
|
||||
longitude=result["lon"],
|
||||
name=name,
|
||||
)
|
||||
return location
|
||||
```
|
||||
|
||||
*Don't forget to make&run migrations after this*
|
||||
|
||||
An finally we can use the API!
|
||||
|
||||
```python
|
||||
location = Location.get_location_from_string("Berlin")
|
||||
print(location)
|
||||
# Berlin, Deutschland (52.51, 13.38)
|
||||
```
|
||||
|
||||
Looking good!
|
||||
|
||||
# Area search
|
||||
|
||||
Now wee have the coordinates - great! But how can we get the distance between coordinates? Lukily we are not the first people with that question and there is the [Haversine Formula](https://en.wikipedia.org/wiki/Haversine_formula) that we can use. It's not a perfect fomula, for example it assumes the erth is perfectly round which the earth is not. But for most use cases of area search this should be irrelevant for the final result.
|
||||
|
||||
Here is my implementation
|
||||
|
||||
```python
|
||||
def calculate_distance_between_coordinates(position1, position2):
|
||||
"""
|
||||
Calculate the distance between two points identified by coordinates
|
||||
It expects the coordinates to be a tuple (lat, lon)
|
||||
|
||||
Based on https://en.wikipedia.org/wiki/Haversine_formula
|
||||
"""
|
||||
earth_radius_km = 6371 # As per https://en.wikipedia.org/wiki/Earth_radius
|
||||
latitude1 = float(position1[0])
|
||||
longitude1 = float(position1[1])
|
||||
latitude2 = float(position2[0])
|
||||
longitude2 = float(position2[1])
|
||||
|
||||
distance_lat = radians(latitude2 - latitude1)
|
||||
distance_long = radians(longitude2 - longitude1)
|
||||
|
||||
a = pow(sin(distance_lat / 2), 2) + cos(radians(latitude1)) * cos(radians(latitude2)) * pow(sin(distance_long / 2),
|
||||
2)
|
||||
c = 2 * atan2(sqrt(a), sqrt(1 - a))
|
||||
|
||||
distance_in_km = earth_radius_km * c
|
||||
|
||||
return distance_in_km
|
||||
```
|
||||
|
||||
And with that we have a functioning area search 🎉
|
BIN
content/post/django-rss/django_rss.png
Normal file
After Width: | Height: | Size: 99 KiB |
160
content/post/django-rss/index.md
Normal file
@@ -0,0 +1,160 @@
|
||||
---
|
||||
title: "Styling an Django RSS Feed"
|
||||
date: 2024-04-16T12:10:10+02:00
|
||||
draft: false
|
||||
image: "uploads/django_rss.png"
|
||||
categrories: ['English']
|
||||
tags: ['django', 'rss', 'privacy', 'rss-styling', 'xml', 'xsl', 'atom', 'feed', 'rss-feed']
|
||||
---
|
||||
|
||||
## Introduction
|
||||
|
||||
RSS is amazing! While not everyone thinks that, most people that *understand* RSS, like it. This presents a problem, as most people don't have chance to learn about it. Unless there is a person in the community that doesn't shut up about how great RSS is (maybe that person is you), they might not even know what it is, let alone use it.
|
||||
|
||||
One big reason for this is, that when you click an link to an RSS feed you download a strange file that most people don't know how to deal with. Maybe your browser is nice and renders some XML which is also not meant for human consumption. Wouldn't it be better if people clicked on the RSS link and were greeted by a text explaining RSS and how to use it? And if the site would still be a valid RSS feed?
|
||||
|
||||
Luckily you don't have to imagine that - it's possible! You can even try it on this blog by clicking the RSS link in the menu ([direct link](https://hyteck.de/index.xml)).
|
||||
|
||||
Doing this has not been my idea. Darek Kay described this in the blog post [Style your RSS feed](https://darekkay.com/blog/rss-styling/) and I just copied most of their work! This was fairly easy for this Hugo blog and is [available in my fork of the hugo-nederburg-theme](https://github.com/moan0s/hugo-nederburg-theme). However, in a Django project it get's a bit more complicated. Let me explain.
|
||||
|
||||
## The Problem
|
||||
|
||||
Django has the great [Syndication feed framework](https://docs.djangoproject.com/en/5.0/ref/contrib/syndication/), a high level framework to create RSS and Atom Feeds. This is great as we only need a few lines of code to create a feed. Here is an example from [notfellchen.org](https://notfellchen.org) that list animals that are in search for a new home. People should be able to follow the RSS feed to see new adoption notices. So lets do it
|
||||
|
||||
```python
|
||||
# in src/fellchennasen/feeds.py
|
||||
from django.contrib.syndication.views import Feed
|
||||
|
||||
from .models import AdoptionNotice
|
||||
|
||||
class LatestAdoptionNoticesFeed(Feed):
|
||||
title = "Notfellchen"
|
||||
link = "/rss/"
|
||||
description = "Updates zu neuen Vermittlungen."
|
||||
|
||||
def items(self):
|
||||
return AdoptionNotice.objects.order_by("-created_at")[:5]
|
||||
|
||||
def item_title(self, item):
|
||||
return item.name
|
||||
|
||||
def item_description(self, item):
|
||||
return item.description
|
||||
```
|
||||
|
||||
```python
|
||||
# in src/fellchennasen/urls.py
|
||||
urlpatterns = [
|
||||
path("", views.index, name="index"),
|
||||
path("rss/", LatestAdoptionNoticesFeed(), name="rss"), # <--- Added
|
||||
...
|
||||
```
|
||||
|
||||
Wait that's it? Yeah! We have a working RSS feed. And it was very convenient, Django allows us to create by just pointing it to the right model and fields we want to display.
|
||||
|
||||
But here is the problem: How do we style this? We can't just add a link to a stylesheet here.
|
||||
|
||||
## The solution
|
||||
|
||||
First we need to add our styling files. I'll not go into detail how they work her, just refer [Darek's blog post](https://darekkay.com/blog/rss-styling/) for that. In Django we add them to our static files
|
||||
|
||||
* `static/rss.xsl` will be adjusted based on [this file](rss.xsl). It is responsible for creating a html rendering of your XML file
|
||||
* for `static/css/rss-styles` you can drop in [this file](rss-styles.css), which is a basic CSS file you can edit to your liking.
|
||||
|
||||
After that comes the hard part. How do tweak this wonderfully simple Feed class to include a link to our style sheet? I first thought "that must be easy, just follow the docs on [custom feed generators](https://docs.djangoproject.com/en/5.0/ref/contrib/syndication/#custom-feed-generators) and add a root element. Something like this:
|
||||
|
||||
```python
|
||||
class FormattedFeed(Rss201rev2Feed):
|
||||
|
||||
def add_root_elements(self, handler):
|
||||
super().add_root_elements(handler)
|
||||
# We want <?xml-stylesheet href="/static/rss.xsl" type="text/xsl"?>
|
||||
handler.addQuickElement("?xml-stylesheet", f'href="{static("rss.xsl")}"')
|
||||
|
||||
class LatestAdoptionNoticesFeed(Feed):
|
||||
feed_type = FormattedFeed
|
||||
title = "Notfellchen"
|
||||
...
|
||||
```
|
||||
|
||||
Looks good. Let's try. Oh no what is this?
|
||||
|
||||
```xml
|
||||
<?xml-stylesheet href="/static/rss.xsl"/>
|
||||
```
|
||||
|
||||
|
||||
Yes, we can't correctly close this tag. There is (to my knowledge) no easy way to do this. So let's take the hard road an implement a custom write function. In the following the write function will be copied from `django.utils.feedgenerator.RssFeed`. We make two important changes to the class:
|
||||
|
||||
1. Changing the content type from `content_type = "application/rss+xml; charset=utf-8"` to `content_type = "text/rss+xml; charset=utf-8`. This will make a browser display the content rather than opening it in a app.
|
||||
2. Adding our xml-stylsheet information. This is done in the `write()` function with this line
|
||||
|
||||
```python
|
||||
handler._write(f'<?xml-stylesheet href="{static("rss.xsl")}" type="text/xsl"?>')
|
||||
```
|
||||
|
||||
Putting it all together we still have a relativly simple solution with only the necessary adjustments. Here is the full `feeds.py`:
|
||||
|
||||
```python
|
||||
from django.contrib.syndication.views import Feed
|
||||
from django.utils.feedgenerator import Rss201rev2Feed
|
||||
from django.templatetags.static import static
|
||||
from django.utils.xmlutils import SimplerXMLGenerator
|
||||
|
||||
from .models import AdoptionNotice
|
||||
|
||||
|
||||
class FormattedFeed(Rss201rev2Feed):
|
||||
content_type = "text/xml; charset=utf-8"
|
||||
def write(self, outfile, encoding):
|
||||
handler = SimplerXMLGenerator(outfile, encoding, short_empty_elements=True)
|
||||
handler.startDocument()
|
||||
handler._write(f'<?xml-stylesheet href="{static("rss.xsl")}" type="text/xsl"?>')
|
||||
handler.startElement("rss", self.rss_attributes())
|
||||
handler.startElement("channel", self.root_attributes())
|
||||
self.add_root_elements(handler)
|
||||
self.write_items(handler)
|
||||
self.endChannelElement(handler)
|
||||
handler.endElement("rss")
|
||||
|
||||
|
||||
class LatestAdoptionNoticesFeed(Feed):
|
||||
feed_type = FormattedFeed
|
||||
title = "Notfellchen"
|
||||
link = "/rss/"
|
||||
description = "Updates zu neuen Vermittlungen."
|
||||
|
||||
def items(self):
|
||||
return AdoptionNotice.objects.order_by("-created_at")[:5]
|
||||
|
||||
def item_title(self, item):
|
||||
return item.name
|
||||
|
||||
def item_description(self, item):
|
||||
return item.description
|
||||
|
||||
```
|
||||
|
||||
And finally we have what we want! A RSS feed displayed in the browser, with beginner-friendly explanation and still completely spec-compliant.
|
||||
|
||||

|
||||
|
||||
## Outlook
|
||||
|
||||
Now you may recognize I'm not a frontend person. The style could be prettier and provide a better overview. But I'd argue the improvement is immense and might help a user to get started with RSS.
|
||||
|
||||
There are still a couple things to improve:
|
||||
|
||||
* Translation: The current text is only displayed in english
|
||||
* The `rss.xsl` file has a hard-coded link to the css stylesheet in it
|
||||
|
||||
```html
|
||||
<link rel="stylesheet" type="text/css" href="/static/fellchensammlung/css/rss-styles.css"/>
|
||||
```
|
||||
|
||||
Both can be solved by templating the `rss.xsl` instead of serving it as static file.
|
||||
|
||||
|
||||
So have fun playing around! If you have created or found a nice-looking RSS feed let me know. Let's keep RSS alive and thriving!
|
||||
|
||||
{{< chat "django-rss" >}}
|
57
content/post/django-rss/rss-styles.css
Normal file
@@ -0,0 +1,57 @@
|
||||
:root {
|
||||
--background-color: #727272;
|
||||
--background-color-dark: #2a2a2a;
|
||||
--text-color: #000000;
|
||||
--link-color: rgb(10, 10, 42);
|
||||
--text-background: #aaaaaa;
|
||||
}
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--background-color-dark);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
alert-box[type="info"] {
|
||||
--alert-border: var(--background-color);
|
||||
--alert-background: var(--text-background);
|
||||
}
|
||||
|
||||
alert-box {
|
||||
display: block;
|
||||
margin: 3rem 0;
|
||||
padding: 2rem 3rem;
|
||||
border: 1px solid var(--alert-border);
|
||||
border-left-width: .5rem;
|
||||
border-radius: .4rem;
|
||||
background-color: var(--alert-background);
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.post-summary {
|
||||
margin: 1rem;
|
||||
padding: 5px;
|
||||
border-radius: .4rem;
|
||||
background-color: var(--text-background);
|
||||
}
|
||||
|
||||
.rss-summary {
|
||||
padding: 15px;
|
||||
border-radius: .4rem;
|
||||
background-color: var(--background-color);
|
||||
}
|
||||
|
||||
.post-summary h1 {
|
||||
color: var(--link-color);
|
||||
font-size: large;
|
||||
}
|
||||
|
||||
.inline-icon {
|
||||
height: 1.5rem;
|
||||
width: 1.5rem;
|
||||
}
|
||||
|
221
content/post/django-rss/rss.xsl
Normal file
@@ -0,0 +1,221 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<xsl:stylesheet version="3.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
|
||||
xmlns:atom="http://www.w3.org/2005/Atom">
|
||||
<xsl:output method="html" version="1.0" encoding="UTF-8" indent="yes"/>
|
||||
<xsl:template match="/">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
|
||||
<head>
|
||||
<title>
|
||||
RSS Feed |
|
||||
<xsl:value-of select="/atom:feed/atom:title"/>
|
||||
</title>
|
||||
<meta charset="utf-8"/>
|
||||
<meta http-equiv="content-type" content="text/html; charset=utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<link rel="stylesheet" type="text/css" href="/static/fellchensammlung/css/rss-styles.css"/>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<alert-box type="info">
|
||||
<strong>This is an RSS feed</strong>. Subscribe by copying
|
||||
the URL from the address bar into your newsreader. Visit <a
|
||||
href="https://aboutfeeds.com">About Feeds
|
||||
</a> to learn more and get started. It’s free.
|
||||
</alert-box>
|
||||
<div class="rss-summary">
|
||||
<h1 class="flex items-start">
|
||||
RSS Feed Preview
|
||||
<svg
|
||||
class="inline-icon"
|
||||
version="1.1"
|
||||
width="128px"
|
||||
height="128px"
|
||||
id="RSSicon"
|
||||
viewBox="0 0 256 256"
|
||||
sodipodi:docname="Feed-icon.svg"
|
||||
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview32"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
showgrid="false"
|
||||
inkscape:zoom="2.9085291"
|
||||
inkscape:cx="161.07798"
|
||||
inkscape:cy="133.22886"
|
||||
inkscape:window-width="2048"
|
||||
inkscape:window-height="1252"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="RSSicon" />
|
||||
<defs
|
||||
id="defs17">
|
||||
<linearGradient
|
||||
x1="0.085"
|
||||
y1="0.085"
|
||||
x2="0.915"
|
||||
y2="0.915"
|
||||
id="RSSg">
|
||||
<stop
|
||||
offset="0.0"
|
||||
stop-color="#E3702D"
|
||||
id="stop2" />
|
||||
<stop
|
||||
offset="0.1071"
|
||||
stop-color="#EA7D31"
|
||||
id="stop4" />
|
||||
<stop
|
||||
offset="0.3503"
|
||||
stop-color="#F69537"
|
||||
id="stop6" />
|
||||
<stop
|
||||
offset="0.5"
|
||||
stop-color="#FB9E3A"
|
||||
id="stop8" />
|
||||
<stop
|
||||
offset="0.7016"
|
||||
stop-color="#EA7C31"
|
||||
id="stop10" />
|
||||
<stop
|
||||
offset="0.8866"
|
||||
stop-color="#DE642B"
|
||||
id="stop12" />
|
||||
<stop
|
||||
offset="1.0"
|
||||
stop-color="#D95B29"
|
||||
id="stop14" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect
|
||||
width="256"
|
||||
height="256"
|
||||
rx="55"
|
||||
ry="55"
|
||||
x="0"
|
||||
y="0"
|
||||
fill="#CC5D15"
|
||||
id="rect19"
|
||||
style="fill:#414141;fill-opacity:1" />
|
||||
<rect
|
||||
width="246"
|
||||
height="246"
|
||||
rx="50"
|
||||
ry="50"
|
||||
x="5"
|
||||
y="5"
|
||||
fill="#F49C52"
|
||||
id="rect21"
|
||||
style="fill:#414141;fill-opacity:1" />
|
||||
<rect
|
||||
width="236"
|
||||
height="236"
|
||||
rx="47"
|
||||
ry="47"
|
||||
x="10"
|
||||
y="10"
|
||||
fill="url(#RSSg)"
|
||||
id="rect23"
|
||||
style="fill:#414141;fill-opacity:1" />
|
||||
<circle
|
||||
cx="68"
|
||||
cy="189"
|
||||
r="24"
|
||||
fill="#FFF"
|
||||
id="circle25" />
|
||||
<path
|
||||
d="M160 213h-34a82 82 0 0 0 -82 -82v-34a116 116 0 0 1 116 116z"
|
||||
fill="#FFF"
|
||||
id="path27" />
|
||||
<path
|
||||
d="M184 213A140 140 0 0 0 44 73 V 38a175 175 0 0 1 175 175z"
|
||||
fill="#FFF"
|
||||
id="path29" />
|
||||
<rect
|
||||
width="256"
|
||||
height="256"
|
||||
rx="55"
|
||||
ry="55"
|
||||
x="299.70761"
|
||||
y="188.99872"
|
||||
fill="#CC5D15"
|
||||
id="rect19-3"
|
||||
style="fill:#414141;fill-opacity:1" />
|
||||
<rect
|
||||
width="246"
|
||||
height="246"
|
||||
rx="50"
|
||||
ry="50"
|
||||
x="304.70761"
|
||||
y="193.99872"
|
||||
fill="#F49C52"
|
||||
id="rect21-6"
|
||||
style="fill:#414141;fill-opacity:1" />
|
||||
<rect
|
||||
width="236"
|
||||
height="236"
|
||||
rx="47"
|
||||
ry="47"
|
||||
x="309.70761"
|
||||
y="198.99872"
|
||||
fill="url(#RSSg)"
|
||||
id="rect23-7"
|
||||
style="fill:#414141;fill-opacity:1" />
|
||||
<circle
|
||||
cx="367.70761"
|
||||
cy="377.99872"
|
||||
r="24"
|
||||
fill="#ffffff"
|
||||
id="circle25-5" />
|
||||
<path
|
||||
d="m 459.7076,401.99872 h -34 a 82,82 0 0 0 -82,-82 v -34 a 116,116 0 0 1 116,116 z"
|
||||
fill="#ffffff"
|
||||
id="path27-3" />
|
||||
<path
|
||||
d="m 483.7076,401.99872 a 140,140 0 0 0 -140,-140 v -35 a 175,175 0 0 1 175,175 z"
|
||||
fill="#ffffff"
|
||||
id="path29-5" />
|
||||
</svg>
|
||||
</h1>
|
||||
<h2><xsl:value-of select="/rss/channel/title"/></h2>
|
||||
<p>
|
||||
<xsl:value-of select="/atom:feed/atom:subtitle"/>
|
||||
</p>
|
||||
<a>
|
||||
<xsl:attribute name="href">
|
||||
<xsl:value-of select="/atom:feed/atom:link/@href"/>
|
||||
</xsl:attribute>
|
||||
Visit Website →
|
||||
</a>
|
||||
|
||||
<h2>Adoption Notices</h2>
|
||||
<xsl:for-each select="/rss/channel/item">
|
||||
<div class="post-summary">
|
||||
<h1>
|
||||
<a>
|
||||
<xsl:attribute name="href">
|
||||
<xsl:value-of select="atom:link/@href"/>
|
||||
</xsl:attribute>
|
||||
<xsl:value-of select="title"/>
|
||||
</a>
|
||||
</h1>
|
||||
|
||||
<div class="text-2 text-offset">
|
||||
<xsl:value-of select="description"/>
|
||||
</div>
|
||||
</div>
|
||||
</xsl:for-each>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
</xsl:template>
|
||||
</xsl:stylesheet>
|
BIN
content/post/django-rss/screenshot1.jpeg
Normal file
After Width: | Height: | Size: 136 KiB |
145
content/post/geocoding-with-nominatim/index.md
Normal file
@@ -0,0 +1,145 @@
|
||||
---
|
||||
title: "Where are you? - Part 1 - Geocoding with Nominatim to empower area search "
|
||||
date: 2024-09-28T12:05:10+02:00
|
||||
draft: false
|
||||
image: "uploads/django_geocoding.png"
|
||||
categrories: ['English']
|
||||
tags: ['django', 'geocoding', 'nominatim', 'OpenStreetMap', 'osm', 'traefik', 'mash-playbook', 'docker', 'docker-compose']
|
||||
---
|
||||
|
||||
# Introduction
|
||||
|
||||
Geocoding is the process of translating a text input like `Ungewitterweg, Berlin` into a location with longitude and latitude such as `52.544022/13.147589`. So whenever you search in OpenStreetMap or Google Maps for a location, it does exactly that (and sometimes more, but we don't focus on that now).
|
||||
|
||||
For a pet project of mine ([notfellchen.org](https://notfellchen.org)) I wanted to do exactly that: When a animal is added there to be adopted, the user must input a location that is geocoded and saved with it's coordinates. When another user visits the site, that wants to adopt a pet in their area, they input their location and it will search for all animals in a specific radius.
|
||||
|
||||
How is that done? I'll show you!
|
||||
|
||||
# Nominatim
|
||||
|
||||
Nominatim is a software that uses OpenStreetMap data for geocoding. It can also do the reverse, find an address for any location on the planet. It is used for the geocoding on [OpenStreetMap](https://openstreetmap.org), so it's quite production-ready. We could use the public API (while obeying the [usage policy](https://operations.osmfoundation.org/policies/nominatim/)) but it's nicer to have our own instance, so we don't stress the resources of a donation funded organization and to improve user privacy.
|
||||
|
||||
Nominatim works by importing geodate from a [PBF](https://wiki.openstreetmap.org/wiki/PBF_Format)-file into a postgres database. This database will later be queried to provide location data. The process is described below.
|
||||
|
||||
## DNS records
|
||||
|
||||
Se let's start by setting the DNS records so that the domain `geocoding.example.org` points to your server. Adjust as needed.
|
||||
| Value | Type | Target |
|
||||
| --- | --- | --- |
|
||||
| geocoding.example.org | CNAME | server1.example.org|
|
||||
|
||||
## Docker-compose Configuration
|
||||
|
||||
We will use Docker Compose to run the official [Nominatim Docker image](https://hub.docker.com/r/mediagis/nominatim).
|
||||
|
||||
It bundles nominatim together with the database postgres. I usually prefere to have a central database for multiple services (e.g. allows easier backups) but for nominatim a seperate database is good for two reasons
|
||||
* import process (described later) will not slow the database for other services
|
||||
* it's easier to nuke everything if things go wrong
|
||||
|
||||
The following environment variables will be used to configure the container
|
||||
|
||||
* `PBF_URL`: The URL from where to download the PBF file that contains the geodate we will import. They can be obtained from [Geofabrik](https://download.geofabrik.de/). It is highly recommended to first download the file to a local server and then set this URL to that server so that the ressources from Geofabrik are not affected if something goes wrong. Feel free to use the pre-set URL for germany while it works if you want to test around.
|
||||
* `REPLICATION_URL`: Where to get updates from. For example Geofabrik's update for the Europe extract are available at `https://download.geofabrik.de/europe-updates/` Other places at Geofabrik follow the pattern `https://download.geofabrik.de/$CONTINENT/$COUNTRY-updates/`
|
||||
* `POSTGRES_` Postgres tuning data, the current setting allows imports on a ressource constrained system. See [postgres tuning docs](https://github.com/mediagis/nominatim-docker/tree/master/4.4#postgresql-tuning) for more info
|
||||
* `NOMINATIM_PASSWORD`: Database password.
|
||||
* `IMPORT_STYLE`: See below
|
||||
|
||||
**Import Styles**
|
||||
|
||||
Import styles will determin how much "resolution" the geocoding has. It has the following options
|
||||
|
||||
* `admin`: Only import administrative boundaries and places.
|
||||
* `street`: Like the admin style but also adds streets.
|
||||
* `address`: Import all data necessary to compute addresses down o house number level.
|
||||
* `full`: Default style that also includes points of interest.
|
||||
* `extratags`: Like the full style but also adds most of the OSM tags into the extratags column.
|
||||
|
||||
It has a huge impact on how long the import will take and how much space it will require. Be aware that the import time is on a machine with 32GB RAM, 4 CPUS and SSDs, these are not fixed numbers. My import of `admin` took 12 hours.
|
||||
|
||||
| Style | Import time | DB size | after drop |
|
||||
| --- | --- | --- | --- |
|
||||
| admin | 4h | 215 GB | 20 GB|
|
||||
| street | 22h | 440 GB | 185 GB |
|
||||
| address | 36h |545 GB | 260 GB |
|
||||
|
||||
Explaining *after drop* (from the [docs](https://nominatim.org/release-docs/3.3/admin/Import-and-Update/))
|
||||
|
||||
> About half of the data in Nominatim's database is not really used for serving the API. It is only there to allow the data to be updated from the latest changes from OSM. For many uses these dynamic updates are not really required. If you don't plan to apply updates, the dynamic part of the database can be safely dropped using the following command: `./utils/setup.php --drop`
|
||||
|
||||
I have not done this, so I don't have any experince with that. But probably it's a good idea if you don't need up-to-date data.
|
||||
|
||||
|
||||
|
||||
## Reverse Proxy
|
||||
|
||||
As with most of my projects, it runs on a server where the [mash-playbook](https://github.com/mother-of-all-self-hosting/mash-playbook) has deployed a [Traefik](https://doc.traefik.io/traefik/), as *Application Proxy*. I'll therefore use trafik labels to configure the revers proxy but the same could be achieved with Caddy or Nginx.
|
||||
|
||||
## Complete configuration
|
||||
|
||||
```
|
||||
services:
|
||||
nominatim:
|
||||
environment:
|
||||
- PBF_URL=https://cdn.hyteck.de/osm/germany-latest.osm.pbf
|
||||
- REPLICATION_URL=https://download.geofabrik.de/europe/germany-updates/
|
||||
- POSTGRES_SHARED_BUFFERS=1GB
|
||||
- POSTGRES_MAINTENANCE_WORK_MEM=1GB
|
||||
- POSTGRES_AUTOVACUUM_WORK_MEM=500MB
|
||||
- POSTGRES_EFFECTIVE_CACHE_SIZE=1GB
|
||||
- IMPORT_STYLE=admin
|
||||
- NOMINATIM_PASSWORD=VERYSECRET
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik"
|
||||
- "traefik.http.routers.nominatim.rule=Host(`geocoding.example.org`)"
|
||||
- "traefik.http.routers.nominatim.service=nominatim-service"
|
||||
- "traefik.http.routers.nominatim.entrypoints=web-secure"
|
||||
- "traefik.http.routers.nominatim.tls=true"
|
||||
- "traefik.http.routers.nominatim.tls.certResolver=default"
|
||||
- "traefik.http.services.nominatim-service.loadbalancer.server.port=8080"
|
||||
|
||||
container_name: nominatim
|
||||
image: mediagis/nominatim:4.4
|
||||
restart: always
|
||||
networks:
|
||||
- traefik
|
||||
volumes:
|
||||
- nominatim-data:/var/lib/postgresql/14/main
|
||||
- nominatim-flatnode:/nominatim/flatnode
|
||||
shm_size: 1gb
|
||||
volumes:
|
||||
nominatim-flatnode:
|
||||
nominatim-data:
|
||||
|
||||
networks:
|
||||
traefik:
|
||||
name: "traefik"
|
||||
external: true
|
||||
```
|
||||
|
||||
## Importing
|
||||
|
||||
Now we are ready to go! Before you type `docker-compose up -d` let me explain what it will do
|
||||
|
||||
1. Start the database
|
||||
2. Download the PBF file from the given URL
|
||||
3. Import the PBF file into the database. Here you are most likely to run into errors because of ressource constraints
|
||||
4. Start the Nominatim server
|
||||
|
||||
If you are ready, lets go: `docker-compose up -d`. Monitor what nominatim is doing with `docker logs -f nominatim` and make a cup of tea. This will take a while (proably several hours).
|
||||
|
||||
## Testing
|
||||
|
||||
You can test your server by visiting the domain. Try `/?q=CITYNAME` to see an actual search result.
|
||||
|
||||
Example: `https://geocoding.example.org/?q=tuebingen`
|
||||
|
||||
# Result
|
||||
|
||||
You should now have a running Nominatim instance that you can use for geocoding 🎉. Initially I wanted to show in the same post how you'd use this server to power area search in django but that will be in part 2. Feel free to ping me for questions, preferably at [@moanos@gay-pirate-assassins.de](https://gay-pirate-assassins.de/@moanos)
|
||||
|
||||
Oh and one last thing:
|
||||
|
||||
## Legal requirements
|
||||
|
||||
Data from OpenStreetMap is licenced under the [Open Database License](https://opendatacommons.org/licenses/odbl/). The ODbL allows you to use the OSM data for any purpose you like but **attribution is required**. For showing map data, you'd usually display a small badge in the bottom left corner of the map. But geocoding also needs attribution, [as per this guideline](https://osmfoundation.org/wiki/Licence/Attribution_Guidelines#Geocoding_(search)).
|
BIN
content/post/improve-osm-by-using-it/improve-osm-by-using-it.png
Normal file
After Width: | Height: | Size: 372 KiB |
100
content/post/improve-osm-by-using-it/index.md
Normal file
@@ -0,0 +1,100 @@
|
||||
---
|
||||
title: "Improve OpenStreetMap data by using it"
|
||||
date: 2025-06-28T14:05:10+02:00
|
||||
draft: false
|
||||
image: "post/improve-osm-by-using-it/improve-osm-by-using-it.png"
|
||||
categories: ['English']
|
||||
tags: ['django', 'OpenStreetMap', 'notfellchen', 'osm', 'open data', 'geojson']
|
||||
---
|
||||
|
||||
## Introduction
|
||||
|
||||
In the last month I improved the mapping of about 100 german animal shelters - not only out of the goodness of my heart, but because it helped me.
|
||||
|
||||
Let me explain why: I develop [notfellchen.org](https://notfellchen.org/), where users can search animals in animal shelters, specifically rats, they might want to adopt.
|
||||
The idea is to have a central website that allows you to search for rats in your area.
|
||||
|
||||
This is necessary because only a small percentage of animal shelters has rats. As a user, just checking your next
|
||||
shelter doesn't work. Some users will stop after checking the second or third one and just buy from a pet shop (which is a very, very bad idea).
|
||||
|
||||
Now a central platform for is nice for users but has one problem: How do I, as operator of notfellchen, know where rats are?
|
||||
|
||||
I need to **manually check every animal shelter in the country** and if they have rats, ask them for permission to use
|
||||
images of the rats on my site.
|
||||
So wait I need to have is a list of animal shelters in germany and have their website, e-mail and phone number.
|
||||
|
||||
The source for all of this: You guessed it - OpenStreetMap 🥳
|
||||
|
||||
|
||||
# Getting the data
|
||||
|
||||
Downloading all german animal shelters is surprisingly easy: You use [Overpass Turbo](https://overpass-turbo.eu/) and get a `.geojson` to download.
|
||||
|
||||
here is the query I used:
|
||||
|
||||
```
|
||||
[out:json][timeout:25];
|
||||
// fetch area “Germany” to search in
|
||||
{{geocodeArea:Germany}}->.searchArea;
|
||||
// Check search area for all objects with animal shelter tag
|
||||
nwr["amenity"="animal_shelter"](area.searchArea);
|
||||
// print results
|
||||
out geom;
|
||||
```
|
||||
|
||||
Now upload it to notfellchen.org and I'll be fine right?
|
||||
|
||||
# Data Issues
|
||||
|
||||
Yeah well, this only *mostly* works. There were two main problems:
|
||||
|
||||
**Missing contact data** is annoying because I quickly want to check the website of animal shelters.
|
||||
|
||||
More annoying were what I'd call **mapping errors**.
|
||||
Most commonly an animal shelter had multiple nodes/ways tagged as `amenity:animal_shelter`.
|
||||
The highlight was the "Tierheim München" where about 10 buildings were tagged as `amenity:animal_shelter` and the contact
|
||||
data was sitting on the building with name "Katzenhaus" ("cat house").
|
||||
|
||||
Now the "Tierheim München" appeared in my list 10 times but 9 of them had no contact data at all.
|
||||
|
||||
# Correcting it
|
||||
|
||||
I could have corrected this only in the notfellchen database. It would have been faster and I could even automate parts of it.
|
||||
But I didn't.
|
||||
|
||||
For each issue I found, I opened OpenStreetMap and added websites, phone numbers or even re-mapped the area.
|
||||
For "Tierheim München" I even [opened a thread in the forum](https://community.openstreetmap.org/t/mapping-of-multiple-related-buildings-animal-shelters/131801)
|
||||
to discuss a proper tagging.
|
||||
|
||||
That makes sense for me because I get one important thing:
|
||||
|
||||
# What I get out of it: Updates
|
||||
|
||||
What if a new shelter was added later or a shelter changed? I already profit a lot from the time people spend adding information, so why stop?
|
||||
|
||||
My database stores the OSM ID, so I can regularly query the data again to get updates.
|
||||
But that only works if I take an "upstream" approach: Fix the data in OSM, then load it into notfellchen.
|
||||
Otherwise, any change in my database will be overwritten by "old" OSM data.
|
||||
|
||||
# Result
|
||||
|
||||
In the last month, I made 86 changes to OSM adding the following information
|
||||
|
||||
| Type of information | Number of times added |
|
||||
|---------------------|-----------------------|
|
||||
| Website | 66 |
|
||||
| Phone Numbers | 65 |
|
||||
| Operator | 63 |
|
||||
| E-Mail | 49 |
|
||||
| Fax | 9 |
|
||||
|
||||
Yes I sometimes even added fax numbers. It was easy enough to add and maybe there is someone might use it.
|
||||
|
||||
# Looking forward
|
||||
|
||||
I'm of course not done. Only half of the rescues known to OSM in germany are currently checked, so I'll continue that work.
|
||||
|
||||
After that I'll start adding the shelters that are just in my database.
|
||||
Currently, 33 animal shelters are known to notfellchen that are not known to OSM. This number will likely grow, maybe double.
|
||||
|
||||
A lot to do. And luckily, this work both benefits me and everyone using OSM. Happy mapping!
|
107
content/post/librarian/index.md
Normal file
@@ -0,0 +1,107 @@
|
||||
---
|
||||
title: "Meine SciFi und Fantasy Empfehlungen - Schriftgelehrte gegen KI"
|
||||
date: 2025-03-03T18:05:10+02:00
|
||||
draft: false
|
||||
image: "uploads/scifi-fantasy.png"
|
||||
categrories: ['Deutsch']
|
||||
tags: ['ki', 'science-fiction', 'schriftgelehrte', 'progressive Fantastik', 'scifi', 'fantasy', 'solarpunk']
|
||||
---
|
||||
|
||||
## Einführung
|
||||
|
||||
KI ist überall. Große Teile des Internets werden gerade durch KI-generierten Inhalten überschwemmt.
|
||||
Teilweise zeigen KI-generierte Artikel auch gefälschte Daten (vor 2022) an um so den Anschein zu erwecken nicht KI-generiert zu sein.
|
||||
Verlässliche und authentische Informationen zu finden ist daher um so schwerer.
|
||||
|
||||
Deshalb beginnt **das Zeitalter der Schriftgelehrten**, denn Informationen zu sammeln und aufzubereiten, das ist, was sie tun. Schriftgelehrte\*r verwende ich hier als Übersetzung des englischen Wortes "Librarian".
|
||||
Und so maße auch ich mir an mich (nur) dafür Schriftgelehrte\*r zu nennen und versuche auf diesem
|
||||
Blog immer wieder Informationen zu sammeln und zu teilen (ironischerweise in dem Wissen, dass dieser Blog von
|
||||
KI-Unternehmen gescrapt wird).
|
||||
|
||||
Starten möchte ich mit Folgendem:
|
||||
|
||||
## Sci-Fi und Fantasy
|
||||
|
||||
Wenn ich nach Sci-Fi und Fantasy suche, will ich weg von profit-optimierten Listen von Online-Buchhändler\*innen,
|
||||
und hin zu echten Empfehlungen. Viele solche Empfehlungen bekomme ich im lokalen Buchladen. Gerade junge Buchhändler\*innen können oft super Empfehlungen geben.
|
||||
Leider sind diese wenigen Mitarbeiter\*innen selten und in Buchläden sind SciFi und Fantasy oft nur wenig vertreten und die Regale viel zu oft voll mit Büchern weißer Männer.
|
||||
|
||||
Deshalb jetzt zu den Empfehlungen die versuchen, das anders zu machen!
|
||||
|
||||
*Die Liste beschreibt die Bücher nur kurz und sollte ohne Spoiler auskommen.
|
||||
In allen Büchern kommen queere Charaktere vor. Alle Links zum führen zu einem lokaln Buchladen oder Websites der Autor\*innen oder des Verlags.*
|
||||
|
||||
### Becky Chambers: "Der lange Weg zu einem kleinen zorningen Planeten"
|
||||
|
||||
Dieses Buch ist eine wirklich wunderschön geschriebene Space-Opera mit Charakteren, die man ins Herz schließt.
|
||||
Auf dem kleinen Schiff ist viel Alltag und auf dem langen Weg lässt einen jede Zwischenstation in eine weitere Welt eintauchen, egal ob in einen trubeligen Markt oder einen abgeschiedenen Eisplanet.
|
||||
|
||||
Der zweite und dritte Teil handeln im gleichen Universum, sind jedoch nur über wenige Personen/geteilte Themen mit der ersten Geschichte verbunden.
|
||||
|
||||
Alle anderen Bücher von Becky Chambers sind auch sehr empfehlenswert "A Psalm for the Wild-Built" ist eine kurze Solarpunk Utopie.
|
||||
|
||||
[Link zum Buch beim Frauenbuchladen Thalestris](https://frauenbuchladen.buchkatalog.de/der-lange-weg-zu-einem-kleinen-zornigen-planeten-9783596035687)
|
||||
|
||||
### Judith & Christian Vogt: Ace in Space
|
||||
|
||||
Ace in Space ist eine tolle Erzählung von einer Raumjäger-Pilotin, einer Gruppe Space-Punks die ihr Leben auf Social Media teilen.
|
||||
Angesiedelt ist das Buch eher im Cyber-Punk. Es geht gegen Großkonzerne, es geht um schnelle Flieger und Bars in engen Quartieren.
|
||||
Außerdem hat es eine der besten Sexszenen, die ich je lesen durfte.
|
||||
|
||||
Judith schreibt oft dystopischere Geschichten als ich normalerweise lese. Aber Laylayland und Wasteland zwei fantastische Bücher die ich nicht missen will.
|
||||
Die Bücher sind feministisch, queer und tollerweise auch mit Haupt(Charaktere) mit Behinderungen. Progressive Phanastik der höchsten Klassen!
|
||||
|
||||
[Verlagsshop](https://amrun-verlag.de/produkt/aceinspace1/)
|
||||
|
||||
Wer eine ganze Kurzgeschichte "der Vögte" (also Judith und Christian Vogt) als Leseprobe haben will, findet diese am Ende des Artikels als PDF.
|
||||
|
||||
### T.J. Klune: Mr. Parnassus' Heim für magisch Begabte
|
||||
|
||||
Wunderschöne Geschichte über einen Beamten der aus der Stadt rauskommt und ein Haus an der See.
|
||||
Mehr verrate ich nicht, ist aber eins meiner Lieblingsbücher.
|
||||
|
||||
[Link zum Buch beim Frauenbuchladen Thalestris](https://frauenbuchladen.buchkatalog.de/mr-parnassus-heim-fuer-magisch-begabte-9783453321366)
|
||||
|
||||
### Lena Richter: Dies ist mein letztes Lied
|
||||
|
||||
Eine mitreisende Geschichte in der die Hauptperson durch ihre Musik von Welt zu Welt gerissen wird. Was sie in den einzelnen Episoden erlebt ist schön und herzzerreißend.
|
||||
Ich habe die Novelle am Stück verschlungen und mit der Hauptperson gelacht und geweint.
|
||||
|
||||
[Verlagsshop](https://www.ohneohren.com/shop/Lena-Richter-Dies-ist-mein-letztes-Lied-p520843015)
|
||||
|
||||
### Rebecca Thorne: "Can't spell treason without tea"
|
||||
|
||||
In einer Fantasy Welt brennt eine Leibwächterin der Königin mit einer Magierin durch und sie eröffnen einen Teeladen.
|
||||
Es gibt auch den Nachfolger "A Pirate's Life for Tea", den hab ich aber noch nicht gelesen
|
||||
|
||||
[Link zum Buch beim Frauenbuchladen Thalestris (Deutsche Übersetzung)](https://frauenbuchladen.buchkatalog.de/cant-spell-treason-without-tea-9783492706896)
|
||||
|
||||
|
||||
### Travis Baldree: "Legends&Latte"
|
||||
|
||||
Eine Ork, die lange Jahre mit einer Gruppe Abenteuer unterwegs war versucht nun einen Buch & Teeladen aufzumachen.
|
||||
Sehr unterhaltsam, unerwartet friedlich und macht unglaublich Lust mehr in Cafes zu gehen!
|
||||
|
||||
"Bookshops & Bonedust" ist die Vorgeschichte, die aber gut nach Legends & Latte gelesen werden kann und auch später geschrieben wurde.
|
||||
|
||||
[Link zum Buch beim Frauenbuchladen Thalestris](https://frauenbuchladen.buchkatalog.de/magie-und-milchschaum-9783423263566)
|
||||
|
||||
### Sammlung: Sonnenseiten - Street Art trifft Solarpunk
|
||||
|
||||
22 Autor\*innen haben Geschichten zusammengetragen die die beiden Kunstformen Street Art und Solarpunk verbinden.
|
||||
Besonders empfehlen kann ich die Geschichte "Uferlos" von Lena Richter die eine schwimmende Stadt zum Denken anregt und "Cloudart" von Dominik Windgätter in der ein "Maskenmädchen" Kunstwerke in den Himmel zeichnet.
|
||||
|
||||
[Link zum Buch beim Frauenbuchladen Thalestris](https://frauenbuchladen.buchkatalog.de/sonnenseiten-9783756803972)
|
||||
|
||||
|
||||
## Schluss
|
||||
|
||||
Ich hoffe die Empfehlungen machen, trotz ihrer Kürze, Lust aufs Lesen! Kauft bei eurem lokalen Buchladen und unterstützt kleine Verlage!
|
||||
|
||||
*Falls das nicht sowieso schon klar war: Ich bekomme für diese Empfehlungen kein Geld, die Links haben kein Tracking, keine Referral Codes oder sonst etwas.*
|
||||
|
||||
### Kurzgeschichte FiatLux
|
||||
|
||||
Die Kurzgeschichte ist von Judith und Christian Vogt und steht unter der Lizenz [CC-BY-NC-SA](https://creativecommons.org/licenses/by-nc-sa/4.0/), darf also mit Namensnennung, nichtkommerziell und unter gleichen Bedingungen geteilt werden (wie schön ist das denn bitte?!).
|
||||
|
||||
{{< pdf FiatLuxVogt>}}
|
107
content/post/musikempfehlungen-1/index.md
Normal file
@@ -0,0 +1,107 @@
|
||||
---
|
||||
title: "Musikempfehlungen- Beat gegen KI"
|
||||
date: 2025-03-03T18:05:10+02:00
|
||||
draft: true
|
||||
image: "uploads/scifi-fantasy.png"
|
||||
categrories: ['Deutsch']
|
||||
tags: ['ki', 'science-fiction', 'schriftgelehrte', 'progressive Fantastik', 'scifi', 'fantasy', 'solarpunk']
|
||||
---
|
||||
|
||||
## Einführung
|
||||
|
||||
KI ist überall. Große Teile des Internets werden gerade durch KI-generierten Inhalten überschwemmt.
|
||||
Teilweise zeigen KI-generierte Artikel auch gefälschte Daten (vor 2022) an um so den Anschein zu erwecken nicht KI-generiert zu sein.
|
||||
Verlässliche und authentische Informationen zu finden ist daher um so schwerer.
|
||||
|
||||
Deshalb beginnt **das Zeitalter der Schriftgelehrten**, denn Informationen zu sammeln und aufzubereiten, das ist, was sie tun. Schriftgelehrte\*r verwende ich hier als Übersetzung des englischen Wortes "Librarian".
|
||||
Und so maße auch ich mir an mich (nur) dafür Schriftgelehrte\*r zu nennen und versuche auf diesem
|
||||
Blog immer wieder Informationen zu sammeln und zu teilen (ironischerweise in dem Wissen, dass dieser Blog von
|
||||
KI-Unternehmen gescrapt wird).
|
||||
|
||||
Starten möchte ich mit Folgendem:
|
||||
|
||||
## Sci-Fi und Fantasy
|
||||
|
||||
Wenn ich nach Sci-Fi und Fantasy suche, will ich weg von profit-optimierten Listen von Online-Buchhändler\*innen,
|
||||
und hin zu echten Empfehlungen. Viele solche Empfehlungen bekomme ich im lokalen Buchladen. Gerade junge Buchhändler\*innen können oft super Empfehlungen geben.
|
||||
Leider sind diese wenigen Mitarbeiter\*innen selten und in Buchläden sind SciFi und Fantasy oft nur wenig vertreten und die Regale viel zu oft voll mit Büchern weißer Männer.
|
||||
|
||||
Deshalb jetzt zu den Empfehlungen die versuchen, das anders zu machen!
|
||||
|
||||
*Die Liste beschreibt die Bücher nur kurz und sollte ohne Spoiler auskommen.
|
||||
In allen Büchern kommen queere Charaktere vor. Alle Links zum führen zu einem lokaln Buchladen oder Websites der Autor\*innen oder des Verlags.*
|
||||
|
||||
### Becky Chambers: "Der lange Weg zu einem kleinen zorningen Planeten"
|
||||
|
||||
Dieses Buch ist eine wirklich wunderschön geschriebene Space-Opera mit Charakteren, die man ins Herz schließt.
|
||||
Auf dem kleinen Schiff ist viel Alltag und auf dem langen Weg lässt einen jede Zwischenstation in eine weitere Welt eintauchen, egal ob in einen trubeligen Markt oder einen abgeschiedenen Eisplanet.
|
||||
|
||||
Der zweite und dritte Teil handeln im gleichen Universum, sind jedoch nur über wenige Personen/geteilte Themen mit der ersten Geschichte verbunden.
|
||||
|
||||
Alle anderen Bücher von Becky Chambers sind auch sehr empfehlenswert "A Psalm for the Wild-Built" ist eine kurze Solarpunk Utopie.
|
||||
|
||||
[Link zum Buch beim Frauenbuchladen Thalestris](https://frauenbuchladen.buchkatalog.de/der-lange-weg-zu-einem-kleinen-zornigen-planeten-9783596035687)
|
||||
|
||||
### Judith & Christian Vogt: Ace in Space
|
||||
|
||||
Ace in Space ist eine tolle Erzählung von einer Raumjäger-Pilotin, einer Gruppe Space-Punks die ihr Leben auf Social Media teilen.
|
||||
Angesiedelt ist das Buch eher im Cyber-Punk. Es geht gegen Großkonzerne, es geht um schnelle Flieger und Bars in engen Quartieren.
|
||||
Außerdem hat es eine der besten Sexszenen, die ich je lesen durfte.
|
||||
|
||||
Judith schreibt oft dystopischere Geschichten als ich normalerweise lese. Aber Laylayland und Wasteland zwei fantastische Bücher die ich nicht missen will.
|
||||
Die Bücher sind feministisch, queer und tollerweise auch mit Haupt(Charaktere) mit Behinderungen. Progressive Phanastik der höchsten Klassen!
|
||||
|
||||
[Verlagsshop](https://amrun-verlag.de/produkt/aceinspace1/)
|
||||
|
||||
Wer eine ganze Kurzgeschichte "der Vögte" (also Judith und Christian Vogt) als Leseprobe haben will, findet diese am Ende des Artikels als PDF.
|
||||
|
||||
### T.J. Klune: Mr. Parnassus' Heim für magisch Begabte
|
||||
|
||||
Wunderschöne Geschichte über einen Beamten der aus der Stadt rauskommt und ein Haus an der See.
|
||||
Mehr verrate ich nicht, ist aber eins meiner Lieblingsbücher.
|
||||
|
||||
[Link zum Buch beim Frauenbuchladen Thalestris](https://frauenbuchladen.buchkatalog.de/mr-parnassus-heim-fuer-magisch-begabte-9783453321366)
|
||||
|
||||
### Lena Richter: Dies ist mein letztes Lied
|
||||
|
||||
Eine mitreisende Geschichte in der die Hauptperson durch ihre Musik von Welt zu Welt gerissen wird. Was sie in den einzelnen Episoden erlebt ist schön und herzzerreißend.
|
||||
Ich habe die Novelle am Stück verschlungen und mit der Hauptperson gelacht und geweint.
|
||||
|
||||
[Verlagsshop](https://www.ohneohren.com/shop/Lena-Richter-Dies-ist-mein-letztes-Lied-p520843015)
|
||||
|
||||
### Rebecca Thorne: "Can't spell treason without tea"
|
||||
|
||||
In einer Fantasy Welt brennt eine Leibwächterin der Königin mit einer Magierin durch und sie eröffnen einen Teeladen.
|
||||
Es gibt auch den Nachfolger "A Pirate's Life for Tea", den hab ich aber noch nicht gelesen
|
||||
|
||||
[Link zum Buch beim Frauenbuchladen Thalestris (Deutsche Übersetzung)](https://frauenbuchladen.buchkatalog.de/cant-spell-treason-without-tea-9783492706896)
|
||||
|
||||
|
||||
### Travis Baldree: "Legends&Latte"
|
||||
|
||||
Eine Ork, die lange Jahre mit einer Gruppe Abenteuer unterwegs war versucht nun einen Buch & Teeladen aufzumachen.
|
||||
Sehr unterhaltsam, unerwartet friedlich und macht unglaublich Lust mehr in Cafes zu gehen!
|
||||
|
||||
"Bookshops & Bonedust" ist die Vorgeschichte, die aber gut nach Legends & Latte gelesen werden kann und auch später geschrieben wurde.
|
||||
|
||||
[Link zum Buch beim Frauenbuchladen Thalestris](https://frauenbuchladen.buchkatalog.de/magie-und-milchschaum-9783423263566)
|
||||
|
||||
### Sammlung: Sonnenseiten - Street Art trifft Solarpunk
|
||||
|
||||
22 Autor\*innen haben Geschichten zusammengetragen die die beiden Kunstformen Street Art und Solarpunk verbinden.
|
||||
Besonders empfehlen kann ich die Geschichte "Uferlos" von Lena Richter die eine schwimmende Stadt zum Denken anregt und "Cloudart" von Dominik Windgätter in der ein "Maskenmädchen" Kunstwerke in den Himmel zeichnet.
|
||||
|
||||
[Link zum Buch beim Frauenbuchladen Thalestris](https://frauenbuchladen.buchkatalog.de/sonnenseiten-9783756803972)
|
||||
|
||||
|
||||
## Schluss
|
||||
|
||||
Ich hoffe die Empfehlungen machen, trotz ihrer Kürze, Lust aufs Lesen! Kauft bei eurem lokalen Buchladen und unterstützt kleine Verlage!
|
||||
|
||||
*Falls das nicht sowieso schon klar war: Ich bekomme für diese Empfehlungen kein Geld, die Links haben kein Tracking, keine Referral Codes oder sonst etwas.*
|
||||
|
||||
### Kurzgeschichte FiatLux
|
||||
|
||||
Die Kurzgeschichte ist von Judith und Christian Vogt und steht unter der Lizenz [CC-BY-NC-SA](https://creativecommons.org/licenses/by-nc-sa/4.0/), darf also mit Namensnennung, nichtkommerziell und unter gleichen Bedingungen geteilt werden (wie schön ist das denn bitte?!).
|
||||
|
||||
{{< pdf FiatLuxVogt>}}
|
106
content/post/oxitraffic-setup/index.md
Normal file
@@ -0,0 +1,106 @@
|
||||
---
|
||||
title: "Tracking blog readers with OxiTraffic"
|
||||
date: 2023-11-10T12:10:10+02:00
|
||||
draft: false
|
||||
image: "uploads/oxitraffic.png"
|
||||
categrories: ['English']
|
||||
tags: ['MASH', 'tracking', 'privacy', 'ansible', 'docker', hugo]
|
||||
---
|
||||
I recently stumbled upon [OxiTraffic](https://codeberg.org/mo8it/oxitraffic), a self-hosted, simple and privacy respecting website traffic tracker which is well suited for blogs. What that means is
|
||||
|
||||
* No personal data is logged
|
||||
* one binary or simple docker container
|
||||
* Readers are only counted if they spend >20s per site
|
||||
|
||||
As I currently have no analytics on my blog and I am not inclined to use anything that adds more than 2 sentences to my privacy disclaimer I thought I give it a try. Naturally I wrote an ansible role for this, which can be found under [mother-of-all-self-hosting/ansible-role-oxitraffic](https://github.com/mother-of-all-self-hosting/ansible-role-oxitraffic). I now have this neat graph.
|
||||
|
||||

|
||||
|
||||
As the main prupose of a blog is to describe how to host the blog, I'll continue in this tradition and describe my process below.
|
||||
|
||||
# The Ansible Role & Playbook Integration
|
||||
|
||||
The ansible role is pretty simple so I won't go into detail. It set's up the configuration file based on your environment variables and sensible defaults and adds a labels file for traefik to use later. The systemd service that starts the container ensures it runs read-only and as non-root user (which worked out of the box, kudos to the developer).
|
||||
|
||||
The [mash-playbook](https://github.com/mother-of-all-self-hosting/mash-playbook) integration is wiring the OxiTraffic to the Traefik reverse proxy and the Postgres database.
|
||||
|
||||
After running `just install-all` everything was set up\*.
|
||||
|
||||
\* Actually I [found a bug which was fixed very fast](https://codeberg.org/mo8it/oxitraffic/issues/7)
|
||||
|
||||
# Hugo Theme Integration
|
||||
|
||||
I maintain a fork of the [hugo-nederburg-theme](https://github.com/moan0s/hugo-nederburg-theme) by Appernetic and naturally wanted to include it there. Adding the following to `themes/hugo-nederburg-theme/layouts/partials/head.html` is all I needed
|
||||
```html
|
||||
{{ with .Site.Params.oxitraffic_url }}
|
||||
<script src="{{ . }}" defer></script>
|
||||
{{ end }}
|
||||
```
|
||||
|
||||
I could then make us of this by setting the Oxitraffic URL in the theme settings
|
||||
|
||||
```toml
|
||||
[params]
|
||||
slogan = "Blog of Julian-Samuel Gebühr"
|
||||
description = "Blog of Julian-Samuel Gebühr" # meta description
|
||||
[...]
|
||||
oxitraffic_url = "https://traffic.hyteck.de/count.js"
|
||||
```
|
||||
|
||||
And that was it. You can have a look at the traffic of this blog at [traffic.hyteck.de](https://traffic.hyteck.de).
|
||||
|
||||
# Advanced: Setting up multiple sites in on one MASH host
|
||||
|
||||
You might have multiple sites that need tracking, but an instance of OxiTraffic can only monitor one site. Setting up multiple instances of OxiTraffic is more complicated in MASH, but can be done. Here is how (always replace `s3` and `other` with you own names):
|
||||
|
||||
1. Re-Do your Inventory as described in [running-multiple-instances](https://github.com/mother-of-all-self-hosting/mash-playbook/blob/main/docs/running-multiple-instances.md#re-do-your-inventory-to-add-supplementary-hosts). I'll use `s3` as my "main" host here and `s3.other` as new host.
|
||||
|
||||
2. Add the following in `inventory/host_vars/s3.other`
|
||||
|
||||
```yaml
|
||||
# PLAYBOOK STUFF
|
||||
mash_playbook_generic_secret_key: 'LONGSECRET'
|
||||
|
||||
mash_playbook_service_identifier_prefix: 'mash-other-'
|
||||
mash_playbook_service_base_directory_name_prefix: 'other-'
|
||||
|
||||
# OXITRAFFIC configuration
|
||||
oxitraffic_enabled: true
|
||||
oxitraffic_hostname: traffic.other-service.de
|
||||
oxitraffic_tracked_origin: https://other-service.de
|
||||
|
||||
oxitraffic_database_hostname: mash-postgres
|
||||
oxitraffic_database_port: 5432
|
||||
oxitraffic_database_name: other-oxitraffic
|
||||
oxitraffic_database_password: VERYSECRET
|
||||
oxitraffic_database_username: other-oxitraffic
|
||||
|
||||
|
||||
oxitraffic_systemd_required_services_list: |
|
||||
{{
|
||||
(['docker.service'])
|
||||
+
|
||||
(['mash-postgres.service'])
|
||||
}}
|
||||
|
||||
oxitraffic_container_additional_networks: |
|
||||
{{
|
||||
(['traefik'])
|
||||
+
|
||||
(['mash-postgres'])
|
||||
}}
|
||||
|
||||
oxitraffic_container_labels_traefik_enabled: "true"
|
||||
oxitraffic_container_labels_traefik_docker_network: "traefik"
|
||||
oxitraffic_container_labels_traefik_entrypoints: "web-secure"
|
||||
oxitraffic_container_labels_traefik_tls_certResolver: "default"
|
||||
```
|
||||
|
||||
3. Create the database
|
||||
|
||||
Unlike for other mash services th database will not be created automatically. You therefore need to set it up yourself. Here are the steps that you need to run in the postgres CLI (which cou can access by running `/mash/postgres/bin/cli`)
|
||||
|
||||
* Create a user: `CREATE USER "other-oxitraffic" with ENCRYPTED PASSWORD 'PASSWORD_FROM_ABOVE';`
|
||||
* Create database: `CREATE DATABASE other-oxitraffic;`
|
||||
* Grant privileges: `GRANT ALL PRIVILEGES ON DATABASE "other-oxitraffic" TO "other-oxitraffic";`
|
||||
* Grant ownership: `ALTER DATABASE "other-oxitraffic" OWNER TO "other-oxitraffic";`
|
BIN
content/post/oxitraffic-setup/oxitraffic_screenshot.jpeg
Normal file
After Width: | Height: | Size: 188 KiB |
106
content/post/public-posts-with-authorized-fetch/index.md
Normal file
@@ -0,0 +1,106 @@
|
||||
---
|
||||
title: "I did something naughty: Circumventing Authorized-Fetch as implemented by GoToSocial"
|
||||
date: 2024-12-11T06:10:10+02:00
|
||||
draft: false
|
||||
image: "uploads/fediproxy/fediproxy.png"
|
||||
categrories: ['English']
|
||||
tags: ['gotosocial', 'fediverse', 'mastodon', 'authorized fetch', 'rss', 'FastAPI',]
|
||||
---
|
||||
|
||||
Yes the title is correct, but I had nothing malicious in mind!
|
||||
|
||||
## What this is about
|
||||
|
||||
For [@qzt@queereszentrumtuebingen.de](https://social.queereszentrumtuebingen.de/@qzt) we include the public feed [in a sidbar on the homepage](https://queereszentrumtuebingen.de/). Initially this was done using the standard API to fetch statuses `/api/v1/accounts/{account_id}/statuses` and worked like a charm. The problem started when [GoToSocial](https://gotosocial.org/) (the fediverse server we use, similar to mastodon) implemented authorized fetch. This is a a good thing! Authorized fetch means, that every call to a endpoint needs to be authorized by an `access_token`. You get an access token from a fedi account. It's what fediverse clients like Tusky or Phanpy do on your behalf to get the posts that make up you timeline.
|
||||
|
||||
Authorized fetch has major advantages as
|
||||
|
||||
* data scraping can only be done by other fediaccounts
|
||||
* blocking can not be circumvented by using the public API
|
||||
|
||||
and much more. Sadly it also broke our website integration.
|
||||
|
||||
## Possible Solutions
|
||||
|
||||
So what now? I initially wanted to turn of authorized fetch for [@qzt@queereszentrumtuebingen.de](https://social.queereszentrumtuebingen.de/@qzt) by messing with the GoToSocial code and turning it off for the whole server. This would have been possible as this is the only user on the server. The GoToSocial devs helped me manage to find where to do that. But it's not ideal and would make me build a custom docker image fore each update.
|
||||
|
||||
Next idea: The whole point of authorized fetch is, that only fedi-accounts (and apps they authorized) can access the API. So lets do that! Set up a new account, add app and authorize it [as described in the GoToSocial documentation](https://docs.gotosocial.org/en/latest/api/authentication/). I used #Bruno for that, that was much more comfortable than using curl for me.
|
||||
With that authorization code you can now get an access token for your app. Put that in the Javascript that loads posts and we are good right? Sadly no. It would totally work. But it would also allow anyone to read and post on behalf of the account. That calls for malicious actors using this for scraping or spamming.
|
||||
|
||||
So instead, we need a proxy that stores the access token securely and restricts the actions.
|
||||
|
||||
## The proxy
|
||||
|
||||
Such a proxie must
|
||||
|
||||
* offer the endpoint that provides the same data as the FediverseAPI
|
||||
* authorize itself to the FediverseAPI via `access_token`
|
||||
* restrict to read access of consenting accounts
|
||||
|
||||
The last point is really important, as we don't want to allow others to use this endpoint to scrape data unauthorized.
|
||||
|
||||
I wrote a short FastAPI server that offers this. It only implements one method
|
||||
|
||||
```
|
||||
@app.get("/api/v1/accounts/{account_id}/statuses")
|
||||
async def fetch_data(account_id):
|
||||
if account_id not in ALLOWED_ACCOUNTS:
|
||||
raise HTTPException(status_code=401, detail="You can only use this proxy to access configured accounts")
|
||||
|
||||
headers = {"Authorization": f"Bearer {ACCESS_TOKEN}"}
|
||||
response = requests.get(f"{EXTERNAL_API_BASE_URL}/api/v1/accounts/{account_id}/statuses", headers=headers)
|
||||
return response.json()
|
||||
```
|
||||
|
||||
Basically this is the whole API code, I only trimmed a few checks and error handling.
|
||||
|
||||
## Deployment
|
||||
|
||||
To deploy, I put it in a docker container and started it via docker-compose. Reverse proxing is handled by Traefik, I won't go into detail here.
|
||||
|
||||
```
|
||||
services:
|
||||
fediproxy.example.org:
|
||||
image: docker.io/moanos/fediproxy
|
||||
container_name: "fediproxy.example.org"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
EXTERNAL_API_BASE_URL: ${EXTERNAL_API_BASE_URL}
|
||||
ACCESS_TOKEN: ${ACCESS_TOKEN}
|
||||
ALLOWED_ACCOUNTS: ${ALLOWED_ACCOUNTS}
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik"
|
||||
- "traefik.http.routers.fediproxy.rule=Host(`fediproxy.example.org`)"
|
||||
- "traefik.http.routers.fediproxy.service=fediproxy-service"
|
||||
- "traefik.http.routers.fediproxy.entrypoints=web-secure"
|
||||
- "traefik.http.routers.fediproxy.tls=true"
|
||||
- "traefik.http.routers.fediproxy.tls.certResolver=default"
|
||||
- "traefik.http.services.fediproxy-service.loadbalancer.server.port=8000"
|
||||
networks:
|
||||
- traefik
|
||||
|
||||
networks:
|
||||
traefik:
|
||||
name: "traefik"
|
||||
external: true
|
||||
|
||||
```
|
||||
|
||||
I added a short `.env` to configure:
|
||||
|
||||
```
|
||||
ACCESS_TOKEN=VERYSECRETTOKENTHATISDEFINETLYREAL
|
||||
EXTERNAL_API_BASE_URL=https://gay-pirate-assassins.de
|
||||
ALLOWED_ACCOUNTS=ZGGZF4G8NNOTREAL81Z8G7RTC
|
||||
```
|
||||
|
||||
## Results
|
||||
|
||||
Now I can again use something like [the wordpress plugin Include Mastodon Feed](https://wordpress.org/plugins/include-mastodon-feed/#installation) just by pointing to the proxy: `[include-mastodon-feed instance="fediproxy.example.org.de" account="ZGGZF4G8NNOTREAL81Z8G7RTC"]`
|
||||
|
||||
Hope you enjoyed the read. Source code for the proxy can be found here: https://git.hyteck.de/moanos/FediProxy
|
||||
If you want to play around a bit you can use https://git.hyteck.de/moanos/include-fedi
|
||||
|
||||
|
||||
Sloth logo of GTS by [Anna Abramek](https://abramek.art/), [Creative Commons BY-SA license](http://creativecommons.org/licenses/by-sa/4.0/).
|
105
content/post/trying-twenty/docker-compose.yml
Normal file
@@ -0,0 +1,105 @@
|
||||
name: twenty
|
||||
|
||||
services:
|
||||
server:
|
||||
image: twentycrm/twenty:${TAG:-latest}
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ./server_local_data
|
||||
target: /app/packages/twenty-server/.local-storage
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
NODE_PORT: 3000
|
||||
PG_DATABASE_URL: postgres://${PG_DATABASE_USER:-postgres}:${PG_DATABASE_PASSWORD:-postgres}@${PG_DATABASE_HOST:-db}:${PG_DATABASE_PORT:-5432}/default
|
||||
SERVER_URL: ${SERVER_URL}
|
||||
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
|
||||
DISABLE_DB_MIGRATIONS: ${DISABLE_DB_MIGRATIONS}
|
||||
DISABLE_CRON_JOBS_REGISTRATION: ${DISABLE_CRON_JOBS_REGISTRATION}
|
||||
|
||||
STORAGE_TYPE: ${STORAGE_TYPE}
|
||||
STORAGE_S3_REGION: ${STORAGE_S3_REGION}
|
||||
STORAGE_S3_NAME: ${STORAGE_S3_NAME}
|
||||
STORAGE_S3_ENDPOINT: ${STORAGE_S3_ENDPOINT}
|
||||
|
||||
APP_SECRET: ${APP_SECRET:-replace_me_with_a_random_string}
|
||||
labels:
|
||||
- "traefik.http.middlewares.twenty-add-response-headers.headers.customresponseheaders.Strict-Transport-Security=max-age=31536000; includeSubDomains"
|
||||
- "traefik.http.middlewares.twenty-add-response-headers.headers.customresponseheaders.Access-Control-Allow-Origin=*"
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik"
|
||||
- "traefik.http.routers.twenty.rule=Host(`twenty.hyteck.de`)"
|
||||
- "traefik.http.routers.twenty.middlewares=twenty-add-response-headers"
|
||||
- "traefik.http.routers.twenty.service=twenty-service"
|
||||
- "traefik.http.routers.twenty.entrypoints=web-secure"
|
||||
- "traefik.http.routers.twenty.tls=true"
|
||||
- "traefik.http.routers.twenty.tls.certResolver=default"
|
||||
- "traefik.http.services.twenty-service.loadbalancer.server.port=3000"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: curl --fail http://localhost:3000/healthz
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
restart: always
|
||||
networks:
|
||||
- traefik
|
||||
- default
|
||||
|
||||
worker:
|
||||
image: twentycrm/twenty:${TAG:-latest}
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ./server_local_data
|
||||
target: /app/packages/twenty-server/.local-storage
|
||||
command: [ "yarn", "worker:prod" ]
|
||||
environment:
|
||||
PG_DATABASE_URL: postgres://${PG_DATABASE_USER:-postgres}:${PG_DATABASE_PASSWORD:-postgres}@${PG_DATABASE_HOST:-db}:${PG_DATABASE_PORT:-5432}/default
|
||||
SERVER_URL: ${SERVER_URL}
|
||||
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
|
||||
DISABLE_DB_MIGRATIONS: "true" # it already runs on the server
|
||||
DISABLE_CRON_JOBS_REGISTRATION: "true" # it already runs on the server
|
||||
|
||||
STORAGE_TYPE: ${STORAGE_TYPE}
|
||||
STORAGE_S3_REGION: ${STORAGE_S3_REGION}
|
||||
STORAGE_S3_NAME: ${STORAGE_S3_NAME}
|
||||
STORAGE_S3_ENDPOINT: ${STORAGE_S3_ENDPOINT}
|
||||
|
||||
APP_SECRET: ${APP_SECRET:-replace_me_with_a_random_string}
|
||||
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
server:
|
||||
condition: service_healthy
|
||||
restart: always
|
||||
networks:
|
||||
- default
|
||||
|
||||
db:
|
||||
image: postgres:16
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ./db_data
|
||||
target: /var/lib/postgresql/data
|
||||
environment:
|
||||
POSTGRES_USER: ${PG_DATABASE_USER:-postgres}
|
||||
POSTGRES_PASSWORD: ${PG_DATABASE_PASSWORD:-postgres}
|
||||
healthcheck:
|
||||
test: pg_isready -U ${PG_DATABASE_USER:-postgres} -h localhost -d postgres
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
restart: always
|
||||
|
||||
redis:
|
||||
image: redis
|
||||
restart: always
|
||||
command: [ "--maxmemory-policy", "noeviction" ]
|
||||
|
||||
networks:
|
||||
traefik:
|
||||
name: "traefik"
|
||||
external: true
|
19
content/post/trying-twenty/env
Normal file
@@ -0,0 +1,19 @@
|
||||
TAG=latest
|
||||
|
||||
#PG_DATABASE_USER=postgres
|
||||
# Use openssl rand -base64 32
|
||||
PG_DATABASE_PASSWORD=
|
||||
#PG_DATABASE_HOST=db
|
||||
#PG_DATABASE_PORT=5432
|
||||
#REDIS_URL=redis://redis:6379
|
||||
|
||||
SERVER_URL=https://twenty.hyteck.de
|
||||
|
||||
# Use openssl rand -base64 32
|
||||
APP_SECRET=
|
||||
|
||||
STORAGE_TYPE=local
|
||||
|
||||
# STORAGE_S3_REGION=eu-west3
|
||||
# STORAGE_S3_NAME=my-bucket
|
||||
# STORAGE_S3_ENDPOINT=
|
BIN
content/post/trying-twenty/fields.png
Normal file
After Width: | Height: | Size: 99 KiB |
169
content/post/trying-twenty/index.md
Normal file
@@ -0,0 +1,169 @@
|
||||
---
|
||||
title: "Trying Twenty: How does an Open Source CRM work?"
|
||||
date: 2025-08-03T06:10:10+02:00
|
||||
lastmod: 2025-08-03T12:10:10+02:00
|
||||
draft: false
|
||||
image: "uploads/twenty.png"
|
||||
categories: ['English']
|
||||
tags: ['crm', 'twenty', 'salesforce', 'django', 'self-hosting']
|
||||
---
|
||||
|
||||
As some of you might know, I spend my day working with Salesforce, a very, very feature-rich CR that you pay big money to use.
|
||||
Salesforce is the opposite of OpenSource and the many features are expensive. Salesforce business model is based on this and on the lock-in effect.
|
||||
If your company invested in implementing Salesforce, they'll likely pay a lot to keep it.
|
||||
|
||||
So what does an alternative look like? Let's have a look at [Twenty](https://twenty.com), an OpenSource CRM that recently reached the magic 1.0 version.
|
||||
|
||||
# Getting started
|
||||
|
||||
There are two options of getting started: Register at [app.twenty.com](https://app.twenty.com) and start right away on the devs instance or self-host Twenty on your own server.
|
||||
I did the ladder, so let's discuss how that. The basic steps I took were
|
||||
|
||||
* point twenty.hyteck.de to a server
|
||||
* Install traefik on the server (I cheated, traefik was already installed)
|
||||
* Deploy [this docker-compose.yml](docker-compose.yml) with [this env file](env)
|
||||
|
||||
Then visit the domain and set up the first user.
|
||||
|
||||
# Features
|
||||
|
||||
Twenty offers an initial datamodel that you should be familiar from other CRMs. the standards objects are
|
||||
|
||||

|
||||
|
||||
* **Persons** A individual person. You can attach notes, E-Mails, etc..
|
||||
* **Companies** The same for organizations. Organization websites must be unique
|
||||
* **Opportunities** The classic opportunity with customizable stages
|
||||
* **Notes** They can be attached to any of the objects above
|
||||
* **Tasks** Items to work on
|
||||
* **Workflows** Automations similar to Salesforce flows. E.g. you can create a task every time an Opportunity is created.
|
||||
|
||||
The basic datamodel can be extended in the GUI. Here is how my "Company" model looks like
|
||||
|
||||

|
||||
|
||||
You can add any of the following fields to an object.
|
||||
|
||||

|
||||
|
||||
### Workflows
|
||||
|
||||
Workflows are Twenty's way of allowing users to build automations. You can start a Workflow when a Record is created,
|
||||
updated or deleted. In addition, they can be started manually, on a schedule and via Webhook (yeah!).
|
||||
|
||||

|
||||
|
||||
You can then add nodes that trigger actions. Available right now are
|
||||
* **Creating, updating or deleting a record**
|
||||
* **Searching records**
|
||||
* **Sending E-Mails** This is the only option to trigger e-mails so far
|
||||
* **Code** Serverless Javascript functions
|
||||
* **Form** The form will pop up on the user's screen when the workflow is launched from a manual trigger. For other types of triggers, it will be displayed in the Workflow run record page.
|
||||
* **HTTP request** Although possible via Code, this is a handy shortcut to trigger HTTP requests
|
||||
|
||||
What is currently completely missing are Foreach-loops and [conditions](https://github.com/twentyhq/core-team-issues/issues/1265). I can not say "If Opportunity stage is updated to X do Y else, do Z".
|
||||
Without this, Workflows are really limited in their power.
|
||||
|
||||
What already seems quite mature though is the code option. It allows to put in arbitrary code and output a result.
|
||||
|
||||

|
||||
|
||||
I did not try a lot, but I assume most basic Javascript works. I successfully built an http request that send data to a server.
|
||||
|
||||
If what you're doing is straightforward enough to not use loops and conditions or if oyu are okay with doing all of them in the Code node, you can do basically anything.
|
||||
|
||||
## API
|
||||
|
||||
Twenty offers an extensive API that allows you to basically do everything. It's well documented and easy to use.
|
||||
|
||||
Here is an example of me, syncing Rescue Organizations from [notfellchen.org](https://notfellchen.org) to Twenty.
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
from fellchensammlung.models import RescueOrganization
|
||||
|
||||
|
||||
def sync_rescue_org_to_twenty(rescue_org: RescueOrganization, base_url, token: str):
|
||||
if rescue_org.twenty_id:
|
||||
update = True
|
||||
else:
|
||||
update = False
|
||||
|
||||
payload = {
|
||||
"eMails": {
|
||||
"primaryEmail": rescue_org.email,
|
||||
"additionalEmails": None
|
||||
},
|
||||
"domainName": {
|
||||
"primaryLinkLabel": rescue_org.website,
|
||||
"primaryLinkUrl": rescue_org.website,
|
||||
"additionalLinks": []
|
||||
},
|
||||
"name": rescue_org.name,
|
||||
}
|
||||
|
||||
if rescue_org.location:
|
||||
payload["address"] = {
|
||||
"addressStreet1": f"{rescue_org.location.street} {rescue_org.location.housenumber}",
|
||||
"addressCity": rescue_org.location.city,
|
||||
"addressPostcode": rescue_org.location.postcode,
|
||||
"addressCountry": rescue_org.location.countrycode,
|
||||
"addressLat": rescue_org.location.latitude,
|
||||
"addressLng": rescue_org.location.longitude,
|
||||
}
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {token}"
|
||||
}
|
||||
|
||||
if update:
|
||||
url = f"{base_url}/rest/companies/{rescue_org.twenty_id}"
|
||||
response = requests.patch(url, json=payload, headers=headers)
|
||||
assert response.status_code == 200
|
||||
else:
|
||||
url = f"{base_url}/rest/companies"
|
||||
response = requests.post(url, json=payload, headers=headers)
|
||||
assert response.status_code == 201
|
||||
rescue_org.twenty_id = response.json()["data"]["createCompany"]["id"]
|
||||
rescue_org.save()
|
||||
|
||||
|
||||
```
|
||||
|
||||
#
|
||||
|
||||
# The Company, Business Model and Paid Features
|
||||
|
||||
The company behind Twenty is called "Twenty.com PBC" and mostly seems to consist of former AirBnB employees in Paris.
|
||||
The company is probably backed by Venture Capital.
|
||||
The current business model is to charge for using the company's instance of Twenty. It starts at 9\$/user/month without
|
||||
enterprise features. SSO and support will cost you 19\$/user/month.
|
||||
|
||||
Selfhosting is free but SSO is locked behind an enterprise badge with seemingly no way to pay for activating it.
|
||||
I suspect that in the future more features will become "Enterprise only" even when self-hosting. All contributors must agree
|
||||
to [a Contributor License Agreement (CLA)](https://github.com/twentyhq/twenty/blob/main/.github/CLA.md), therefore I
|
||||
believe they could change the License in the future, including switching away from Open Source.
|
||||
|
||||
|
||||
# Conclusion
|
||||
|
||||
Twenty is a really promising start of building a good CRM. The ease of customizing the datamodel,
|
||||
using the API and a solid beginning to Flows allows users to get a lot of value from it already.
|
||||
Flows need some more work to become as powerful as they should be and the E-Mail integration needs to get better.
|
||||
|
||||
Stating the obvious: This is not something that could ever replace Salesforce. But it doesn't have to!
|
||||
There are many organizations that would benefit a lot from a CRM like Twenty, they simply don't need, can't handle or
|
||||
don't want to pay for all the features other CRMs offer.
|
||||
|
||||
If Twenty continues to focus on small to medium companies and the right mix of standard features vs. custom development options I see a bright future for it.
|
||||
There are the usual problems of VC-backed OSS development, we shall see how it goes for them.
|
||||
|
||||
|
||||
# Addendum: Important Features
|
||||
|
||||
Here is a short list of features I missed and their place on the roadmap if they have one
|
||||
|
||||
* **Compose & Send E-Mails** Planned [Q4 2025](https://github.com/orgs/twentyhq/projects/1?pane=issue&itemId=106097937&issue=twentyhq%7Ccore-team-issues%7C811)
|
||||
* **Foreach loops in Workflows** [Q3 2025](https://github.com/orgs/twentyhq/projects/1/views/33?pane=issue&itemId=93150024&issue=twentyhq%7Ccore-team-issues%7C21)
|
||||
* **Conditions in Flows** [Q4 2025](https://github.com/orgs/twentyhq/projects/1/views/33?pane=issue&itemId=121287765&issue=twentyhq%7Ccore-team-issues%7C1265)
|
BIN
content/post/trying-twenty/organization_dm.png
Normal file
After Width: | Height: | Size: 146 KiB |
BIN
content/post/trying-twenty/person-model.png
Normal file
After Width: | Height: | Size: 95 KiB |
BIN
content/post/trying-twenty/serverless_function.png
Normal file
After Width: | Height: | Size: 57 KiB |
BIN
content/post/trying-twenty/workflow1.png
Normal file
After Width: | Height: | Size: 228 KiB |
@@ -1,5 +1,4 @@
|
||||
<div>
|
||||
<object data="/uploads/{{ index .Params 0}}.pdf" type="application/pdf" width="100%" height="500px">
|
||||
<p><a href="/uploads/{{ index .Params 0}}.pdf">Download the PDF!</a></p>
|
||||
<object class="fitvidsignore" data="/uploads/{{ index .Params 0}}.pdf" type="application/pdf" width="100%" height="500px">
|
||||
<p><a href="/uploads/{{ index .Params 0}}.pdf">Download the PDF!</a></p>
|
||||
</object>
|
||||
|
||||
|
@@ -1,14 +0,0 @@
|
||||
<?php
|
||||
|
||||
//database settings
|
||||
define ("DB_USER", "moanos");
|
||||
define ("DB_HOST", "localhost");
|
||||
define ("DB_PW", "dwDs5k4PMQ1a7tK51OjK");
|
||||
define ("DB_DATABASE", "moanos_gartensia");
|
||||
|
||||
//database tables:
|
||||
define ("TABLE_USER", "user");
|
||||
|
||||
define("MODULE_PATH", $_SERVER['DOCUMENT_ROOT']);
|
||||
|
||||
?>
|
@@ -1,45 +0,0 @@
|
||||
|
||||
<?php
|
||||
require_once(__dir__."/config.inc.php");
|
||||
|
||||
$aData[TABLE_USER] = array(
|
||||
'user_ID' => array(
|
||||
'type' => 'INT',
|
||||
'size' => 11,
|
||||
'unique' => 'TRUE',
|
||||
'standard' => 'NOT NULL',
|
||||
'extra' => 'AUTO_INCREMENT PRIMARY KEY'
|
||||
|
||||
),
|
||||
'name' => array(
|
||||
'type' => 'VARCHAR',
|
||||
'size' => 255,
|
||||
'standard' => 'NOT NULL'
|
||||
),
|
||||
'email' => array(
|
||||
'type' => 'VARCHAR',
|
||||
'size' => 255,
|
||||
'standard' => 'NOT NULL'
|
||||
),
|
||||
|
||||
'signalmessenger' => array(
|
||||
'type' => 'VARCHAR',
|
||||
'size' => 255,
|
||||
'standard' => 'NOT NULL'
|
||||
),
|
||||
'sms' => array(
|
||||
'type' => 'VARCHAR',
|
||||
'size' => 255,
|
||||
'standard' => 'NOT NULL'
|
||||
),
|
||||
'telegram' => array(
|
||||
'type' => 'VARCHAR',
|
||||
'size' => 255,
|
||||
'standard' => 'NOT NULL'
|
||||
),
|
||||
'threema' => array(
|
||||
'type' => 'VARCHAR',
|
||||
'size' => 255,
|
||||
'standard' => 'NOT NULL'
|
||||
)
|
||||
);
|
277
static/form.php
@@ -1,277 +0,0 @@
|
||||
<?php
|
||||
ini_set('display_errors', 0);
|
||||
ini_set('display_startup_errors', 0);
|
||||
error_reporting(E_ALL);
|
||||
class Data{
|
||||
function __construct(){
|
||||
$this->link_database();
|
||||
$this->em_check_database();
|
||||
$this->read_variables();
|
||||
date_default_timezone_set('Europe/Berlin');
|
||||
}
|
||||
|
||||
function read_variables() {
|
||||
//reads all GET and POST variables into the object, addslashing both
|
||||
if (count($_POST)) {
|
||||
foreach ($_POST as $key => $val){
|
||||
$key=addslashes("r_".$key);
|
||||
if (is_array($val)) {
|
||||
for ($z=0;$z<count($val);$z++) {
|
||||
$val[$z]=addslashes($val[$z]);
|
||||
}
|
||||
}
|
||||
else {
|
||||
$val=addslashes($val);
|
||||
}
|
||||
$this->$key=$val;
|
||||
}
|
||||
}
|
||||
if (count($_GET)) {
|
||||
foreach ($_GET as $key => $val){
|
||||
$key=addslashes("r_".$key);
|
||||
if (is_array($val)) {
|
||||
for ($z=0;$z<count($val);$z++) {
|
||||
$val[$z]=addslashes($val[$z]);
|
||||
}
|
||||
}
|
||||
else {
|
||||
$val=addslashes($val);
|
||||
}
|
||||
|
||||
$this->$key=$val;
|
||||
}
|
||||
}
|
||||
}//end of function read variables
|
||||
|
||||
|
||||
function link_database() {
|
||||
$this->databaselink = new mysqli(DB_HOST,DB_USER,DB_PW,DB_DATABASE);
|
||||
$this->databaselink->set_charset('utf8');
|
||||
if ($this->databaselink->connect_errno) {
|
||||
return "Datenbank nicht erreichbar: (" . $this->databaselink->connect_errno . ") " . $this->databaselink->connect_error;
|
||||
}
|
||||
else{
|
||||
$this->databasename=DB_DATABASE;
|
||||
$this->databaselink->query("SET SQL_MODE = '';");
|
||||
return True;
|
||||
}
|
||||
}
|
||||
|
||||
function em_check_database() {
|
||||
/*
|
||||
params:
|
||||
None
|
||||
returns:
|
||||
None
|
||||
This function compares the database structure to a predefined structure which is saved in db_array_config.php
|
||||
and adds missing structures. Makes installation+updates easy
|
||||
*/
|
||||
$aTable=array();
|
||||
//Alle Tabellen in Array lesen, inklusive aller Eigenschaften
|
||||
$result=$this->databaselink->query("show tables from ".DB_DATABASE);
|
||||
while($row = $result->fetch_array(MYSQLI_BOTH)){
|
||||
$aTable[]=$row[0];
|
||||
}
|
||||
$aData=array();
|
||||
$database_structure_path = __DIR__."/config/db_array.inc.php";
|
||||
include($database_structure_path);
|
||||
foreach($aData as $table=>$fields){
|
||||
if(!in_array($table,$aTable)) {
|
||||
//Add table to database
|
||||
$mCounter=0;
|
||||
$sCommand="CREATE TABLE IF NOT EXISTS `".$table."` (";
|
||||
foreach($fields as $fieldname=>$properties){
|
||||
$extra = "";
|
||||
if($mCounter==0) {
|
||||
$key="KEY `".$fieldname."` (`".$fieldname."`)";
|
||||
}
|
||||
if($properties["size"]!="") {
|
||||
$size="(".$properties["size"].")";
|
||||
}
|
||||
else {
|
||||
$size="";
|
||||
}
|
||||
if((isset($properties["unique"])) and ($properties['unique']==true)) {
|
||||
$unique="UNIQUE KEY `".$fieldname."_2` (`".$fieldname."`),";}
|
||||
else {
|
||||
$unique="";
|
||||
}
|
||||
if((isset($properties["extra"])) and ($properties != "")){
|
||||
$extra = $properties['extra'];
|
||||
}
|
||||
$sCommand .= "`".$fieldname."` ".$properties["type"].$size." ".$properties["standard"]." ".$extra.",";
|
||||
$mCounter++;
|
||||
|
||||
}
|
||||
$sCommand.=$unique.$key.") ENGINE=InnoDB ;";
|
||||
$this->last_query[]=$sCommand;
|
||||
$updateresult=$this->databaselink->query($sCommand);
|
||||
}
|
||||
else {
|
||||
//Felder checken und Tabelle updaten
|
||||
$resultField=$this->databaselink->query("show fields from ".DB_DATABASE.".".$table);
|
||||
while($aRowF = $resultField->fetch_array(MYSQLI_BOTH)){
|
||||
$aTableFields[]=$aRowF[0];
|
||||
}
|
||||
foreach($fields as $fieldname=>$properties) {
|
||||
if(!in_array($fieldname,$aTableFields)) {
|
||||
if((isset($properties["size"]) and ($properties['size']!=""))) {
|
||||
$size="(".$properties["size"].")";
|
||||
}
|
||||
else {
|
||||
$size="";
|
||||
}
|
||||
$sCommand="ALTER TABLE `".$table."` ADD `".$fieldname."` ".$properties["type"].$size." ".$properties["standard"];
|
||||
$this->last_query[]=$sCommand;
|
||||
$updateresult=$this->databaselink->query($sCommand);
|
||||
}
|
||||
}
|
||||
}
|
||||
unset($aTableFields);
|
||||
unset($aFields);
|
||||
unset($properties);
|
||||
}
|
||||
unset($aData);
|
||||
}
|
||||
|
||||
function store_data($sTable,$aFields,$sKey_ID,$mID) {
|
||||
//updates or inserts data
|
||||
//returns ID or -1 if fails
|
||||
$i=0; $returnID = 0;
|
||||
|
||||
if(($mID>0) or ($mID!="") or ($mID != null)) {
|
||||
//search for it
|
||||
$aCheckFields=array($sKey_ID=>$mID);
|
||||
$aRow=$this->select_row($sTable,$aCheckFields);
|
||||
$returnID=$aRow[$sKey_ID];
|
||||
}
|
||||
if(($returnID>0) or ($returnID!="")) {
|
||||
$sQuery="update ".$sTable." set ";
|
||||
foreach($aFields as $key=>$value) {
|
||||
$sQuery.=$key."='".$value."'";
|
||||
$i++;
|
||||
if($i<count($aFields)) {
|
||||
$sQuery.=",";
|
||||
}
|
||||
}
|
||||
$sQuery.=" where ".$sKey_ID."='".$mID."'";
|
||||
$mDataset_ID=$returnID;
|
||||
}
|
||||
else {
|
||||
$sKeys = ""; $sValues = "";
|
||||
$sQuery="insert into ".$sTable." (";
|
||||
foreach($aFields as $sKey=>$value) {
|
||||
$sKeys.=$sKey;
|
||||
$sValues.="'".$value."'";
|
||||
$i++;
|
||||
if($i<count($aFields)) {
|
||||
$sKeys.=",";
|
||||
$sValues.=",";
|
||||
}
|
||||
}
|
||||
$sQuery.=$sKeys.") values (".$sValues.")";
|
||||
}
|
||||
$this->last_query[]=$sQuery;
|
||||
if ($pResult = $this->databaselink->query($sQuery)) {
|
||||
if(($returnID>0) or ($returnID!="")) {
|
||||
return $returnID;
|
||||
}
|
||||
else {
|
||||
return $this->databaselink->insert_id;
|
||||
}
|
||||
}
|
||||
else {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
function save_user($aUser){
|
||||
/*
|
||||
args:
|
||||
Array $aUser
|
||||
Array of user information which will be saved.
|
||||
e.g. array(
|
||||
'forename' => String $forname,
|
||||
'surname' => String $surname,
|
||||
'email' => String $email,
|
||||
'UID' => String $UID,
|
||||
'language' => String $language,
|
||||
'admin' => Bool $admin,
|
||||
'password' => String md5(str_rev($password)), #deprecated, do not use!
|
||||
'password_hash' => password_hash(String $password, PASSWORD_DEFAULT)
|
||||
);
|
||||
|
||||
returns:
|
||||
None
|
||||
Function will save user Information given in $aUser. If user exists it will
|
||||
overwrite existing data but not delete not-specified data
|
||||
*/
|
||||
|
||||
$aFields = $aUser;
|
||||
if ((isset($this->r_user_ID))and ($this->r_user_ID != "")){
|
||||
$this->ID=$this->store_data(TABLE_USER, $aFields, 'user_ID' , $this->r_user_ID);
|
||||
}
|
||||
else{
|
||||
$this->ID=$this->store_data(TABLE_USER, $aFields, NULL , NULL);
|
||||
}
|
||||
}
|
||||
|
||||
function get_view($Datei) {
|
||||
ob_start(); //startet Buffer
|
||||
include($Datei);
|
||||
$output=ob_get_contents(); //Buffer wird geschrieben
|
||||
ob_end_clean(); //Buffer wird gelöscht
|
||||
return $output;
|
||||
}
|
||||
}
|
||||
|
||||
//end of class
|
||||
|
||||
session_start();
|
||||
|
||||
|
||||
include ("config/config.inc.php");
|
||||
|
||||
|
||||
$oObject = new Data;
|
||||
|
||||
$oObject->output = "";
|
||||
switch ($oObject->r_ac){
|
||||
case 'user_save':
|
||||
$aUser = array();
|
||||
if(isset($oObject->r_user_ID)){
|
||||
$aUser['user_ID'] = $oObject->r_user_ID;
|
||||
}
|
||||
if(isset($oObject->r_name)){
|
||||
$aUser['name'] = $oObject->r_name;
|
||||
}
|
||||
if(isset($oObject->r_email)){
|
||||
$aUser['email'] = $oObject->r_email;
|
||||
}
|
||||
if(isset($oObject->r_email)){
|
||||
$aUser['signalmessenger'] = $oObject->r_signalmessenger;
|
||||
}
|
||||
if(isset($oObject->r_email)){
|
||||
$aUser['sms'] = $oObject->r_sms;
|
||||
}
|
||||
if(isset($oObject->r_email)){
|
||||
$aUser['telegram'] = $oObject->r_telegram;
|
||||
}
|
||||
if(isset($oObject->r_email)){
|
||||
$aUser['threema'] = $oObject->r_threema;
|
||||
}
|
||||
$oObject->save_user($aUser);
|
||||
$oObject->output .= "Erfolgreich gespeichert";
|
||||
break;
|
||||
default:
|
||||
$oObject->output = $oObject->get_view("views/user_form.php");
|
||||
break;
|
||||
}
|
||||
function output($oObject){
|
||||
echo $oObject->get_view("views/head.php");
|
||||
echo $oObject->get_view("views/body.php");
|
||||
}
|
||||
output($oObject);
|
||||
|
||||
|
||||
?>
|
BIN
static/img/uberspace_badge_dark.png
Normal file
After Width: | Height: | Size: 9.0 KiB |
BIN
static/img/uberspace_badge_light.png
Normal file
After Width: | Height: | Size: 8.7 KiB |
BIN
static/uploads/FiatLuxVogt.pdf
Normal file
BIN
static/uploads/django_geocoding.png
Normal file
After Width: | Height: | Size: 92 KiB |
BIN
static/uploads/django_geocoding2.png
Normal file
After Width: | Height: | Size: 104 KiB |
BIN
static/uploads/django_rss.png
Normal file
After Width: | Height: | Size: 99 KiB |
BIN
static/uploads/fediproxy/fediproxy.png
Normal file
After Width: | Height: | Size: 124 KiB |
BIN
static/uploads/html-e-mails.png
Normal file
After Width: | Height: | Size: 197 KiB |
BIN
static/uploads/oxitraffic.png
Normal file
After Width: | Height: | Size: 151 KiB |
BIN
static/uploads/scifi-fantasy.png
Normal file
After Width: | Height: | Size: 472 KiB |
BIN
static/uploads/twenty.png
Normal file
After Width: | Height: | Size: 51 KiB |
@@ -1,13 +0,0 @@
|
||||
<body>
|
||||
|
||||
|
||||
<?php
|
||||
if ((isset($this->error)) and ($this->error != "")){
|
||||
echo "<div id=error>";
|
||||
echo $this->error;
|
||||
echo "</div>";
|
||||
}
|
||||
echo "<div id=content>";
|
||||
echo $this->output;
|
||||
echo "</div>";
|
||||
?>
|
@@ -1,15 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||||
<meta name="author" content="Sam">
|
||||
<meta http-equiv="pragma" content="no-cache">
|
||||
<meta http-equiv="cache-control" content="no-cache">
|
||||
<link rel="SHORTCUT ICON" type="image/x-icon" href="images/favicon.ico">
|
||||
<?php
|
||||
echo ' <link REL="stylesheet" TYPE="text/css" HREF="css/styles.css">
|
||||
<title>Address collection</title>
|
||||
|
||||
';
|
||||
?>
|
||||
</head>
|
@@ -1,17 +0,0 @@
|
||||
<?php
|
||||
$form = '<form action="'.htmlspecialchars($_SERVER["PHP_SELF"]).'" method="post">';
|
||||
$form .='
|
||||
<input type = hidden name="ac" value = "user_save">
|
||||
<input type = hidden name="user_ID" value = "">';
|
||||
$form .= 'Name: <input type="text" name="name" value=""><br>';
|
||||
$form .= 'E-Mail: <input type="text" name="email" value=""><br>';
|
||||
$form .= 'Signal: <input type="text" name="signalmessenger" value=""><br>';
|
||||
$form .= 'SMS: <input type="text" name="sms" value=""><br>';
|
||||
$form .= 'Telegram: <input type="text" name="telegram" value=""><br>';
|
||||
$form .= 'Threema: <input type="text" name="threema" value=""><br>';
|
||||
$form .= '
|
||||
<input type="submit" value="Send">
|
||||
<input type="reset" value="Reset";
|
||||
</form>';
|
||||
echo $form;
|
||||
?>
|