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 %}
|