diff options
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | README.md | 11 | ||||
| -rw-r--r-- | app.py | 688 | ||||
| -rw-r--r-- | templates/index.html.j2 | 41 | ||||
| -rw-r--r-- | templates/policy_sample.html.j2 | 137 |
5 files changed, 565 insertions, 313 deletions
@@ -5,3 +5,4 @@ __pycache__ db.sqlite3* db/ pyrightconfig.json +templates/policy.html.j2 @@ -36,9 +36,14 @@ You can also run the app without containerization: | EMAIL_USE_TLS | Use STARTTLS with SMTP? | | EMAIL_HEADERS | Additional SMTP headers, format: `header1=foo,header2=bar` | | EMAIL_MESSAGE_FROM | Email message from address | +| POLICY_URL | Address of the policy & terms page | +| POLICY_INSTANCE_DOMAIN | The domain part of the BASE_URL | +| POLICY_CONTACT_EMAIL | Your contact email | `EMAIL_` variables are only required if at least one of them is defined. +`POLICY_` variables are used for the Privacy policy & Terms of use page. See relevant section below. + ## Screenshots ### Front page @@ -62,3 +67,9 @@ You can also run the app without containerization: Run Flask in dev mode: flask --app app --debug run -p 8000 + +## Privacy policy and Terms of use page + +By defining a privacy and terms address in `POLICY_URL`, a link to it is rendered in the front page footer. You may set the value to `/policy` and create a `templates/policy.html.j2` file to serve a policy page as part of the app. The repository contains a sample policy template in `templates/policy_sample.html.j2` that uses the environment variables `POLICY_INSTANCE_DOMAIN` and `POLICY_CONTACT_EMAIL`. You may copy that sample to `templates/policy.html.j2` and make necessary changes to reflect the reality of your hosted environment. + +The developers of `diddle` take no responsibility about the content and legality of any policy you display to your users, whether it's based on the sample policy or not. @@ -1,29 +1,36 @@ -from dotenv import load_dotenv -load_dotenv() - import datetime import os +import queue +import subprocess import sys -import traceback -import uuid import threading -import queue import time -from typing import Callable -from user_agents import parse as parse_user_agent +import traceback +import uuid from dataclasses import dataclass -from flask import Flask, render_template, redirect, request, make_response +from typing import Callable + +from dotenv import load_dotenv +from flask import Flask, make_response, redirect, render_template, request from flask_compress import Compress +from jinja2.exceptions import TemplateNotFound +from user_agents import parse as parse_user_agent + +_ = load_dotenv() import db import email_client BASE_URL = os.environ["BASE_URL"] +POLICY_INSTANCE_DOMAIN = os.environ["POLICY_INSTANCE_DOMAIN"] +POLICY_CONTACT_EMAIL = os.environ["POLICY_CONTACT_EMAIL"] + @dataclass class ChoicesByVoter: - name: str - votes: list[bool] + name: str + votes: list[bool] + TITLE_MAX_LENGTH = 100 DESCRIPTION_MAX_LENGTH = 1000 @@ -36,392 +43,479 @@ background_tasks_queue: queue.Queue[Task] = queue.Queue() ### Init + def background_thread(): - print("Background thread started") - while True: - task = background_tasks_queue.get() - task() - time.sleep(0.1) + print("Background thread started") + while True: + task = background_tasks_queue.get() + task() + time.sleep(0.1) + + +def get_version() -> str | None: + try: + return ( + subprocess.check_output( + ["git", "describe", "--tags", "--always"], + stderr=subprocess.DEVNULL, + text=True, + ).strip() + or None + ) + except Exception: + return None + + +APP_VERSION = get_version() + def create_app() -> Flask: threading.Thread(target=background_thread, daemon=True).start() app = Flask(__name__) Compress(app) + app.jinja_env.globals["app_version"] = APP_VERSION return app + app = create_app() + +def policy_page_exists(): + try: + _ = app.jinja_env.get_template("policy.html.j2") + return True + except TemplateNotFound: + return False + + +POLICY_PAGE_EXISTS = policy_page_exists() + ### Routes + def voter_selection_on_choice(voter_name: str, choice: db.Choice) -> int | None: - for vote in choice.votes: - if vote.voter_name == voter_name: - return vote.value - return None + for vote in choice.votes: + if vote.voter_name == voter_name: + return vote.value + return None + def error_page(message: str, code: int = 400): - return render_template("error.html.j2", error=message), code + return render_template("error.html.j2", error=message), code + @app.errorhandler(404) -def not_found(e): - return error_page("Not found", 404) +def not_found(_e: BaseException | None = None): + return error_page("Not found", 404) + @app.errorhandler(Exception) -def error_handler(e): - traceback.print_exception(e, file=sys.stderr) - return error_page("Internal server error", 500) +def error_handler(e: BaseException): + traceback.print_exception(e, file=sys.stderr) + return error_page("Internal server error", 500) + def validate_uuid(s: str) -> bool: - try: - uuid.UUID(s) - return True - except ValueError: - return False + try: + _ = uuid.UUID(s) + return True + except ValueError: + return False + + +@app.route("/policy") +def privacy(): + if not POLICY_PAGE_EXISTS: + return not_found() + + return render_template( + "policy.html.j2", + instance_domain=POLICY_INSTANCE_DOMAIN, + contact_email=POLICY_CONTACT_EMAIL, + ) + @app.route("/") def index(): - created_poll_codes = [] - for k, _ in request.cookies.items(): - if k.startswith("diddle_manage_code_"): - created_poll_codes.append(k.replace("diddle_manage_code_", "")) + created_poll_codes: list[str] = [] + for k, _ in request.cookies.items(): + if k.startswith("diddle_manage_code_"): + created_poll_codes.append(k.replace("diddle_manage_code_", "")) + + created_polls = ( + db.get_polls_by_codes(created_poll_codes) if len(created_poll_codes) > 0 else [] + ) - created_polls = db.get_polls_by_codes(created_poll_codes) if len(created_poll_codes) > 0 else [] + return render_template( + "index.html.j2", + created_polls=created_polls, + now=datetime.datetime.now(), + has_policy=POLICY_PAGE_EXISTS, + ) - return render_template('index.html.j2', - created_polls=created_polls, - now=datetime.datetime.now()) @app.post("/poll/create") def create(): - form = request.form + form = request.form - title = form.get("title") - title = title.strip() if title is not None else None - if title is None or len(title) == 0: - return error_page("Title is required") - if len(title) > TITLE_MAX_LENGTH: - return error_page(f"Title must be {TITLE_MAX_LENGTH} characters or fewer") - description = form.get("description") - description = description.strip() if description is not None else None - if description is not None and len(description) > DESCRIPTION_MAX_LENGTH: - return error_page(f"Description must be {DESCRIPTION_MAX_LENGTH} characters or fewer") - author_name = form.get("author_name") - author_name = author_name.strip() if author_name is not None else None - if author_name is None or len(author_name) == 0: - return error_page("Author name is required") - if len(author_name) > AUTHOR_NAME_MAX_LENGTH: - return error_page(f"Author name must be {AUTHOR_NAME_MAX_LENGTH} characters or fewer") - author_email = form.get("author_email") - author_email = author_email.strip() if author_email is not None else None - if author_email is not None and len(author_email) > AUTHOR_EMAIL_MAX_LENGTH: - return error_page(f"Author email must be {AUTHOR_EMAIL_MAX_LENGTH} characters or fewer") + title = form.get("title") + title = title.strip() if title is not None else None + if title is None or len(title) == 0: + return error_page("Title is required") + if len(title) > TITLE_MAX_LENGTH: + return error_page(f"Title must be {TITLE_MAX_LENGTH} characters or fewer") + description = form.get("description") + description = description.strip() if description is not None else None + if description is not None and len(description) > DESCRIPTION_MAX_LENGTH: + return error_page( + f"Description must be {DESCRIPTION_MAX_LENGTH} characters or fewer" + ) + author_name = form.get("author_name") + author_name = author_name.strip() if author_name is not None else None + if author_name is None or len(author_name) == 0: + return error_page("Author name is required") + if len(author_name) > AUTHOR_NAME_MAX_LENGTH: + return error_page( + f"Author name must be {AUTHOR_NAME_MAX_LENGTH} characters or fewer" + ) + author_email = form.get("author_email") + author_email = author_email.strip() if author_email is not None else None + if author_email is not None and len(author_email) > AUTHOR_EMAIL_MAX_LENGTH: + return error_page( + f"Author email must be {AUTHOR_EMAIL_MAX_LENGTH} characters or fewer" + ) - poll = db.create_poll( - title, - description, - author_name, - author_email, - "is_whole_day" in form, - ) + poll = db.create_poll( + title, + description, + author_name, + author_email, + "is_whole_day" in form, + ) - if email_client.email_enabled: - def task(): - email_client.send_poll_created_email(poll_id=poll.id) + if email_client.email_enabled: - background_tasks_queue.put(task) + def task(): + email_client.send_poll_created_email(poll_id=poll.id) - resp = make_response( - redirect(f"/manage/{poll.manage_code}") - ) - resp.set_cookie(f"diddle_manage_code_{poll.manage_code}", "1", - samesite="Strict", secure=False) - return resp + background_tasks_queue.put(task) + + resp = make_response(redirect(f"/manage/{poll.manage_code}")) + resp.set_cookie( + f"diddle_manage_code_{poll.manage_code}", "1", samesite="Strict", secure=False + ) + return resp VoterNameChoiceIdPair = tuple[str, str] + + @app.get("/poll/<id>") -def poll(id): - if not validate_uuid(id): - return error_page("Invalid poll ID", 400) +def poll(id: str): + if not validate_uuid(id): + return error_page("Invalid poll ID", 400) - prefill_voter_name = request.args.get("prefill_voter_name") + prefill_voter_name = request.args.get("prefill_voter_name") - voter_codes: list[str] = [] - for k, _ in request.cookies.items(): - if k.startswith("diddle_voter_code_"): - voter_codes.append(k.replace("diddle_voter_code_", "")) + voter_codes: list[str] = [] + for k, _ in request.cookies.items(): + if k.startswith("diddle_voter_code_"): + voter_codes.append(k.replace("diddle_voter_code_", "")) - poll = db.get_poll(id) - if poll is None: - return error_page("Poll not found", 404) + poll = db.get_poll(id) + if poll is None: + return error_page("Poll not found", 404) - display_mode_cookie = request.cookies.get("diddle_display_mode") - if display_mode_cookie is None: - user_agent = parse_user_agent(request.user_agent.string) - if user_agent.is_mobile or user_agent.is_tablet: - display_mode = "list" + display_mode_cookie = request.cookies.get("diddle_display_mode") + if display_mode_cookie is None: + user_agent = parse_user_agent(request.user_agent.string) + if user_agent.is_mobile or user_agent.is_tablet: + display_mode = "list" + else: + display_mode = "table" else: - display_mode = "table" - else: - display_mode = display_mode_cookie + display_mode = display_mode_cookie - voter_names_set: set[str] = set() - selections: dict[VoterNameChoiceIdPair, int] = {} - managed_voter_names: dict[str, str] = {} - for choice in poll.choices: - for vote in choice.votes: - selections[(vote.voter_name, choice.id)] = vote.value - voter_names_set.add(vote.voter_name) + voter_names_set: set[str] = set() + selections: dict[VoterNameChoiceIdPair, int] = {} + managed_voter_names: dict[str, str] = {} + for choice in poll.choices: + for vote in choice.votes: + selections[(vote.voter_name, choice.id)] = vote.value + voter_names_set.add(vote.voter_name) - if vote.manage_code in voter_codes: - managed_voter_names[vote.voter_name] = vote.manage_code + if vote.manage_code in voter_codes: + managed_voter_names[vote.voter_name] = vote.manage_code - voter_names = list(voter_names_set) - voter_names.sort() + voter_names = list(voter_names_set) + voter_names.sort() - most_voted_choice_ids: set[str] = set() - most_voted_value = 1 # start from 1 to avoid marking 0 votes as most voted - for choice in poll.choices: - n_votes = 0 - for vote in choice.votes: - n_votes += vote.value + most_voted_choice_ids: set[str] = set() + most_voted_value = 1 # start from 1 to avoid marking 0 votes as most voted + for choice in poll.choices: + n_votes = 0 + for vote in choice.votes: + n_votes += vote.value - if n_votes > most_voted_value: - most_voted_choice_ids = { choice.id } - most_voted_value = n_votes - elif n_votes == most_voted_value: - most_voted_choice_ids.add(choice.id) + if n_votes > most_voted_value: + most_voted_choice_ids = {choice.id} + most_voted_value = n_votes + elif n_votes == most_voted_value: + most_voted_choice_ids.add(choice.id) - resp = make_response( - render_template("poll.html.j2", - poll=poll, - selections=selections, - choices=poll.choices, - most_voted_choice_ids=most_voted_choice_ids, - prefill_voter_name=prefill_voter_name, - voter_names=voter_names, - managed_voter_names=managed_voter_names, - now=datetime.datetime.now(), - display_mode=display_mode)) + resp = make_response( + render_template( + "poll.html.j2", + poll=poll, + selections=selections, + choices=poll.choices, + most_voted_choice_ids=most_voted_choice_ids, + prefill_voter_name=prefill_voter_name, + voter_names=voter_names, + managed_voter_names=managed_voter_names, + now=datetime.datetime.now(), + display_mode=display_mode, + ) + ) - resp.set_cookie("diddle_display_mode", display_mode, - samesite="Lax", secure=False) - return resp + resp.set_cookie("diddle_display_mode", display_mode, samesite="Lax", secure=False) + return resp @app.post("/poll/<id>/vote") -def vote_poll(id): - if not validate_uuid(id): - return error_page("Invalid poll ID", 400) +def vote_poll(id: str): + if not validate_uuid(id): + return error_page("Invalid poll ID", 400) - form = request.form - voter_name = form.get("voter_name") - voter_name = voter_name.strip() if voter_name is not None else None - if voter_name is None or len(voter_name) == 0: - return error_page("Voter name is required") - if len(voter_name) > VOTER_NAME_MAX_LENGTH: - return error_page(f"Voter name must be {VOTER_NAME_MAX_LENGTH} characters or fewer") + form = request.form + voter_name = form.get("voter_name") + voter_name = voter_name.strip() if voter_name is not None else None + if voter_name is None or len(voter_name) == 0: + return error_page("Voter name is required") + if len(voter_name) > VOTER_NAME_MAX_LENGTH: + return error_page( + f"Voter name must be {VOTER_NAME_MAX_LENGTH} characters or fewer" + ) - poll = db.get_poll(id) - if poll is None: - return error_page("Poll not found") + poll = db.get_poll(id) + if poll is None: + return error_page("Poll not found") - selections: dict[str, int] = {} - for choice in poll.choices: - selections[choice.id] = 0 + selections: dict[str, int] = {} + for choice in poll.choices: + selections[choice.id] = 0 - for k in form.keys(): - if k.startswith("choice_"): - choice_id = k.replace("choice_", "") - selections[choice_id] = 1 + for k in form.keys(): + if k.startswith("choice_"): + choice_id = k.replace("choice_", "") + selections[choice_id] = 1 - manage_code = db.vote_poll(id, voter_name, selections) - if manage_code is None: - return error_page("That name is already in use") + manage_code = db.vote_poll(id, voter_name, selections) + if manage_code is None: + return error_page("That name is already in use") - if email_client.email_enabled: - def task(): - email_client.send_participation_email(poll_id=id, voter_name=voter_name) - background_tasks_queue.put(task) + if email_client.email_enabled: + + def task(): + email_client.send_participation_email(poll_id=id, voter_name=voter_name) + + background_tasks_queue.put(task) + + response = make_response(redirect(f"/poll/{id}")) + response.set_cookie( + f"diddle_voter_code_{manage_code}", "1", samesite="Strict", secure=False + ) + return response - response = make_response( - redirect(f"/poll/{id}") - ) - response.set_cookie(f"diddle_voter_code_{manage_code}", "1", - samesite="Strict", secure=False) - return response @app.post("/poll/<id>/delete_voter") -def delete_voter(id): - if not validate_uuid(id): - return error_page("Invalid poll ID", 400) +def delete_voter(id: str): + if not validate_uuid(id): + return error_page("Invalid poll ID", 400) + + voter_code = request.form["voter_code"] + voter_name = db.get_voter_name_by_manage_code(voter_code) - voter_code = request.form["voter_code"] - voter_name = db.get_voter_name_by_manage_code(voter_code) + return render_template( + "voter_confirm_delete.html.j2", + voter_name=voter_name, + voter_code=voter_code, + poll_id=id, + ) - return render_template("voter_confirm_delete.html.j2", - voter_name=voter_name, - voter_code=voter_code, - poll_id=id) @app.post("/poll/<id>/confirm_delete_voter") -def confirm_delete_voter(id): - if not validate_uuid(id): - return error_page("Invalid poll ID", 400) +def confirm_delete_voter(id: str): + if not validate_uuid(id): + return error_page("Invalid poll ID", 400) - voter_manage_code = request.form["voter_code"] + voter_manage_code = request.form["voter_code"] - voter_name = db.get_voter_name_by_manage_code(voter_manage_code) - if voter_name is None: - return error_page("Voter not found", 404) + voter_name = db.get_voter_name_by_manage_code(voter_manage_code) + if voter_name is None: + return error_page("Voter not found", 404) - db.delete_voter(voter_manage_code) + db.delete_voter(voter_manage_code) + + resp = make_response(redirect(f"/poll/{id}?prefill_voter_name={voter_name}")) + resp.set_cookie( + f"diddle_voter_code_{voter_manage_code}", + "", + expires=0, + samesite="Strict", + secure=False, + ) + return resp - resp = make_response( - redirect(f"/poll/{id}?prefill_voter_name={voter_name}") - ) - resp.set_cookie(f"diddle_voter_code_{voter_manage_code}", "", expires=0, - samesite="Strict", secure=False) - return resp @app.post("/manage/<code>/update_info") -def update_poll_info(code): - if not validate_uuid(code): - return error_page("Invalid manage code", 400) +def update_poll_info(code: str): + if not validate_uuid(code): + return error_page("Invalid manage code", 400) + + form = request.form - form = request.form + title = form.get("title") + title = title.strip() if title is not None else None + if title is None or len(title) == 0: + return error_page("Title is required") + if len(title) > TITLE_MAX_LENGTH: + return error_page(f"Title must be {TITLE_MAX_LENGTH} characters or fewer") + description = form.get("description") + description = description.strip() if description is not None else None + if description is not None and len(description) > DESCRIPTION_MAX_LENGTH: + return error_page( + f"Description must be {DESCRIPTION_MAX_LENGTH} characters or fewer" + ) + author_name = form.get("author_name") + author_name = author_name.strip() if author_name is not None else None + if author_name is None or len(author_name) == 0: + return error_page("Author name is required") + if len(author_name) > AUTHOR_NAME_MAX_LENGTH: + return error_page( + f"Author name must be {AUTHOR_NAME_MAX_LENGTH} characters or fewer" + ) + author_email = form.get("author_email") + author_email = author_email.strip() if author_email is not None else None + if author_email is not None and len(author_email) > AUTHOR_EMAIL_MAX_LENGTH: + return error_page( + f"Author email must be {AUTHOR_EMAIL_MAX_LENGTH} characters or fewer" + ) - title = form.get("title") - title = title.strip() if title is not None else None - if title is None or len(title) == 0: - return error_page("Title is required") - if len(title) > TITLE_MAX_LENGTH: - return error_page(f"Title must be {TITLE_MAX_LENGTH} characters or fewer") - description = form.get("description") - description = description.strip() if description is not None else None - if description is not None and len(description) > DESCRIPTION_MAX_LENGTH: - return error_page(f"Description must be {DESCRIPTION_MAX_LENGTH} characters or fewer") - author_name = form.get("author_name") - author_name = author_name.strip() if author_name is not None else None - if author_name is None or len(author_name) == 0: - return error_page("Author name is required") - if len(author_name) > AUTHOR_NAME_MAX_LENGTH: - return error_page(f"Author name must be {AUTHOR_NAME_MAX_LENGTH} characters or fewer") - author_email = form.get("author_email") - author_email = author_email.strip() if author_email is not None else None - if author_email is not None and len(author_email) > AUTHOR_EMAIL_MAX_LENGTH: - return error_page(f"Author email must be {AUTHOR_EMAIL_MAX_LENGTH} characters or fewer") + changed = db.update_poll_info( + code, + title, + description, + author_name, + author_email, + "is_whole_day" in form, + ) - changed = db.update_poll_info( - code, - title, - description, - author_name, - author_email, - "is_whole_day" in form, - ) + if changed is None: + return error_page("Poll not found", 404) - if changed is None: - return error_page("Poll not found", 404) + return redirect(f"/manage/{code}") - return redirect(f"/manage/{code}") @app.post("/manage/<code>/add_choice") -def add_choice(code): - if not validate_uuid(code): - return error_page("Invalid manage code", 400) +def add_choice(code: str): + if not validate_uuid(code): + return error_page("Invalid manage code", 400) + + form = request.form + if "start_datetime" not in form or len(form["start_datetime"]) == 0: + return error_page("Start datetime is required") + if "end_datetime" not in form or len(form["end_datetime"]) == 0: + return error_page("End datetime is required") + if form["start_datetime"] > form["end_datetime"]: + return error_page("Start datetime must be before end datetime") - form = request.form - if "start_datetime" not in form or len(form["start_datetime"]) == 0: - return error_page("Start datetime is required") - if "end_datetime" not in form or len(form["end_datetime"]) == 0: - return error_page("End datetime is required") - if form["start_datetime"] > form["end_datetime"]: - return error_page("Start datetime must be before end datetime") + start_datetime = form["start_datetime"] + if len(start_datetime) == 10: + start_datetime += " 00:00:00" + else: + start_datetime = start_datetime.replace("T", " ") + start_datetime += ":00" - start_datetime = form["start_datetime"] - if len(start_datetime) == 10: - start_datetime += " 00:00:00" - else: - start_datetime = start_datetime.replace("T", " ") - start_datetime += ":00" + end_datetime = form["end_datetime"] + if len(end_datetime) == 10: + end_datetime += " 23:59:00" + else: + end_datetime = end_datetime.replace("T", " ") + end_datetime += ":00" - end_datetime = form["end_datetime"] - if len(end_datetime) == 10: - end_datetime += " 23:59:00" - else: - end_datetime = end_datetime.replace("T", " ") - end_datetime += ":00" + db.add_choice_to_poll( + code, + start_datetime, + end_datetime, + ) - db.add_choice_to_poll( - code, - start_datetime, - end_datetime, - ) + return redirect(f"/manage/{code}?focus_next=1") - return redirect(f"/manage/{code}?focus_next=1") @app.post("/manage/<code>/delete_choice/<choice_id>") -def delete_choice(code, choice_id): - if not validate_uuid(code): - return error_page("Invalid manage code", 400) +def delete_choice(code: str, choice_id: str): + if not validate_uuid(code): + return error_page("Invalid manage code", 400) - poll = db.get_poll_by_code(code) - if poll is None: - return error_page("Poll not found", code=404) + poll = db.get_poll_by_code(code) + if poll is None: + return error_page("Poll not found", code=404) - db.delete_choice(choice_id) + db.delete_choice(choice_id) + + return redirect(f"/manage/{code}?focus_next=1") - return redirect(f"/manage/{code}?focus_next=1") @app.get("/manage/<code>") -def manage(code): - if not validate_uuid(code): - return error_page("Invalid manage code", 400) +def manage(code: str): + if not validate_uuid(code): + return error_page("Invalid manage code", 400) + + poll = db.get_poll_by_code(code) + if poll is None: + return error_page("Poll not found") - poll = db.get_poll_by_code(code) - if poll is None: - return error_page("Poll not found") + last_choice_id = poll.choices[-1].id if len(poll.choices) > 0 else None + resp = make_response( + render_template("manage.html.j2", poll=poll, last_choice_id=last_choice_id) + ) + resp.set_cookie(f"diddle_manage_code_{code}", "1", samesite="Strict", secure=False) + return resp - last_choice_id = poll.choices[-1].id if len(poll.choices) > 0 else None - resp = make_response( - render_template("manage.html.j2", - poll=poll, - last_choice_id=last_choice_id)) - resp.set_cookie(f"diddle_manage_code_{code}", "1", - samesite="Strict", secure=False) - return resp @app.post("/manage/<code>/delete") -def delete_poll(code): - if not validate_uuid(code): - return error_page("Invalid manage code", 400) +def delete_poll(code: str): + if not validate_uuid(code): + return error_page("Invalid manage code", 400) + + poll = db.get_poll_by_code(code) + return render_template("poll_confirm_delete.html.j2", poll=poll) - poll = db.get_poll_by_code(code) - return render_template("poll_confirm_delete.html.j2", poll=poll) @app.post("/manage/<code>/confirm_delete") -def confirm_delete_poll(code): - if not validate_uuid(code): - return error_page("Invalid manage code", 400) +def confirm_delete_poll(code: str): + if not validate_uuid(code): + return error_page("Invalid manage code", 400) + + db.delete_poll(code) + resp = make_response(redirect("/")) + resp.set_cookie( + f"diddle_manage_code_{code}", "", expires=0, samesite="Strict", secure=False + ) + return resp - db.delete_poll(code) - resp = make_response(redirect("/")) - resp.set_cookie(f"diddle_manage_code_{code}", "", expires=0, - samesite="Strict", secure=False) - return resp @app.post("/options/toggle_display_mode") def toggle_display_mode(): - poll_id = request.form["poll_id"] - redirect_url = f"/poll/{poll_id}" - display_mode = request.cookies.get("diddle_display_mode", "table") - if display_mode == "table": - display_mode = "list" - else: - display_mode = "table" + poll_id = request.form["poll_id"] + redirect_url = f"/poll/{poll_id}" + display_mode = request.cookies.get("diddle_display_mode", "table") + if display_mode == "table": + display_mode = "list" + else: + display_mode = "table" - resp = make_response(redirect(redirect_url)) - resp.set_cookie("diddle_display_mode", display_mode, - samesite="Lax", secure=False) - return resp + resp = make_response(redirect(redirect_url)) + resp.set_cookie("diddle_display_mode", display_mode, samesite="Lax", secure=False) + return resp diff --git a/templates/index.html.j2 b/templates/index.html.j2 index ea2e225..9daee75 100644 --- a/templates/index.html.j2 +++ b/templates/index.html.j2 @@ -1,32 +1,41 @@ -{% extends "base.html.j2" %} - -{% block content %} +{% extends "base.html.j2" %} {% block content %} <h2>Create new diddle</h2> <form class="poll-form" action="/poll/create" method="post"> - {% include "poll_info_table.html.j2" %} + {% include "poll_info_table.html.j2" %} - <p>You can add time options as well as modify all settings after submitting.</p> - <input class="blue" type="submit" value="Create"> + <p> + You can add time options as well as modify all settings after + submitting. + </p> + <input class="blue" type="submit" value="Create" /> </form> {% if created_polls | length != 0 %} -<br> +<br /> <h2>My diddles</h2> <ul> - {% for poll in created_polls %} - <li><a href="/manage/{{ poll.manage_code }}">{{ poll.title }}</a></li> - {% endfor %} + {% for poll in created_polls %} + <li><a href="/manage/{{ poll.manage_code }}">{{ poll.title }}</a></li> + {% endfor %} </ul> {% endif %} -<br> -<br> +<br /> +<br /> <h2>About diddle</h2> -<p><i>diddle</i> is a minimalist, mobile friendly, fast and self-hosted scheduling tool, - licensed under <a href="https://github.com/jantuomi/diddle/blob/main/LICENSE">Apache 2.0.</a> +<p> + <i>diddle</i> is a minimalist, mobile friendly, fast and self-hosted scheduling tool, licensed under + <a href="https://github.com/jantuomi/diddle/blob/main/LICENSE">Apache 2.0.</a><br /> + Find <i>diddle</i> on <a href="https://github.com/jantuomi/diddle">GitHub</a>.<br /> + This site uses the <a href="https://fonts.google.com/specimen/Inclusive+Sans">Inclusive Sans</a> typeface. </p> <p> - Find <i>diddle</i> on <a href="https://github.com/jantuomi/diddle">GitHub</a>. © {{ now.year }} Jan Tuomi. All rights reserved.<br> - This site uses the <a href="https://fonts.google.com/specimen/Inclusive+Sans">Inclusive Sans</a> typeface. + © {{ now.year }} Jan Tuomi. All rights reserved. + {% if has_policy %} + <a href="/policy">Privacy and terms</a>. + {% endif %} + {% if app_version %} + <br />Version: <span style="font-family: monospace">{{ app_version }}</span>. + {% endif %} </p> {% endblock %} diff --git a/templates/policy_sample.html.j2 b/templates/policy_sample.html.j2 new file mode 100644 index 0000000..92b88a5 --- /dev/null +++ b/templates/policy_sample.html.j2 @@ -0,0 +1,137 @@ +{% extends "base.html.j2" %} + +{% block head_meta %} +<title>Privacy and terms – {{ instance_domain }} 👉👈</title> +<meta + name="description" + content="Privacy policy and terms of use for {{ instance_domain }}" +/> +{% endblock %} + +{% block content %} + +<h2 style="color: red">This is a sample privacy policy and terms of use document. You must replace this with your own when deploying.</h2> + +<p>Last updated: 19 April 2026</p> + +<hr /> + +<h2>Terms of use</h2> + +<h3>Acceptance of terms</h3> +<p> + By using <i>diddle</i> on <a href="/">{{ instance_domain }}</a>, you agree to these terms. If you do not agree, please do not use the service. +</p> + +<h3>Description of service</h3> +<p> + <i>diddle</i> is a self-hosted scheduling tool that allows users to create polls and vote on time options. The service is provided as-is and free-of-charge. +</p> + +<h3>User responsibilities</h3> +<p>You agree not to:</p> +<ul> + <li>Use the service for any unlawful purpose.</li> + <li>Submit content that is offensive, harmful, or infringes on the rights of others.</li> + <li>Attempt to disrupt or compromise the service or its infrastructure.</li> +</ul> + +<h3>Disclaimer of warranties</h3> +<p> + The service is provided "as is" and "as available" without warranties of any + kind, whether express or implied. We do not guarantee that the service will + be uninterrupted, error-free, or that any data will be retained or preserved. +</p> + +<h3>Limitation of liability</h3> +<p> + To the fullest extent permitted by law, the operator of this diddle instance + shall not be liable for any indirect, incidental, or consequential damages + arising from your use of the service, including but not limited to loss of data. +</p> + +<h3>Changes to these terms</h3> +<p> + We may update these terms at any time. Continued use of the service after + changes constitutes acceptance of the updated terms. +</p> + +<hr /> + +<h2>Privacy policy</h2> + +<h3>What data do we collect?</h3> +<p>When you use <a href="/">{{ instance_domain }}</a>, we collect the data you provide directly:</p> +<ul> + <li>Poll: title, description, author name, author email address, poll settings.</li> + <li>Votes: voter name and selected time choices.</li> +</ul> +<p> + Additionally, connection information such as your IP address may be stored in server logs for administrative purposes. +</p> +<p> + We do not collect any other kinds of data. +</p> + +<h3>How do we use your data?</h3> +<p> + Your data is used solely to provide the scheduling functionality of diddle. + Your email address maybe used to send you notifications about polls you manage. + We do not use your data for any other purpose. +</p> + +<h3>Cookies</h3> +<p> + Diddle uses only strictly necessary functional cookies to remember which + polls you have created or voted in, and your display mode preference. These + cookies do not track you across websites and are not used for analytics, + advertising, or any other purpose. +</p> + +<h3>Data storage and transfers</h3> +<p> + All data is stored within the European Union. + Your data is not transferred outside the EU and is not shared with any third parties. + Your data is stored encrypted at rest and in flight. +</p> + +<h3>Data retention</h3> +<p> + No specific data retention period is guaranteed. + No data durability or backup guarantees are provided. + Data may be lost due to technical failures. +</p> + +<h3>Data deletion</h3> +<p> + By using the <i>Delete poll</i> functionality, poll data and any related data is deleted from the database. +</p> +<p> + By using the <i>Delete voter</i> functionality, voter name and selected time choices are deleted from the database. +</p> + +<h3>Your rights</h3> +<p> + Under the General Data Protection Regulation (GDPR), you have the right to: +</p> +<ul> + <li>Access the personal data we hold about you.</li> + <li>Request correction of inaccurate data.</li> + <li>Request erasure of your data.</li> + <li>Restrict or object to processing of your data.</li> + <li>Request portability of your data.</li> +</ul> +<p> + To exercise any of these rights, please contact us using the details below. + Data is removed from the database upon request. If we are unable to verify + your identity, we may not be able to act on your request. +</p> + +<hr /> + +<h3>Contact</h3> +<p> + Email: {{ contact_email }} +</p> +<br /> +{% endblock %} |
