diff --git a/README.md b/README.md index cb48e88..47cd6bb 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,37 @@ The app will start on **http://localhost:5000**. Open this URL in your browser. --- +## ☁️ Free Hosting and Deployment + +If you want to host Geli so you and your friends can use it on your own devices anywhere in the world, the application is ready for free hosting! Geli uses SQLite, which makes it incredibly simple to host. + +**Recommended Free Host: PythonAnywhere** +PythonAnywhere provides excellent free tiers for hosting Flask applications that use SQLite databases. + +### Deploying to PythonAnywhere: +1. Go to [PythonAnywhere](https://www.pythonanywhere.com/) and create a "Beginner" (free) account. +2. Under the **Web** tab, click **Add a new web app**. +3. Choose **Flask**, then select **Python 3.11** (or the latest available). +4. For the path, the default (`/home/yourusername/mysite/flask_app.py`) is fine. +5. Go to the **Consoles** tab and start a **Bash** console. +6. Clone your fork or the main repository: `git clone https://github.com/NSC508/Geli.git` +7. Install the required dependencies: `pip3 install --user flask requests werkzeug` +8. In the **Files** tab, upload your `creds.json` file inside the cloned `Geli` directory, or use environment variables. +9. Go back to the **Web** tab and click on the WSGI configuration file (e.g., `/var/www/yourusername_pythonanywhere_com_wsgi.py`). +10. Update the WSGI file to point to your `Geli` directory and import your app: + ```python + import sys + path = '/home/yourusername/Geli' + if path not in sys.path: + sys.path.append(path) + + from app import app as application + ``` +11. **Security Note:** In `app.py`, be sure to change `app.secret_key` to a strong, random string before deploying to keep user sessions secure! +12. Hit **Reload** on the Web tab. Your app is now live at `yourusername.pythonanywhere.com`! + +--- + ## 🎯 How to Use 1. **Switch Media** — Click the **Geli logo** in the navbar to switch between Games, Books, Movies, and TV Shows. diff --git a/app.py b/app.py index 9702dc0..34b4566 100644 --- a/app.py +++ b/app.py @@ -1,6 +1,8 @@ """Geli — Multi-Media Rating App (Flask application).""" import json -from flask import Flask, render_template, request, jsonify, session, redirect, url_for +from flask import Flask, render_template, request, jsonify, session, redirect, url_for, flash +from werkzeug.security import generate_password_hash, check_password_hash +from functools import wraps from igdb_client import IGDBClient from openlibrary_client import OpenLibraryClient from tmdb_client import TMDBClient @@ -40,22 +42,86 @@ def ensure_db(): models.init_db() +# ─── Authentication Decorator ──────────────────────────────────────────────── + +def login_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if "user_id" not in session: + if request.path.startswith(f"/{kwargs.get('media_type', 'games')}/api/"): + return jsonify({"error": "Unauthorized"}), 401 + return redirect(url_for("login")) + return f(*args, **kwargs) + return decorated_function + + +# ─── Auth Routes ───────────────────────────────────────────────────────────── + +@app.route("/register", methods=["GET", "POST"]) +def register(): + if request.method == "POST": + username = request.form.get("username", "").strip() + password = request.form.get("password", "") + + if not username or not password: + flash("Username and password are required.", "error") + return redirect(url_for("register")) + + password_hash = generate_password_hash(password) + if models.create_user(username, password_hash): + flash("Registration successful. Please log in.", "success") + return redirect(url_for("login")) + else: + flash("Username already exists.", "error") + return redirect(url_for("register")) + + return render_template("register.html") + + +@app.route("/login", methods=["GET", "POST"]) +def login(): + if request.method == "POST": + username = request.form.get("username", "").strip() + password = request.form.get("password", "") + + user = models.get_user_by_username(username) + if user and check_password_hash(user["password_hash"], password): + session["user_id"] = user["id"] + session["username"] = user["username"] + return redirect(url_for("root")) + else: + flash("Invalid username or password.", "error") + return redirect(url_for("login")) + + return render_template("login.html") + + +@app.route("/logout") +def logout(): + session.pop("user_id", None) + session.pop("username", None) + return redirect(url_for("login")) + + # ─── Root redirect ─────────────────────────────────────────────────────────── @app.route("/") def root(): + if "user_id" not in session: + return redirect(url_for("login")) return redirect(url_for("index", media_type="games")) # ─── Pages ─────────────────────────────────────────────────────────────────── @app.route("//") +@login_required def index(media_type): """Rankings page — show all items stack-ranked with optional scores.""" if media_type not in VALID_MEDIA_TYPES: return redirect(url_for("index", media_type="games")) - items = models.get_all_ranked_items(media_type) + items = models.get_all_ranked_items(session["user_id"], media_type) total = len(items) show_scores = total >= 10 if show_scores: @@ -82,6 +148,7 @@ def index(media_type): @app.route("//search") +@login_required def search_page(media_type): """Search page for finding and rating items.""" if media_type not in VALID_MEDIA_TYPES: @@ -97,6 +164,7 @@ def search_page(media_type): @app.route("//compare") +@login_required def compare_page(media_type): """Pairwise comparison page.""" if media_type not in VALID_MEDIA_TYPES: @@ -111,11 +179,11 @@ def compare_page(media_type): low = state["low"] high = state["high"] - mid, target_item = ranking.get_comparison_target(media_type, tier, low, high) + mid, target_item = ranking.get_comparison_target(session["user_id"], media_type, tier, low, high) state["mid"] = mid session["compare_state"] = state - tier_count = models.count_items_in_tier(media_type, tier) + tier_count = models.count_items_in_tier(session["user_id"], media_type, tier) import math remaining = max(1, int(math.log2(max(high - low + 1, 1))) + 1) @@ -137,6 +205,7 @@ def compare_page(media_type): # ─── API Endpoints ─────────────────────────────────────────────────────────── @app.route("//api/search") +@login_required def api_search(media_type): """Search for items.""" if media_type not in VALID_MEDIA_TYPES: @@ -167,7 +236,7 @@ def api_search(media_type): # Mark items that are already ranked for item in results: - item["already_ranked"] = models.item_exists(media_type, item["external_id"]) + item["already_ranked"] = models.item_exists(session["user_id"], media_type, item["external_id"]) return jsonify(results) except Exception as e: @@ -175,6 +244,7 @@ def api_search(media_type): @app.route("//api/rate", methods=["POST"]) +@login_required def api_rate(media_type): """Receive initial Like/Neutral/Dislike rating and start comparison if needed.""" if media_type not in VALID_MEDIA_TYPES: @@ -185,14 +255,14 @@ def api_rate(media_type): tier = data["tier"] # Check if item already ranked - if models.item_exists(media_type, item_data["external_id"]): + if models.item_exists(session["user_id"], media_type, item_data["external_id"]): return jsonify({"error": "Already ranked"}), 400 # Check if comparison is needed - comp_state = ranking.get_comparison_state(media_type, tier) + comp_state = ranking.get_comparison_state(session["user_id"], media_type, tier) if comp_state is None: # First item in tier — insert directly at position 1 - ranking.insert_item(item_data, media_type, tier, 1) + ranking.insert_item(session["user_id"], item_data, media_type, tier, 1) return jsonify({"status": "done", "redirect": url_for("index", media_type=media_type)}) # Start comparison session @@ -208,6 +278,7 @@ def api_rate(media_type): @app.route("//api/compare", methods=["POST"]) +@login_required def api_compare(media_type): """Process a comparison answer (better/worse).""" state = session.get("compare_state") @@ -224,7 +295,7 @@ def api_compare(media_type): new_low, new_high, insert_pos = ranking.process_comparison(answer, low, high, mid) if insert_pos is not None: - ranking.insert_item(state["item_data"], media_type, state["tier"], insert_pos) + ranking.insert_item(session["user_id"], state["item_data"], media_type, state["tier"], insert_pos) session.pop("compare_state", None) return jsonify({"status": "done", "redirect": url_for("index", media_type=media_type)}) @@ -235,6 +306,7 @@ def api_compare(media_type): @app.route("//api/remove", methods=["POST"]) +@login_required def api_remove(media_type): """Remove an item from rankings.""" if media_type not in VALID_MEDIA_TYPES: @@ -242,7 +314,7 @@ def api_remove(media_type): data = request.get_json() external_id = data["external_id"] - models.remove_item(media_type, external_id) + models.remove_item(session["user_id"], media_type, external_id) return jsonify({"status": "ok"}) diff --git a/models.py b/models.py index ff82ffa..59527b1 100644 --- a/models.py +++ b/models.py @@ -1,6 +1,7 @@ """SQLite database models for Geli — multi-media rankings storage.""" import sqlite3 import os +from werkzeug.security import generate_password_hash DB_PATH = os.path.join(os.path.dirname(__file__), "geli.db") @@ -19,32 +20,89 @@ def init_db(): """Create tables if they don't exist and migrate if needed.""" conn = get_db() + # Create users table + conn.executescript(""" + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """) + # Check if we need to migrate from the old schema cursor = conn.execute("PRAGMA table_info(games)") columns = {row["name"] for row in cursor.fetchall()} if not columns: - # Fresh install — create the new schema - conn.executescript(""" - CREATE TABLE IF NOT EXISTS items ( - external_id TEXT NOT NULL, - media_type TEXT NOT NULL CHECK(media_type IN ('games','books','movies','tv')), - name TEXT NOT NULL, - cover_url TEXT, - meta_line TEXT, - genres TEXT, - release_year INTEGER, - summary TEXT, - tier TEXT NOT NULL CHECK(tier IN ('like','neutral','dislike')), - rank_position INTEGER NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (external_id, media_type) - ); - """) + # Check items table for migration + cursor = conn.execute("PRAGMA table_info(items)") + item_columns = {row["name"] for row in cursor.fetchall()} + + if not item_columns: + # Fresh install — create the new schema with user_id + conn.executescript(""" + CREATE TABLE items ( + user_id INTEGER NOT NULL, + external_id TEXT NOT NULL, + media_type TEXT NOT NULL CHECK(media_type IN ('games','books','movies','tv')), + name TEXT NOT NULL, + cover_url TEXT, + meta_line TEXT, + genres TEXT, + release_year INTEGER, + summary TEXT, + tier TEXT NOT NULL CHECK(tier IN ('like','neutral','dislike')), + rank_position INTEGER NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, external_id, media_type), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + """) + elif "user_id" not in item_columns: + # Migrate items table to include user_id + + # Create a default user to migrate existing items to + default_username = "default_user" + default_password_hash = generate_password_hash("password") + + conn.execute("INSERT OR IGNORE INTO users (id, username, password_hash) VALUES (1, ?, ?)", (default_username, default_password_hash)) + + conn.executescript(""" + CREATE TABLE items_new ( + user_id INTEGER NOT NULL, + external_id TEXT NOT NULL, + media_type TEXT NOT NULL CHECK(media_type IN ('games','books','movies','tv')), + name TEXT NOT NULL, + cover_url TEXT, + meta_line TEXT, + genres TEXT, + release_year INTEGER, + summary TEXT, + tier TEXT NOT NULL CHECK(tier IN ('like','neutral','dislike')), + rank_position INTEGER NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, external_id, media_type), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + INSERT INTO items_new (user_id, external_id, media_type, name, cover_url, meta_line, genres, release_year, summary, tier, rank_position, created_at) + SELECT 1, external_id, media_type, name, cover_url, meta_line, genres, release_year, summary, tier, rank_position, created_at + FROM items; + + DROP TABLE items; + ALTER TABLE items_new RENAME TO items; + """) elif "media_type" not in columns: # Migrate from old single-table games schema → new multi-media schema + default_username = "default_user" + default_password_hash = generate_password_hash("password") + + conn.execute("INSERT OR IGNORE INTO users (id, username, password_hash) VALUES (1, ?, ?)", (default_username, default_password_hash)) + conn.executescript(""" CREATE TABLE IF NOT EXISTS items ( + user_id INTEGER NOT NULL, external_id TEXT NOT NULL, media_type TEXT NOT NULL CHECK(media_type IN ('games','books','movies','tv')), name TEXT NOT NULL, @@ -56,34 +114,35 @@ def init_db(): tier TEXT NOT NULL CHECK(tier IN ('like','neutral','dislike')), rank_position INTEGER NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (external_id, media_type) + PRIMARY KEY (user_id, external_id, media_type), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); INSERT OR IGNORE INTO items - (external_id, media_type, name, cover_url, meta_line, genres, + (user_id, external_id, media_type, name, cover_url, meta_line, genres, release_year, summary, tier, rank_position, created_at) SELECT - CAST(igdb_id AS TEXT), 'games', name, cover_url, platforms, genres, + 1, CAST(igdb_id AS TEXT), 'games', name, cover_url, platforms, genres, release_year, summary, tier, rank_position, created_at FROM games; DROP TABLE IF EXISTS games; """) - # If 'items' table already exists with media_type, nothing to do conn.commit() conn.close() -def add_item(item_data, media_type, tier, rank_position): +def add_item(user_id, item_data, media_type, tier, rank_position): """Insert a new item into the database.""" conn = get_db() conn.execute( """INSERT OR REPLACE INTO items - (external_id, media_type, name, cover_url, meta_line, genres, + (user_id, external_id, media_type, name, cover_url, meta_line, genres, release_year, summary, tier, rank_position) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", ( + user_id, str(item_data["external_id"]), media_type, item_data["name"], @@ -100,18 +159,18 @@ def add_item(item_data, media_type, tier, rank_position): conn.close() -def get_items_by_tier(media_type, tier): +def get_items_by_tier(user_id, media_type, tier): """Get all items in a tier for a media type, ordered by rank_position.""" conn = get_db() rows = conn.execute( - "SELECT * FROM items WHERE media_type = ? AND tier = ? ORDER BY rank_position ASC", - (media_type, tier), + "SELECT * FROM items WHERE user_id = ? AND media_type = ? AND tier = ? ORDER BY rank_position ASC", + (user_id, media_type, tier), ).fetchall() conn.close() return [dict(r) for r in rows] -def get_all_ranked_items(media_type): +def get_all_ranked_items(user_id, media_type): """Get all items for a media type ordered by tier then rank.""" conn = get_db() rows = conn.execute(""" @@ -122,84 +181,121 @@ def get_all_ranked_items(media_type): WHEN 'dislike' THEN 3 END as tier_order FROM items - WHERE media_type = ? + WHERE user_id = ? AND media_type = ? ORDER BY tier_order ASC, rank_position ASC - """, (media_type,)).fetchall() + """, (user_id, media_type,)).fetchall() conn.close() return [dict(r) for r in rows] -def count_items(media_type): +def count_items(user_id, media_type): """Return total number of ranked items for a media type.""" conn = get_db() count = conn.execute( - "SELECT COUNT(*) FROM items WHERE media_type = ?", (media_type,) + "SELECT COUNT(*) FROM items WHERE user_id = ? AND media_type = ?", (user_id, media_type,) ).fetchone()[0] conn.close() return count -def count_items_in_tier(media_type, tier): +def count_items_in_tier(user_id, media_type, tier): """Return number of items in a specific tier for a media type.""" conn = get_db() count = conn.execute( - "SELECT COUNT(*) FROM items WHERE media_type = ? AND tier = ?", - (media_type, tier), + "SELECT COUNT(*) FROM items WHERE user_id = ? AND media_type = ? AND tier = ?", + (user_id, media_type, tier), ).fetchone()[0] conn.close() return count -def get_item_at_rank(media_type, tier, rank_position): +def get_item_at_rank(user_id, media_type, tier, rank_position): """Get the item at a specific rank position within a tier.""" conn = get_db() row = conn.execute( - "SELECT * FROM items WHERE media_type = ? AND tier = ? AND rank_position = ?", - (media_type, tier, rank_position), + "SELECT * FROM items WHERE user_id = ? AND media_type = ? AND tier = ? AND rank_position = ?", + (user_id, media_type, tier, rank_position), ).fetchone() conn.close() return dict(row) if row else None -def shift_ranks_down(media_type, tier, from_position): +def shift_ranks_down(user_id, media_type, tier, from_position): """Shift all items at or after from_position down by 1.""" conn = get_db() conn.execute( """UPDATE items SET rank_position = rank_position + 1 - WHERE media_type = ? AND tier = ? AND rank_position >= ?""", - (media_type, tier, from_position), + WHERE user_id = ? AND media_type = ? AND tier = ? AND rank_position >= ?""", + (user_id, media_type, tier, from_position), ) conn.commit() conn.close() -def item_exists(media_type, external_id): +def item_exists(user_id, media_type, external_id): """Check if an item is already ranked.""" conn = get_db() row = conn.execute( - "SELECT 1 FROM items WHERE media_type = ? AND external_id = ?", - (media_type, str(external_id)), + "SELECT 1 FROM items WHERE user_id = ? AND media_type = ? AND external_id = ?", + (user_id, media_type, str(external_id)), ).fetchone() conn.close() return row is not None -def remove_item(media_type, external_id): +def remove_item(user_id, media_type, external_id): """Remove an item from rankings.""" conn = get_db() item = conn.execute( - "SELECT tier, rank_position FROM items WHERE media_type = ? AND external_id = ?", - (media_type, str(external_id)), + "SELECT tier, rank_position FROM items WHERE user_id = ? AND media_type = ? AND external_id = ?", + (user_id, media_type, str(external_id)), ).fetchone() if item: conn.execute( - "DELETE FROM items WHERE media_type = ? AND external_id = ?", - (media_type, str(external_id)), + "DELETE FROM items WHERE user_id = ? AND media_type = ? AND external_id = ?", + (user_id, media_type, str(external_id)), ) conn.execute( """UPDATE items SET rank_position = rank_position - 1 - WHERE media_type = ? AND tier = ? AND rank_position > ?""", - (media_type, item["tier"], item["rank_position"]), + WHERE user_id = ? AND media_type = ? AND tier = ? AND rank_position > ?""", + (user_id, media_type, item["tier"], item["rank_position"]), + ) + conn.commit() + conn.close() + +# ─── User Models ───────────────────────────────────────────────────────────── + +def create_user(username, password_hash): + """Create a new user.""" + conn = get_db() + try: + conn.execute( + "INSERT INTO users (username, password_hash) VALUES (?, ?)", + (username, password_hash) ) conn.commit() + return True + except sqlite3.IntegrityError: + return False + finally: + conn.close() + + +def get_user_by_username(username): + """Retrieve a user by username.""" + conn = get_db() + user = conn.execute( + "SELECT * FROM users WHERE username = ?", (username,) + ).fetchone() + conn.close() + return dict(user) if user else None + + +def get_user_by_id(user_id): + """Retrieve a user by ID.""" + conn = get_db() + user = conn.execute( + "SELECT * FROM users WHERE id = ?", (user_id,) + ).fetchone() conn.close() + return dict(user) if user else None diff --git a/ranking.py b/ranking.py index 1c3ab77..85f6e5c 100644 --- a/ranking.py +++ b/ranking.py @@ -10,12 +10,12 @@ } -def get_comparison_state(media_type, tier): +def get_comparison_state(user_id, media_type, tier): """Initialize binary search state for a new item being inserted into a tier. Returns dict with low, high for the binary search bounds. If tier is empty, returns None (no comparison needed). """ - tier_count = models.count_items_in_tier(media_type, tier) + tier_count = models.count_items_in_tier(user_id, media_type, tier) if tier_count == 0: return None # First item in tier, just insert at position 1 return { @@ -24,12 +24,12 @@ def get_comparison_state(media_type, tier): } -def get_comparison_target(media_type, tier, low, high): +def get_comparison_target(user_id, media_type, tier, low, high): """Return the item at the midpoint of [low, high] for the next comparison. Returns (mid_position, item_dict). """ mid = (low + high) // 2 - item = models.get_item_at_rank(media_type, tier, mid) + item = models.get_item_at_rank(user_id, media_type, tier, mid) return mid, item @@ -59,10 +59,10 @@ def process_comparison(answer, low, high, mid): return new_low, new_high, None -def insert_item(item_data, media_type, tier, position): +def insert_item(user_id, item_data, media_type, tier, position): """Insert an item at the given position in the tier, shifting others down.""" - models.shift_ranks_down(media_type, tier, position) - models.add_item(item_data, media_type, tier, position) + models.shift_ranks_down(user_id, media_type, tier, position) + models.add_item(user_id, item_data, media_type, tier, position) def calculate_scores(items_list): diff --git a/templates/base.html b/templates/base.html index 9c2cdba..bc5f810 100644 --- a/templates/base.html +++ b/templates/base.html @@ -38,6 +38,12 @@ class="nav-link {% if request.endpoint == 'search_page' %}active{% endif %}"> + Add {{ media_config.singular }} + {% if session.get('username') %} +
+ {{ session.username }} + Logout +
+ {% endif %} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..cd42537 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,97 @@ + + + + + + Login - Geli + + + + + +
+

Log in to Geli

+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + +
+ + + +
+ +
+ + diff --git a/templates/register.html b/templates/register.html new file mode 100644 index 0000000..617e1bb --- /dev/null +++ b/templates/register.html @@ -0,0 +1,97 @@ + + + + + + Register - Geli + + + + + +
+

Join Geli

+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + +
+ + + +
+ +
+ +