Initial public commit

This commit is contained in:
Joost Rijneveld 2024-08-04 10:01:08 +02:00
commit b35de92531
34 changed files with 1432 additions and 0 deletions

1
.dockerignore Normal file
View File

@ -0,0 +1 @@
.git

12
.env.example Normal file
View File

@ -0,0 +1,12 @@
FLASK_OIDC_PROVIDER_NAME = 'custom'
FLASK_OIDC_CLIENT_ID = 'adrusboek'
FLASK_OIDC_CLIENT_SECRET = 'CLIENT SECRET GOES HERE'
FLASK_OIDC_FORCE_SCHEME = 'https'
FLASK_OIDC_CONFIG_URL = 'https://idm.hashru.nl/oauth2/openid/adrusboek/.well-known/openid-configuration'
FLASK_OIDC_USER_ID_FIELD = 'sub'
FLASK_OIDC_SCOPES = 'openid'
ENV = 'development'
SQLALCHEMY_DATABASE_URI = 'sqlite:///sessions.db'
SECRET_KEY = 'SECRET KEY GOES HERE'
RADICALE_DATA_PATH = 'instance/radicale/'
CARDDAV_URL = 'https://contacts.example.com'

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
__pycache__/
instance/
.env

15
Dockerfile Normal file
View File

@ -0,0 +1,15 @@
FROM python:3.12.3-alpine3.20
RUN apk update
RUN apk add git
WORKDIR /srv
RUN pip install --upgrade pip
RUN pip install flask Flask-SQLAlchemy requests
RUN pip install gunicorn
RUN pip install python-dotenv
RUN pip install git+https://github.com/maxcountryman/flask-seasurf@f383b48
RUN pip install git+https://github.com/joostrijneveld/flaskoidc.git
RUN pip install python-dateutil
COPY . /srv
ENV FLASK_APP=app
EXPOSE 5000
CMD ["gunicorn", "-w", "4", "app:app", "-b", "0.0.0.0:5000"]

233
app.py Normal file
View File

@ -0,0 +1,233 @@
import json
import math
from dotenv import load_dotenv
from typing import List
import logging
import os
import datetime
import dateutil
from pathlib import Path
load_dotenv() # load environment before initializing flaskoidc
logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.DEBUG)
from flask import render_template, session, request, abort, redirect, url_for, flash
from flask_seasurf import SeaSurf
from sqlalchemy import extract
import requests
if "ENV" in os.environ and os.environ["ENV"] == "development":
# if we're running on localhost we cannot use the production OIDC provider
from oidcmock import FlaskOIDCMock
app = FlaskOIDCMock(__name__)
else:
from flaskoidc import FlaskOIDC
app = FlaskOIDC(__name__)
csrf = SeaSurf(app) # CSRF protection
db = app.db # FlaskOIDC already creates a db instance
with app.app_context():
from models import User
app.db.create_all()
import cli
import template_filters
from radicale import Radicale
app.radicale = Radicale(data_path=os.environ["RADICALE_DATA_PATH"])
@app.before_request
def _populate_rich_user():
"""Ensure the current user object is always up-to-date and available."""
# skip flaskoidc routes; user session variable is not guaranteed to be ready
if request.path in ["/logout", "/login", app.config.get("REDIRECT_URI", "/auth")]:
return
session.rich_user = User.create_or_update(session.get("user"))
db.session.commit()
def get_map_label(user: User):
return user.display_name
@app.route("/")
def overview_map():
all_users = User.all_viewable_users()
markers = dict()
for u in all_users:
if u.lat and u.lon:
if (coords := (u.lat, u.lon)) in markers:
markers[coords].append(get_map_label(u))
else:
markers[coords] = [get_map_label(u)]
markers = [
{
"label": " & ".join(sorted(v)),
"lat": lat,
"lon": lon,
}
for (lat, lon), v in markers.items()
]
return render_template("map.html.j2", markers=markers, user=session.rich_user)
@app.route("/profile", methods=["GET", "POST"])
def profile():
user = session.rich_user
if request.method == "POST":
user.first_name = request.form.get("first_name", "")
user.last_name = request.form.get("last_name", "")
user.email = request.form.get("email", "")
user.phone = request.form.get("phone", "")
user.street = request.form.get("street", "")
user.number = request.form.get("number", "")
user.postal = request.form.get("postal", "")
user.city = request.form.get("city", "")
user.country = request.form.get("country", "")
user.lat = request.form.get("lat", "")
user.lon = request.form.get("lon", "")
birthdate = request.form.get("birthdate")
if birthdate == "":
user.birthdate = None
else:
user.birthdate = datetime.date.fromisoformat(birthdate)
user.last_updated = datetime.datetime.now()
# user.include_in_views = request.form.get("include_in_views", "") == "on"
# do not give users a choice; include after first modification
user.include_in_views = True
db.session.commit()
app.radicale.create_or_update_vcf(user)
flash("Je wijzigingen zijn succesvol opgeslagen!", "success")
return redirect(url_for("profile"))
return render_template("profile.html.j2", user=user)
@app.route("/calendar")
def calendar():
def days_in_month(m):
return [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][m - 1]
now = datetime.datetime.now().astimezone(dateutil.tz.gettz("Europe/Amsterdam"))
try:
month = ((int(request.args.get("month", now.month)) - 1) % 12) + 1
except ValueError:
month = now.month
all_users: List[User] = db.session.execute(
db.select(User)
.filter_by(include_in_views=True)
.filter(extract("month", User.birthdate) == month)
).scalars()
buckets = [{"day": i + 1, "bucket": []} for i in range(days_in_month(month))]
for u in all_users:
buckets[u.birthdate.day - 1]["bucket"].append(u.display_name)
ncols = 3
column_size = math.ceil(len(buckets) / ncols)
columns = [
buckets[i : i + column_size] for i in range(0, len(buckets), column_size)
]
try:
img_src = next(Path("static/img/calendar/").glob(f"{month}-*.webp"))
except StopIteration:
img_src = next(Path("static/img/calendar/").glob("default*.webp"))
img_credits = None
if "-" in img_src.stem:
img_credits = img_src.stem.split("-")[1]
return render_template(
"calendar.html.j2",
user=session.rich_user,
month=month,
columns=columns,
img_src=img_src,
img_credits=img_credits,
today=now.day if month == now.month else None,
)
def get_profile(username):
if username is None:
return None
profile = db.session.execute(
db.select(User)
.filter_by(include_in_views=True)
.filter(User.username == username)
).first()
if profile is not None:
profile: User = profile[0]
profile.marker = {
"lat": profile.lat,
"lon": profile.lon,
"label": get_map_label(profile),
}
return profile
@app.route("/addressbook_card/<username>/")
@app.route("/addressbook_card/", defaults={"username": None})
def addressbook_card(username):
profile = get_profile(username)
return render_template(
"addressbook_card.html.j2",
user=session.rich_user,
profile=profile,
)
@app.route("/addressbook/<username>/")
@app.route("/addressbook/", defaults={"username": None})
def addressbook(username):
contacts = sorted(User.all_viewable_users(), key=lambda x: x.display_name)
profile = get_profile(username)
if username and profile is None:
return redirect(url_for("addressbook"))
return render_template(
"addressbook.html.j2",
user=session.rich_user,
contacts=contacts,
profile=profile,
)
@app.route("/generate_carddav_password", methods=["POST"])
def generate_carddav_password():
session.rich_user.reset_carddav_password()
db.session.commit()
flash("Er is een nieuw CardDAV-wachtwoord gegenereerd!", "carddav")
return redirect(url_for("carddav"))
@app.route("/carddav")
def carddav():
return render_template(
"carddav.html.j2", user=session.rich_user, carddav_url=os.environ["CARDDAV_URL"]
)
@app.route("/geocoding")
def geocoding():
params = dict(request.args)
params.update({"format": "jsonv2"})
headers = {
"User-Agent": "adRUsboek v1.0",
}
resp = requests.get(
"https://nominatim.openstreetmap.org/search", params=params, headers=headers
)
data = json.loads(resp.content.decode("utf8"))
if not data or "lat" not in data[0] or "lon" not in data[0]:
abort(404)
return json.dumps({"lat": data[0]["lat"], "lon": data[0]["lon"]})
if __name__ == "__main__":
app.run(host="0.0.0.0", debug=True, port=5000)

