add decorators to fetch entity securely

This commit is contained in:
david 2026-02-26 10:51:24 +03:00
parent 806447e221
commit 56510d71d5
12 changed files with 730 additions and 388 deletions

159
backend/app/decorators.py Normal file
View file

@ -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

View file

@ -1,11 +1,20 @@
from datetime import UTC, datetime from datetime import UTC, datetime
from flask import Blueprint, jsonify, request 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 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, from app.models import (Board, Card, CardLabel, CheckItem, Checklist, Comment,
Label, List, User) Label, List, User)
from app.schemas import (BoardCreateRequest, CardCreateRequest,
CheckItemCreateRequest, ChecklistCreateRequest,
CommentCreateRequest, LabelCreateRequest,
ListCreateRequest)
kanban_bp = Blueprint("kanban", __name__) kanban_bp = Blueprint("kanban", __name__)
@ -18,24 +27,16 @@ kanban_bp = Blueprint("kanban", __name__)
@jwt_required() @jwt_required()
def get_boards(): def get_boards():
"""Get all boards for current user""" """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() boards = Board.query.filter_by(user_id=user_id).all()
return jsonify([board.to_dict() for board in boards]), 200 return jsonify([board.to_dict() for board in boards]), 200
@kanban_bp.route("/boards/<int:board_id>", methods=["GET"]) @kanban_bp.route("/boards/<int:board_id>", methods=["GET"])
@jwt_required() @jwt_required()
def get_board(board_id): @load_board_owned
def get_board(board_id, board):
"""Get a single board with all its details""" """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 # Get all lists for this board
lists_data = [] lists_data = []
for lst in board.lists.filter_by(closed=False).order_by(List.pos).all(): 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"]) @kanban_bp.route("/boards", methods=["POST"])
@jwt_required() @jwt_required()
def create_board(): @validate(body=BoardCreateRequest)
def create_board(body: BoardCreateRequest):
"""Create a new board""" """Create a new board"""
user_id = int(get_jwt_identity()) user_id = get_current_user_id()
data = request.get_json()
if not data or not data.get("name"):
return jsonify({"error": "Board name is required"}), 400
board = Board( board = Board(
name=data["name"], name=body.name,
description=data.get("description"), description=body.description,
user_id=user_id, user_id=user_id,
url=data.get("url"), url=body.url,
short_link=data.get("short_link"), short_link=body.short_link,
short_url=data.get("short_url"), short_url=body.short_url,
prefs=data.get("prefs"), prefs=body.prefs,
label_names=data.get("label_names"), label_names=body.label_names,
limits=data.get("limits"), limits=body.limits,
) )
db.session.add(board) db.session.add(board)
db.session.commit() db.session.commit()
return jsonify(board.to_dict()), 201 return board.to_dict(), 201
@kanban_bp.route("/boards/<int:board_id>", methods=["PUT"]) @kanban_bp.route("/boards/<int:board_id>", methods=["PUT"])
@jwt_required() @jwt_required()
def update_board(board_id): @load_board_owned
@validate(body=BoardCreateRequest)
def update_board(board_id, board, body: BoardCreateRequest):
"""Update a board""" """Update a board"""
user_id = int(get_jwt_identity()) board.name = body.name
board = db.session.get(Board, board_id) 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) board.date_last_activity = datetime.now(UTC)
db.session.commit() db.session.commit()
return jsonify(board.to_dict()), 200 return board.to_dict(), 200
@kanban_bp.route("/boards/<int:board_id>", methods=["DELETE"]) @kanban_bp.route("/boards/<int:board_id>", methods=["DELETE"])
@jwt_required() @jwt_required()
def delete_board(board_id): @load_board_owned
def delete_board(board_id, board):
"""Delete a 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.delete(board)
db.session.commit() 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/<int:board_id>/lists", methods=["POST"]) @kanban_bp.route("/boards/<int:board_id>/lists", methods=["POST"])
@jwt_required() @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""" """Create a new list in a board"""
user_id = int(get_jwt_identity()) lst = List(
board = db.session.get(Board, board_id) name=body.name,
board_id=board_id,
if not board: pos=body.pos,
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))
db.session.add(lst) db.session.add(lst)
db.session.commit() db.session.commit()
return jsonify(lst.to_dict()), 201 return lst.to_dict(), 201
@kanban_bp.route("/lists/<int:list_id>", methods=["PUT"]) @kanban_bp.route("/lists/<int:list_id>", methods=["PUT"])
@jwt_required() @jwt_required()
def update_list(list_id): @load_list_owned
@validate(body=ListCreateRequest)
def update_list(list_id, lst, body: ListCreateRequest):
"""Update a list""" """Update a list"""
user_id = int(get_jwt_identity()) lst.name = body.name
lst = db.session.get(List, list_id) if request.json.get("closed") is not None:
lst.closed = request.json.get("closed")
if not lst: lst.pos = body.pos
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)
db.session.commit() db.session.commit()
return jsonify(lst.to_dict()), 200 return lst.to_dict(), 200
@kanban_bp.route("/lists/<int:list_id>", methods=["DELETE"]) @kanban_bp.route("/lists/<int:list_id>", methods=["DELETE"])
@jwt_required() @jwt_required()
def delete_list(list_id): @load_list_owned
def delete_list(list_id, lst):
"""Delete a list""" """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.delete(lst)
db.session.commit() 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/<int:list_id>/cards", methods=["POST"]) @kanban_bp.route("/lists/<int:list_id>/cards", methods=["POST"])
@jwt_required() @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""" """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( card = Card(
name=data["name"], name=body.name,
description=data.get("description"), description=body.description,
board_id=lst.board_id, board_id=lst.board_id,
list_id=list_id, list_id=list_id,
pos=data.get("pos", 65535.0), pos=body.pos,
due=data.get("due"), due=body.due,
due_complete=data.get("due_complete", False), due_complete=body.due_complete,
badges=data.get("badges"), badges=body.badges,
cover=data.get("cover"), cover=body.cover,
desc_data=data.get("desc_data"), desc_data=body.desc_data,
) )
# Update board activity
board.date_last_activity = datetime.now(UTC)
db.session.add(card) db.session.add(card)
db.session.commit() db.session.commit()
return jsonify(card.to_dict()), 201 return card.to_dict(), 201
@kanban_bp.route("/cards/<int:card_id>", methods=["GET"]) @kanban_bp.route("/cards/<int:card_id>", methods=["GET"])
@jwt_required() @jwt_required()
def get_card(card_id): @load_card_owned
def get_card(card_id, card):
"""Get a single card with full details""" """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() card_dict = card.to_dict()
# Add labels # Add labels
@ -311,63 +249,50 @@ def get_card(card_id):
@kanban_bp.route("/cards/<int:card_id>", methods=["PUT"]) @kanban_bp.route("/cards/<int:card_id>", methods=["PUT"])
@jwt_required() @jwt_required()
def update_card(card_id): @load_card_owned
@validate(body=CardCreateRequest)
def update_card(card_id, card, body: CardCreateRequest):
"""Update a card""" """Update a card"""
user_id = int(get_jwt_identity()) card.name = body.name
card = db.session.get(Card, card_id) if body.description is not None:
card.description = body.description
if not card: if request.json.get("closed") is not None:
return jsonify({"error": "Card not found"}), 404 card.closed = request.json.get("closed")
card.pos = body.pos
board = db.session.get(Board, card.board_id) card.due = body.due
if board.user_id != user_id: card.due_complete = body.due_complete
return jsonify({"error": "Access denied"}), 403 if body.badges is not None:
card.badges = body.badges
data = request.get_json() if body.cover is not None:
card.cover = body.cover
card.name = data.get("name", card.name) if body.desc_data is not None:
card.description = data.get("description", card.description) card.desc_data = body.desc_data
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)
# Handle moving card to different list # Handle moving card to different list
if "list_id" in data: if "list_id" in request.json:
new_list_id = data["list_id"] new_list_id = request.json["list_id"]
new_list = db.session.get(List, new_list_id) new_list = db.session.get(List, new_list_id)
if new_list and new_list.board_id == card.board_id: if new_list and new_list.board_id == card.board_id:
card.list_id = new_list_id card.list_id = new_list_id
card.date_last_activity = datetime.now(UTC) card.date_last_activity = datetime.now(UTC)
board = db.session.get(Board, card.board_id)
board.date_last_activity = datetime.now(UTC) board.date_last_activity = datetime.now(UTC)
db.session.commit() db.session.commit()
return jsonify(card.to_dict()), 200 return card.to_dict(), 200
@kanban_bp.route("/cards/<int:card_id>", methods=["DELETE"]) @kanban_bp.route("/cards/<int:card_id>", methods=["DELETE"])
@jwt_required() @jwt_required()
def delete_card(card_id): @load_card_owned
def delete_card(card_id, card):
"""Delete a 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.delete(card)
db.session.commit() 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/<int:board_id>/labels", methods=["GET"]) @kanban_bp.route("/boards/<int:board_id>/labels", methods=["GET"])
@jwt_required() @jwt_required()
def get_labels(board_id): @load_board_owned
def get_labels(board_id, board):
"""Get all labels for a 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() 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/<int:board_id>/labels", methods=["POST"]) @kanban_bp.route("/boards/<int:board_id>/labels", methods=["POST"])
@jwt_required() @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""" """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( label = Label(
name=data["name"], name=body.name,
color=data.get("color"), color=body.color,
board_id=board_id, board_id=board_id,
) )
db.session.add(label) db.session.add(label)
db.session.commit() db.session.commit()
return jsonify(label.to_dict()), 201 return label.to_dict(), 201
@kanban_bp.route("/cards/<int:card_id>/labels", methods=["POST"]) @kanban_bp.route("/cards/<int:card_id>/labels", methods=["POST"])
@jwt_required() @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""" """Add a label to a card"""
user_id = int(get_jwt_identity()) data = request.json
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()
label_id = data.get("label_id") label_id = data.get("label_id")
if not 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) label = db.session.get(Label, label_id)
if not label or label.board_id != card.board_id: if not label or label.board_id != card.board_id:
return ( return {"error": "Label not found or doesn't belong to this board"}, 404
jsonify({"error": "Label not found or doesn't belong to this board"}),
404,
)
# Check if already labeled # Check if already labeled
existing = CardLabel.query.filter_by(card_id=card_id, label_id=label_id).first() existing = CardLabel.query.filter_by(card_id=card_id, label_id=label_id).first()
if existing: 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) card_label = CardLabel(card_id=card_id, label_id=label_id)
db.session.add(card_label) db.session.add(card_label)
db.session.commit() db.session.commit()
return jsonify(card_label.to_dict()), 201 return card_label.to_dict(), 201
@kanban_bp.route("/cards/<int:card_id>/labels/<int:label_id>", methods=["DELETE"]) @kanban_bp.route("/cards/<int:card_id>/labels/<int:label_id>", methods=["DELETE"])
@jwt_required() @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""" """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() card_label = CardLabel.query.filter_by(card_id=card_id, label_id=label_id).first()
if not card_label: 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.delete(card_label)
db.session.commit() 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/<int:card_id>/checklists", methods=["POST"]) @kanban_bp.route("/cards/<int:card_id>/checklists", methods=["POST"])
@jwt_required() @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""" """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( checklist = Checklist(
name=data["name"], name=body.name,
card_id=card_id, card_id=card_id,
board_id=card.board_id, board_id=card.board_id,
pos=data.get("pos", 0), pos=body.pos,
) )
db.session.add(checklist) db.session.add(checklist)
db.session.commit() db.session.commit()
return jsonify(checklist.to_dict()), 201 return checklist.to_dict(), 201
@kanban_bp.route("/checklists/<int:checklist_id>/items", methods=["POST"]) @kanban_bp.route("/checklists/<int:checklist_id>/items", methods=["POST"])
@jwt_required() @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""" """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( check_item = CheckItem(
name=data["name"], name=body.name,
checklist_id=checklist_id, checklist_id=checklist_id,
pos=data.get("pos", 0), pos=body.pos,
state=data.get("state", "incomplete"), state=body.state,
due=data.get("due"), due=body.due,
) )
db.session.add(check_item) db.session.add(check_item)
db.session.commit() db.session.commit()
return jsonify(check_item.to_dict()), 201 return check_item.to_dict(), 201
@kanban_bp.route("/check-items/<int:item_id>", methods=["PUT"]) @kanban_bp.route("/check-items/<int:item_id>", methods=["PUT"])
@jwt_required() @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""" """Update a check item"""
user_id = int(get_jwt_identity()) check_item.name = body.name
check_item = db.session.get(CheckItem, item_id) check_item.state = body.state
check_item.pos = body.pos
if not check_item: check_item.due = body.due
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")
db.session.commit() db.session.commit()
return jsonify(check_item.to_dict()), 200 return check_item.to_dict(), 200
@kanban_bp.route("/checklists/<int:checklist_id>", methods=["DELETE"]) @kanban_bp.route("/checklists/<int:checklist_id>", methods=["DELETE"])
@jwt_required() @jwt_required()
def delete_checklist(checklist_id): @load_checklist_owned
def delete_checklist(checklist_id, checklist):
"""Delete a 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.delete(checklist)
db.session.commit() db.session.commit()
return jsonify({"message": "Checklist deleted"}), 200 return {"message": "Checklist deleted"}, 200
@kanban_bp.route("/check-items/<int:item_id>", methods=["DELETE"]) @kanban_bp.route("/check-items/<int:item_id>", methods=["DELETE"])
@jwt_required() @jwt_required()
def delete_check_item(item_id): @load_check_item_owned
def delete_check_item(item_id, check_item):
"""Delete a 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.delete(check_item)
db.session.commit() 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/<int:card_id>/comments", methods=["GET"]) @kanban_bp.route("/cards/<int:card_id>/comments", methods=["GET"])
@jwt_required() @jwt_required()
def get_comments(card_id): @load_card_owned
def get_comments(card_id, card):
"""Get all comments for a 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 = [] comments = []
for comment in card.comments.order_by(Comment.created_at.desc()).all(): for comment in card.comments.order_by(Comment.created_at.desc()).all():
comment_dict = comment.to_dict() comment_dict = comment.to_dict()
@ -653,72 +468,48 @@ def get_comments(card_id):
comment_dict["user"] = user.to_dict() if user else None comment_dict["user"] = user.to_dict() if user else None
comments.append(comment_dict) comments.append(comment_dict)
return jsonify(comments), 200 return comments, 200
@kanban_bp.route("/cards/<int:card_id>/comments", methods=["POST"]) @kanban_bp.route("/cards/<int:card_id>/comments", methods=["POST"])
@jwt_required() @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""" """Create a new comment on a card"""
user_id = int(get_jwt_identity()) user_id = get_current_user_id()
card = db.session.get(Card, card_id)
if not card: comment = Comment(
return jsonify({"error": "Card not found"}), 404 text=body.text,
card_id=card_id,
board = db.session.get(Board, card.board_id) user_id=user_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)
db.session.add(comment) db.session.add(comment)
db.session.commit() db.session.commit()
return jsonify(comment.to_dict()), 201 return comment.to_dict(), 201
@kanban_bp.route("/comments/<int:comment_id>", methods=["PUT"]) @kanban_bp.route("/comments/<int:comment_id>", methods=["PUT"])
@jwt_required() @jwt_required()
def update_comment(comment_id): @load_comment_owned
@validate(body=CommentCreateRequest)
def update_comment(comment_id, comment, body: CommentCreateRequest):
"""Update a comment""" """Update a comment"""
user_id = int(get_jwt_identity()) comment.text = body.text
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)
db.session.commit() db.session.commit()
return jsonify(comment.to_dict()), 200 return comment.to_dict(), 200
@kanban_bp.route("/comments/<int:comment_id>", methods=["DELETE"]) @kanban_bp.route("/comments/<int:comment_id>", methods=["DELETE"])
@jwt_required() @jwt_required()
def delete_comment(comment_id): @load_comment_owned
def delete_comment(comment_id, comment):
"""Delete a 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.delete(comment)
db.session.commit() db.session.commit()
return jsonify({"message": "Comment deleted"}), 200 return {"message": "Comment deleted"}, 200

View file

@ -1,4 +1,36 @@
"""Pydantic schemas for request/response validation""" """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 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",
]

