Project N - Een webgebaseerd valuta-dashboard
01 - Doel en voorkennis
In project L heb je API-data opgehaald en als JSON en CSV opgeslagen. In project M heb je Flask, templates, SQL en een browsergrafiek gebruikt. Nu combineer je die kennis: een Bitcoin-prijs ophalen, een meting in een eigen SQLite-database bewaren en de laatst opgeslagen prijs op een webpagina tonen.
De nieuwe onderdelen zijn schrijven naar SQLite en een webpagina bijwerken met een knop. Eerst testen we de database en de API los; daarna verbinden we ze met Flask. Een grafiek is de laatste, optionele stap.
02 - Projectmap en bekende Python-omgeving
Maak een nieuwe projectmap coins, open die in VS Code en open daar een terminal. Gebruik de werkwijze uit project A, blok 02; dit is een herhaling van .venv, geen nieuwe introductie.
python -m venv .venv
Gebruik buiten een omgeving op macOS/Linux zo nodig python3. Activeer op Windows PowerShell:
.venv\Scripts\Activate.ps1
Op Windows cmd:
.venv\Scripts\activate.bat
Op macOS/Linux:
source .venv/bin/activate
Kies ook in VS Code de interpreter uit .venv. Alle volgende opdrachten voer je uit vanuit coins met die omgeving actief.
03 - Bekende packages installeren
Flask ken je uit M en Requests uit L. Beide moeten in deze nieuwe omgeving worden geïnstalleerd. requirements.txt en het schrijven met > zijn uitgelegd in M, blok 03.
python -m pip install flask requests
python -m pip freeze > requirements.txt
SQLite en de overige modules zijn onderdeel van Python. Daarvoor gebruik je pip niet.
04 - Bestanden scheiden voordat je code schrijft
Functies in eigen modules opslaan ken je uit hoofdstuk 8. We scheiden de database en de API van de Flask-routes, zodat je ze eerst apart kunt testen. Maak onderstaande bestanden; de database wordt door Python aangemaakt, dus die maak je niet zelf als leeg tekstbestand.
coins/
├── app.py
├── database.py
├── markt.py
├── controle_db.py
├── requirements.txt
├── templates/
│ └── index.html
└── static/
├── style.css
└── app.js
database.py regelt de opslag, markt.py haalt de prijs op en app.py verbindt deze functies met de browser. controle_db.py is een losse controle. De standaardmappen templates en static behouden hun taak uit M.
05 - Een tabel maken, schrijven en teruglezen
In M gebruikte je SELECT om bestaande gegevens te lezen. Voor een nieuwe database zijn deze SQL-onderdelen nodig vóór je ze gebruikt:
CREATE TABLE IF NOT EXISTSmaakt de tabel alleen als die ontbreekt. Een kolom heeft een naam en een datatype:INTEGERvoor gehele getallen,REALvoor een benaderend kommagetal enTEXTvoor tekst.PRIMARY KEY AUTOINCREMENTgeeft iedere meting een eigen volgnummer.NOT NULLweigert een ontbrekende waarde.INSERT INTO ... VALUES (?, ?, ?)voegt een rij toe. De vraagtekens zijn invulplaatsen; Python geeft de waarden apart mee in een tuple. Plak invoer niet rechtstreeks in SQL-tekst.commit()bevestigt een wijziging.finallysluit de verbinding ook bij fouten, zoals geoefend in M.ORDER BY id DESC LIMIT 1kiest de nieuwste meting.fetchone()geeft één rij ofNoneals de tabel leeg is.is Nonecontroleert die ontbrekende uitkomst.
Zet deze volledige code in database.py. Het maken van data gebeurt vóór het openen van de database:
from pathlib import Path
import sqlite3
DB_PATH = Path(__file__).resolve().parent / "data" / "coins.db"
def init_db():
DB_PATH.parent.mkdir(exist_ok=True)
connection = sqlite3.connect(DB_PATH)
try:
connection.execute("""
CREATE TABLE IF NOT EXISTS bitcoin_prices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
price_eur REAL NOT NULL,
source TEXT NOT NULL,
created_at TEXT NOT NULL
)
""")
connection.commit()
finally:
connection.close()
def save_bitcoin_price(price_data):
connection = sqlite3.connect(DB_PATH)
try:
connection.execute("""
INSERT INTO bitcoin_prices (price_eur, source, created_at)
VALUES (?, ?, ?)
""", (price_data["price_eur"], price_data["source"], price_data["created_at"]))
connection.commit()
finally:
connection.close()
def get_latest_price():
connection = sqlite3.connect(DB_PATH)
connection.row_factory = sqlite3.Row
try:
row = connection.execute("""
SELECT price_eur, source, created_at
FROM bitcoin_prices
ORDER BY id DESC
LIMIT 1
""").fetchone()
if row is None:
return None
return dict(row)
finally:
connection.close()
Zet in controle_db.py:
from database import init_db, get_latest_price
init_db()
print("Laatst opgeslagen meting:", get_latest_price())
python controle_db.py
Bij een nieuwe database is de uitkomst None; dat is correct. Het bestand data/coins.db bestaat nu. We slaan straks pas een echte meting op.
06 - De bekende API-aanpak op een prijs toepassen
Zoals bij L controleer je eerst de URL, HTTP-status en JSON-structuur. We gebruiken de publieke BTC-EUR-prijsroute van Bitvavo. Het verwachte JSON-object bevat market en een price als tekst. Dit is een structuurvoorbeeld, geen actuele koers:
{"market": "BTC-EUR", "price": "64250"}
float maakt van de prijs een getal voor de grafiek; dit is meetdata, geen factuurberekening. math.isfinite weigert oneindig en NaN. Met datetime.now(timezone.utc) bewaren we het ophaalmoment in UTC, een vaste referentietijd. isoformat(timespec="seconds") maakt tekst met datum, tijd en tijdzone tot op seconden.
Zet in markt.py:
from datetime import datetime, timezone
import math
import requests
BTC_API_URL = "https://api.bitvavo.com/v2/ticker/price?market=BTC-EUR"
def fetch_bitcoin_price():
response = requests.get(BTC_API_URL, timeout=10)
response.raise_for_status()
data = response.json()
if not isinstance(data, dict) or data.get("market") != "BTC-EUR":
raise ValueError("Verwacht marktgegevens voor BTC-EUR")
try:
price = float(data["price"])
except (KeyError, TypeError, ValueError) as fout:
raise ValueError("De API geeft geen bruikbare prijs") from fout
if not math.isfinite(price) or price <= 0:
raise ValueError("De API-prijs moet positief en eindig zijn")
return {
"price_eur": price,
"source": "bitvavo",
"created_at": datetime.now(timezone.utc).isoformat(timespec="seconds")
}
if __name__ == "__main__":
try:
print(fetch_bitcoin_price())
except (requests.RequestException, ValueError) as fout:
print(f"Ophalen mislukt: {fout}")
python markt.py
Bij succes zie je een dictionary met prijs, bron en ophaaltijd. Deze stap schrijft nog niets in de database. Bij een fout controleer je eerst de API, voordat je Flask toevoegt. De timeout werkt zoals uitgelegd in L: wachten op verbinding en ontvangst is begrensd, niet de totale scriptduur.
07 - De werkende onderdelen verbinden met Flask
Nu zijn alle functies die je nodig hebt bekend en apart getest. refresh_bitcoin_data() haalt één meting op en bewaart die. De startpagina leest de laatst opgeslagen meting; alleen bij een lege database probeert ze eerst een prijs op te halen.
Een tweede route /api/bitcoin laat de browser een nieuwe meting aanvragen. jsonify zet de Python-dictionary om naar een HTTP-response met JSON. De response (jsonify(...), 502) geeft bij een externe API-fout status 502 mee, zodat de browser een fout van een geslaagde aanvraag kan onderscheiden.
De routefouten vangen netwerk- en invoerproblemen op. Bij zo’n fout wordt geen nieuwe meting opgeslagen. Bestaande data blijft beschikbaar. Zet de volledige code in app.py:
import requests
from flask import Flask, jsonify, render_template
from database import init_db, save_bitcoin_price, get_latest_price
from markt import fetch_bitcoin_price
app = Flask(__name__)
def refresh_bitcoin_data():
price_data = fetch_bitcoin_price()
save_bitcoin_price(price_data)
return price_data
@app.route("/")
def index():
latest_price = get_latest_price()
error = ""
if latest_price is None:
try:
latest_price = refresh_bitcoin_data()
except (requests.RequestException, ValueError):
error = "Er is nog geen prijs beschikbaar. Probeer straks opnieuw."
return render_template("index.html", bitcoin=latest_price, error=error)
@app.route("/api/bitcoin")
def api_bitcoin():
try:
latest_price = refresh_bitcoin_data()
except (requests.RequestException, ValueError):
return jsonify({"error": "Nieuwe prijs ophalen mislukt."}), 502
return jsonify(latest_price)
if __name__ == "__main__":
init_db()
app.run(debug=True)
Start nog niet voordat je in blok 08 de template hebt ingevuld. De Python-functies zijn nu verbonden; de HTML die render_template nodig heeft komt als volgende stap.
08 - De webpagina met data en lege toestand
Je kent HTML, CSS en Jinja uit M. Nieuw is een voorwaarde in de template: {% if bitcoin %} toont de prijs als er een meting is; {% else %} toont tekst voor een lege database en {% endif %} sluit de voorwaarde. Jinja kan dictionarywaarden ook met een punt lezen: bitcoin.price_eur.
De id-attributen geven onderdelen een naam voor JavaScript. button maakt een knop. Het element met role="status" bevat voortgangs- en foutmeldingen. Zet in templates/index.html:
<!doctype html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bitcoin Dashboard</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<main>
<h1>Bitcoin Dashboard</h1>
<section class="card">
<h2>Laatst opgeslagen prijs</h2>
{% if bitcoin %}
<p id="bitcoin-price">EUR {{ "%.2f"|format(bitcoin.price_eur) }}</p>
<p id="bitcoin-time">Opgehaald (UTC): {{ bitcoin.created_at }}</p>
{% else %}
<p id="bitcoin-price">Nog geen prijs</p>
<p id="bitcoin-time">Nog geen meting</p>
{% endif %}
<p id="status" role="status">{{ error }}</p>
<button id="refresh-button">Ververs prijs</button>
</section>
</main>
<script src="{{ url_for('static', filename='app.js') }}"></script>
</body>
</html>
Zet in static/style.css:
body {
font-family: Arial, sans-serif;
background: #f4f7fb;
margin: 0;
padding: 32px;
}
main { max-width: 900px; margin: 0 auto; }
.card { background: white; border-radius: 12px; padding: 24px; margin-bottom: 24px; }
#bitcoin-price { font-size: 2rem; font-weight: bold; }
button { padding: 12px 18px; cursor: pointer; }
canvas { max-height: 400px; }
Het JavaScript-bestand mag nog leeg zijn. Start nu alvast:
python app.py
Open http://127.0.0.1:5000/. Bij een lege database en werkende API wordt de eerste prijs opgehaald en opgeslagen. Je ziet de opgeslagen tijd, niet automatisch de tijd van ieder paginabezoek. De knop werkt pas na het volgende blok.
09 - JavaScript voor de verversknop
JavaScript en getElementById ken je uit M. Voor een aanvraag tijdens een klik komen er enkele onderdelen bij:
async functionmaakt een functie die op een aanvraag kan wachten.await fetch(...)wacht op de response;await response.json()leest de JSON. Het padapi/bitcoinis relatief aan de startpagina, zodat een hostingpad behouden blijft.response.okis waar bij een geslaagde HTTP-status.!betekent niet.throw new Error(...)geeft bij een foutstatus een fout door aancatch.||kiest een vervangende tekst wanneer het eerste veld leeg is.textContentvervangt de tekst van een element.Number(...).toFixed(2)toont de prijs met twee decimalen.error.messagebevat de fouttekst.addEventListener("click", ...)verbindt de knop met de functie.disabledblokkeert de knop tijdens het wachten.finallymaakt hem daarna weer beschikbaar, ook bij een fout.
Zet in static/app.js:
const priceElement = document.getElementById("bitcoin-price");
const timeElement = document.getElementById("bitcoin-time");
const statusElement = document.getElementById("status");
const refreshButton = document.getElementById("refresh-button");
async function refreshBitcoinPrice() {
refreshButton.disabled = true;
statusElement.textContent = "Bezig met ophalen...";
try {
const response = await fetch("api/bitcoin");
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "Ophalen mislukt.");
}
priceElement.textContent = "EUR " + Number(data.price_eur).toFixed(2);
timeElement.textContent = "Opgehaald (UTC): " + data.created_at;
statusElement.textContent = "Nieuwe prijs opgeslagen.";
} catch (error) {
statusElement.textContent = error.message;
} finally {
refreshButton.disabled = false;
}
}
refreshButton.addEventListener("click", refreshBitcoinPrice);
Herlaad de pagina en klik één keer. Bij succes veranderen prijs en ophaaltijd en verschijnt een bevestiging. De pagina wordt niet volledig herladen.
10 - De hele keten controleren
- Klik één keer op verversen en voer daarna
python controle_db.pyuit in een tweede terminal met de omgeving actief. Prijs en ophaaltijd moeten overeenkomen met het dashboard. - Stop en herstart Flask: de laatst opgeslagen meting blijft zichtbaar. Alleen herladen van de pagina voegt geen meting toe zolang er al een is.
- Verander voor één proef in
markt.pyhet URL-pad naar/v2/bestaat-niet. Een klik meldt de fout en bewaart geen nieuwe meting. De oude prijs blijft zichtbaar. Herstel daarna de URL. - Een lege database en onbereikbare API geven de melding dat er nog geen prijs is. De template toont dan geen verzonnen nulprijs.
- Opnieuw
init_db()uitvoeren verwijdert geen metingen:IF NOT EXISTSlaat de bestaande tabel staan.
Automatisch verversen is optioneel. setInterval(functie, 60000) vraagt de browser een functie iedere 60.000 milliseconden te starten. Voeg dit pas onderaan app.js toe nadat de handmatige knop werkt:
setInterval(refreshBitcoinPrice, 60000);
Voor de oefening is één browservenster voldoende. Iedere klik of intervalaanroep vraagt één prijs op en schrijft één meting.
11 - Optioneel: de bekende grafiek met opgeslagen metingen
Gebruik de Chart.js-aanpak uit M, blok 09. Je hebt daarvoor een lijst met eerdere metingen nodig. De query gebruikt de bekende SELECT, ORDER BY en LIMIT. reversed draait de nieuwste-eerst lijst om, zodat de oudste meting links staat.
Voeg onderaan database.py deze functie toe:
def get_recent_prices():
connection = sqlite3.connect(DB_PATH)
connection.row_factory = sqlite3.Row
try:
rows = connection.execute("""
SELECT price_eur, created_at
FROM bitcoin_prices
ORDER BY id DESC
LIMIT 20
""").fetchall()
return [dict(row) for row in reversed(rows)]
finally:
connection.close()
Voeg get_recent_prices toe aan de bestaande importregel uit database in app.py. Vervang daarna de volledige bestaande route index(), inclusief de decorator, door:
@app.route("/")
def index():
latest_price = get_latest_price()
error = ""
if latest_price is None:
try:
latest_price = refresh_bitcoin_data()
except (requests.RequestException, ValueError):
error = "Er is nog geen prijs beschikbaar. Probeer straks opnieuw."
metingen = get_recent_prices()
labels = [meting["created_at"] for meting in metingen]
totals = [meting["price_eur"] for meting in metingen]
return render_template("index.html", bitcoin=latest_price, error=error,
labels=labels, totals=totals)
Voeg onder de prijskaart, binnen main, het volgende toe aan templates/index.html. line kiest nu een lijngrafiek; de rest van de grafiekopbouw en tojson ken je uit M:
<section class="card">
<h2>Laatste twintig metingen</h2>
<canvas id="pricesChart"></canvas>
</section>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
<script>
new Chart(document.getElementById("pricesChart"), {
type: "line",
data: {
labels: {{ labels|tojson }},
datasets: [{label: "BTC-EUR", data: {{ totals|tojson }}}]
},
options: {responsive: true}
});
</script>
Verzamel eerst enkele metingen met de knop en herlaad dan de pagina. De grafiek toont de laatste maximaal twintig opgeslagen metingen; met één meting zie je alleen een punt. De knop werkt de prijskaart bij. De grafiek vernieuw je in deze versie door de hele pagina te herladen.
12 - Afronden en voorbereiden op hosting
De basis is afgerond als ophalen, opslaan, teruglezen en verversen werken en je ook een API-fout kunt uitleggen. Bewaar je werkende code en requirements.txt. De grafiek is een uitbreiding en is niet nodig voor project O.
Voor hosting moet je begrijpen hoe het startblok werkt: python app.py voert init_db() uit. Een serverprogramma dat app importeert voert dat startblok niet uit. In O initialiseer je daarom de database expliciet vóór de eerste serverstart.
Meer markten, goud of zilver zijn losse vervolgopdrachten. Controleer daarvoor eerst de API-documentatie, valuta, meeteenheid en JSON-velden; gebruik de naam price_eur alleen voor werkelijk naar euro omgerekende bedragen. De volledige basiscode staat per bestand in blokken 05 t/m 09; er is geen afwijkende tweede eindversie.
Bekijk een uitgebreider eindproduct als inspiratie. Naslag: Bitvavo API-documentatie.