42
cli.py Normal file
View File

@ -0,0 +1,42 @@
from flask import current_app
from models import User
import datetime
import uuid
app = current_app
db = app.db
@app.cli.command("create-testdata")
def create_sample_users():
users = [
User(
uuid=str(uuid.uuid4()),
username=f"test{i}@idm.localhost",
display_name=f"Test{i}",
first_name=f"Test{i}",
last_name="Achternaam",
email=f"test{i}@testmail.test",
phone="06-12345678",
last_updated=datetime.datetime.now(),
street="Teststraat",
number=str(i),
postal="1234 AB",
city="Stad",
country="Land",
lat=51.84049936832415 - 0.01 * i / 5,
lon=5.813714861947134 + 0.02 * (i % 5),
birthdate=datetime.datetime.now().date() + datetime.timedelta(days=i % 40),
include_in_views=True,
)
for i in range(100)
]
for user in users:
user.reset_carddav_password()
# to test duplicate coordinates
users[25].lat = users[24].lat
users[25].lon = users[24].lon
for user in users:
db.session.add(user)
db.session.commit()

73
models.py Normal file
View File

@ -0,0 +1,73 @@
import json
from typing import Iterable, Optional, Dict
import datetime
from sqlalchemy.orm import Mapped, mapped_column
import logging
from flask import current_app
import random, string
logger = logging.getLogger(__name__)
db = current_app.db
class User(db.Model):
uuid: Mapped[str] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(unique=True)
display_name: Mapped[str] = mapped_column(server_default="")
first_name: Mapped[str] = mapped_column(server_default="")
last_name: Mapped[str] = mapped_column(server_default="")
email: Mapped[str] = mapped_column(server_default="")
phone: Mapped[str] = mapped_column(server_default="")
street: Mapped[str] = mapped_column(server_default="")
number: Mapped[str] = mapped_column(server_default="")
postal: Mapped[str] = mapped_column(server_default="")
city: Mapped[str] = mapped_column(server_default="")
country: Mapped[str] = mapped_column(server_default="")
lat: Mapped[str] = mapped_column(server_default="")
lon: Mapped[str] = mapped_column(server_default="")
birthdate: Mapped[Optional[datetime.date]]
last_updated: Mapped[datetime.datetime]
include_in_views: Mapped[Optional[bool]]
carddav_key: Mapped[str]
def reset_carddav_password(self):
self.carddav_key = "".join(
random.choices(string.ascii_lowercase + string.digits, k=20)
)
current_app.radicale.create_or_update_authfile()
@staticmethod
def create_or_update(oidc_user: Dict) -> "User":
uuid = oidc_user["sub"]
user = db.session.execute(
db.select(User).filter_by(uuid=uuid)
).scalar_one_or_none()
if not user:
logger.debug(f"User with UUID {uuid} not yet found; creating..")
user = User(uuid=uuid, last_updated=datetime.datetime.now())
user.reset_carddav_password() # init carddav password
db.session.add(user)
user.display_name = oidc_user["name"]
if user.username != oidc_user["preferred_username"]:
# possibly changed username
user.username = oidc_user["preferred_username"]
current_app.radicale.ensure_user_exists(user)
current_app.radicale.create_or_update_authfile()
return user
def __repr__(self):
return self.username
def to_dict(self):
return {k: self.__getattribute__(k) for k in User.__dict__.keys()}
@staticmethod
def all_users() -> Iterable["User"]:
return db.session.execute(db.select(User)).scalars()
@staticmethod
def all_viewable_users() -> Iterable["User"]:
return db.session.execute(
db.select(User).filter_by(include_in_views=True)
).scalars()

