Refactored file structure and added admin dashboard

This commit is contained in:
Milan Martin Jäckel 2026-06-13 18:32:13 +02:00
parent 54ffb721dd
commit f823028d17
22 changed files with 1997 additions and 250 deletions

1
.gitignore vendored
View File

@ -2,6 +2,7 @@
__pycache__/ __pycache__/
*.pyc *.pyc
questions.db questions.db
instance/
.env .env
.vscode/ .vscode/
.idea/ .idea/

131
app.py
View File

@ -1,134 +1,7 @@
from flask import Flask, render_template, request, redirect from app import create_app
from datetime import date
import sqlite3
import hashlib
app = Flask(__name__)
DB = "questions.db"
def get_db(): app = create_app()
return sqlite3.connect(DB)
@app.route("/")
def home():
conn = get_db()
cur = conn.cursor()
cur.execute("""
SELECT *
FROM questions
ORDER BY id DESC
LIMIT 1
""")
question = cur.fetchone()
conn.close()
return render_template(
"index.html",
question=question
)
@app.route("/vote", methods=["POST"])
def vote():
qid = request.form["qid"]
vote = request.form["vote"]
ip = request.remote_addr
voter_hash = hashlib.sha256(
ip.encode()
).hexdigest()
conn = get_db()
cur = conn.cursor()
cur.execute("""
SELECT *
FROM votes
WHERE voter_hash = ?
AND question_id = ?
""", (voter_hash, qid))
existing = cur.fetchone()
if not existing:
cur.execute("""
INSERT INTO votes
(question_id, vote, voter_hash)
VALUES (?, ?, ?)
""", (qid, vote, voter_hash))
conn.commit()
conn.close()
return redirect("/results")
@app.route("/results")
def results():
conn = get_db()
cur = conn.cursor()
cur.execute("""
SELECT *
FROM questions
ORDER BY id DESC
LIMIT 1
""")
question = cur.fetchone()
qid = question[0]
cur.execute("""
SELECT COUNT(*)
FROM votes
WHERE question_id = ?
AND vote = 'A'
""", (qid,))
votes_a = cur.fetchone()[0]
cur.execute("""
SELECT COUNT(*)
FROM votes
WHERE question_id = ?
AND vote = 'B'
""", (qid,))
votes_b = cur.fetchone()[0]
conn.close()
total = votes_a + votes_b
if total == 0:
percent_a = 0
percent_b = 0
else:
percent_a = round(votes_a / total * 100)
percent_b = round(votes_b / total * 100)
return render_template(
"results.html",
question=question,
votes_a=votes_a,
votes_b=votes_b,
percent_a=percent_a,
percent_b=percent_b,
total=total
)
if __name__ == "__main__": if __name__ == "__main__":

29
app/__init__.py Normal file
View File

@ -0,0 +1,29 @@
import os
from flask import Flask
def create_app(test_config=None):
app = Flask(__name__, instance_relative_config=True)
app.config.from_mapping(
SECRET_KEY=os.environ.get("WYR_SECRET_KEY", "dev-only-change-me"),
DATABASE=os.path.join(app.instance_path, "questions.db"),
)
if test_config is not None:
app.config.update(test_config)
os.makedirs(app.instance_path, exist_ok=True)
from . import database
app.teardown_appcontext(database.close_db)
from .admin import bp as admin_bp
from .routes import bp as public_bp
app.register_blueprint(public_bp)
app.register_blueprint(admin_bp)
return app

301
app/admin.py Normal file
View File

