diff --git a/app/__init__.py b/app/__init__.py index 32bd689..a20d23c 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -26,9 +26,11 @@ def create_app(): from .routes.auth import auth_bp from .routes.main import main_bp + from .routes.friends import friends_bp - app.register_blueprint(auth_bp) app.register_blueprint(main_bp) + app.register_blueprint(auth_bp) + app.register_blueprint(friends_bp) init_db(app) diff --git a/app/routes/friends.py b/app/routes/friends.py new file mode 100644 index 0000000..27e9858 --- /dev/null +++ b/app/routes/friends.py @@ -0,0 +1,327 @@ +from flask import Blueprint, flash, jsonify, redirect, request, url_for +from flask_login import current_user, login_required + +from app.db import get_db + + +friends_bp = Blueprint("friends", __name__) + + +def _get_friendship_row(user_a_id: int, user_b_id: int): + db = get_db() + return db.execute( + """ + SELECT requester_id, addressee_id, status + FROM friendships + WHERE (requester_id = ? AND addressee_id = ?) + OR (requester_id = ? AND addressee_id = ?) + LIMIT 1 + """, + (user_a_id, user_b_id, user_b_id, user_a_id), + ).fetchone() + + +def _send_friend_request_or_accept(addressee_id: int): + if addressee_id == current_user.id: + return {"error": "cannot send a request to yourself"}, 400 + + db = get_db() + user_exists = db.execute( + "SELECT id FROM users WHERE id = ?", + (addressee_id,), + ).fetchone() + if not user_exists: + return {"error": "user not found"}, 404 + + friendship = _get_friendship_row(current_user.id, addressee_id) + if friendship: + if friendship["status"] == "accepted": + return {"error": "already friends"}, 409 + if friendship["status"] == "blocked": + return {"error": "cannot send request"}, 403 + if friendship["status"] == "pending": + if friendship["addressee_id"] == current_user.id: + db.execute( + """ + UPDATE friendships + SET status = 'accepted' + WHERE requester_id = ? AND addressee_id = ? + """, + (addressee_id, current_user.id), + ) + db.commit() + return {"status": "accepted"}, 200 + return {"error": "request already sent"}, 409 + + db.execute( + """ + INSERT INTO friendships (requester_id, addressee_id, status) + VALUES (?, ?, 'pending') + """, + (current_user.id, addressee_id), + ) + db.commit() + return {"status": "pending"}, 201 + + +def _accept_friend_request(requester_id: int): + db = get_db() + updated = db.execute( + """ + UPDATE friendships + SET status = 'accepted' + WHERE requester_id = ? + AND addressee_id = ? + AND status = 'pending' + """, + (requester_id, current_user.id), + ).rowcount + + if updated == 0: + return {"error": "request not found"}, 404 + + db.commit() + return {"status": "accepted"}, 200 + + +def _decline_friend_request(requester_id: int): + db = get_db() + deleted = db.execute( + """ + DELETE FROM friendships + WHERE requester_id = ? + AND addressee_id = ? + AND status = 'pending' + """, + (requester_id, current_user.id), + ).rowcount + + if deleted == 0: + return {"error": "request not found"}, 404 + + db.commit() + return {"status": "declined"}, 200 + + +def _cancel_outgoing_friend_request(addressee_id: int): + db = get_db() + deleted = db.execute( + """ + DELETE FROM friendships + WHERE requester_id = ? + AND addressee_id = ? + AND status = 'pending' + """, + (current_user.id, addressee_id), + ).rowcount + + if deleted == 0: + return {"error": "request not found"}, 404 + + db.commit() + return {"status": "canceled"}, 200 + + +def _friends_page_data(search_query: str = ""): + db = get_db() + + friends = db.execute( + """ + SELECT DISTINCT u.id, u.username, + CASE + WHEN u.last_seen_at IS NOT NULL + AND u.last_seen_at >= datetime('now', '-35 seconds') + THEN 1 ELSE 0 + END AS is_online + FROM friendships f + JOIN users u + ON ( + (f.requester_id = ? AND f.addressee_id = u.id) + OR + (f.addressee_id = ? AND f.requester_id = u.id) + ) + WHERE f.status = 'accepted' + ORDER BY u.username COLLATE NOCASE ASC + """, + (current_user.id, current_user.id), + ).fetchall() + + incoming = db.execute( + """ + SELECT f.requester_id AS id, u.username + FROM friendships f + JOIN users u ON u.id = f.requester_id + WHERE f.addressee_id = ? + AND f.status = 'pending' + ORDER BY u.username COLLATE NOCASE ASC + """, + (current_user.id,), + ).fetchall() + + outgoing = db.execute( + """ + SELECT f.addressee_id AS id, u.username + FROM friendships f + JOIN users u ON u.id = f.addressee_id + WHERE f.requester_id = ? + AND f.status = 'pending' + ORDER BY u.username COLLATE NOCASE ASC + """, + (current_user.id,), + ).fetchall() + + search_results = [] + normalized_query = search_query.strip() + if len(normalized_query) >= 2: + like_query = f"%{normalized_query}%" + rows = db.execute( + """ + SELECT u.id, u.username + FROM users u + WHERE u.id != ? + AND u.username LIKE ? + ORDER BY u.username COLLATE NOCASE ASC + LIMIT 20 + """, + (current_user.id, like_query), + ).fetchall() + + for row in rows: + relation = "none" + friendship = _get_friendship_row(current_user.id, row["id"]) + if friendship: + if friendship["status"] == "accepted": + relation = "accepted" + elif friendship["status"] == "pending": + relation = ( + "incoming" + if friendship["addressee_id"] == current_user.id + else "outgoing" + ) + else: + relation = friendship["status"] + + search_results.append( + { + "id": row["id"], + "username": row["username"], + "relation": relation, + } + ) + + return { + "friends": friends, + "incoming_requests": incoming, + "outgoing_requests": outgoing, + "search_results": search_results, + "search_query": normalized_query, + } + + +@friends_bp.route("/friends/request", methods=["POST"]) +@login_required +def request_page_action(): + try: + addressee_id = int(request.form.get("addressee_id", "")) + except ValueError: + flash("Invalid user id", "error") + return redirect(url_for("main.friends", q=request.form.get("q", ""))) + + payload, status = _send_friend_request_or_accept(addressee_id) + if status in (200, 201): + flash("Friend request updated", "success") + else: + flash(payload["error"], "error") + + return redirect(url_for("main.friends", q=request.form.get("q", ""))) + + +@friends_bp.route("/friends/requests//accept", methods=["POST"]) +@login_required +def accept_page_action(requester_id: int): + payload, status = _accept_friend_request(requester_id) + if status == 200: + flash("Friend request accepted", "success") + else: + flash(payload["error"], "error") + + return redirect(url_for("main.friends", q=request.form.get("q", ""))) + + +@friends_bp.route("/friends/requests//decline", methods=["POST"]) +@login_required +def decline_page_action(requester_id: int): + payload, status = _decline_friend_request(requester_id) + if status == 200: + flash("Friend request declined", "success") + else: + flash(payload["error"], "error") + + return redirect(url_for("main.friends", q=request.form.get("q", ""))) + + +@friends_bp.route("/friends/requests//cancel", methods=["POST"]) +@login_required +def cancel_page_action(addressee_id: int): + payload, status = _cancel_outgoing_friend_request(addressee_id) + if status == 200: + flash("Outgoing request canceled", "success") + else: + flash(payload["error"], "error") + + return redirect(url_for("main.friends", q=request.form.get("q", ""))) + + +@friends_bp.route("/api/friends", methods=["GET"]) +@login_required +def list_friends(): + data = _friends_page_data("") + return jsonify({"friends": [dict(row) for row in data["friends"]]}) + + +@friends_bp.route("/api/friends/search", methods=["GET"]) +@login_required +def search_people(): + data = _friends_page_data(request.args.get("q", "")) + return jsonify({"results": data["search_results"]}) + + +@friends_bp.route("/api/friends/requests/incoming", methods=["GET"]) +@login_required +def incoming_friend_requests(): + data = _friends_page_data("") + return jsonify({"requests": [dict(row) for row in data["incoming_requests"]]}) + + +@friends_bp.route("/api/friends/requests/outgoing", methods=["GET"]) +@login_required +def outgoing_friend_requests(): + data = _friends_page_data("") + return jsonify({"requests": [dict(row) for row in data["outgoing_requests"]]}) + + +@friends_bp.route("/api/friends/requests", methods=["POST"]) +@login_required +def send_friend_request(): + payload = request.get_json(silent=True) or {} + addressee_id = payload.get("addressee_id") + + if not isinstance(addressee_id, int): + return jsonify({"error": "addressee_id must be an integer"}), 400 + + response_payload, status = _send_friend_request_or_accept(addressee_id) + return jsonify(response_payload), status + + +@friends_bp.route("/api/friends/requests//accept", methods=["POST"]) +@login_required +def accept_friend_request(requester_id: int): + payload, status = _accept_friend_request(requester_id) + return jsonify(payload), status + + +@friends_bp.route("/api/friends/requests//decline", methods=["POST"]) +@login_required +def decline_friend_request(requester_id: int): + payload, status = _decline_friend_request(requester_id) + return jsonify(payload), status diff --git a/app/routes/main.py b/app/routes/main.py index 2675480..f4a832f 100644 --- a/app/routes/main.py +++ b/app/routes/main.py @@ -1,16 +1,10 @@ -from flask import Blueprint, render_template, redirect, url_for +from flask import Blueprint, render_template, redirect, url_for, request from flask_login import login_required, current_user +from app.routes.friends import _friends_page_data main_bp = Blueprint("main", __name__) -""" def login_required(view): - @wraps(view) - def wrapped_view(**kwargs): - if "user_id" not in session: - return redirect(url_for("auth.login")) - return view(**kwargs) - return wrapped_view - """ + @main_bp.route("/", methods=["GET", "POST"]) def index(): @@ -18,10 +12,10 @@ def index(): return redirect(url_for("main.home")) return render_template("index.html") + @main_bp.route("/home", methods=["GET", "POST"]) @login_required def home(): - print(f"Current user: {current_user.username}") return render_template("home.html") @@ -31,7 +25,9 @@ def play(): return render_template("play.html") +#todo: decide if this should get moved to the friends.py file @main_bp.route("/friends", methods=["GET"]) @login_required def friends(): - return render_template("friends.html") + data = _friends_page_data(request.args.get("q", "")) + return render_template("friends.html", **data) \ No newline at end of file diff --git a/app/templates/friends.html b/app/templates/friends.html index 15d359b..95e80a9 100644 --- a/app/templates/friends.html +++ b/app/templates/friends.html @@ -1,48 +1,153 @@ - - - - - - Friends - - - Friends + +
+