22
oidcmock.py Normal file
View File

@ -0,0 +1,22 @@
from flask import Flask, session
from flask_sqlalchemy import SQLAlchemy
import os
import uuid
import datetime
class FlaskOIDCMock(Flask):
def _before_request(self):
session["user"] = {
"sub": "939ac645-66f9-4b11-9573-140a2ec55e42",
"preferred_username": "joost@idm.localhost",
"name": "Joost",
}
def __init__(self, *args, **kwargs):
super(FlaskOIDCMock, self).__init__(*args, **kwargs)
self.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///sessions.db"
self.config["SECRET_KEY"] = os.environ["SECRET_KEY"]
self.db = SQLAlchemy(self)
self.before_request(self._before_request)

113
radicale.py Normal file
View File

@ -0,0 +1,113 @@
import json
import logging
from pathlib import Path
from models import User
logger = logging.getLogger(__name__)
class Radicale:
data_path: Path
collection_path: Path
ROOT_USER = "root"
def __init__(self, data_path: str) -> None:
self.data_path = Path(data_path)
self.collection_root = self.data_path / Path("collections", "collection-root")
self.collection_path = self.collection_root / Path(
Radicale.ROOT_USER, "contacts"
)
self.init_radicale_dir()
def init_radicale_dir(self):
logger.info("Initializing Radicale directory..")
self.collection_path.mkdir(parents=True, exist_ok=True)
with open(self.collection_path / Path(".Radicale.props"), "w") as f:
meta = {
"D:displayname": "contacts",
"tag": "VADDRESSBOOK",
"{http://inf-it.com/ns/ab/}addressbook-color": "#ff0000ff",
}
f.write(json.dumps(meta))
# ensure radicale data is in the correct state
for user in User.all_viewable_users():
self.create_or_update_vcf(user)
# delete and recreate user symlinks
for dir in self.collection_root.iterdir():
if dir.is_symlink():
dir.unlink()
for user in User.all_users():
self.ensure_user_exists(user)
self.create_or_update_authfile()
@staticmethod
def user_to_vcf(user: User):
fname = f"{user.uuid}.vcf"
content = [
"BEGIN:VCARD",
"VERSION:2.1",
f"UID:{user.uuid}",
f"REV:{user.last_updated.strftime('%Y%m%dT%H%M%SZ')}",
]
fn = " ".join(filter(None, [user.first_name, user.last_name]))
# RFC6350 says FN has cardinality 1*, so must have at least one
# software may not support multiple FNs, so output exactly one
if fn:
content.append(f"FN:{fn}")
elif user.display_name:
content.append(f"FN:{user.display_name}")
else:
content.append(f"FN:{user.username}")
# only include N if one of its fields is non-empty
if user.last_name or user.first_name:
content.append(
f"N:"
f"{user.last_name if user.last_name else ''};"
f"{user.first_name if user.first_name else ''};;;"
)
if user.phone:
content.append(f"TEL:{user.phone}")
if user.email:
content.append(f"EMAIL:{user.email}")
if user.birthdate:
content.append(f"BDAY:{user.birthdate.strftime('%Y%m%d')}")
if user.lat and user.lon:
content.append(f"GEO:geo:{user.lat},{user.lon}")
if any([user.street, user.number, user.postal, user.city, user.country]):
street = " ".join(filter(None, [user.street, user.number]))
content.append(
f"ADR:"
";" # po-box
";" # ext addr
f"{street};"
f"{user.city if user.city else ''};"
";" # region
f"{user.postal if user.postal else ''};"
f"{user.country if user.country else ''};"
)
content.append("END:VCARD")
return Path(fname), "\n".join(content)
def create_or_update_vcf(self, user: User):
vcf_fname, vcf_content = Radicale.user_to_vcf(user)
with open(self.collection_path / vcf_fname, "w") as f:
f.write(vcf_content)
def ensure_user_exists(self, user: User):
if user.username == Radicale.ROOT_USER:
raise Exception(
f"Got user object with reserved Radicale username '{Radicale.ROOT_USER}'. Panicking!"
)
userpath = self.collection_root / Path(user.username)
if userpath.exists():
userpath.unlink()
userpath.symlink_to(Path(Radicale.ROOT_USER))
def create_or_update_authfile(self):
with open(self.data_path / Path("users"), "w") as f:
f.write(
"\n".join(
f"{user.username}:{user.carddav_key}" for user in User.all_users()
)
)

7
requirements.txt Normal file
View File

@ -0,0 +1,7 @@
Flask
python-dotenv
git+https://github.com/joostrijneveld/flaskoidc.git
flask-sqlalchemy
requests
git+https://github.com/maxcountryman/flask-seasurf@f383b48
python-dateutil

12
static/css/bootstrap-flatly.min.css vendored Normal file

File diff suppressed because one or more lines are too long

60
static/css/style.css Normal file
View File