@ -0,0 +1,301 @@
from functools import wraps
import sqlite3
from flask import (
Blueprint,
flash,
redirect,
render_template,
request,
session,
url_for,
)
from werkzeug.security import check_password_hash, generate_password_hash
from .database import get_db
bp = Blueprint("admin", __name__, url_prefix="/admin")
def admin_required(view):
@wraps(view)
def wrapped_view(**kwargs):
if not session.get("admin"):
return redirect(url_for("admin.login"))
return view(**kwargs)
return wrapped_view
def validate_question_form(form, question_id=None):
question = form.get("question", "").strip()
option_a = form.get("option_a", "").strip()
option_b = form.get("option_b", "").strip()
question_date = form.get("date", "").strip()
data = {
"question": question,
"option_a": option_a,
"option_b": option_b,
"date": question_date,
}
if not all(data.values()):
return data, "All fields are required."
db = get_db()
params = [question_date]
duplicate_sql = "SELECT id FROM questions WHERE date = ?"
if question_id is not None:
duplicate_sql += " AND id != ?"
params.append(question_id)
duplicate = db.execute(duplicate_sql, params).fetchone()
if duplicate is not None:
return data, "Date already has a question."
return data, None
def validate_account_form(form, admin):
username = form.get("username", "").strip()
current_password = form.get("current_password", "")
new_password = form.get("new_password", "")
confirm_password = form.get("confirm_password", "")
data = {"username": username}
if not username:
return data, "Username is required."
if not check_password_hash(admin["password_hash"], current_password):
return data, "Current password is incorrect."
if new_password or confirm_password:
if len(new_password) < 8:
return data, "New password must be at least 8 characters."
if new_password != confirm_password:
return data, "New passwords do not match."
return data, None
@bp.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
db = get_db()
admin = db.execute(
"SELECT id, username, password_hash FROM admins WHERE username = ?",
(username,),
).fetchone()
if admin is not None and check_password_hash(admin["password_hash"], password):
session.clear()
session["admin"] = True
session["admin_username"] = admin["username"]
return redirect(url_for("admin.dashboard"))
flash("Invalid username or password.")
return render_template("admin/login.html")
@bp.route("/logout")
def logout():
session.clear()
return redirect(url_for("admin.login"))
@bp.route("/dashboard")
@admin_required
def dashboard():
db = get_db()
questions = db.execute(
"""
SELECT id, question, option_a, option_b, date
FROM questions
ORDER BY date DESC, id DESC
"""
).fetchall()
return render_template("admin/dashboard.html", questions=questions)
@bp.route("/account", methods=["GET", "POST"])
@admin_required
def account():
db = get_db()
admin = db.execute(
"""
SELECT id, username, password_hash
FROM admins
WHERE username = ?
""",
(session.get("admin_username"),),
).fetchone()
if admin is None:
session.clear()
return redirect(url_for("admin.login"))
error = None
form_data = {"username": admin["username"]}
if request.method == "POST":
form_data, error = validate_account_form(request.form, admin)
if error is None:
password_hash = admin["password_hash"]
new_password = request.form.get("new_password", "")
if new_password:
password_hash = generate_password_hash(new_password)
try:
db.execute(
"""
UPDATE admins
SET username = ?, password_hash = ?
WHERE id = ?
""",
(form_data["username"], password_hash, admin["id"]),
)
db.commit()
except sqlite3.IntegrityError:
error = "Username is already taken."
else:
session["admin_username"] = form_data["username"]
flash("Admin credentials updated.")
return redirect(url_for("admin.dashboard"))
return render_template(
"admin/account.html",
admin=form_data,
error=error,
)
@bp.route("/question/new", methods=["GET", "POST"])
@admin_required
def new_question():
question = None
error = None
if request.method == "POST":
data, error = validate_question_form(request.form)
if error is None:
db = get_db()
try:
db.execute(
"""
INSERT INTO questions (question, option_a, option_b, date)
VALUES (?, ?, ?, ?)
""",
(
data["question"],
data["option_a"],
data["option_b"],
data["date"],
),
)
db.commit()
return redirect(url_for("admin.dashboard"))
except sqlite3.IntegrityError:
error = "Date already has a question."
question = data
return render_template(
"admin/question_form.html",
question=question,
error=error,
title="Create Question",
)
@bp.route("/question/edit/<int:question_id>", methods=["GET", "POST"])
@admin_required
def edit_question(question_id):
db = get_db()
question = db.execute(
"""
SELECT id, question, option_a, option_b, date
FROM questions
WHERE id = ?
""",
(question_id,),
).fetchone()
if question is None:
flash("Question not found.")
return redirect(url_for("admin.dashboard"))
error = None
if request.method == "POST":
data, error = validate_question_form(request.form, question_id=question_id)
if error is None:
try:
db.execute(
"""
UPDATE questions
SET question = ?, option_a = ?, option_b = ?, date = ?
WHERE id = ?
""",
(
data["question"],
data["option_a"],
data["option_b"],
data["date"],
question_id,
),
)
db.commit()
return redirect(url_for("admin.dashboard"))
except sqlite3.IntegrityError:
error = "Date already has a question."
question = data
return render_template(
"admin/question_form.html",
question=question,
error=error,
title="Edit Question",
)
@bp.route("/question/delete/<int:question_id>", methods=["GET", "POST"])
@admin_required
def delete_question(question_id):
db = get_db()
question = db.execute(
"""
SELECT id, question, option_a, option_b, date
FROM questions
WHERE id = ?
""",
(question_id,),
).fetchone()
if question is None:
flash("Question not found.")
return redirect(url_for("admin.dashboard"))
if request.method == "POST":
db.execute("DELETE FROM votes WHERE question_id = ?", (question_id,))
db.execute("DELETE FROM questions WHERE id = ?", (question_id,))
db.commit()
return redirect(url_for("admin.dashboard"))
return render_template("admin/delete_confirm.html", question=question)

48
app/database.py Normal file
View File

@ -0,0 +1,48 @@
import sqlite3
from flask import current_app, g
def get_db():
if "db" not in g:
g.db = sqlite3.connect(current_app.config["DATABASE"])
g.db.row_factory = sqlite3.Row
return g.db
def close_db(error=None):
db = g.pop("db", None)
if db is not None:
db.close()
def init_db():
db = get_db()
db.executescript(
"""
CREATE TABLE IF NOT EXISTS questions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
question TEXT NOT NULL,
option_a TEXT NOT NULL,
option_b TEXT NOT NULL,
date TEXT NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS votes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
question_id INTEGER,
vote TEXT NOT NULL,
voter_hash TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS admins (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL
);
"""
)
db.commit()

