diff --git a/app/admin.py b/app/admin.py index 8d90b82..b570717 100644 --- a/app/admin.py +++ b/app/admin.py @@ -1,5 +1,6 @@ from functools import wraps import sqlite3 +from datetime import date from flask import ( Blueprint, @@ -84,6 +85,13 @@ def validate_account_form(form, admin): return data, None +def question_content_changed(question, data): + return any( + question[field] != data[field] + for field in ("question", "option_a", "option_b", "date") + ) + + @bp.route("/login", methods=["GET", "POST"]) def login(): if request.method == "POST": @@ -246,6 +254,11 @@ def edit_question(question_id): if error is None: try: + should_clear_votes = ( + question["date"] == date.today().isoformat() + and question_content_changed(question, data) + ) + db.execute( """ UPDATE questions @@ -260,6 +273,10 @@ def edit_question(question_id): question_id, ), ) + + if should_clear_votes: + db.execute("DELETE FROM votes WHERE question_id = ?", (question_id,)) + db.commit() return redirect(url_for("admin.dashboard")) except sqlite3.IntegrityError: diff --git a/app/routes.py b/app/routes.py index f3ddce1..70a3658 100644 --- a/app/routes.py +++ b/app/routes.py @@ -1,5 +1,5 @@ import hashlib -from datetime import date +from datetime import date, datetime, time, timedelta from flask import Blueprint, redirect, render_template, request, url_for @@ -7,6 +7,8 @@ from .database import get_db bp = Blueprint("public", __name__) +VOTE_COOKIE_NAME = "wyr_daily_vote" + def get_todays_question(): db = get_db() @@ -23,6 +25,46 @@ def get_todays_question(): ).fetchone() +def get_question_version(question): + parts = ( + str(question["id"]), + question["date"], + question["question"], + question["option_a"], + question["option_b"], + ) + fingerprint = "\x1f".join(parts) + + return hashlib.sha256(fingerprint.encode()).hexdigest() + + +def get_vote_cookie_value(question): + return f"{question['date']}:{question['id']}:{get_question_version(question)}" + + +def get_seconds_until_tomorrow(): + now = datetime.now() + tomorrow = datetime.combine(date.today() + timedelta(days=1), time.min) + + return max(1, int((tomorrow - now).total_seconds())) + + +def has_voted_for_question(question): + return request.cookies.get(VOTE_COOKIE_NAME) == get_vote_cookie_value(question) + + +def set_vote_cookie(response, question): + response.set_cookie( + VOTE_COOKIE_NAME, + get_vote_cookie_value(question), + max_age=get_seconds_until_tomorrow(), + httponly=True, + samesite="Lax", + ) + + return response + + @bp.route("/") def home(): question = get_todays_question() @@ -41,36 +83,26 @@ def 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( + if has_voted_for_question(question): + return redirect(url_for("public.results")) + + db.execute( """ - SELECT id - FROM votes - WHERE voter_hash = ? - AND question_id = ? + INSERT INTO votes (question_id, vote, voter_hash) + VALUES (?, ?, ?) """, - (voter_hash, qid), - ).fetchone() + (qid, selected_vote, "cookie"), + ) + db.commit() - 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")) + response = redirect(url_for("public.results")) + return set_vote_cookie(response, question) @bp.route("/results") diff --git a/tests/test_app.py b/tests/test_app.py index 4a30c70..ac29706 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -80,6 +80,35 @@ class AppTestCase(unittest.TestCase): self.assertEqual(count, 1) + def test_new_browser_can_vote_without_cookie(self): + self.seed_question() + + first = self.client.post("/vote", data={"qid": "1", "vote": "A"}) + other_client = self.app.test_client() + second = other_client.post("/vote", data={"qid": "1", "vote": "B"}) + + 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, 2) + + def test_invalid_vote_does_not_set_cookie(self): + response = self.client.post("/vote", data={"qid": "", "vote": "A"}) + + self.assertEqual(response.status_code, 302) + self.assertNotIn("wyr_daily_vote", response.headers.get("Set-Cookie", "")) + + def test_non_today_vote_does_not_set_cookie(self): + self.seed_question("2030-01-01") + + response = self.client.post("/vote", data={"qid": "1", "vote": "A"}) + + self.assertEqual(response.status_code, 302) + self.assertNotIn("wyr_daily_vote", response.headers.get("Set-Cookie", "")) + def test_homepage_uses_todays_question_only(self): self.seed_question("2030-01-01") response = self.client.get("/") @@ -103,6 +132,42 @@ class AppTestCase(unittest.TestCase): self.assertEqual(count, 0) + def test_editing_todays_question_clears_votes_and_allows_new_vote(self): + self.seed_question() + self.client.post("/vote", data={"qid": "1", "vote": "A"}) + + self.login() + response = self.client.post( + "/admin/question/edit/1", + data={ + "date": date.today().isoformat(), + "question": "Would you rather retest A or retest B?", + "option_a": "Retest A", + "option_b": "Retest B", + }, + ) + + self.assertEqual(response.status_code, 302) + + with self.app.app_context(): + count_after_edit = ( + get_db().execute("SELECT COUNT(*) FROM votes").fetchone()[0] + ) + + self.assertEqual(count_after_edit, 0) + + self.client.post("/vote", data={"qid": "1", "vote": "B"}) + + with self.app.app_context(): + db = get_db() + total = db.execute("SELECT COUNT(*) FROM votes").fetchone()[0] + vote_b = db.execute( + "SELECT COUNT(*) FROM votes WHERE vote = 'B'", + ).fetchone()[0] + + self.assertEqual(total, 1) + self.assertEqual(vote_b, 1) + def test_admin_login_success_and_failure(self): failed = self.client.post( "/admin/login",