Refactored file structure and added admin dashboard
This commit is contained in:
parent
54ffb721dd
commit
f823028d17
1
.gitignore
vendored
1
.gitignore
vendored
@ -2,6 +2,7 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
questions.db
|
||||
instance/
|
||||
.env
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
133
app.py
133
app.py
@ -1,135 +1,8 @@
|
||||
from flask import Flask, render_template, request, redirect
|
||||
from datetime import date
|
||||
import sqlite3
|
||||
import hashlib
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
DB = "questions.db"
|
||||
from app import create_app
|
||||
|
||||
|
||||
def get_db():
|
||||
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
|
||||
)
|
||||
app = create_app()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(debug=True)
|
||||
app.run(debug=True)
|
||||
|
||||
29
app/__init__.py
Normal file
29
app/__init__.py
Normal 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
301
app/admin.py
Normal 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
48
app/database.py
Normal 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
130
app/routes.py
Normal 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
127
app/static/style.css
Normal 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;
|
||||
}
|
||||
}
|
||||
63
app/templates/admin/account.html
Normal file
63
app/templates/admin/account.html
Normal 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>
|
||||
46
app/templates/admin/dashboard.html
Normal file
46
app/templates/admin/dashboard.html
Normal 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>
|
||||
24
app/templates/admin/delete_confirm.html
Normal file
24
app/templates/admin/delete_confirm.html
Normal 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>
|
||||
32
app/templates/admin/login.html
Normal file
32
app/templates/admin/login.html
Normal 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>
|
||||
55
app/templates/admin/question_form.html
Normal file
55
app/templates/admin/question_form.html
Normal 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>
|
||||
49
app/templates/public/index.html
Normal file
49
app/templates/public/index.html
Normal 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>
|
||||
37
app/templates/public/results.html
Normal file
37
app/templates/public/results.html
Normal 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>
|
||||
39
init_db.py
39
init_db.py
@ -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
795
project-context.md
Normal 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
8
run.py
Normal 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
63
scripts/init_db.py
Normal 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")
|
||||
@ -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;
|
||||
}
|
||||
@ -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>
|
||||
@ -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
186
tests/test_app.py
Normal 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()
|
||||
Loading…
Reference in New Issue
Block a user