130
app/routes.py Normal file
View File

@ -0,0 +1,130 @@
import hashlib
from datetime import date
from flask import Blueprint, redirect, render_template, request, url_for
from .database import get_db
bp = Blueprint("public", __name__)
def get_todays_question():
db = get_db()
today = date.today().isoformat()
return db.execute(
"""
SELECT id, question, option_a, option_b, date
FROM questions
WHERE date = ?
LIMIT 1
""",
(today,),
).fetchone()
@bp.route("/")
def home():
question = get_todays_question()
return render_template(
"public/index.html",
question=question,
)
@bp.route("/vote", methods=["POST"])
def vote():
qid = request.form.get("qid")
selected_vote = request.form.get("vote")
if not qid or selected_vote not in {"A", "B"}:
return redirect(url_for("public.home"))
ip = request.remote_addr or ""
voter_hash = hashlib.sha256(ip.encode()).hexdigest()
db = get_db()
question = get_todays_question()
if question is None or str(question["id"]) != str(qid):
return redirect(url_for("public.home"))
existing = db.execute(
"""
SELECT id
FROM votes
WHERE voter_hash = ?
AND question_id = ?
""",
(voter_hash, qid),
).fetchone()
if existing is None:
db.execute(
"""
INSERT INTO votes (question_id, vote, voter_hash)
VALUES (?, ?, ?)
""",
(qid, selected_vote, voter_hash),
)
db.commit()
return redirect(url_for("public.results"))
@bp.route("/results")
def results():
question = get_todays_question()
if question is None:
return render_template(
"public/results.html",
question=None,
votes_a=0,
votes_b=0,
percent_a=0,
percent_b=0,
total=0,
)
db = get_db()
votes_a = db.execute(
"""
SELECT COUNT(*)
FROM votes
WHERE question_id = ?
AND vote = 'A'
""",
(question["id"],),
).fetchone()[0]
votes_b = db.execute(
"""
SELECT COUNT(*)
FROM votes
WHERE question_id = ?
AND vote = 'B'
""",
(question["id"],),
).fetchone()[0]
total = votes_a + votes_b
if total == 0:
percent_a = 0
percent_b = 0
else:
percent_a = round(votes_a / total * 100)
percent_b = round(votes_b / total * 100)
return render_template(
"public/results.html",
question=question,
votes_a=votes_a,
votes_b=votes_b,
percent_a=percent_a,
percent_b=percent_b,
total=total,
)

127
app/static/style.css Normal file
View File

@ -0,0 +1,127 @@
body {
max-width: 800px;
margin: auto;
padding-top: 60px;
text-align: center;
font-family: Arial, sans-serif;
}
button {
width: 250px;
height: 100px;
margin: 10px;
font-size: 22px;
cursor: pointer;
}
a {
font-size: 20px;
}
.site-nav {
display: flex;
justify-content: center;
gap: 16px;
margin-bottom: 32px;
}
.admin-page .site-nav {
justify-content: flex-start;
}
.page {
padding: 0 20px 60px;
}
.admin-page {
text-align: left;
}
.admin-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
}
.admin-header nav,
.row-actions,
.form-actions {
display: flex;
align-items: center;
gap: 16px;
}
.admin-form {
display: grid;
gap: 10px;
max-width: 520px;
}
.admin-form input,
.admin-form textarea {
box-sizing: border-box;
width: 100%;
padding: 10px;
font: inherit;
}
.button-compact {
width: auto;
height: auto;
margin: 10px 0;
padding: 12px 18px;
font-size: 18px;
}
.danger {
background: #b00020;
color: white;
}
.error {
color: #b00020;
font-weight: bold;
}
.notice {
color: #245900;
font-weight: bold;
}
.question-list {
display: grid;
gap: 14px;
}
.question-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
border: 1px solid #ddd;
border-radius: 6px;
padding: 16px;
}
.question-row h2,
.question-row p {
margin: 0 0 8px;
}
.question-date {
color: #666;
font-weight: bold;
}
@media (max-width: 640px) {
.admin-header,
.question-row {
display: block;
}
.admin-header nav,
.row-actions {
margin-top: 12px;
}
}

View File

