changed vote limit to cookie-based rather than IP-based
This commit is contained in:
parent
a484f13e7d
commit
8dbddf8fb8
17
app/admin.py
17
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:
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user