Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
92 changes: 82 additions & 10 deletions app.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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("/<media_type>/")
@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:
Expand All @@ -82,6 +148,7 @@ def index(media_type):


@app.route("/<media_type>/search")
@login_required
def search_page(media_type):
"""Search page for finding and rating items."""
if media_type not in VALID_MEDIA_TYPES:
Expand All @@ -97,6 +164,7 @@ def search_page(media_type):


@app.route("/<media_type>/compare")
@login_required
def compare_page(media_type):
"""Pairwise comparison page."""
if media_type not in VALID_MEDIA_TYPES:
Expand All @@ -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)

Expand All @@ -137,6 +205,7 @@ def compare_page(media_type):
# ─── API Endpoints ───────────────────────────────────────────────────────────

@app.route("/<media_type>/api/search")
@login_required
def api_search(media_type):
"""Search for items."""
if media_type not in VALID_MEDIA_TYPES:
Expand Down Expand Up @@ -167,14 +236,15 @@ 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:
return jsonify({"error": str(e)}), 500


@app.route("/<media_type>/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:
Expand All @@ -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
Expand All @@ -208,6 +278,7 @@ def api_rate(media_type):


@app.route("/<media_type>/api/compare", methods=["POST"])
@login_required
def api_compare(media_type):
"""Process a comparison answer (better/worse)."""
state = session.get("compare_state")
Expand All @@ -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)})

Expand All @@ -235,14 +306,15 @@ def api_compare(media_type):


@app.route("/<media_type>/api/remove", methods=["POST"])
@login_required
def api_remove(media_type):
"""Remove an item from rankings."""
if media_type not in VALID_MEDIA_TYPES:
return jsonify({"error": "Invalid media type"}), 400

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"})


Expand Down
Loading