@ -0,0 +1,63 @@
<!DOCTYPE html>
<html>
<head>
<title>Change Admin Credentials</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<main class="page admin-page">
<nav class="site-nav">
<a href="{{ url_for('admin.dashboard') }}">Dashboard</a>
<a href="{{ url_for('public.home') }}">Public Site</a>
</nav>
<h1>Change Admin Credentials</h1>
{% if error %}
<p class="error">{{ error }}</p>
{% endif %}
<form class="admin-form" method="POST">
<label for="username">Username</label>
<input
id="username"
name="username"
type="text"
value="{{ admin.username }}"
required
>
<label for="current_password">Current Password</label>
<input
id="current_password"
name="current_password"
type="password"
required
>
<label for="new_password">New Password</label>
<input
id="new_password"
name="new_password"
type="password"
minlength="8"
>
<label for="confirm_password">Confirm New Password</label>
<input
id="confirm_password"
name="confirm_password"
type="password"
minlength="8"
>
<div class="form-actions">
<button class="button-compact" type="submit">Save Credentials</button>
<a href="{{ url_for('admin.dashboard') }}">Cancel</a>
</div>
</form>
</main>
</body>
</html>

View File

@ -0,0 +1,46 @@
<!DOCTYPE html>
<html>
<head>
<title>Admin Dashboard</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<main class="page admin-page">
<div class="admin-header">
<h1>Questions</h1>
<nav>
<a href="{{ url_for('public.home') }}">Public Site</a>
<a href="{{ url_for('admin.new_question') }}">Create Question</a>
<a href="{{ url_for('admin.account') }}">Change Credentials</a>
<a href="{{ url_for('admin.logout') }}">Log Out</a>
</nav>
</div>
{% for message in get_flashed_messages() %}
<p class="notice">{{ message }}</p>
{% endfor %}
{% if questions %}
<div class="question-list">
{% for question in questions %}
<article class="question-row">
<div>
<p class="question-date">{{ question.date }}</p>
<h2>{{ question.question }}</h2>
<p>{{ question.option_a }} or {{ question.option_b }}</p>
</div>
<div class="row-actions">
<a href="{{ url_for('admin.edit_question', question_id=question.id) }}">Edit</a>
<a href="{{ url_for('admin.delete_question', question_id=question.id) }}">Delete</a>
</div>
</article>
{% endfor %}
</div>
{% else %}
<p>No questions yet.</p>
{% endif %}
</main>
</body>
</html>

View File

@ -0,0 +1,24 @@
<!DOCTYPE html>
<html>
<head>
<title>Delete Question</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<main class="page admin-page">
<h1>Delete Question</h1>
<p>Delete the question scheduled for {{ question.date }}?</p>
<h2>{{ question.question }}</h2>
<form class="admin-form" method="POST">
<div class="form-actions">
<button class="button-compact danger" type="submit">Delete</button>
<a href="{{ url_for('admin.dashboard') }}">Cancel</a>
</div>
</form>
</main>
</body>
</html>

View File

@ -0,0 +1,32 @@
<!DOCTYPE html>
<html>
<head>
<title>Admin Login</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<main class="page admin-page">
<nav class="site-nav">
<a href="{{ url_for('public.home') }}">Public Site</a>
</nav>
<h1>Admin Login</h1>
{% for message in get_flashed_messages() %}
<p class="error">{{ message }}</p>
{% endfor %}
<form class="admin-form" action="{{ url_for('admin.login') }}" method="POST">
<label for="username">Username</label>
<input id="username" name="username" type="text" required>
<label for="password">Password</label>
<input id="password" name="password" type="password" required>
<button class="button-compact" type="submit">Log In</button>
</form>
</main>
</body>
</html>

View File

@ -0,0 +1,55 @@
<!DOCTYPE html>
<html>
<head>
<title>{{ title }}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<main class="page admin-page">
<h1>{{ title }}</h1>
{% if error %}
<p class="error">{{ error }}</p>
{% endif %}
<form class="admin-form" method="POST">
<label for="date">Date</label>
<input
id="date"
name="date"
type="date"
value="{{ question.date if question else '' }}"
required
>
<label for="question">Question</label>
<textarea id="question" name="question" rows="4" required>{{ question.question if question else '' }}</textarea>
<label for="option_a">Option A</label>
<input
id="option_a"
name="option_a"
type="text"
value="{{ question.option_a if question else '' }}"
required
>
<label for="option_b">Option B</label>
<input
id="option_b"
name="option_b"
type="text"
value="{{ question.option_b if question else '' }}"
required
>
<div class="form-actions">
<button class="button-compact" type="submit">Save</button>
<a href="{{ url_for('admin.dashboard') }}">Cancel</a>
</div>
</form>
</main>
</body>
</html>

View File

@ -0,0 +1,49 @@
<!DOCTYPE html>
<html>
<head>
<title>Would You Rather</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<main class="page">
<nav class="site-nav">
<a href="{{ url_for('admin.dashboard') }}">Admin</a>
</nav>
{% if question %}
<h1>{{ question.question }}</h1>
<form action="{{ url_for('public.vote') }}" method="POST">
<input
type="hidden"
name="qid"
value="{{ question.id }}"
>
<button
type="submit"
name="vote"
value="A"
>
{{ question.option_a }}
</button>
<button
type="submit"
name="vote"
value="B"
>
{{ question.option_b }}
</button>
</form>
{% else %}
<h1>No question is scheduled yet.</h1>
<p>Check back soon.</p>
{% endif %}
</main>
</body>
</html>

