From c1e04fafee5b36b270d093f291d27005f7e3cddf Mon Sep 17 00:00:00 2001 From: Alex Do Date: Wed, 4 Mar 2026 10:44:10 +0000 Subject: [PATCH] feat(elections): design voting system --- .env.example | 2 + .github/workflows/deploy.yml | 1 + codesoc.py | 330 ++++++++++++++++- data/elections.json | 17 +- db_manager.py | 31 +- db_schema.py | 52 ++- static/js/elections-admin.js | 93 +++++ static/js/elections-category.js | 170 +++++++++ static/styles/styles-body-elections.css | 464 ++++++++++++++++++++++++ templates/elections-admin-login.html | 40 ++ templates/elections-admin.html | 54 +++ templates/elections-category.html | 124 +++++++ templates/elections.html | 56 +++ templates/reusable/header.html | 6 + 14 files changed, 1432 insertions(+), 8 deletions(-) create mode 100644 static/js/elections-admin.js create mode 100644 static/js/elections-category.js create mode 100644 static/styles/styles-body-elections.css create mode 100644 templates/elections-admin-login.html create mode 100644 templates/elections-admin.html create mode 100644 templates/elections-category.html create mode 100644 templates/elections.html diff --git a/.env.example b/.env.example index d15afdd..cc04fe0 100644 --- a/.env.example +++ b/.env.example @@ -10,3 +10,5 @@ MAIL_PASSWORD=your-gmail-app-password # Advent of Code session cookie for leaderboard API AOC_SESSION_COOKIE=your-aoc-session-cookie + +ADMIN_PASSWORD=codesoc-pass diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5e7ad7a..a0948d1 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -48,6 +48,7 @@ jobs: echo "MAIL_PASSWORD=${{ secrets.MAIL_PASSWORD }}" >> .env echo "AOC_SESSION_COOKIE=${{ secrets.AOC_SESSION_COOKIE }}" >> .env echo "SU_KEY=${{ secrets.SU_KEY }}" >> .env + echo "ADMIN_PASSWORD=${{ secrets.ADMIN_PASSWORD }}" >> .env - name: Install dependencies if: success() diff --git a/codesoc.py b/codesoc.py index 7e37408..0114da7 100644 --- a/codesoc.py +++ b/codesoc.py @@ -1,14 +1,18 @@ -from flask import Flask, render_template, request, jsonify +from flask import Flask, render_template, request, jsonify, redirect, url_for, abort, session from flask_migrate import Migrate -from db_schema import db, ExecMember, BlogPost, Sponsor, SponsorNews +from db_schema import db, ExecMember, BlogPost, Sponsor, SponsorNews, ElectionCandidate, ElectionVote, ElectionSettings from flask_mail import Mail, Message import click import os +import json +import xml.etree.ElementTree as ET from dotenv import load_dotenv from db_manager import DatabaseManager from schema_generator import generate_all_schemas, save_schemas_to_file +import csv import requests from datetime import datetime, timedelta +from sqlalchemy.exc import IntegrityError # Load environment variables from .env file # Get the directory where this script is located @@ -18,6 +22,9 @@ app = Flask(__name__) +ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "") +app.secret_key = ADMIN_PASSWORD + app.config["TEMPLATES_AUTO_RELOAD"] = True app.config["MAIL_SERVER"] = "smtp.gmail.com" app.config["MAIL_PORT"] = 465 @@ -51,6 +58,49 @@ # Cache for leaderboard data aoc_cache = {"data": None, "last_updated": None} +# SU membership API configuration +SU_KEY = os.environ.get("SU_KEY", "") +SU_CACHE_DURATION = timedelta(hours=1) +su_cache = {"members": None, "last_updated": None} + + +def get_su_members(): + """Fetch and cache SU membership list. Returns dict of {student_id: full_name}.""" + now = datetime.now() + if ( + su_cache["members"] is not None + and su_cache["last_updated"] is not None + and now - su_cache["last_updated"] < SU_CACHE_DURATION + ): + return su_cache["members"] + + url = f"https://www.warwicksu.com/membershipapi/listMembers/{SU_KEY}/" + response = requests.get(url, timeout=15) + response.raise_for_status() + + root = ET.fromstring(response.text) + members = {} + for member in root.findall("Member"): + uid_el = member.find("UniqueID") + first_el = member.find("FirstName") + last_el = member.find("LastName") + if uid_el is not None: + uid = int(uid_el.text) + first = (first_el.text or "").strip() if first_el is not None else "" + last = (last_el.text or "").strip() if last_el is not None else "" + members[uid] = f"{first} {last}".strip() + + su_cache["members"] = members + su_cache["last_updated"] = now + return members + + +def load_elections_json(): + """Load elections.json and return the categories list.""" + path = os.path.join(os.path.dirname(__file__), "data", "elections.json") + with open(path, "r", encoding="utf-8") as f: + return json.load(f)["categories"] + @app.route("/") def home(): @@ -202,7 +252,283 @@ def aoc_leaderboard(): return jsonify({"error": str(e)}), 500 +# ── Elections ───────────────────────────────────────────────────────────────── + +@app.context_processor +def inject_elections_status(): + try: + settings = ElectionSettings.query.get(1) + return {"elections_open": settings.isOpen if settings else False} + except Exception: + return {"elections_open": False} + + +@app.route("/elections") +def elections(): + settings = ElectionSettings.query.get(1) + is_open = settings.isOpen if settings else False + categories = load_elections_json() + return render_template("elections.html", pageName="elections", is_open=is_open, categories=categories) + + +@app.route("/elections/administrator/login", methods=["GET", "POST"]) +def elections_admin_login(): + error = None + if request.method == "POST": + if request.form.get("password") == ADMIN_PASSWORD: + session["admin"] = True + return redirect(url_for("elections_admin")) + error = "Incorrect password." + return render_template("elections-admin-login.html", error=error) + + +@app.route("/elections/administrator/logout") +def elections_admin_logout(): + session.pop("admin", None) + return redirect(url_for("elections_admin_login")) + + +@app.route("/elections/administrator") +def elections_admin(): + if not session.get("admin"): + return redirect(url_for("elections_admin_login")) + settings = ElectionSettings.query.get(1) + if not settings: + settings = ElectionSettings() + settings.id = 1 + db.session.add(settings) + db.session.commit() + return render_template("elections-admin.html", pageName="elections", is_open=settings.isOpen) + + +@app.route("/elections/") +def elections_category(slug): + settings = ElectionSettings.query.get(1) + if not settings or not settings.isOpen: + return redirect(url_for("elections")) + + categories = load_elections_json() + category = next((c for c in categories if c["slug"] == slug), None) + if category is None: + abort(404) + + position_order = [p["title"] for p in category["positions"]] + candidates = ElectionCandidate.query.filter_by(categorySlug=slug).all() + + positions_dict = {title: [] for title in position_order} + for c in candidates: + if c.positionTitle in positions_dict: + positions_dict[c.positionTitle].append(c) + + # Only include positions that have at least one candidate + positions = [(title, positions_dict[title]) for title in position_order if positions_dict[title]] + + return render_template( + "elections-category.html", + pageName="elections", + category=category, + positions=positions, + ) + + +@app.route("/api/elections/settings", methods=["GET"]) +def elections_settings_get(): + settings = ElectionSettings.query.get(1) + return jsonify({"isOpen": settings.isOpen if settings else False}) + + +@app.route("/api/elections/settings", methods=["POST"]) +def elections_settings_post(): + data = request.get_json() + settings = ElectionSettings.query.get(1) + if not settings: + settings = ElectionSettings() + settings.id = 1 + db.session.add(settings) + closing = settings.isOpen and not bool(data.get("isOpen", False)) + settings.isOpen = bool(data.get("isOpen", False)) + db.session.commit() + + if closing: + _dump_votes_to_csv() + + return jsonify({"isOpen": settings.isOpen}) + + +@app.route("/api/elections/verify", methods=["POST"]) +def elections_verify(): + data = request.get_json() + category_slug = data.get("category_slug") + + try: + raw_id = str(data.get("student_id", "")).strip().lstrip("Uu") + student_id = int(raw_id) + except (ValueError, TypeError): + return jsonify({"valid": False, "error": "Invalid student ID format"}), 400 + + try: + members = get_su_members() + except Exception: + return jsonify({"valid": False, "error": "Could not verify membership, please try again"}), 500 + + if student_id not in members: + return jsonify({"valid": False, "error": "Student ID not found in CodeSoc membership list"}) + + already_voted = False + if category_slug: + already_voted = ElectionVote.query.filter_by( + voterStudentID=student_id, categorySlug=category_slug + ).first() is not None + + return jsonify({"valid": True, "name": members[student_id], "alreadyVoted": already_voted}) + + +@app.route("/api/elections/vote", methods=["POST"]) +def elections_vote(): + settings = ElectionSettings.query.get(1) + if not settings or not settings.isOpen: + return jsonify({"error": "Elections are currently closed"}), 403 + + data = request.get_json() + category_slug = data.get("category_slug") + votes = data.get("votes", []) + + try: + raw_id = str(data.get("student_id", "")).strip().lstrip("Uu") + student_id = int(raw_id) + except (ValueError, TypeError): + return jsonify({"error": "Invalid student ID"}), 400 + + if not category_slug: + return jsonify({"error": "Missing category"}), 400 + + # Re-verify membership server-side + try: + members = get_su_members() + except Exception: + return jsonify({"error": "Could not verify membership"}), 500 + + if student_id not in members: + return jsonify({"error": "Not a CodeSoc member"}), 403 + + # Check already voted + if ElectionVote.query.filter_by(voterStudentID=student_id, categorySlug=category_slug).first(): + return jsonify({"error": "Already voted in this category"}), 409 + + POINTS = {1: 5, 2: 2, 3: 1} + + try: + for v in votes: + rank = v.get("rank") + candidate_id = v.get("candidate_id") + if rank not in POINTS or not candidate_id: + continue + candidate = ElectionCandidate.query.get(candidate_id) + if not candidate or candidate.categorySlug != category_slug: + return jsonify({"error": "Invalid candidate"}), 400 + db.session.add(ElectionVote( + voterStudentID=student_id, + candidateID=candidate_id, + positionTitle=candidate.positionTitle, + categorySlug=category_slug, + rank=rank, + points=POINTS[rank], + )) + db.session.commit() + return jsonify({"success": True}) + except IntegrityError: + db.session.rollback() + return jsonify({"error": "Duplicate vote detected"}), 409 + except Exception as e: + db.session.rollback() + return jsonify({"error": str(e)}), 500 + + +@app.route("/api/elections/results") +def elections_results(): + categories = load_elections_json() + results = [] + + for category in categories: + cat_data = {"name": category["name"], "slug": category["slug"], "positions": []} + for position in category["positions"]: + title = position["title"] + candidates = ElectionCandidate.query.filter_by( + categorySlug=category["slug"], positionTitle=title + ).all() + + rows = [] + for c in candidates: + r1 = ElectionVote.query.filter_by(candidateID=c.candidateID, rank=1).count() + r2 = ElectionVote.query.filter_by(candidateID=c.candidateID, rank=2).count() + r3 = ElectionVote.query.filter_by(candidateID=c.candidateID, rank=3).count() + rows.append({ + "name": c.name, + "points": r1 * 5 + r2 * 2 + r3, + "rank1": r1, "rank2": r2, "rank3": r3, + }) + rows.sort(key=lambda x: x["points"], reverse=True) + if rows: + cat_data["positions"].append({"title": title, "candidates": rows}) + + if cat_data["positions"]: + results.append(cat_data) + + return jsonify(results) + + +def _dump_votes_to_csv(): + """Write all current votes to a timestamped CSV file. Returns the filename.""" + timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + filename = f"votes_{timestamp}.csv" + + votes = ElectionVote.query.order_by( + ElectionVote.voterStudentID, + ElectionVote.categorySlug, + ElectionVote.positionTitle, + ElectionVote.rank, + ).all() + + candidates = {c.candidateID: c.name for c in ElectionCandidate.query.all()} + + with open(filename, "w", newline="", encoding="utf-8") as f: + writer = csv.writer(f) + writer.writerow([ + "voter_student_id", + "category_slug", + "position_title", + "rank", + "points", + "candidate_id", + "candidate_name", + "submitted_at", + ]) + for v in votes: + writer.writerow([ + v.voterStudentID, + v.categorySlug, + v.positionTitle, + v.rank, + v.points, + v.candidateID, + candidates.get(v.candidateID, "Unknown"), + v.submittedAt.isoformat() if v.submittedAt else "", + ]) + + return filename, len(votes) + + # CLI Commands for database management +@app.cli.command("db-dump-votes") +def db_dump_votes(): + """Dump all election votes to a timestamped CSV file.""" + filename, count = _dump_votes_to_csv() + if count == 0: + click.echo("⚠ No votes to dump.") + else: + click.echo(f"✅ Dumped {count} votes to {filename}") + + @app.cli.command("db-reset") def db_reset(): """Reset database with validated JSON data""" diff --git a/data/elections.json b/data/elections.json index b26dc8d..3f989a1 100644 --- a/data/elections.json +++ b/data/elections.json @@ -13,8 +13,13 @@ "name": "Finance Team", "slug": "finance", "positions": [ - { "title": "Treasurer", "candidates": [] }, - { "title": "Merchandise Manager", "candidates": [] } + { "title": "Treasurer", "candidates": [ + { "name": "Aaron Gonsalves", "manifestoLink": "https://drive.google.com/file/d/1KORgg5NuHDexzX8JiIqVuCY5plCL1O1h/view" }, + { "name": "Sean Odera Onwuazo", "manifestoLink": "https://drive.google.com/file/d/18VKf4lUjRH4PAd3KYJiRbJSU6y83RE-U/view" } + ]}, + { "title": "Merchandise Manager", "candidates": [ + { "name": "Riyasath Meah", "manifestoLink": "https://drive.google.com/file/d/12OjWpMp0Fmh62qNeLCBLRhLQlW3FKBxF/view" } + ]} ] }, { @@ -22,7 +27,9 @@ "slug": "academic", "positions": [ { "title": "Head of Mentoring", "candidates": [] }, - { "title": "Python Course Lead", "candidates": [] }, + { "title": "Python Course Lead", "candidates": [ + { "name": "Aaron Gonsalves", "manifestoLink": "https://drive.google.com/file/d/1KORgg5NuHDexzX8JiIqVuCY5plCL1O1h/view" } + ]}, { "title": "C++ Course Lead", "candidates": [] }, { "title": "HPC Team Lead", "candidates": [] } ] @@ -42,7 +49,9 @@ "positions": [ { "title": "Social Secretary", "candidates": [] }, { "title": "Sports Coordinator", "candidates": [] }, - { "title": "Diversity Director", "candidates": [] } + { "title": "Diversity Director", "candidates": [ + { "name": "Riyasath Meah", "manifestoLink": "https://drive.google.com/file/d/12OjWpMp0Fmh62qNeLCBLRhLQlW3FKBxF/view" } + ]} ] } ] diff --git a/db_manager.py b/db_manager.py index 0398c27..68bf367 100644 --- a/db_manager.py +++ b/db_manager.py @@ -7,7 +7,7 @@ from datetime import datetime from flask import Flask from jsonschema import validate, ValidationError -from db_schema import db, ExecMember, BlogPost, Sponsor, SponsorNews +from db_schema import db, ExecMember, BlogPost, Sponsor, SponsorNews, ElectionCandidate, ElectionVote from schema_generator import generate_all_schemas @@ -104,6 +104,8 @@ def clear_database(self): db.session.query(Sponsor).delete() db.session.query(BlogPost).delete() db.session.query(ExecMember).delete() + db.session.query(ElectionVote).delete() + db.session.query(ElectionCandidate).delete() db.session.commit() print("✓ Database cleared") @@ -185,9 +187,36 @@ def load_data_to_database(self, validated_data): db.session.add(news) print(f" ✓ Added {len(validated_data['sponsor_news'])} sponsor news items") + self.load_elections_candidates() + db.session.commit() print("✅ All data loaded successfully") + def load_elections_candidates(self): + """Load election candidates from elections.json""" + elections_path = "data/elections.json" + if not os.path.exists(elections_path): + print(" ⚠ data/elections.json not found, skipping election candidates") + return + + with open(elections_path, "r", encoding="utf-8") as f: + data = json.load(f) + + count = 0 + for category in data.get("categories", []): + for position in category.get("positions", []): + for candidate in position.get("candidates", []): + c = ElectionCandidate( + categorySlug=category["slug"], + categoryName=category["name"], + positionTitle=position["title"], + name=candidate["name"], + manifestoLink=candidate.get("manifestoLink", "") + ) + db.session.add(c) + count += 1 + print(f" ✓ Added {count} election candidates") + def reset_database(self): """Complete database reset with validation""" print("🔄 Starting database reset...") diff --git a/db_schema.py b/db_schema.py index 2716495..0115521 100644 --- a/db_schema.py +++ b/db_schema.py @@ -1,5 +1,5 @@ from flask_sqlalchemy import SQLAlchemy -from datetime import date +from datetime import date, datetime db = SQLAlchemy() @@ -52,6 +52,56 @@ def __init__(self, postTitle, postDescription, postTags, postContent, postAuthor self.postAuthor = postAuthor self.postImageURL = postImageURL +class ElectionCandidate(db.Model): + __tablename__ = "electionCandidates" + + candidateID = db.Column(db.Integer, primary_key=True) + categorySlug = db.Column(db.String(64)) + categoryName = db.Column(db.String(128)) + positionTitle = db.Column(db.String(128)) + name = db.Column(db.String(128)) + manifestoLink = db.Column(db.String(512), default="") + + def __init__(self, categorySlug, categoryName, positionTitle, name, manifestoLink=""): + self.categorySlug = categorySlug + self.categoryName = categoryName + self.positionTitle = positionTitle + self.name = name + self.manifestoLink = manifestoLink + + +class ElectionVote(db.Model): + __tablename__ = "electionVotes" + __table_args__ = ( + db.UniqueConstraint("voterStudentID", "positionTitle", "rank", name="uq_voter_position_rank"), + db.UniqueConstraint("voterStudentID", "candidateID", name="uq_voter_candidate"), + ) + + voteID = db.Column(db.Integer, primary_key=True) + voterStudentID = db.Column(db.Integer, nullable=False) + candidateID = db.Column(db.Integer, nullable=False) + positionTitle = db.Column(db.String(128)) + categorySlug = db.Column(db.String(64)) + rank = db.Column(db.Integer) + points = db.Column(db.Integer) + submittedAt = db.Column(db.DateTime, default=datetime.utcnow) + + def __init__(self, voterStudentID, candidateID, positionTitle, categorySlug, rank, points): + self.voterStudentID = voterStudentID + self.candidateID = candidateID + self.positionTitle = positionTitle + self.categorySlug = categorySlug + self.rank = rank + self.points = points + + +class ElectionSettings(db.Model): + __tablename__ = "electionSettings" + + id = db.Column(db.Integer, primary_key=True) + isOpen = db.Column(db.Boolean, default=False) + + class Sponsor(db.Model): __tablename__ = "sponsors" diff --git a/static/js/elections-admin.js b/static/js/elections-admin.js new file mode 100644 index 0000000..87fb2d4 --- /dev/null +++ b/static/js/elections-admin.js @@ -0,0 +1,93 @@ +// ── Toggle ──────────────────────────────────────────────────────────────────── + +$("#toggle-btn").on("click", function () { + $("#toggle-error").hide(); + $("#toggle-btn").prop("disabled", true); + + $.ajax({ + url: "/api/elections/settings", + method: "POST", + contentType: "application/json", + data: JSON.stringify({ isOpen: !currentlyOpen }), + success: function (data) { + currentlyOpen = data.isOpen; + const $label = $("#elections-status-label"); + if (currentlyOpen) { + $label.text("OPEN").removeClass("badge-closed").addClass("badge-open"); + $("#toggle-btn").text("Close Elections"); + } else { + $label.text("CLOSED").removeClass("badge-open").addClass("badge-closed"); + $("#toggle-btn").text("Open Elections"); + } + }, + error: function () { + $("#toggle-error").text("Failed to update election status.").show(); + }, + complete: function () { + $("#toggle-btn").prop("disabled", false); + }, + }); +}); + +// ── Results ─────────────────────────────────────────────────────────────────── + +function loadResults() { + $.ajax({ + url: "/api/elections/results", + method: "GET", + success: function (data) { + $("#results-loading").hide(); + + if (!data.length) { + $("#results-container").html("