Find people

+
+ - - - -
-
- Chess - -
{{ current_user.username }}
-
+ + -

Friends

- -
-
-

Online now

-
    -
  • name1
  • -
  • friend2
  • -
  • bro has a lot of friends
  • -
-
- -
-

Incoming invites

-

No pending invitations -

-
-
- + {% if search_query %} +
+ {% if search_results %} {% for person in search_results %} +
+
+
{{ person.username }}
+
{{ person.relation|capitalize }}
+
+
+ {% if person.relation == 'none' %} +
+ + + +
+ {% elif person.relation == 'incoming' %} +
+ + +
+
+ + +
+ {% elif person.relation == 'outgoing' %} +
+ + +
+ {% else %} + No action available + {% endif %} +
- - + {% endfor %} {% else %} +

No users found for "{{ search_query }}".

+ {% endif %} +
+ {% endif %} +
+ +
+ +

Your friends

+ {% if friends %} +
+ {% for friend in friends %} +
+
+
{{ friend.username }}
+
+ {{ 'Online' if friend.is_online else 'Offline' }} +
+
+ + +
+ {% endfor %} +
+ {% else %} +

No friends yet.

+ {% endif %} +
+ +
+

Requests

+ +

Incoming

+ {% if incoming_requests %} +
+ {% for req in incoming_requests %} +
+
+
{{ req.username }}
+
Pending
+
+
+
+ +
+
+ +
+
+
+ {% endfor %} +
+ {% else %} +

No incoming requests.

+ {% endif %} + +

Outgoing

+ {% if outgoing_requests %} +
+ {% for req in outgoing_requests %} +
+
+
{{ req.username }}
+
Pending
+
+
+ +
+
+ {% endfor %} +
+ {% else %} +

No outgoing requests.

+ {% endif %} +
+{% endblock %}