View File

@ -0,0 +1,37 @@
<!DOCTYPE html>
<html>
<head>
<title>Results</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<main class="page">
<nav class="site-nav">
<a href="{{ url_for('public.home') }}">Vote</a>
<a href="{{ url_for('admin.dashboard') }}">Admin</a>
</nav>
{% if question %}
<h1>{{ question.question }}</h1>
<h2>{{ question.option_a }}</h2>
<p>{{ percent_a }}% ({{ votes_a }} votes)</p>
<h2>{{ question.option_b }}</h2>
<p>{{ percent_b }}% ({{ votes_b }} votes)</p>
<hr>
<p>Total votes: {{ total }}</p>
<a href="{{ url_for('public.home') }}">Back</a>
{% else %}
<h1>No results yet.</h1>
<p>No question is scheduled yet.</p>
<a href="{{ url_for('public.home') }}">Back</a>
{% endif %}
</main>
</body>
</html>

View File

@ -1,39 +0,0 @@
import sqlite3
conn = sqlite3.connect("questions.db")
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS questions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
question TEXT NOT NULL,
option_a TEXT NOT NULL,
option_b TEXT NOT NULL,
date TEXT NOT NULL UNIQUE
)
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS votes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
question_id INTEGER,
vote TEXT NOT NULL,
voter_hash TEXT NOT NULL
)
""")
cursor.execute("""
INSERT OR IGNORE INTO questions
(question, option_a, option_b, date)
VALUES (
'Would you rather be blind or deaf?',
'Blind',
'Deaf',
'2030-01-01'
)
""")
conn.commit()
conn.close()
print("Database created.")

795
project-context.md Normal file
View File

@ -0,0 +1,795 @@
# Would You Rather Website - Project Context
## Project Goal
Build a lightweight "Would You Rather" website hosted on a Raspberry Pi 5.
Users visit the website, answer a daily question, and immediately see aggregated results after voting.
Example:
> Would you rather be blind or deaf?
* Blind
* Deaf
After voting:
* Blind: 62%
* Deaf: 38%
Total votes: 523
---
# Current Development Strategy
Development is being done on a laptop first.
Reasons:
* Easier coding workflow
* Faster testing
* Git integration from day one
* Easier version control
* Safer than developing directly on the Raspberry Pi
Deployment strategy:
```text
Laptop
Git
GitHub
Raspberry Pi
git pull
Production
```
---
# Technology Stack
## Backend
* Python
* Flask
* Flask application factory pattern
* Flask blueprints for public and admin routes
## Database
* SQLite
* Local development database path: `instance/questions.db`
## Frontend
* HTML
* CSS
* Jinja2 templates
## Authentication
* Flask sessions
* Werkzeug password hashing for admin passwords
## Version Control
* Git
* GitHub
## Future Production
* Gunicorn
* Systemd
* Nginx
* HTTPS with Let's Encrypt
---
# Current Local Project Structure
The project has been refactored out of a single-file Flask app.
Current structure:
```text
would-you-rather/
├── .venv/
├── .gitignore
├── app.py
├── run.py
├── requirements.txt
├── project-context.md
├── app/
│ ├── __init__.py
│ ├── admin.py
│ ├── database.py
│ ├── routes.py
│ │
│ ├── templates/
│ │ ├── public/
│ │ │ ├── index.html
│ │ │ └── results.html
│ │ │
│ │ └── admin/
│ │ ├── dashboard.html
│ │ ├── delete_confirm.html
│ │ ├── login.html
│ │ └── question_form.html
│ │
│ └── static/
│ └── style.css
├── scripts/
│ └── init_db.py
├── tests/
│ └── test_app.py
└── instance/
└── questions.db
```
Notes:
* `app.py` remains as a compatibility entrypoint.
* `run.py` is the preferred local development entrypoint.
* `instance/` is ignored by Git.
* Root-level `questions.db` is ignored and is no longer the active app database.
---
# Current Application Architecture
## App Factory
File:
```text
app/__init__.py
```
Purpose:
* Creates the Flask app with `create_app()`.
* Configures `SECRET_KEY`.
* Configures SQLite database path.
* Ensures the `instance/` directory exists.
* Registers public and admin blueprints.
* Registers database teardown behavior.
Important config:
```python
SECRET_KEY = os.environ.get("WYR_SECRET_KEY", "dev-only-change-me")
DATABASE = instance/questions.db
```
Production must set a real `WYR_SECRET_KEY`.
---
## Database Helpers
File:
```text
app/database.py
```
Purpose:
* Opens SQLite connections with `get_db()`.
* Uses `sqlite3.Row` so routes/templates can access fields by name.
* Closes request-scoped connections with `close_db()`.
* Creates all tables with `init_db()`.
---
## Public Routes
File:
```text
app/routes.py
```
Routes:
```text
/
/vote
/results
```
Behavior:
* `/` displays the question scheduled for today's date.
* `/vote` accepts POSTed `qid` and `vote`.
* `/vote` only accepts votes for today's scheduled question.
* `/results` displays today's vote counts, percentages, and total votes.
* Empty states are shown if no question exists for today.
* Duplicate voting is still blocked by hashing the request IP address.
Current duplicate prevention is temporary.
Future plan:
* Use cookies or session identifiers instead of IP hashing.
---
# Current Database Design
## Questions Table
```sql
CREATE TABLE questions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
question TEXT NOT NULL,
option_a TEXT NOT NULL,
option_b TEXT NOT NULL,
date TEXT NOT NULL UNIQUE
);
```
Purpose:
Stores scheduled daily questions.
---
## Votes Table
```sql
CREATE TABLE votes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
question_id INTEGER,
vote TEXT NOT NULL,
voter_hash TEXT NOT NULL
);
```
Purpose:
Stores user votes.
Stored values:
* `question_id`
* selected vote, currently `A` or `B`
* `voter_hash`
---
## Admins Table
```sql
CREATE TABLE admins (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL
);
```
Purpose:
Stores admin login accounts.
Passwords must never be stored in plain text.
Current implementation uses:
```python
from werkzeug.security import generate_password_hash, check_password_hash
```
---
# Admin Dashboard V1
Admin functionality has been implemented.
File:
```text
app/admin.py
```
Routes:
```text
/admin/login
/admin/logout
/admin/account
/admin/dashboard
/admin/question/new
/admin/question/edit/<id>
/admin/question/delete/<id>
```
Implemented behavior:
* Admin login with username/password.
* Password verification with Werkzeug password hashing.
* Successful login stores `session["admin"] = True`.
* Protected admin pages use `@admin_required`.
* Unauthenticated users are redirected to `/admin/login`.
* Admins can change their username and password from `/admin/account`.
* Changing credentials requires the current password.
* Dashboard lists all questions ordered by date descending.
* Admins can create questions.
* Admins can edit questions.
* Admins can delete questions.
* Delete also removes votes for that question.
* Duplicate question dates are blocked.
* Required fields are validated.
Dashboard V1 is intentionally simple.
No JavaScript calendar is implemented yet.
---
# Local Development Setup
## Install Dependencies
Use the virtual environment:
```bash
source .venv/bin/activate
pip install -r requirements.txt
```
Or run commands directly through `.venv/bin/python`.
---
## Initialize the Database
Run:
```bash
.venv/bin/python scripts/init_db.py
```
This creates:
```text
instance/questions.db
```
It also seeds:
* one sample question
* one local admin account
Default local admin login:
```text
username: admin
password: admin
```
To override the seeded admin account:
```bash
WYR_ADMIN_USERNAME=myname WYR_ADMIN_PASSWORD=mypassword .venv/bin/python scripts/init_db.py
```
If the admin user already exists, the script will not overwrite that password.
---
## Run the Site Locally
Preferred command:
```bash
.venv/bin/python run.py
```
Then open:
```text
http://127.0.0.1:5000
```
Admin login:
```text
http://127.0.0.1:5000/admin/login
```
Default local login:
```text
admin / admin
```
---
## Run Tests
Run:
```bash
.venv/bin/python -m unittest discover -s tests
```
Current automated tests cover:
* public empty state
* voting flow
* duplicate vote prevention
* admin login success
* admin login failure
* admin credential changes
* protected dashboard redirect
* duplicate date validation when creating questions
---
# Manual Testing Checklist
## Public Site
1. Start the app:
```bash
.venv/bin/python run.py
```
2. Open:
```text
http://127.0.0.1:5000
```
3. Confirm the sample question appears if it is scheduled for today's date.
4. Click one answer.
5. Confirm the site redirects to `/results`.
6. Confirm results show percentages and total votes.
7. Vote again from the same browser/IP.
8. Confirm the duplicate vote does not increase the vote count.
---
## Admin Site
1. Open:
```text
http://127.0.0.1:5000/admin/login
```
2. Log in with:
```text
admin / admin
```
3. Confirm the dashboard appears.
4. Click `Change Credentials`.
5. Confirm changing credentials requires the current password.
6. Save new credentials, log out, and confirm the new login works.
7. Click `Create Question`.
8. Add today's date or a future date, question, option A, and option B.
9. Save and confirm the new question appears in the dashboard.
10. Edit the question and confirm changes persist.
11. Try creating another question with the same date.
12. Confirm this error appears:
```text
Date already has a question.
```
13. Delete a question and confirm it disappears from the dashboard.
14. Log out.
15. Visit `/admin/dashboard` while logged out and confirm it redirects to `/admin/login`.
---
# Development Notes
## Current Compatibility Entrypoints
Preferred local command:
```bash
.venv/bin/python run.py
```
Compatibility command:
```bash
.venv/bin/python app.py
```
Both create the app through:
```python
from app import create_app
```
---
## Git Ignore Rules
The following local-only files/directories are ignored:
```text
.venv/
__pycache__/
*.pyc
questions.db
instance/
.env
.vscode/
.idea/
```
The active SQLite database is intentionally not committed.
---
# Future Admin Features
## Dashboard Version 2
Use a proper calendar interface.
Possible library:
* FullCalendar
Desired behavior:
```text
June 2026
13 Question
14 Question
15 Empty
16 Question
```
Clicking a date:
* opens edit view if the date has a question
* opens create view if the date is empty
---
## Search
Search questions by:
* date
* question text
---
## Statistics View
Admin dashboard should eventually show:
```text
Question
Blind: 142
Deaf: 98
Total: 240
```
Useful for engagement analytics.
---
# Future Public Features
## Better Duplicate Vote Prevention
Replace IP hashing with:
* cookies
* sessions
* or lightweight anonymous visitor IDs
---
## Archive
Browse historical questions.
---
## Comments
Discussion under questions.
---
## Country Statistics
Example:
```text
Denmark
Blind: 65%
Deaf: 35%
United States
Blind: 52%
Deaf: 48%
```
---
## AI Generated Images
One image per question.
Example:
```text
Would you rather live underwater or in space?
```
with generated artwork.
---
## User Accounts
Optional.
Users can:
* save history
* track participation
---
# Future AI Features
Not a priority yet.
Planned later.
Possible route:
```text
Generate next 30 questions
```
Workflow:
1. AI generates questions.
2. Questions fill empty dates.
3. Admin reviews.
4. Admin approves.
Potential providers:
* OpenAI
* OpenRouter
* Mistral
---
# Future Raspberry Pi Deployment
Not currently being worked on.
Planned deployment stack:
```text
Internet
Cloudflare
Nginx
Gunicorn
Flask
SQLite
```
---
# Deployment Workflow
On laptop:
```bash
git add .
git commit -m "feature"
git push
```
On Pi:
```bash
git pull
sudo systemctl restart wyr
```
---
# Immediate Roadmap
## Current Status
Local refactor and Admin Dashboard V1 are implemented.
---
## Next Step
Polish the admin flow and public UI:
* improve layout
* add clearer success messages
* add better vote duplicate handling
* improve the empty state when no question is scheduled for today
---
## After Admin Polish
Implement:
* calendar view
* question scheduling by date
* admin statistics
---
## After Calendar
Implement:
* AI-assisted question generation
---
# Long-Term Goal
A polished daily "Would You Rather" website that:
* runs on a Raspberry Pi 5
* uses a custom domain
* supports HTTPS
* allows browser-based question management
* supports AI-assisted content generation
* scales beyond the initial MVP

8
run.py Normal file
View File

@ -0,0 +1,8 @@
from app import create_app
app = create_app()
if __name__ == "__main__":
app.run(debug=True)

63
scripts/init_db.py Normal file
View File

@ -0,0 +1,63 @@
import os
import sys
from datetime import date
from pathlib import Path
from werkzeug.security import generate_password_hash
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from app import create_app
from app.database import get_db, init_db
def seed_question(db):
db.execute(
"""
INSERT OR IGNORE INTO questions (question, option_a, option_b, date)
VALUES (?, ?, ?, ?)
""",
(
"Would you rather be blind or deaf?",
"Blind",
"Deaf",
date.today().isoformat(),
),
)
def seed_admin(db):
username = os.environ.get("WYR_ADMIN_USERNAME", "admin")
password = os.environ.get("WYR_ADMIN_PASSWORD", "admin")
existing = db.execute(
"SELECT id FROM admins WHERE username = ?",
(username,),
).fetchone()
if existing is None:
db.execute(
"""
INSERT INTO admins (username, password_hash)
VALUES (?, ?)
""",
(username, generate_password_hash(password)),
)
return username, password
app = create_app()
with app.app_context():
init_db()
db = get_db()
seed_question(db)
username, password = seed_admin(db)
db.commit()
print("Database created.")
if username == "admin" and password == "admin":
print("Default admin login: admin / admin")

View File

@ -1,19 +0,0 @@
body {
max-width: 800px;
margin: auto;
padding-top: 60px;
text-align: center;
font-family: Arial, sans-serif;
}
button {
width: 250px;
height: 100px;
margin: 10px;
font-size: 22px;
cursor: pointer;
}
a {
font-size: 20px;
}

View File

@ -1,38 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Would You Rather</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<h1>{{ question[1] }}</h1>
<form action="/vote" method="POST">
<input
type="hidden"
name="qid"
value="{{ question[0] }}"
>
<button
type="submit"
name="vote"
value="A"
>
{{ question[2] }}
</button>
<button
type="submit"
name="vote"
value="B"
>
{{ question[3] }}
</button>
</form>
</body>
</html>

View File

@ -1,24 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Results</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<h1>{{ question[1] }}</h1>
<h2>{{ question[2] }}</h2>
<p>{{ percent_a }}% ({{ votes_a }} votes)</p>
<h2>{{ question[3] }}</h2>
<p>{{ percent_b }}% ({{ votes_b }} votes)</p>
<hr>
<p>Total votes: {{ total }}</p>
<a href="/">Back</a>
</body>
</html>

186
tests/test_app.py Normal file
View File

@ -0,0 +1,186 @@
import os
import tempfile
import unittest
from datetime import date
from werkzeug.security import generate_password_hash
from app import create_app
from app.database import get_db, init_db
class AppTestCase(unittest.TestCase):
def setUp(self):
self.db_fd, self.db_path = tempfile.mkstemp()
self.app = create_app(
{
"TESTING": True,
"DATABASE": self.db_path,
"SECRET_KEY": "test-secret",
}
)
with self.app.app_context():
init_db()
db = get_db()
db.execute(
"""
INSERT INTO admins (username, password_hash)
VALUES (?, ?)
""",
("admin", generate_password_hash("password")),
)
db.commit()
self.client = self.app.test_client()
def tearDown(self):
os.close(self.db_fd)
os.unlink(self.db_path)
def seed_question(self, question_date=None):
if question_date is None:
question_date = date.today().isoformat()
with self.app.app_context():
db = get_db()
db.execute(
"""
INSERT INTO questions (question, option_a, option_b, date)
VALUES (?, ?, ?, ?)
""",
("Would you rather test A or test B?", "Test A", "Test B", question_date),
)
db.commit()
def login(self):
return self.client.post(
"/admin/login",
data={"username": "admin", "password": "password"},
follow_redirects=True,
)
def test_public_empty_state(self):
response = self.client.get("/")
self.assertEqual(response.status_code, 200)
self.assertIn(b"No question is scheduled yet.", response.data)
def test_voting_flow_blocks_duplicate_vote(self):
self.seed_question()
first = self.client.post("/vote", data={"qid": "1", "vote": "A"})
second = self.client.post("/vote", data={"qid": "1", "vote": "A"})
self.assertEqual(first.status_code, 302)
self.assertEqual(second.status_code, 302)
with self.app.app_context():
count = get_db().execute("SELECT COUNT(*) FROM votes").fetchone()[0]
self.assertEqual(count, 1)
def test_homepage_uses_todays_question_only(self):
self.seed_question("2030-01-01")
response = self.client.get("/")
self.assertIn(b"No question is scheduled yet.", response.data)
self.seed_question()
response = self.client.get("/")
self.assertIn(b"Would you rather test A or test B?", response.data)
def test_vote_rejects_non_today_question(self):
self.seed_question("2030-01-01")
response = self.client.post("/vote", data={"qid": "1", "vote": "A"})
self.assertEqual(response.status_code, 302)
with self.app.app_context():
count = get_db().execute("SELECT COUNT(*) FROM votes").fetchone()[0]
self.assertEqual(count, 0)
def test_admin_login_success_and_failure(self):
failed = self.client.post(
"/admin/login",
data={"username": "admin", "password": "wrong"},
)
self.assertIn(b"Invalid username or password.", failed.data)
success = self.login()
self.assertIn(b"Questions", success.data)
def test_protected_route_redirects_to_login(self):
response = self.client.get("/admin/dashboard")
self.assertEqual(response.status_code, 302)
self.assertIn("/admin/login", response.headers["Location"])
def test_question_create_duplicate_date_failure(self):
self.seed_question()
self.login()
response = self.client.post(
"/admin/question/new",
data={
"date": date.today().isoformat(),
"question": "Another question?",
"option_a": "A",
"option_b": "B",
},
)
self.assertIn(b"Date already has a question.", response.data)
def test_admin_can_change_credentials(self):
self.login()
response = self.client.post(
"/admin/account",
data={
"username": "newadmin",
"current_password": "password",
"new_password": "new-password",
"confirm_password": "new-password",
},
follow_redirects=True,
)
self.assertIn(b"Admin credentials updated.", response.data)
self.client.get("/admin/logout")
old_login = self.client.post(
"/admin/login",
data={"username": "admin", "password": "password"},
)
self.assertIn(b"Invalid username or password.", old_login.data)
new_login = self.client.post(
"/admin/login",
data={"username": "newadmin", "password": "new-password"},
follow_redirects=True,
)
self.assertIn(b"Questions", new_login.data)
def test_admin_account_requires_current_password(self):
self.login()
response = self.client.post(
"/admin/account",
data={
"username": "newadmin",
"current_password": "wrong",
"new_password": "new-password",
"confirm_password": "new-password",
},
)
self.assertIn(b"Current password is incorrect.", response.data)
if __name__ == "__main__":
unittest.main()