Initial public commit
1
.dockerignore
Normal file
@ -0,0 +1 @@
|
|||||||
|
.git
|
12
.env.example
Normal 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
@ -0,0 +1,3 @@
|
|||||||
|
__pycache__/
|
||||||
|
instance/
|
||||||
|
.env
|
15
Dockerfile
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
60
static/css/style.css
Normal 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;
|
||||||
|
}
|
BIN
static/img/calendar/1-Annelies.webp
Normal file
After Width: | Height: | Size: 270 KiB |
BIN
static/img/calendar/10-Annelies.webp
Normal file
After Width: | Height: | Size: 239 KiB |
BIN
static/img/calendar/11-Annelies.webp
Normal file
After Width: | Height: | Size: 425 KiB |
BIN
static/img/calendar/12-Annelies.webp
Normal file
After Width: | Height: | Size: 232 KiB |
BIN
static/img/calendar/2-Annelies.webp
Normal file
After Width: | Height: | Size: 184 KiB |
BIN
static/img/calendar/3-Annelies.webp
Normal file
After Width: | Height: | Size: 283 KiB |
BIN
static/img/calendar/4-Annelies.webp
Normal file
After Width: | Height: | Size: 270 KiB |
BIN
static/img/calendar/5-Annelies.webp
Normal file
After Width: | Height: | Size: 215 KiB |
BIN
static/img/calendar/6-Annelies.webp
Normal file
After Width: | Height: | Size: 132 KiB |
BIN
static/img/calendar/7-Annelies.webp
Normal file
After Width: | Height: | Size: 453 KiB |
BIN
static/img/calendar/8-Annelies.webp
Normal file
After Width: | Height: | Size: 190 KiB |
BIN
static/img/calendar/9-Annelies.webp
Normal file
After Width: | Height: | Size: 209 KiB |
BIN
static/img/calendar/default-Annelies.webp
Normal file
After Width: | Height: | Size: 453 KiB |
BIN
static/img/logo.png
Normal file
After Width: | Height: | Size: 2.7 KiB |
36
template_filters.py
Normal 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}"
|
155
templates/addressbook.html.j2
Normal 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 %}
|
85
templates/addressbook_card.html.j2
Normal 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
@ -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>
|
52
templates/calendar.html.j2
Normal 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
@ -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
@ -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
@ -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 %}
|