@ -0,0 +1,60 @@
body {
/* fallback */
height: 100vh;
height: 100dvh;
}
/* do not add padding when container is full width, on smallest screen */
@media (min-width: 576px) {
/* to blend the scrollbar in */
body {
padding-left: calc(100vw - 100%);
}
}
#logo {
margin-right: .5rem;
height: 22px;
margin-bottom: 3px;
}
/* address book */
#osm-map-small {
height: 18em;
}
@media (min-width: 576px) {
#osm-map-small {
height: 22em;
}
}
@media (min-width: 768px) {
#osm-map-small {
height: 26em;
}
}
.profile-button {
border-radius: 0 !important;
}
/* calendar */
#calendar_img {
width: 100%;
}
#img_credits:empty::after {
content: ".";
visibility: hidden;
}
a.monthlink {
color: black;
}
a.monthlink:hover {
color: #606060;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 425 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 KiB

BIN
static/img/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

36
template_filters.py Normal file
View File

@ -0,0 +1,36 @@
import datetime
from flask import current_app
import dateutil
app = current_app
@app.template_filter()
def to_month(m):
return [
"januari",
"februari",
"maart",
"april",
"mei",
"juni",
"juli",
"augustus",
"september",
"oktober",
"november",
"december",
][m - 1]
@app.template_filter()
def datetime_format(value: datetime.datetime):
value = value.astimezone(dateutil.tz.gettz("Europe/Amsterdam"))
return (
f"{value.day} {to_month(value.month)} {value.year} om {value.strftime('%H:%M')}"
)
@app.template_filter()
def date_format(value: datetime.date):
return f"{value.day} {to_month(value.month)} {value.year}"

View File

@ -0,0 +1,155 @@
{% extends 'base.html.j2' %}
{% set active_page = "addressbook" %}
{% block content %}
<div class="flex-grow-1 overflow-hidden d-flex pt-3 pb-lg-3" style='flex-basis: 0;'>
<div class="overflow-auto col-12 col-lg-4 {% if profile%}d-none{% endif %} d-lg-block" id="contacts">
{% if contacts %}
<div class="list-group">
{% for user in contacts %}
<button type="button" onclick="handleButtonClick(this)" data-username='{{ user.username }}'
class="profile-button list-group-item list-group-item-action {% if profile.username == user.username %}active{% endif%}">{{
user.display_name }}</button>
{% endfor %}
</div>
{% else %}
<div class="h-100 w-100 d-flex align-items-center">
<div class="w-100 text-center" style="color: lightgrey"><em>Geen contacten om te tonen</em></div>
</div>
{% endif %}
</div>
<div class="col pt-lg-2 ps-lg-4 pe-lg-4">
<div class="container overflow-auto h-100 pb-3 pb-lg-0" id="profile-wrap">
{% if profile %}
{% include 'addressbook_card.html.j2' %}
{% elif contacts %}
<div class="h-100 w-100 d-flex align-items-center">
<div class="w-100 text-center">
<p class="fst-italic" style="color: lightgrey">Niemand geselecteerd</p>
</div>
</div>
{% endif %}
</div>
</div>
</div>
<div class="toast-container position-fixed bottom-0 end-0 p-3">
<div id="toast-loading" class="toast align-items-center text-bg-primary border-0" role="alert" aria-live="assertive"
data-bs-autohide="false" aria-atomic="true">
<div class="d-flex">
<div class="toast-body">
<div class="spinner-border spinner-border-sm me-2" role="status"></div>Het duurt wat langer om te
laden..
</div>
</div>
</div>
</div>
<div class="toast-container position-fixed bottom-0 end-0 p-3">
<div id="toast-error" class="toast align-items-center text-bg-danger border-0" role="alert" aria-live="assertive"
data-bs-autohide="false" aria-atomic="true">
<div class="d-flex">
<div class="toast-body">
<i class="bi bi-exclamation-circle-fill me-1"></i>
<strong>Laden mislukt!</strong><br>
Herlaad de pagina of probeer het later opnieuw.
</div>
</div>
</div>
</div>
{% endblock %}
{% block js %}
<script src="https://unpkg.com/leaflet@1.6.0/dist/leaflet.js"></script>
<script type="text/javascript">
var addressbookURL = {{ url_for('addressbook') | tojson}}
var cardURL = {{ url_for('addressbook_card') | tojson}}
function showContacts() {
document.getElementById('contacts').classList.remove('d-none');
scrollToActive();
}
function handleButtonClick(element) {
var username = element.dataset.username;
history.pushState({ 'username': username }, '', addressbookURL + username);
selectProfile(username);
}
function renderMap() {
var element = document.getElementById('osm-map-small');
if (element) {
var data = element.dataset
var map = L.map(element);
L.tileLayer('https://a.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png').addTo(map);
var marker = L.marker(L.latLng(data['lat'], data['lon'])).addTo(map);
marker.bindTooltip(data['label'], { 'permanent': true })
var target = L.latLng(data['lat'], data['lon']);
map.setView(target, 15);
}
}
function selectProfile(username) {
Array.from(document.getElementsByClassName('profile-button')).forEach(
function (element, idx, array) {
element.classList.remove('active')
if (element.dataset.username == username) {
element.classList.add('active')
}
}
)
var timeoutLoading = setTimeout(() => {
bootstrap.Toast.getOrCreateInstance(document.getElementById('toast-loading')).show()
}, 1000);
var timeoutError = setTimeout(() => {
bootstrap.Toast.getOrCreateInstance(document.getElementById('toast-error')).show()
}, 10000);
fetch(cardURL + username)
.then(function (response) {
if (response.ok) {
return response.text();
}
return Promise.reject(response);
})
.then(
function (html) {
document.getElementById('profile-wrap').innerHTML = html
renderMap();
document.getElementById('contacts').classList.add('d-none');
clearTimeout(timeoutLoading);
clearTimeout(timeoutError);
},
function (html) {
clearTimeout(timeoutLoading);
clearTimeout(timeoutError);
bootstrap.Toast.getOrCreateInstance(document.getElementById('toast-error')).show()
}
)
}
function scrollToActive() {
var active = document.getElementsByClassName('profile-button active');
if (active.length > 0) {
var grandParent = active[0].parentNode.parentNode;
grandParent.scrollTop = active[0].offsetTop - grandParent.offsetTop - grandParent.clientHeight / 2 + active[0].clientHeight / 2
}
}
window.addEventListener('popstate', function () {
var url = window.location.href;
var username = url.split('/').filter((fragment) => fragment.length > 0).at(-1);
if (username == 'addressbook') {
return
}
selectProfile(username)
scrollToActive();
})
window.addEventListener('load', function () {
scrollToActive();
renderMap();
});
</script>
{% endblock %}

View File

@ -0,0 +1,85 @@
<div class="row">
<div class="col">
<h4 class="display-4 d-flex"><span class='flex-fill'>{{ profile.display_name }}</span>
<button class="d-lg-none btn btn-primary align-self-center" type="button" onclick="showContacts()"><i
class="bi bi-people-fill"></i></button>
</h4>
</div>
</div>
<div class="row">
<div class="col-4 fst-italic">Voornaam:
</div>
<div class="col">{{ profile.first_name }}
</div>
</div>
<div class="row">
<div class="col-4 fst-italic">Achternaam:
</div>
<div class="col">{{ profile.last_name }}
</div>
</div>
<hr>
<div class="row">
<div class="col-4 fst-italic">E-mailadres:
</div>
<div class="col">{{ profile.email }}
</div>
</div>
<div class="row">
<div class="col-4 fst-italic">Telefoonnummer:
</div>
<div class="col">{{ profile.phone }}
</div>
</div>
<hr>
<div class="row">
<div class="col-4 fst-italic">Straatnaam:
</div>
<div class="col">{{ profile.street }}
</div>
</div>
<div class="row">
<div class="col-4 fst-italic">Huisnummer:
</div>
<div class="col">{{ profile.number }}
</div>
</div>
<div class="row">
<div class="col-4 fst-italic">Postcode:
</div>
<div class="col">{{ profile.postal }}
</div>
</div>
<div class="row">
<div class="col-4 fst-italic">Stad:
</div>
<div class="col">{{ profile.city }}
</div>
</div>
<div class="row">
<div class="col-4 fst-italic">Land:
</div>
<div class="col">{{ profile.country }}
</div>
</div>
{% if profile.lat and profile.lon %}
<div class="row mt-3">
<div class="col">
<div id="osm-map-small" data-lat="{{ profile.marker.lat }}" data-lon="{{ profile.marker.lon }}"
data-label="{{ profile.marker.label }}"></div>
</div>
</div>
{% endif %}
<hr>
<div class="row">
<div class="col-4 fst-italic">Verjaardag:
</div>
<div class="col">{% if profile.birthdate %}{{ profile.birthdate | date_format }}{% endif %}
</div>
</div>
<hr>
<div class="row">
<div class="col text-center"><em>Laatst bijgewerkt: {{ profile.last_updated | datetime_format }}</em>
</div>
</div>

69
templates/base.html.j2 Normal file
View File

@ -0,0 +1,69 @@
<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>adRUsboek</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<link href="https://unpkg.com/leaflet@1.6.0/dist/leaflet.css" rel="stylesheet" />
<link href="{{ url_for('static', filename='css/bootstrap-flatly.min.css') }}" rel="stylesheet" />
<link href="{{ url_for('static', filename='css/style.css') }}" rel="stylesheet" />
{% block header %}
{% endblock %}
</head>
<body>
<div class="container h-100 d-flex flex-column p-0">
<nav class="navbar navbar-expand-lg bg-body-tertiary">
<div class="container-fluid">
<a class="navbar-brand" href="#">
<img id='logo' src="{{ url_for('static', filename='img/logo.png') }}" />adRUsboek
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav"
aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link {% if active_page == 'map' %}active{% endif %}"
href="{{ url_for('overview_map') }}">Kaartje</a>
</li>
<li class="nav-item">
<a class="nav-link {% if active_page == 'calendar' %}active{% endif %}"
href="{{ url_for('calendar') }}">Verjaardagskalender</a>
</li>
<li class="nav-item">
<a class="nav-link {% if active_page == 'addressbook' %}active{% endif %}"
href="{{ url_for('addressbook') }}">Adresboek</a>
</li>
<li class="nav-item">
<a class="nav-link {% if active_page == 'carddav' %}active{% endif %}"
href="{{ url_for('carddav') }}">CardDAV</a>
</li>
</ul>
<ul class="navbar-nav ms-auto">
<li>
<a class="nav-link {% if active_page == 'profile' %}active{% endif %}"
href="{{ url_for('profile')}}">Mijn profiel ({{user.username }})</a>
</li>
</ul>
</div>
</div>
</nav>
{% block content %}{% endblock %}
</div>
{% block toplevel %}{% endblock %}
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/dist/umd/popper.min.js"
integrity="sha384-I7E8VVD/ismYTF4hNIPjVp/Zjvgyol6VFvRkX/vR+Vc4jQkC+hVqc2pM8ODewa9r"
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.min.js"
integrity="sha384-0pUGZvbkm6XF6gxjEnlmuGrJXVbNuzT9qBBavbLwCsOGabYfZo0T0to5eqruptLy"
crossorigin="anonymous"></script>
{% block js %}{% endblock %}
</body>
</html>

View File

@ -0,0 +1,52 @@
{% extends 'base.html.j2' %}
{% set active_page = "calendar" %}
{% block content %}
<div class="container mt-3">
<div class="row mb-3">
<div class="col p-0">
<div class="ratio" style="--bs-aspect-ratio: 50%;">
<img class='img-fluid' src="{{ img_src }}" id="calendar_img" />
</div>
{#<div class="text-end pt-1 small fst-italic">
<small id="img_credits">{% if img_credits %}Foto: {{ img_credits }}{% endif %}</small>
</div>#}
</div>
</div>
<div class="row mb-3 d-flex align-items-center">
<div class="col text-start">
{% set prevmonth = ((month - 1 + 12 - 1) % 12) + 1 %}
{% set nextmonth = ((month + 1 - 1) % 12) + 1 %}
<a class="monthlink" href="{{ url_for('calendar')}}?month={{ prevmonth }}"><i
class="bi bi-caret-left-fill"></i>
</a>
</div>
<div class="col">
<h1 class="display-5 text-center">{{ month | to_month | capitalize }}</h1>
</div>
<div class="col text-end">
<a class="monthlink" href="{{ url_for('calendar')}}?month={{ nextmonth }}"><i
class="bi bi-caret-right-fill"></i></a>
</div>
</div>
<div class="row">
{% for col in columns %}
<div class="col-md-4">
{% for birthdate in col %}
<p style="display: block; border-bottom:1px solid black;">
<span class='me-1'>{{ birthdate.day }}.</span> {{
birthdate.bucket | join(",
") }}
{% if birthdate.day == today and birthdate.bucket %}<span class='ms-1'>🎉</span>{% endif %}
</p>
{% endfor %}
</div>
{% endfor %}
</div>
</div>
{% endblock %}

127
templates/carddav.html.j2 Normal file
View File

@ -0,0 +1,127 @@
{% extends 'base.html.j2' %}
{% set active_page = "carddav" %}
{% block content %}
<div class="container mt-3">
<div class="row">
<div class="col">
<h1 class="display-6">CardDAV</h1>
<p class="lead">
Zodat je adRUsboek altijd dichtbij is.
</p>
<p>
De gegevens van 't adRUsboek zijn via CardDAV te importeren in je favoriete contacten-applicatie. Hoe je
dat precies configureert verschilt een beetje per applicatie. Voor een aantal veelgebruikte applicaties
verzamelen we hieronder instructies. Ontbreekt er een applicatie of kom je er niet uit? Laat het vooral
even weten aan Joost!
</p>
<p>
In het algemeen geldt dat je de volgende gegevens kunt proberen:
</p>
<div class="container mb-3">
<div class="row">
<div class="col-md-3 col-lg-2 text-md-end">
Gebruikersnaam:
</div>
<div class="col-md-9 col-lg-10">
<code>{{ user.username }}</code>
</div>
</div>
<div class="row">
<div class="col-md-3 col-lg-2 text-md-end">
Wachtwoord:
</div>
<div class="col-md-9 col-lg-10">
<code>{{ user.carddav_key }}</code>
</div>
</div>
<div class="row">
<div class="col-md-3 col-lg-2 text-md-end">
Serveradres:
</div>
<div class="col-md-9 col-lg-10">
<code>{{ carddav_url }}</code>
</div>
</div>
</div>
<p>Let op! Je wachtwoord is persoonlijk, en specifiek voor CardDAV-toegang. Opnieuw genereren?
<a href="#" data-bs-toggle="modal" data-bs-target="#cardDAVModal">Klik hier</a>.
</p>
{% with successes = get_flashed_messages(category_filter=["carddav"]) %}
{% if successes %}
{% for msg in successes %}
<div class="alert alert-success" role="alert">
{{ msg }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
<hr>
<h4>Contacts.app op iOS</h4>
Om het adRUsboek toe te voegen:
<ol>
<li>Ga naar <strong>Settings > Contacts > Accounts</strong>.</li>
<li>Ga naar <strong>Accounts</strong>.</li>
<li>Kies <strong>Add Account</strong>.</li>
<li>Kies <strong>Other</strong>.</li>
<li>Kies <strong>Add CardDAV account</strong>.</li>
<li>Vul de hierboven genoemde gegevens in.</li>
<li>Vul eventueel een <em>Description</em> in (bijvoorbeeld 'adRUsboek').</li>
<li>Kies <strong>Next</strong>. Als je gegevens kloppen verschijnen er vinkjes.</li>
</ol>
Om het adRUsboek te bekijken:
<ol>
<li>Open de contacten-app en klik op <strong>List</strong>.</li>
<li>Kies de lijst die overeenkomt met je gekozen <em>Description</em>.</li>
</ol>
<hr>
<h4>Contacts.app op macOS</h4>
<p><em>TODO: om voor mij onduidelijke redenen werkt het onderstaande niet :(</em></p>
Om het adRUsboek toe te voegen:
<ol>
<li>Start <strong>Contacts</strong>.</li>
<li>Ga naar <strong>Settings > Accounts</strong>.</li>
<li>Klik op de <strong>+</strong> onderaan de lijst.</li>
<li>Kies <strong>'Other Contacts account..'</strong> en klik op <strong>Continue</strong>.</li>
<li>Kies bij <em>Account type</em> voor <strong>Manual</strong>.</li>
<li>Vul de hierboven genoemde gegevens in.</li>
<li>Klik op <strong>Sign in</strong>. Het account verschijnt nu in de lijst.</li>
</ol>
Om het adRUsboek te bekijken:
<ol>
<li>Open de contacten-app en klik op <strong>idm.hashru.nl</strong>.</li>
<li>.. zie een lege lijst? :(</li>
</ol>
</div>
</div>
</div>
{% endblock %}
{% block toplevel %}
<div class="modal fade" id="cardDAVModal" tabindex="-1" aria-labelledby="cardDAVModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="cardDAVModalLabel">Wachtwoord resetten</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
Weet je zeker dat je je CardDAV-wachtwoord opnieuw wil genereren? Het oude wachtwoord wordt daarmee
ongeldig.
</div>
<div class="modal-footer">
<form method="post" action="{{ url_for('generate_carddav_password') }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuleren</button>
<button type="submit" class="btn btn-primary">Wachtwoord resetten</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

23
templates/map.html.j2 Normal file
View File

@ -0,0 +1,23 @@
{% extends 'base.html.j2' %}
{% set active_page = "map" %}
{% block content %}
<div class="flex-fill mt-3 mb-sm-3" id="osm-map"></div>
{% endblock %}
{% block js %}
<script src="https://unpkg.com/leaflet@1.6.0/dist/leaflet.js"></script>
<script type="text/javascript">
var markerdata = {{ markers | tojson }};
var map = L.map(document.getElementById('osm-map'));
L.tileLayer('https://a.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png').addTo(map);
markerdata.forEach(data => {
marker = L.marker(L.latLng(data['lat'], data['lon'])).addTo(map);
marker.bindTooltip(data['label'])
});
var target = L.latLng('51.8449', '5.8428'); // Nijmegen
map.setView(target, 13);
</script>
{% endblock %}

292
templates/profile.html.j2 Normal file
View File

@ -0,0 +1,292 @@
{% extends 'base.html.j2' %}
{% set active_page = "profile" %}
{% block content %}
<div class="container mt-3">
<div class="row">
<div class="col">
<h1 class="display-6">Mijn profiel</h1>
<p>
Op je profiel kun je je gegevens invullen. Alle velden zijn optioneel.
{# Je kunt ook aangeven dat je je gegevens alleen met bepaalde gebruikers wilt
delen; voor niet-geselecteerde gebruikers zal het lijken alsof je de niet-gedeelde gegevens niet hebt
ingevuld. #}
</p>
{% with successes = get_flashed_messages(category_filter=["success"]) %}
{% if successes %}
{% for msg in successes %}
<div class="alert alert-success" role="alert">
{{ msg }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="post" action="{{ url_for('profile') }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="row">
<label for="input-firstname" class="col-md-2 col-sm-4 col-form-label text-sm-end">Voornaam</label>
<div class="col-md-4 col-sm-8 mb-sm-3">
<input type="text" class="form-control" id="input-firstname" name="first_name"
value="{{ user.first_name }}">
</div>
<label for="input-lastname" class="col-md-2 col-sm-4 col-form-label text-sm-end">Achternaam</label>
<div class="col-md-4 col-sm-8 mb-sm-3">
<input type="text" class="form-control" id="input-lastname" name="last_name"
value="{{ user.last_name }}">
</div>
</div>
<div class="row mb-3">
<label for="input-displayname" class="col-md-2 col-sm-4 col-form-label text-sm-end">
<span class='d-none d-md-inline d-lg-none'>Weergave</span>
<span class='d-inline d-md-none d-lg-inline'>Weergavenaam</span>
</label>
<div class="col-md-4 col-sm-8">
<input type="text" class="form-control" id="input-displayname" disabled
value="{{ user.display_name }}">
<small class="text-muted">Afkomstig van idm.hashru.nl</small>
</div>
</div>
<hr class="mb-2 mb-sm-3">
<div class="row">
<label for="input-email" class="col-md-2 col-sm-4 col-form-label text-sm-end">E-mailadres</label>
<div class="col-md-4 col-sm-8 mb-md-0 mb-sm-3">
<input type="text" class="form-control" id="input-email" name="email" value="{{ user.email }}">
</div>
<label id="label-phone" for="input-phone" class="col-md-2 col-sm-4 col-form-label text-sm-end">
<span class='d-none d-md-inline d-lg-none'>Telefoonnr.</span>
<span class='d-inline d-md-none d-lg-inline'>Telefoonnummer</span>
</label>
<div class="col-md-4 col-sm-8">
<input type="text" class="form-control" id="input-phone" name="phone" value="{{ user.phone }}">
</div>
</div>
{# <div class="row mb-3">
<div class="col-sm-10 offset-sm-2">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="contact-whitelist-check">
<label class="form-check-label" for="contact-whitelist-check">
Contactgegevens alleen tonen aan geselecteerde gebruikers.
</label>
</div>
</div>
</div> #}
<hr class="mb-2 mb-sm-3">
<div class="row mb-3">
<div class="col-md-6">
<div class="row mb-sm-3">
<label for="input-street" class="col-sm-4 col-form-label text-sm-end">Straatnaam</label>
<div class="col-sm-8">
<input type="text" class="form-control update-latlon" id="input-street" name="street"
value="{{ user.street }}">
</div>
</div>
<div class="row mb-sm-3">
<label for="input-number" class="col-sm-4 col-form-label text-sm-end">Huisnummer</label>
<div class="col-sm-8">
<input type="text" class="form-control update-latlon" id="input-number" name="number"
value="{{ user.number }}">
</div>
</div>
<div class="row mb-sm-3">
<label for="input-postal" class="col-sm-4 col-form-label text-sm-end">Postcode</label>
<div class="col-sm-8">
<input type="text" class="form-control update-latlon" id="input-postal" name="postal"
value="{{ user.postal }}">
</div>
</div>
<div class="row mb-sm-3">
<label for="input-city" class="col-sm-4 col-form-label text-sm-end">Woonplaats</label>
<div class="col-sm-8">
<input type="text" class="form-control update-latlon" id="input-city" name="city"
value="{{ user.city }}">
</div>
</div>
<div class="row">
<label for="input-country" class="col-sm-4 col-form-label text-sm-end">Land</label>
<div class="col-sm-8">
<input type="text" class="form-control update-latlon" id="input-country" name="country"
value="{{ user.country }}">
</div>
</div>
<div class="row">
<!-- <label for="input-lat" class="col-sm-4 col-form-label text-sm-end">Latitude</label> -->
<div class="col-sm-3">
<input type="hidden" class="form-control" id="input-lat" name="lat"
value="{{ user.lat }}">
</div>
<!-- <label for="input-lon" class="col-sm-2 col-form-label text-sm-end">Longitude</label> -->
<div class="col-sm-3">
<input type="hidden" class="form-control" id="input-lon" name="lon"
value="{{ user.lon }}">
</div>
</div>
<div class="row">
<div class="offset-sm-4">
<!-- <small class="text-muted">Coördinaten zijn alleen voor weergave op de kaart.</small> -->
</div>
</div>
</div>
<div class="col-md-6 pt-3 pt-md-0">
<div class='h-100' id="osm-map" style="min-height:18em;"></div>
</div>
</div>
{# <div class="row mb-3">
<div class="col-sm-10 offset-sm-2">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="address-whitelist-check">
<label class="form-check-label" for="address-whitelist-check">
Adresgegevens alleen tonen aan geselecteerde gebruikers.
</label>
</div>
</div>
</div> #}
<hr class="mb-2 mb-sm-3">
<div class="row mb-3">
<label for="input-birthdate" class="col-md-2 col-sm-4 col-form-label text-sm-end">Verjaardag</label>
<div class="col-md-4 col-sm-4">
<input id="input-birthdate" class="form-control" name="birthdate" type="date"
value="{{ user.birthdate }}" />
</div>
</div>
{# <div class="row mb-3">
<div class="col-sm-10 offset-sm-2">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="birthdate-whitelist-check">
<label class="form-check-label" for="birthdate-whitelist-check">
Verjaardag alleen tonen aan geselecteerde gebruikers
</label>
</div>
</div>
</div> #}
{#
<hr>
<div class="row mb-3">
<div class="col-sm-10 offset-sm-2">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="include-check" name="include_in_views"
{% if user.include_in_views or user.include_in_views is none %}checked{% endif %}>
<label class="form-check-label" for="include-check">
Toon mij op de kaart, in 't adresboek en op de verjaardagskalender.
</label>
</div>
</div>
</div> #}
<div class="row mb-3">
<div class="col-sm-10 offset-sm-2">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="gdpr-check" required checked>
<label class="form-check-label" for="gdpr-check">
Ik geef toestemming mijn gegevens op te slaan en te delen binnen de context van
adRUsboek.
</label>
<div class="invalid-feedback">
Je moet toestemming geven om je gegevens te verwerken voordat je ze op kunt slaan.
</div>
</div>
</div>
</div>
<div class="row col-sm-12 mb-3">
<div class="col text-center">
{% if user.include_in_views %}
<p>
Je profiel is voor het laatst bijgewerkt op {{ user.last_updated | datetime_format }}.
</p>
{% endif %}
<button type="submit" class="btn btn-primary">Opslaan</button>
</div>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block js %}
<script src="https://unpkg.com/leaflet@1.6.0/dist/leaflet.js"></script>
<script type="text/javascript">
(function () {
'use strict'
const tileserver_osm = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
const tileserver_humanitarian = "https://tile.openstreetmap.fr/hot/{z}/{x}/{y}.png"
var map = L.map(document.getElementById('osm-map'));
L.tileLayer(tileserver_humanitarian).addTo(map);
function init_map() {
var target = L.latLng('51.8449', '5.8428'); // Nijmegen
map.setView(target, 12);
}
function place_marker(lat, lon) {
var target = L.latLng(lat, lon);
map.setView(target, 15);
if (marker) {
marker.remove();
}
marker = L.marker(target, { 'draggable': true, 'autoPan': true }).addTo(map);
var name = document.getElementById('input-displayname').value
if (name) {
marker.bindTooltip(name, { permanent: true });
}
marker.addEventListener('move', (event) => {
document.getElementById('input-lat').value = event.latlng['lat'].toFixed(7);
document.getElementById('input-lon').value = event.latlng['lng'].toFixed(7);
})
}
function debounce(func, delay) {
let debounceTimer
return function () {
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => func.apply(this, arguments), delay)
}
}
var marker = null;
var lat = document.getElementById('input-lat').value;
var lon = document.getElementById('input-lon').value;
if (lat != '' && lon != '') {
place_marker(lat, lon);
}
else {
init_map();
}
var latlon_fields = document.querySelectorAll('.update-latlon');
const geocoding_url = "{{ url_for('geocoding') }}";
Array.from(latlon_fields).forEach(element => {
element.addEventListener("input", debounce((event) => {
fetch(geocoding_url + '?' + new URLSearchParams({
'street': `${document.getElementById('input-street').value} ${document.getElementById('input-number').value}`,
'postalcode': document.getElementById('input-postal').value,
'city': document.getElementById('input-city').value,
'country': document.getElementById('input-country').value,
}).toString())
.then(function (response) {
if (response.ok) {
return response.json();
}
return Promise.reject(response);
})
.then(function (json) {
if (json['lat'] && json['lon']) {
document.getElementById('input-lat').value = json['lat'];
document.getElementById('input-lon').value = json['lon'];
place_marker(json['lat'], json['lon']);
}
})
.catch(function (response) {
document.getElementById('input-lat').value = '';
document.getElementById('input-lon').value = '';
if (marker) {
marker.remove();
}
init_map();
});
}, 1000));
});
})()
</script>
{% endblock %}