From 56510d71d55d9b45692dcd2f81f7d164c7f5244e Mon Sep 17 00:00:00 2001 From: david Date: Thu, 26 Feb 2026 10:51:24 +0300 Subject: [PATCH] add decorators to fetch entity securely --- backend/app/decorators.py | 159 ++++++++ backend/app/routes/kanban.py | 557 +++++++++------------------- backend/app/schemas/__init__.py | 34 +- backend/app/schemas/board.py | 68 ++++ backend/app/schemas/card.py | 72 ++++ backend/app/schemas/check_item.py | 52 +++ backend/app/schemas/checklist.py | 41 ++ backend/app/schemas/comment.py | 44 +++ backend/app/schemas/label.py | 41 ++ backend/app/schemas/list_model.py | 41 ++ backend/requirements/base.txt | 7 +- backend/tests/routes/test_boards.py | 2 +- 12 files changed, 730 insertions(+), 388 deletions(-) create mode 100644 backend/app/decorators.py create mode 100644 backend/app/schemas/board.py create mode 100644 backend/app/schemas/card.py create mode 100644 backend/app/schemas/check_item.py create mode 100644 backend/app/schemas/checklist.py create mode 100644 backend/app/schemas/comment.py create mode 100644 backend/app/schemas/label.py create mode 100644 backend/app/schemas/list_model.py diff --git a/backend/app/decorators.py b/backend/app/decorators.py new file mode 100644 index 0000000..dc5543c --- /dev/null +++ b/backend/app/decorators.py @@ -0,0 +1,159 @@ +from functools import wraps + +from flask import abort, g +from flask_jwt_extended import get_jwt_identity + +from app.models import Board, Card, CheckItem, Checklist, Comment, List + + +def get_current_user_id(): + """Helper to consistently get user ID""" + # Cache in g if you want to avoid decoding JWT multiple times per request + if not hasattr(g, "jwt_identity"): + g.jwt_identity = int(get_jwt_identity()) + return g.jwt_identity + + +def load_board_owned(f): + """ + Loads a Board and ensures it belongs to the current user. + Injects 'board' into the route kwargs. + Aborts with 404 if not found or not owned. + """ + + @wraps(f) + def decorated_function(*args, **kwargs): + user_id = get_current_user_id() + board_id = kwargs.get("board_id") + + # SECURE QUERY: Filter by ID *and* User ID in the DB + board = Board.query.filter_by(id=board_id, user_id=user_id).first() + + if not board: + abort(404) + + kwargs["board"] = board + return f(*args, **kwargs) + + return decorated_function + + +def load_card_owned(f): + """ + Loads a Card and ensures its Parent Board belongs to the current user. + Injects 'card' into the route kwargs. + """ + + @wraps(f) + def decorated_function(*args, **kwargs): + user_id = get_current_user_id() + card_id = kwargs.get("card_id") + + # Join Board to check ownership securely in one query + card = ( + Card.query.join(Board) + .filter(Card.id == card_id, Board.user_id == user_id) + .first() + ) + + if not card: + abort(404) + + kwargs["card"] = card + return f(*args, **kwargs) + + return decorated_function + + +def load_list_owned(f): + """Loads a List ensuring Parent Board ownership.""" + + @wraps(f) + def decorated_function(*args, **kwargs): + user_id = get_current_user_id() + list_id = kwargs.get("list_id") + + lst = ( + List.query.join(Board) + .filter(List.id == list_id, Board.user_id == user_id) + .first() + ) + + if not lst: + abort(404) + + kwargs["lst"] = lst + return f(*args, **kwargs) + + return decorated_function + + +def load_checklist_owned(f): + """Loads a Checklist ensuring Parent Board ownership.""" + + @wraps(f) + def decorated_function(*args, **kwargs): + user_id = get_current_user_id() + checklist_id = kwargs.get("checklist_id") + + checklist = ( + Checklist.query.join(Card) + .join(Board) + .filter(Checklist.id == checklist_id, Board.user_id == user_id) + .first() + ) + + if not checklist: + abort(404) + + kwargs["checklist"] = checklist + return f(*args, **kwargs) + + return decorated_function + + +def load_check_item_owned(f): + """Loads a CheckItem ensuring Parent Board ownership.""" + + @wraps(f) + def decorated_function(*args, **kwargs): + user_id = get_current_user_id() + item_id = kwargs.get("item_id") + + check_item = ( + CheckItem.query.join(Checklist) + .join(Card) + .join(Board) + .filter(CheckItem.id == item_id, Board.user_id == user_id) + .first() + ) + + if not check_item: + abort(404) + + kwargs["check_item"] = check_item + return f(*args, **kwargs) + + return decorated_function + + +def load_comment_owned(f): + """ + Loads a Comment ensuring the Comment itself belongs to the user. + (Based on schema where Comment has user_id) + """ + + @wraps(f) + def decorated_function(*args, **kwargs): + user_id = get_current_user_id() + comment_id = kwargs.get("comment_id") + + comment = Comment.query.filter_by(id=comment_id, user_id=user_id).first() + + if not comment: + abort(404) + + kwargs["comment"] = comment + return f(*args, **kwargs) + + return decorated_function diff --git a/backend/app/routes/kanban.py b/backend/app/routes/kanban.py index d1ed83e..0d4fc1b 100644 --- a/backend/app/routes/kanban.py +++ b/backend/app/routes/kanban.py @@ -1,11 +1,20 @@ from datetime import UTC, datetime from flask import Blueprint, jsonify, request -from flask_jwt_extended import get_jwt_identity, jwt_required +from flask_jwt_extended import jwt_required +from flask_pydantic import validate from app import db +from app.decorators import (get_current_user_id, load_board_owned, + load_card_owned, load_check_item_owned, + load_checklist_owned, load_comment_owned, + load_list_owned) from app.models import (Board, Card, CardLabel, CheckItem, Checklist, Comment, Label, List, User) +from app.schemas import (BoardCreateRequest, CardCreateRequest, + CheckItemCreateRequest, ChecklistCreateRequest, + CommentCreateRequest, LabelCreateRequest, + ListCreateRequest) kanban_bp = Blueprint("kanban", __name__) @@ -18,24 +27,16 @@ kanban_bp = Blueprint("kanban", __name__) @jwt_required() def get_boards(): """Get all boards for current user""" - user_id = int(get_jwt_identity()) + user_id = get_current_user_id() boards = Board.query.filter_by(user_id=user_id).all() return jsonify([board.to_dict() for board in boards]), 200 @kanban_bp.route("/boards/", methods=["GET"]) @jwt_required() -def get_board(board_id): +@load_board_owned +def get_board(board_id, board): """Get a single board with all its details""" - user_id = int(get_jwt_identity()) - board = db.session.get(Board, board_id) - - if not board: - return jsonify({"error": "Board not found"}), 404 - - if board.user_id != user_id: - return jsonify({"error": "Access denied"}), 403 - # Get all lists for this board lists_data = [] for lst in board.lists.filter_by(closed=False).order_by(List.pos).all(): @@ -65,79 +66,67 @@ def get_board(board_id): @kanban_bp.route("/boards", methods=["POST"]) @jwt_required() -def create_board(): +@validate(body=BoardCreateRequest) +def create_board(body: BoardCreateRequest): """Create a new board""" - user_id = int(get_jwt_identity()) - data = request.get_json() - - if not data or not data.get("name"): - return jsonify({"error": "Board name is required"}), 400 + user_id = get_current_user_id() board = Board( - name=data["name"], - description=data.get("description"), + name=body.name, + description=body.description, user_id=user_id, - url=data.get("url"), - short_link=data.get("short_link"), - short_url=data.get("short_url"), - prefs=data.get("prefs"), - label_names=data.get("label_names"), - limits=data.get("limits"), + url=body.url, + short_link=body.short_link, + short_url=body.short_url, + prefs=body.prefs, + label_names=body.label_names, + limits=body.limits, ) db.session.add(board) db.session.commit() - return jsonify(board.to_dict()), 201 + return board.to_dict(), 201 @kanban_bp.route("/boards/", methods=["PUT"]) @jwt_required() -def update_board(board_id): +@load_board_owned +@validate(body=BoardCreateRequest) +def update_board(board_id, board, body: BoardCreateRequest): """Update a board""" - user_id = int(get_jwt_identity()) - board = db.session.get(Board, board_id) + board.name = body.name + if body.description is not None: + board.description = body.description + if request.json.get("closed") is not None: + board.closed = request.json.get("closed") + if body.url is not None: + board.url = body.url + if body.short_url is not None: + board.short_url = body.short_url + if body.prefs is not None: + board.prefs = body.prefs + if body.label_names is not None: + board.label_names = body.label_names + if body.limits is not None: + board.limits = body.limits - if not board: - return jsonify({"error": "Board not found"}), 404 - - if board.user_id != user_id: - return jsonify({"error": "Access denied"}), 403 - - data = request.get_json() - - board.name = data.get("name", board.name) - board.description = data.get("description", board.description) - board.closed = data.get("closed", board.closed) - board.url = data.get("url", board.url) - board.short_url = data.get("short_url", board.short_url) - board.prefs = data.get("prefs", board.prefs) - board.label_names = data.get("label_names", board.label_names) - board.limits = data.get("limits", board.limits) board.date_last_activity = datetime.now(UTC) db.session.commit() - return jsonify(board.to_dict()), 200 + return board.to_dict(), 200 @kanban_bp.route("/boards/", methods=["DELETE"]) @jwt_required() -def delete_board(board_id): +@load_board_owned +def delete_board(board_id, board): """Delete a board""" - user_id = int(get_jwt_identity()) - board = db.session.get(Board, board_id) - - if not board: - return jsonify({"error": "Board not found"}), 404 - - if board.user_id != user_id: - return jsonify({"error": "Access denied"}), 403 - db.session.delete(board) db.session.commit() - return jsonify({"message": "Board deleted"}), 200 + return {"message": "Board deleted"}, 200 # ============================================================================= @@ -147,73 +136,47 @@ def delete_board(board_id): @kanban_bp.route("/boards//lists", methods=["POST"]) @jwt_required() -def create_list(board_id): +@load_board_owned +@validate(body=ListCreateRequest) +def create_list(board_id, board, body: ListCreateRequest): """Create a new list in a board""" - user_id = int(get_jwt_identity()) - board = db.session.get(Board, board_id) - - if not board: - return jsonify({"error": "Board not found"}), 404 - - if board.user_id != user_id: - return jsonify({"error": "Access denied"}), 403 - - data = request.get_json() - - if not data or not data.get("name"): - return jsonify({"error": "List name is required"}), 400 - - lst = List(name=data["name"], board_id=board_id, pos=data.get("pos", 65535.0)) + lst = List( + name=body.name, + board_id=board_id, + pos=body.pos, + ) db.session.add(lst) db.session.commit() - return jsonify(lst.to_dict()), 201 + return lst.to_dict(), 201 @kanban_bp.route("/lists/", methods=["PUT"]) @jwt_required() -def update_list(list_id): +@load_list_owned +@validate(body=ListCreateRequest) +def update_list(list_id, lst, body: ListCreateRequest): """Update a list""" - user_id = int(get_jwt_identity()) - lst = db.session.get(List, list_id) - - if not lst: - return jsonify({"error": "List not found"}), 404 - - board = db.session.get(Board, lst.board_id) - if board.user_id != user_id: - return jsonify({"error": "Access denied"}), 403 - - data = request.get_json() - - lst.name = data.get("name", lst.name) - lst.closed = data.get("closed", lst.closed) - lst.pos = data.get("pos", lst.pos) + lst.name = body.name + if request.json.get("closed") is not None: + lst.closed = request.json.get("closed") + lst.pos = body.pos db.session.commit() - return jsonify(lst.to_dict()), 200 + return lst.to_dict(), 200 @kanban_bp.route("/lists/", methods=["DELETE"]) @jwt_required() -def delete_list(list_id): +@load_list_owned +def delete_list(list_id, lst): """Delete a list""" - user_id = int(get_jwt_identity()) - lst = db.session.get(List, list_id) - - if not lst: - return jsonify({"error": "List not found"}), 404 - - board = db.session.get(Board, lst.board_id) - if board.user_id != user_id: - return jsonify({"error": "Access denied"}), 403 - db.session.delete(lst) db.session.commit() - return jsonify({"message": "List deleted"}), 200 + return {"message": "List deleted"}, 200 # ============================================================================= @@ -223,59 +186,34 @@ def delete_list(list_id): @kanban_bp.route("/lists//cards", methods=["POST"]) @jwt_required() -def create_card(list_id): +@load_list_owned +@validate(body=CardCreateRequest) +def create_card(list_id, lst, body: CardCreateRequest): """Create a new card in a list""" - user_id = int(get_jwt_identity()) - lst = db.session.get(List, list_id) - - if not lst: - return jsonify({"error": "List not found"}), 404 - - board = db.session.get(Board, lst.board_id) - if board.user_id != user_id: - return jsonify({"error": "Access denied"}), 403 - - data = request.get_json() - - if not data or not data.get("name"): - return jsonify({"error": "Card name is required"}), 400 - card = Card( - name=data["name"], - description=data.get("description"), + name=body.name, + description=body.description, board_id=lst.board_id, list_id=list_id, - pos=data.get("pos", 65535.0), - due=data.get("due"), - due_complete=data.get("due_complete", False), - badges=data.get("badges"), - cover=data.get("cover"), - desc_data=data.get("desc_data"), + pos=body.pos, + due=body.due, + due_complete=body.due_complete, + badges=body.badges, + cover=body.cover, + desc_data=body.desc_data, ) - # Update board activity - board.date_last_activity = datetime.now(UTC) - db.session.add(card) db.session.commit() - return jsonify(card.to_dict()), 201 + return card.to_dict(), 201 @kanban_bp.route("/cards/", methods=["GET"]) @jwt_required() -def get_card(card_id): +@load_card_owned +def get_card(card_id, card): """Get a single card with full details""" - user_id = int(get_jwt_identity()) - card = db.session.get(Card, card_id) - - if not card: - return jsonify({"error": "Card not found"}), 404 - - board = db.session.get(Board, card.board_id) - if board.user_id != user_id: - return jsonify({"error": "Access denied"}), 403 - card_dict = card.to_dict() # Add labels @@ -311,63 +249,50 @@ def get_card(card_id): @kanban_bp.route("/cards/", methods=["PUT"]) @jwt_required() -def update_card(card_id): +@load_card_owned +@validate(body=CardCreateRequest) +def update_card(card_id, card, body: CardCreateRequest): """Update a card""" - user_id = int(get_jwt_identity()) - card = db.session.get(Card, card_id) - - if not card: - return jsonify({"error": "Card not found"}), 404 - - board = db.session.get(Board, card.board_id) - if board.user_id != user_id: - return jsonify({"error": "Access denied"}), 403 - - data = request.get_json() - - card.name = data.get("name", card.name) - card.description = data.get("description", card.description) - card.closed = data.get("closed", card.closed) - card.pos = data.get("pos", card.pos) - card.due = data.get("due") - card.due_complete = data.get("due_complete", card.due_complete) - card.badges = data.get("badges", card.badges) - card.cover = data.get("cover", card.cover) - card.desc_data = data.get("desc_data", card.desc_data) + card.name = body.name + if body.description is not None: + card.description = body.description + if request.json.get("closed") is not None: + card.closed = request.json.get("closed") + card.pos = body.pos + card.due = body.due + card.due_complete = body.due_complete + if body.badges is not None: + card.badges = body.badges + if body.cover is not None: + card.cover = body.cover + if body.desc_data is not None: + card.desc_data = body.desc_data # Handle moving card to different list - if "list_id" in data: - new_list_id = data["list_id"] + if "list_id" in request.json: + new_list_id = request.json["list_id"] new_list = db.session.get(List, new_list_id) if new_list and new_list.board_id == card.board_id: card.list_id = new_list_id card.date_last_activity = datetime.now(UTC) + board = db.session.get(Board, card.board_id) board.date_last_activity = datetime.now(UTC) db.session.commit() - return jsonify(card.to_dict()), 200 + return card.to_dict(), 200 @kanban_bp.route("/cards/", methods=["DELETE"]) @jwt_required() -def delete_card(card_id): +@load_card_owned +def delete_card(card_id, card): """Delete a card""" - user_id = int(get_jwt_identity()) - card = db.session.get(Card, card_id) - - if not card: - return jsonify({"error": "Card not found"}), 404 - - board = db.session.get(Board, card.board_id) - if board.user_id != user_id: - return jsonify({"error": "Access denied"}), 403 - db.session.delete(card) db.session.commit() - return jsonify({"message": "Card deleted"}), 200 + return {"message": "Card deleted"}, 200 # ============================================================================= @@ -377,112 +302,71 @@ def delete_card(card_id): @kanban_bp.route("/boards//labels", methods=["GET"]) @jwt_required() -def get_labels(board_id): +@load_board_owned +def get_labels(board_id, board): """Get all labels for a board""" - user_id = int(get_jwt_identity()) - board = db.session.get(Board, board_id) - - if not board: - return jsonify({"error": "Board not found"}), 404 - - if board.user_id != user_id: - return jsonify({"error": "Access denied"}), 403 - labels = board.labels.all() - return jsonify([label.to_dict() for label in labels]), 200 + return [label.to_dict() for label in labels], 200 @kanban_bp.route("/boards//labels", methods=["POST"]) @jwt_required() -def create_label(board_id): +@load_board_owned +@validate(body=LabelCreateRequest) +def create_label(board_id, board, body: LabelCreateRequest): """Create a new label in a board""" - user_id = int(get_jwt_identity()) - board = db.session.get(Board, board_id) - - if not board: - return jsonify({"error": "Board not found"}), 404 - - if board.user_id != user_id: - return jsonify({"error": "Access denied"}), 403 - - data = request.get_json() - - if not data or not data.get("name"): - return jsonify({"error": "Label name is required"}), 400 - label = Label( - name=data["name"], - color=data.get("color"), + name=body.name, + color=body.color, board_id=board_id, ) db.session.add(label) db.session.commit() - return jsonify(label.to_dict()), 201 + return label.to_dict(), 201 @kanban_bp.route("/cards//labels", methods=["POST"]) @jwt_required() -def add_label_to_card(card_id): +@load_card_owned +def add_label_to_card(card_id, card): """Add a label to a card""" - user_id = int(get_jwt_identity()) - card = db.session.get(Card, card_id) - - if not card: - return jsonify({"error": "Card not found"}), 404 - - board = db.session.get(Board, card.board_id) - if board.user_id != user_id: - return jsonify({"error": "Access denied"}), 403 - - data = request.get_json() + data = request.json label_id = data.get("label_id") if not label_id: - return jsonify({"error": "Label ID is required"}), 400 + return {"error": "Label ID is required"}, 400 label = db.session.get(Label, label_id) if not label or label.board_id != card.board_id: - return ( - jsonify({"error": "Label not found or doesn't belong to this board"}), - 404, - ) + return {"error": "Label not found or doesn't belong to this board"}, 404 # Check if already labeled existing = CardLabel.query.filter_by(card_id=card_id, label_id=label_id).first() if existing: - return jsonify({"error": "Label already added to card"}), 400 + return {"error": "Label already added to card"}, 400 card_label = CardLabel(card_id=card_id, label_id=label_id) db.session.add(card_label) db.session.commit() - return jsonify(card_label.to_dict()), 201 + return card_label.to_dict(), 201 @kanban_bp.route("/cards//labels/", methods=["DELETE"]) @jwt_required() -def remove_label_from_card(card_id, label_id): +@load_card_owned +def remove_label_from_card(card_id, card, label_id): """Remove a label from a card""" - user_id = int(get_jwt_identity()) - card = db.session.get(Card, card_id) - - if not card: - return jsonify({"error": "Card not found"}), 404 - - board = db.session.get(Board, card.board_id) - if board.user_id != user_id: - return jsonify({"error": "Access denied"}), 403 - card_label = CardLabel.query.filter_by(card_id=card_id, label_id=label_id).first() if not card_label: - return jsonify({"error": "Label not found on card"}), 404 + return {"error": "Label not found on card"}, 404 db.session.delete(card_label) db.session.commit() - return jsonify({"message": "Label removed from card"}), 200 + return {"message": "Label removed from card"}, 200 # ============================================================================= @@ -492,139 +376,79 @@ def remove_label_from_card(card_id, label_id): @kanban_bp.route("/cards//checklists", methods=["POST"]) @jwt_required() -def create_checklist(card_id): +@load_card_owned +@validate(body=ChecklistCreateRequest) +def create_checklist(card_id, card, body: ChecklistCreateRequest): """Create a new checklist in a card""" - user_id = int(get_jwt_identity()) - card = db.session.get(Card, card_id) - - if not card: - return jsonify({"error": "Card not found"}), 404 - - board = db.session.get(Board, card.board_id) - if board.user_id != user_id: - return jsonify({"error": "Access denied"}), 403 - - data = request.get_json() - - if not data or not data.get("name"): - return jsonify({"error": "Checklist name is required"}), 400 - checklist = Checklist( - name=data["name"], + name=body.name, card_id=card_id, board_id=card.board_id, - pos=data.get("pos", 0), + pos=body.pos, ) db.session.add(checklist) db.session.commit() - return jsonify(checklist.to_dict()), 201 + return checklist.to_dict(), 201 @kanban_bp.route("/checklists//items", methods=["POST"]) @jwt_required() -def create_check_item(checklist_id): +@load_checklist_owned +@validate(body=CheckItemCreateRequest) +def create_check_item(checklist_id, checklist, body: CheckItemCreateRequest): """Create a new check item in a checklist""" - user_id = int(get_jwt_identity()) - checklist = db.session.get(Checklist, checklist_id) - - if not checklist: - return jsonify({"error": "Checklist not found"}), 404 - - card = db.session.get(Card, checklist.card_id) - board = db.session.get(Board, card.board_id) - if board.user_id != user_id: - return jsonify({"error": "Access denied"}), 403 - - data = request.get_json() - - if not data or not data.get("name"): - return jsonify({"error": "Check item name is required"}), 400 - check_item = CheckItem( - name=data["name"], + name=body.name, checklist_id=checklist_id, - pos=data.get("pos", 0), - state=data.get("state", "incomplete"), - due=data.get("due"), + pos=body.pos, + state=body.state, + due=body.due, ) db.session.add(check_item) db.session.commit() - return jsonify(check_item.to_dict()), 201 + return check_item.to_dict(), 201 @kanban_bp.route("/check-items/", methods=["PUT"]) @jwt_required() -def update_check_item(item_id): +@load_check_item_owned +@validate(body=CheckItemCreateRequest) +def update_check_item(item_id, check_item, body: CheckItemCreateRequest): """Update a check item""" - user_id = int(get_jwt_identity()) - check_item = db.session.get(CheckItem, item_id) - - if not check_item: - return jsonify({"error": "Check item not found"}), 404 - - checklist = db.session.get(Checklist, check_item.checklist_id) - card = db.session.get(Card, checklist.card_id) - board = db.session.get(Board, card.board_id) - if board.user_id != user_id: - return jsonify({"error": "Access denied"}), 403 - - data = request.get_json() - - check_item.name = data.get("name", check_item.name) - check_item.state = data.get("state", check_item.state) - check_item.pos = data.get("pos", check_item.pos) - check_item.due = data.get("due") + check_item.name = body.name + check_item.state = body.state + check_item.pos = body.pos + check_item.due = body.due db.session.commit() - return jsonify(check_item.to_dict()), 200 + return check_item.to_dict(), 200 @kanban_bp.route("/checklists/", methods=["DELETE"]) @jwt_required() -def delete_checklist(checklist_id): +@load_checklist_owned +def delete_checklist(checklist_id, checklist): """Delete a checklist""" - user_id = int(get_jwt_identity()) - checklist = db.session.get(Checklist, checklist_id) - - if not checklist: - return jsonify({"error": "Checklist not found"}), 404 - - card = db.session.get(Card, checklist.card_id) - board = db.session.get(Board, card.board_id) - if board.user_id != user_id: - return jsonify({"error": "Access denied"}), 403 - db.session.delete(checklist) db.session.commit() - return jsonify({"message": "Checklist deleted"}), 200 + return {"message": "Checklist deleted"}, 200 @kanban_bp.route("/check-items/", methods=["DELETE"]) @jwt_required() -def delete_check_item(item_id): +@load_check_item_owned +def delete_check_item(item_id, check_item): """Delete a check item""" - user_id = int(get_jwt_identity()) - check_item = db.session.get(CheckItem, item_id) - - if not check_item: - return jsonify({"error": "Check item not found"}), 404 - - checklist = db.session.get(Checklist, check_item.checklist_id) - card = db.session.get(Card, checklist.card_id) - board = db.session.get(Board, card.board_id) - if board.user_id != user_id: - return jsonify({"error": "Access denied"}), 403 - db.session.delete(check_item) db.session.commit() - return jsonify({"message": "Check item deleted"}), 200 + return {"message": "Check item deleted"}, 200 # ============================================================================= @@ -634,18 +458,9 @@ def delete_check_item(item_id): @kanban_bp.route("/cards//comments", methods=["GET"]) @jwt_required() -def get_comments(card_id): +@load_card_owned +def get_comments(card_id, card): """Get all comments for a card""" - user_id = int(get_jwt_identity()) - card = db.session.get(Card, card_id) - - if not card: - return jsonify({"error": "Card not found"}), 404 - - board = db.session.get(Board, card.board_id) - if board.user_id != user_id: - return jsonify({"error": "Access denied"}), 403 - comments = [] for comment in card.comments.order_by(Comment.created_at.desc()).all(): comment_dict = comment.to_dict() @@ -653,72 +468,48 @@ def get_comments(card_id): comment_dict["user"] = user.to_dict() if user else None comments.append(comment_dict) - return jsonify(comments), 200 + return comments, 200 @kanban_bp.route("/cards//comments", methods=["POST"]) @jwt_required() -def create_comment(card_id): +@load_card_owned +@validate(body=CommentCreateRequest) +def create_comment(card_id, card, body: CommentCreateRequest): """Create a new comment on a card""" - user_id = int(get_jwt_identity()) - card = db.session.get(Card, card_id) + user_id = get_current_user_id() - if not card: - return jsonify({"error": "Card not found"}), 404 - - board = db.session.get(Board, card.board_id) - if board.user_id != user_id: - return jsonify({"error": "Access denied"}), 403 - - data = request.get_json() - - if not data or not data.get("text"): - return jsonify({"error": "Comment text is required"}), 400 - - comment = Comment(text=data["text"], card_id=card_id, user_id=user_id) + comment = Comment( + text=body.text, + card_id=card_id, + user_id=user_id, + ) db.session.add(comment) db.session.commit() - return jsonify(comment.to_dict()), 201 + return comment.to_dict(), 201 @kanban_bp.route("/comments/", methods=["PUT"]) @jwt_required() -def update_comment(comment_id): +@load_comment_owned +@validate(body=CommentCreateRequest) +def update_comment(comment_id, comment, body: CommentCreateRequest): """Update a comment""" - user_id = int(get_jwt_identity()) - comment = db.session.get(Comment, comment_id) - - if not comment: - return jsonify({"error": "Comment not found"}), 404 - - if comment.user_id != user_id: - return jsonify({"error": "Access denied"}), 403 - - data = request.get_json() - - comment.text = data.get("text", comment.text) + comment.text = body.text db.session.commit() - return jsonify(comment.to_dict()), 200 + return comment.to_dict(), 200 @kanban_bp.route("/comments/", methods=["DELETE"]) @jwt_required() -def delete_comment(comment_id): +@load_comment_owned +def delete_comment(comment_id, comment): """Delete a comment""" - user_id = int(get_jwt_identity()) - comment = db.session.get(Comment, comment_id) - - if not comment: - return jsonify({"error": "Comment not found"}), 404 - - if comment.user_id != user_id: - return jsonify({"error": "Access denied"}), 403 - db.session.delete(comment) db.session.commit() - return jsonify({"message": "Comment deleted"}), 200 + return {"message": "Comment deleted"}, 200 diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index f5aaf47..7978324 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -1,4 +1,36 @@ """Pydantic schemas for request/response validation""" +from app.schemas.board import BoardCreateRequest, BoardResponse +from app.schemas.card import CardCreateRequest, CardResponse +from app.schemas.check_item import CheckItemCreateRequest, CheckItemResponse +from app.schemas.checklist import ChecklistCreateRequest, ChecklistResponse +from app.schemas.comment import CommentCreateRequest, CommentResponse +from app.schemas.label import LabelCreateRequest, LabelResponse +from app.schemas.list_model import ListCreateRequest, ListResponse from app.schemas.product import ProductCreateRequest, ProductResponse -__all__ = ["ProductCreateRequest", "ProductResponse"] +__all__ = [ + # Product schemas + "ProductCreateRequest", + "ProductResponse", + # Board schemas + "BoardCreateRequest", + "BoardResponse", + # List schemas + "ListCreateRequest", + "ListResponse", + # Card schemas + "CardCreateRequest", + "CardResponse", + # Label schemas + "LabelCreateRequest", + "LabelResponse", + # Checklist schemas + "ChecklistCreateRequest", + "ChecklistResponse", + # CheckItem schemas + "CheckItemCreateRequest", + "CheckItemResponse", + # Comment schemas + "CommentCreateRequest", + "CommentResponse", +] diff --git a/backend/app/schemas/board.py b/backend/app/schemas/board.py new file mode 100644 index 0000000..aeaaedc --- /dev/null +++ b/backend/app/schemas/board.py @@ -0,0 +1,68 @@ +"""Pydantic schemas for Board model""" +from datetime import datetime +from typing import Any, Dict, Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class BoardCreateRequest(BaseModel): + """Schema for creating a new board""" + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "name": "Project Alpha", + "description": "Main project board", + "url": "https://trello.com/b/example", + "short_link": "abc123", + "short_url": "https://trello.com/c/abc123", + "prefs": {"permissionLevel": "private"}, + "label_names": {"green": "Done", "yellow": "In Progress"}, + "limits": {"lists": {"open": 10}}, + } + } + ) + + name: str = Field(..., min_length=1, max_length=200, description="Board name") + description: Optional[str] = Field(None, description="Board description") + url: Optional[str] = Field(None, max_length=500, description="Board URL") + short_link: Optional[str] = Field(None, max_length=100, description="Short link") + short_url: Optional[str] = Field(None, max_length=500, description="Short URL") + prefs: Optional[Dict[str, Any]] = Field(None, description="Board preferences") + label_names: Optional[Dict[str, str]] = Field(None, description="Label names") + limits: Optional[Dict[str, Any]] = Field(None, description="Board limits") + + +class BoardResponse(BaseModel): + """Schema for board response""" + + model_config = ConfigDict( + from_attributes=True, + json_schema_extra={ + "example": { + "id": 1, + "name": "Project Alpha", + "description": "Main project board", + "closed": False, + "url": "https://trello.com/b/example", + "short_link": "abc123", + "short_url": "https://trello.com/c/abc123", + "prefs": {"permissionLevel": "private"}, + "label_names": {"green": "Done"}, + "limits": {"lists": {"open": 10}}, + "date_last_activity": "2024-01-15T10:30:00", + } + }, + ) + + id: int + name: str + description: Optional[str] = None + closed: bool + url: Optional[str] = None + short_link: Optional[str] = None + short_url: Optional[str] = None + prefs: Optional[Dict[str, Any]] = None + label_names: Optional[Dict[str, str]] = None + limits: Optional[Dict[str, Any]] = None + date_last_activity: Optional[datetime] = None diff --git a/backend/app/schemas/card.py b/backend/app/schemas/card.py new file mode 100644 index 0000000..bf97088 --- /dev/null +++ b/backend/app/schemas/card.py @@ -0,0 +1,72 @@ +"""Pydantic schemas for Card model""" +from datetime import datetime +from typing import Any, Dict, Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class CardCreateRequest(BaseModel): + """Schema for creating a new card""" + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "name": "Task 1", + "description": "Complete the task", + "pos": 65535.0, + "due": "2024-12-31T23:59:59", + "due_complete": False, + "badges": {"votes": 0}, + "cover": "https://example.com/cover.jpg", + "desc_data": "