View file

@ -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

View file

@ -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": "<p>Task description</p>",
}
}
)
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": "<p>Task description</p>",
"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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -1,8 +1,9 @@
Flask==3.0.0 Flask==3.0.0
Flask-CORS==4.0.0 Flask-CORS
Flask-SQLAlchemy==3.1.1 Flask-SQLAlchemy
Flask-Migrate==4.0.5 Flask-Migrate==4.0.5
Flask-JWT-Extended==4.5.3 Flask-JWT-Extended==4.5.3
flask-pydantic==0.14.0
psycopg2-binary==2.9.9 psycopg2-binary==2.9.9
python-dotenv==1.0.0 python-dotenv==1.0.0
Werkzeug==3.0.1 Werkzeug==3.0.1
@ -14,4 +15,4 @@ pytest-flask==1.3.0
pytest-cov==4.1.0 pytest-cov==4.1.0
pytest-mock==3.12.0 pytest-mock==3.12.0
factory-boy==3.3.0 factory-boy==3.3.0
faker==20.1.0 faker==20.1.0

View file

@ -98,7 +98,7 @@ class TestBoardRoutes:
assert response.status_code == 400 assert response.status_code == 400
data = response.get_json() data = response.get_json()
assert "error" in data assert "validation_error" in data
def test_create_board_unauthorized(self, client, db_session): def test_create_board_unauthorized(self, client, db_session):
"""Test creating a board without authentication""" """Test creating a board without authentication"""