No votes recorded yet.

").show(); + return; + } + + let html = ""; + data.forEach(function (category) { + html += `
`; + html += `

${category.name}

`; + + category.positions.forEach(function (position) { + html += `
`; + html += `

${position.title}

`; + html += ` + + + + + + + + + + + + `; + position.candidates.forEach(function (c, i) { + const rowClass = i === 0 && c.points > 0 ? "admin-results-leader" : ""; + html += ` + + + + + + + + `; + }); + html += `
CandidatePoints1st2nd3rd
${c.name}${c.points}${c.rank1}${c.rank2}${c.rank3}
`; + }); + + html += `
`; + }); + + $("#results-container").html(html).show(); + }, + error: function () { + $("#results-loading").text("Failed to load results."); + }, + }); +} + +loadResults(); diff --git a/static/js/elections-category.js b/static/js/elections-category.js new file mode 100644 index 0000000..74409c3 --- /dev/null +++ b/static/js/elections-category.js @@ -0,0 +1,170 @@ +// rankings[positionTitle] = { candidateId: rank } +const rankings = {}; + +let studentId = null; + +// ── Verify ──────────────────────────────────────────────────────────────────── + +$("#verify-btn").on("click", function () { + const id = $("#student-id-input").val().trim(); + if (!id) return; + + $("#verify-btn").prop("disabled", true).text("Verifying…"); + $("#verify-error").hide(); + + $.ajax({ + url: "/api/elections/verify", + method: "POST", + contentType: "application/json", + data: JSON.stringify({ student_id: id, category_slug: CATEGORY_SLUG }), + success: function (data) { + if (data.valid) { + studentId = id; + if (data.alreadyVoted) { + $("#verify-section").hide(); + $("#already-voted-section").show(); + } else { + $("#voter-name").text(data.name); + $("#verify-section").hide(); + $("#voting-section").show(); + } + } else { + showVerifyError(data.error || "Verification failed"); + } + }, + error: function () { + showVerifyError("Something went wrong. Please try again."); + }, + complete: function () { + $("#verify-btn").prop("disabled", false).text("Verify"); + }, + }); +}); + +$("#student-id-input").on("keydown", function (e) { + if (e.key === "Enter") $("#verify-btn").click(); +}); + +function showVerifyError(msg) { + $("#verify-error").text(msg).show(); +} + +// ── Ranking logic ───────────────────────────────────────────────────────────── + +$(document).on("click", ".candidate-card", function () { + const $card = $(this); + const positionTitle = $card.closest(".position-block").data("position"); + const candidateId = $card.data("candidate-id"); + + if (!rankings[positionTitle]) rankings[positionTitle] = {}; + + const current = rankings[positionTitle][candidateId]; + + if (current) { + // Deselect + delete rankings[positionTitle][candidateId]; + } else { + // Assign next free rank (1 → 2 → 3) + const usedRanks = Object.values(rankings[positionTitle]); + const nextRank = [1, 2, 3].find((r) => !usedRanks.includes(r)); + if (!nextRank) return; // All 3 slots full — ignore click + rankings[positionTitle][candidateId] = nextRank; + } + + updatePositionDisplay(positionTitle); +}); + +function updatePositionDisplay(positionTitle) { + const $block = $(`.position-block[data-position="${CSS.escape(positionTitle)}"]`); + const posRankings = rankings[positionTitle] || {}; + + // Update rank slots + $block.find(".rank-slot").each(function () { + const rank = parseInt($(this).data("rank")); + const candidateId = Object.keys(posRankings).find( + (id) => posRankings[id] === rank + ); + if (candidateId) { + const name = $block + .find(`.candidate-card[data-candidate-id="${candidateId}"]`) + .data("candidate-name"); + $(this).find(".rank-slot-name").text(name); + $(this).addClass("rank-slot-filled"); + } else { + $(this).find(".rank-slot-name").text("—"); + $(this).removeClass("rank-slot-filled"); + } + }); + + // Update candidate cards + $block.find(".candidate-card").each(function () { + const id = $(this).data("candidate-id").toString(); + const rank = posRankings[id]; + const $badge = $(this).find(".candidate-rank-badge"); + if (rank) { + const labels = { 1: "1st", 2: "2nd", 3: "3rd" }; + $badge.text(labels[rank]).show(); + $(this).addClass("candidate-ranked"); + } else { + $badge.hide(); + $(this).removeClass("candidate-ranked"); + } + }); +} + +// Allow clicking a filled rank slot to clear that rank +$(document).on("click", ".rank-slot.rank-slot-filled", function (e) { + e.stopPropagation(); + const rank = parseInt($(this).data("rank")); + const positionTitle = $(this).closest(".position-block").data("position"); + const posRankings = rankings[positionTitle] || {}; + const candidateId = Object.keys(posRankings).find( + (id) => posRankings[id] === rank + ); + if (candidateId) { + delete rankings[positionTitle][candidateId]; + updatePositionDisplay(positionTitle); + } +}); + +// ── Submit ──────────────────────────────────────────────────────────────────── + +$("#submit-btn").on("click", function () { + $("#submit-error").hide(); + + const votes = []; + for (const candRanks of Object.values(rankings)) { + for (const [candId, rank] of Object.entries(candRanks)) { + votes.push({ candidate_id: parseInt(candId), rank: rank }); + } + } + + if (votes.length === 0) { + $("#submit-error").text("Please rank at least one candidate before submitting.").show(); + return; + } + + $("#submit-btn").prop("disabled", true).text("Submitting…"); + + $.ajax({ + url: "/api/elections/vote", + method: "POST", + contentType: "application/json", + data: JSON.stringify({ + student_id: studentId, + category_slug: CATEGORY_SLUG, + votes: votes, + }), + success: function () { + $("#voting-section").hide(); + $("#confirmation-section").show(); + }, + error: function (xhr) { + const msg = + (xhr.responseJSON && xhr.responseJSON.error) || + "Submission failed. Please try again."; + $("#submit-error").text(msg).show(); + $("#submit-btn").prop("disabled", false).text("Submit Votes"); + }, + }); +}); diff --git a/static/styles/styles-body-elections.css b/static/styles/styles-body-elections.css new file mode 100644 index 0000000..de13861 --- /dev/null +++ b/static/styles/styles-body-elections.css @@ -0,0 +1,464 @@ +/* ── Elections — shared layout ───────────────────────────────────────────── */ + +main { + padding: 2rem; + max-width: 1400px; + margin: 0 auto; + color: var(--primary-white); +} + +#pageTitle { + font-family: "objektiv-mk1", sans-serif; + font-size: 3rem; + font-weight: 700; + color: #f0f0f0; + margin-bottom: 1rem; +} + +#pageTitle span { + color: var(--primary-blue); +} + +.elections-back { + display: inline-block; + margin-bottom: 0.25rem; + color: var(--shades-dark); + text-decoration: none; + font-size: 0.9rem; +} + +.elections-back:hover { + color: var(--shades-blue); +} + +.elections-subtitle { + color: var(--shades-dark); + font-size: 1.05rem; + margin-bottom: 2rem; +} + +.elections-error { + color: #e05c5c; + font-size: 0.9rem; + margin-top: 0.5rem; +} + +/* ── Shared button ───────────────────────────────────────────────────────── */ + +.elections-btn { + background-color: var(--primary-blue); + color: white; + border: none; + border-radius: 6px; + padding: 0.5rem 1.25rem; + font-size: 0.95rem; + font-weight: 500; + cursor: pointer; + text-decoration: none; + display: inline-block; + font-family: "azo-sans-web", sans-serif; + transition: background-color 0.2s; +} + +.elections-btn:hover { + background-color: var(--shades-blue); +} + +.elections-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.elections-btn-primary { + padding: 0.65rem 2.5rem; + font-size: 1rem; +} + +.elections-btn-admin { + background-color: var(--background-100); + border: 1px solid var(--shades-dark); +} + +.elections-btn-admin:hover { + background-color: var(--shades-dark); +} + +/* ── Landing — category grid ─────────────────────────────────────────────── */ + +.elections-category-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 1.25rem; + margin-top: 1rem; +} + +.elections-category-card { + display: flex; + flex-direction: column; + gap: 0.4rem; + padding: 1.5rem 1.75rem; + border-radius: 12px; + background-color: var(--background-100); + text-decoration: none; + color: var(--primary-white); + transition: background-color 0.2s, transform 0.15s; +} + +.elections-category-card:hover { + background-color: var(--shades-dark); + transform: translateY(-2px); +} + +.elections-category-card h2 { + font-size: 1.2rem; + margin: 0; + color: #f0f0f0; +} + +.elections-category-card p { + font-size: 0.85rem; + color: var(--shades-dark); + margin: 0; +} + +.elections-vote-cta { + margin-top: auto; + padding-top: 1rem; + font-size: 0.9rem; + color: var(--primary-blue); + font-weight: 600; +} + +.elections-closed { + padding: 3rem 0; + color: var(--shades-dark); + font-size: 1.1rem; +} + +/* ── Category page — verify ──────────────────────────────────────────────── */ + +.verify-box { + background-color: var(--background-100); + border-radius: 12px; + padding: 2rem; + max-width: 480px; + margin-bottom: 2.5rem; +} + +.verify-box h2 { + margin: 0 0 0.4rem; + font-size: 1.2rem; + color: #f0f0f0; +} + +.verify-box p { + color: var(--shades-dark); + font-size: 0.9rem; + margin: 0 0 1.2rem; +} + +.verify-input-row { + display: flex; + gap: 0.75rem; +} + +.verify-input-row input { + flex: 1; + padding: 0.55rem 0.9rem; + border-radius: 6px; + border: 1px solid var(--shades-dark); + background-color: var(--background-300); + color: var(--primary-white); + font-size: 1rem; + font-family: "azo-sans-web", sans-serif; + outline: none; + transition: border-color 0.2s; +} + +.verify-input-row input:focus { + border-color: var(--primary-blue); +} + +/* ── Status boxes ────────────────────────────────────────────────────────── */ + +.elections-status-box { + padding: 2rem; + border-radius: 12px; + max-width: 500px; + background-color: var(--background-100); +} + +.elections-status-box p { + color: #f0f0f0; + margin: 0 0 0.25rem; +} + +.elections-status-success h2 { + color: var(--primary-blue); + margin: 0 0 0.5rem; +} + +/* ── Voting form ─────────────────────────────────────────────────────────── */ + +.elections-verified-name { + color: var(--shades-dark); + font-size: 0.9rem; + margin-bottom: 0.25rem; +} + +.elections-instructions { + color: var(--shades-dark); + font-size: 0.88rem; + max-width: 680px; + margin-bottom: 2rem; +} + +#positions-container { + display: flex; + flex-direction: column; + gap: 2rem; + max-width: 720px; +} + +.position-block { + background-color: var(--background-100); + border-radius: 12px; + padding: 1.5rem; +} + +.position-title { + font-size: 1.1rem; + margin: 0 0 1.2rem; + color: #f0f0f0; +} + +/* Rank slots */ + +.rank-slots { + display: flex; + gap: 0.75rem; + margin-bottom: 1.25rem; + flex-wrap: wrap; +} + +.rank-slot { + display: flex; + flex-direction: column; + align-items: center; + padding: 0.5rem 1rem; + border: 1px dashed var(--shades-dark); + border-radius: 8px; + min-width: 110px; + cursor: pointer; + transition: border-color 0.15s; + user-select: none; + background-color: var(--background-300); +} + +.rank-slot:hover { + border-color: var(--shades-blue); +} + +.rank-slot.rank-slot-filled { + border-style: solid; + border-color: var(--primary-blue); + background-color: var(--background-500); +} + +.rank-label { + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--primary-blue); + margin-bottom: 0.25rem; +} + +.rank-slot-name { + font-size: 0.85rem; + color: #f0f0f0; +} + +/* Candidate cards */ + +.candidates-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.candidate-card { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.75rem 1rem; + border-radius: 8px; + background-color: var(--background-300); + cursor: pointer; + border: 1px solid transparent; + transition: background-color 0.15s, border-color 0.15s; + user-select: none; +} + +.candidate-card:hover { + background-color: var(--background-500); + border-color: var(--shades-dark); +} + +.candidate-card.candidate-ranked { + border-color: var(--primary-blue); + background-color: var(--background-500); +} + +.candidate-name { + flex: 1; + font-size: 0.95rem; + color: #f0f0f0; +} + +.manifesto-link { + font-size: 0.8rem; + color: var(--shades-blue); + text-decoration: underline; + white-space: nowrap; +} + +.manifesto-link:hover { + color: var(--primary-blue); +} + +.candidate-rank-badge { + font-size: 0.75rem; + font-weight: 700; + color: white; + background-color: var(--primary-blue); + border-radius: 4px; + padding: 0.15rem 0.5rem; + white-space: nowrap; +} + +/* Submit row */ + +.elections-submit-row { + margin-top: 2rem; +} + +/* ── Admin page ──────────────────────────────────────────────────────────── */ + +.admin-section { + margin-bottom: 3rem; + max-width: 900px; +} + +.admin-section h2 { + font-size: 1.25rem; + margin: 0 0 1rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--background-100); + color: #f0f0f0; +} + +.admin-toggle-row { + display: flex; + align-items: center; + gap: 1.5rem; +} + +.elections-status-badge { + font-size: 0.82rem; + font-weight: 700; + letter-spacing: 0.1em; + padding: 0.35rem 0.9rem; + border-radius: 5px; +} + +.badge-open { + background-color: rgba(0, 142, 255, 0.15); + color: var(--primary-blue); + border: 1px solid var(--primary-blue); +} + +.badge-closed { + background-color: rgba(92, 100, 129, 0.2); + color: var(--shades-dark); + border: 1px solid var(--shades-dark); +} + +.admin-category-block { + margin-bottom: 2.5rem; +} + +.admin-category-title { + font-size: 1.05rem; + color: var(--primary-blue); + margin: 0 0 1rem; +} + +.admin-position-block { + margin-bottom: 1.5rem; +} + +.admin-position-title { + font-size: 0.9rem; + font-weight: 600; + margin: 0 0 0.5rem; + color: #f0f0f0; +} + +.admin-results-table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; +} + +.admin-results-table th, +.admin-results-table td { + padding: 0.55rem 0.75rem; + text-align: left; + border-bottom: 1px solid var(--background-100); + color: #f0f0f0; +} + +.admin-results-table th { + color: var(--shades-dark); + font-weight: 600; + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.06em; + background-color: var(--background-500); +} + +.admin-results-leader td { + background-color: rgba(0, 142, 255, 0.06); +} + + +/* ── Responsive ──────────────────────────────────────────────────────────── */ + +@media (max-width: 768px) { + main { + padding: 1.25rem; + } + + #pageTitle { + font-size: 2rem; + } + + .elections-category-grid { + grid-template-columns: 1fr 1fr; + } + + .rank-slots { + flex-wrap: wrap; + } +} + +@media (max-width: 480px) { + .elections-category-grid { + grid-template-columns: 1fr; + } + + .verify-input-row { + flex-direction: column; + } +} diff --git a/templates/elections-admin-login.html b/templates/elections-admin-login.html new file mode 100644 index 0000000..0fcfaab --- /dev/null +++ b/templates/elections-admin-login.html @@ -0,0 +1,40 @@ + + + + + Admin Login | CodeSoc + + + + + + + + + + + + + {% include "reusable/header.html" %} + +
+

Admin Login.

+
+
+

Elections Administrator

+

Enter the admin password to continue.

+
+ + +
+ {% if error %} +

{{ error }}

+ {% endif %} +
+
+
+ + {% include "reusable/footer.html" %} + + + diff --git a/templates/elections-admin.html b/templates/elections-admin.html new file mode 100644 index 0000000..c1da9f1 --- /dev/null +++ b/templates/elections-admin.html @@ -0,0 +1,54 @@ + + + + + Elections Administrator | CodeSoc + + + + + + + + + + + + + {% include "reusable/header.html" %} + +
+

Elections Admin.

+ Logout → + + +
+

Status

+
+ + {{ 'OPEN' if is_open else 'CLOSED' }} + + +
+ +
+ + +
+

Results

+
Loading results…
+ +
+
+ + + + + {% include "reusable/footer.html" %} + + + diff --git a/templates/elections-category.html b/templates/elections-category.html new file mode 100644 index 0000000..6978aee --- /dev/null +++ b/templates/elections-category.html @@ -0,0 +1,124 @@ + + + + + {{ category.name }} Elections | CodeSoc + + + + + + + + + + + + + + + +
+ +
+ + {% include "reusable/header.html" %} + +
+ ← All categories +

{{ category.name }}.

+ + +
+
+

Verify your membership

+

Enter your Warwick student ID to confirm you're a CodeSoc member.

+
+ + +
+ +
+
+ + + + + + + + + +
+ + + + + {% include "reusable/footer.html" %} + + + diff --git a/templates/elections.html b/templates/elections.html new file mode 100644 index 0000000..03eb920 --- /dev/null +++ b/templates/elections.html @@ -0,0 +1,56 @@ + + + + + Elections | CodeSoc + + + + + + + + + + + + + + + +
+ +
+ + {% include "reusable/header.html" %} + +
+

Elections.

+ + {% if is_open %} +

Vote for your next Warwick Coding Society exec team. Select a team below to begin.

+ + {% else %} +
+

Elections are not currently open. Check back later.

+
+ {% endif %} +
+ + {% include "reusable/footer.html" %} + + + diff --git a/templates/reusable/header.html b/templates/reusable/header.html index 4516bff..971f5a6 100644 --- a/templates/reusable/header.html +++ b/templates/reusable/header.html @@ -37,6 +37,9 @@ {% endif %} + {% if elections_open %} + Elections + {% endif %} @@ -86,6 +89,9 @@ Events Blog + {% if elections_open %} + Elections + {% endif %}