Task description

", + } + } + ) + + name: str = Field(..., min_length=1, max_length=200, description="Card name") + description: Optional[str] = Field(None, description="Card description") + pos: float = Field(default=65535.0, description="Card position") + due: Optional[datetime] = Field(None, description="Card due date") + due_complete: bool = Field(default=False, description="Due complete status") + badges: Optional[Dict[str, Any]] = Field(None, description="Card badges") + cover: Optional[str] = Field(None, description="Cover image URL") + desc_data: Optional[str] = Field(None, description="Description data (HTML)") + + +class CardResponse(BaseModel): + """Schema for card response""" + + model_config = ConfigDict( + from_attributes=True, + json_schema_extra={ + "example": { + "id": 1, + "name": "Task 1", + "description": "Complete the task", + "closed": False, + "pos": 65535.0, + "list_id": 1, + "board_id": 1, + "due": "2024-12-31T23:59:59", + "due_complete": False, + "badges": {"votes": 0}, + "cover": "https://example.com/cover.jpg", + "desc_data": "

Task description

", + "date_last_activity": "2024-01-15T10:30:00", + } + }, + ) + + id: int + name: str + description: Optional[str] = None + closed: bool + pos: float + list_id: int + board_id: int + due: Optional[datetime] = None + due_complete: bool + badges: Optional[Dict[str, Any]] = None + cover: Optional[str] = None + desc_data: Optional[str] = None + date_last_activity: Optional[datetime] = None diff --git a/backend/app/schemas/check_item.py b/backend/app/schemas/check_item.py new file mode 100644 index 0000000..4bebc73 --- /dev/null +++ b/backend/app/schemas/check_item.py @@ -0,0 +1,52 @@ +"""Pydantic schemas for CheckItem model""" +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class CheckItemCreateRequest(BaseModel): + """Schema for creating a new check item""" + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "name": "First step", + "pos": 0, + "state": "incomplete", + "due": "2024-12-31T23:59:59", + } + } + ) + + name: str = Field(..., min_length=1, max_length=200, description="Check item name") + pos: int = Field(default=0, description="Check item position") + state: str = Field( + default="incomplete", description="State: complete or incomplete" + ) + due: Optional[datetime] = Field(None, description="Due date") + + +class CheckItemResponse(BaseModel): + """Schema for check item response""" + + model_config = ConfigDict( + from_attributes=True, + json_schema_extra={ + "example": { + "id": 1, + "name": "First step", + "pos": 0, + "state": "incomplete", + "due": "2024-12-31T23:59:59", + "checklist_id": 1, + } + }, + ) + + id: int + name: str + pos: int + state: str + due: Optional[datetime] = None + checklist_id: int diff --git a/backend/app/schemas/checklist.py b/backend/app/schemas/checklist.py new file mode 100644 index 0000000..900be87 --- /dev/null +++ b/backend/app/schemas/checklist.py @@ -0,0 +1,41 @@ +"""Pydantic schemas for Checklist model""" +from pydantic import BaseModel, ConfigDict, Field + + +class ChecklistCreateRequest(BaseModel): + """Schema for creating a new checklist""" + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "name": "Steps to complete", + "pos": 0, + } + } + ) + + name: str = Field(..., min_length=1, max_length=200, description="Checklist name") + pos: int = Field(default=0, description="Checklist position") + + +class ChecklistResponse(BaseModel): + """Schema for checklist response""" + + model_config = ConfigDict( + from_attributes=True, + json_schema_extra={ + "example": { + "id": 1, + "name": "Steps to complete", + "pos": 0, + "card_id": 1, + "board_id": 1, + } + }, + ) + + id: int + name: str + pos: int + card_id: int + board_id: int diff --git a/backend/app/schemas/comment.py b/backend/app/schemas/comment.py new file mode 100644 index 0000000..b1f1d96 --- /dev/null +++ b/backend/app/schemas/comment.py @@ -0,0 +1,44 @@ +"""Pydantic schemas for Comment model""" +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class CommentCreateRequest(BaseModel): + """Schema for creating a new comment""" + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "text": "This is a comment", + } + } + ) + + text: str = Field(..., min_length=1, description="Comment text") + + +class CommentResponse(BaseModel): + """Schema for comment response""" + + model_config = ConfigDict( + from_attributes=True, + json_schema_extra={ + "example": { + "id": 1, + "text": "This is a comment", + "card_id": 1, + "user_id": 1, + "created_at": "2024-01-15T10:30:00", + "updated_at": "2024-01-15T10:30:00", + } + }, + ) + + id: int + text: str + card_id: int + user_id: int + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None diff --git a/backend/app/schemas/label.py b/backend/app/schemas/label.py new file mode 100644 index 0000000..6fc53a7 --- /dev/null +++ b/backend/app/schemas/label.py @@ -0,0 +1,41 @@ +"""Pydantic schemas for Label model""" +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class LabelCreateRequest(BaseModel): + """Schema for creating a new label""" + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "name": "High Priority", + "color": "#ff0000", + } + } + ) + + name: str = Field(..., min_length=1, max_length=100, description="Label name") + color: Optional[str] = Field(None, max_length=7, description="Label color (hex)") + + +class LabelResponse(BaseModel): + """Schema for label response""" + + model_config = ConfigDict( + from_attributes=True, + json_schema_extra={ + "example": { + "id": 1, + "name": "High Priority", + "color": "#ff0000", + "board_id": 1, + } + }, + ) + + id: int + name: str + color: Optional[str] = None + board_id: int diff --git a/backend/app/schemas/list_model.py b/backend/app/schemas/list_model.py new file mode 100644 index 0000000..bfbc45e --- /dev/null +++ b/backend/app/schemas/list_model.py @@ -0,0 +1,41 @@ +"""Pydantic schemas for List model""" +from pydantic import BaseModel, ConfigDict, Field + + +class ListCreateRequest(BaseModel): + """Schema for creating a new list""" + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "name": "To Do", + "pos": 65535.0, + } + } + ) + + name: str = Field(..., min_length=1, max_length=200, description="List name") + pos: float = Field(default=65535.0, description="List position") + + +class ListResponse(BaseModel): + """Schema for list response""" + + model_config = ConfigDict( + from_attributes=True, + json_schema_extra={ + "example": { + "id": 1, + "name": "To Do", + "closed": False, + "pos": 65535.0, + "board_id": 1, + } + }, + ) + + id: int + name: str + closed: bool + pos: float + board_id: int diff --git a/backend/requirements/base.txt b/backend/requirements/base.txt index 306f23c..f7d67aa 100644 --- a/backend/requirements/base.txt +++ b/backend/requirements/base.txt @@ -1,8 +1,9 @@ Flask==3.0.0 -Flask-CORS==4.0.0 -Flask-SQLAlchemy==3.1.1 +Flask-CORS +Flask-SQLAlchemy Flask-Migrate==4.0.5 Flask-JWT-Extended==4.5.3 +flask-pydantic==0.14.0 psycopg2-binary==2.9.9 python-dotenv==1.0.0 Werkzeug==3.0.1 @@ -14,4 +15,4 @@ pytest-flask==1.3.0 pytest-cov==4.1.0 pytest-mock==3.12.0 factory-boy==3.3.0 -faker==20.1.0 +faker==20.1.0 \ No newline at end of file diff --git a/backend/tests/routes/test_boards.py b/backend/tests/routes/test_boards.py index ddc7db4..99b4f4f 100644 --- a/backend/tests/routes/test_boards.py +++ b/backend/tests/routes/test_boards.py @@ -98,7 +98,7 @@ class TestBoardRoutes: assert response.status_code == 400 data = response.get_json() - assert "error" in data + assert "validation_error" in data def test_create_board_unauthorized(self, client, db_session): """Test creating a board without authentication"""