diff --git a/backend/app/routes/kanban.py b/backend/app/routes/kanban.py deleted file mode 100644 index 0d4fc1b..0000000 --- a/backend/app/routes/kanban.py +++ /dev/null @@ -1,515 +0,0 @@ -from datetime import UTC, datetime - -from flask import Blueprint, jsonify, request -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__) - -# ============================================================================= -# BOARD ROUTES -# ============================================================================= - - -@kanban_bp.route("/boards", methods=["GET"]) -@jwt_required() -def get_boards(): - """Get all boards for current user""" - 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() -@load_board_owned -def get_board(board_id, board): - """Get a single board with all its details""" - # Get all lists for this board - lists_data = [] - for lst in board.lists.filter_by(closed=False).order_by(List.pos).all(): - cards_data = [] - for card in lst.cards.filter_by(closed=False).order_by(Card.pos).all(): - card_dict = card.to_dict() - # Add labels for this card - card_dict["labels"] = [ - label.to_dict() - for label in ( - db.session.query(Label) - .join(CardLabel) - .filter(CardLabel.card_id == card.id) - .all() - ) - ] - cards_data.append(card_dict) - - lists_data.append({**lst.to_dict(), "cards": cards_data}) - - board_dict = board.to_dict() - board_dict["lists"] = lists_data - board_dict["labels"] = [label.to_dict() for label in board.labels.all()] - - return jsonify(board_dict), 200 - - -@kanban_bp.route("/boards", methods=["POST"]) -@jwt_required() -@validate(body=BoardCreateRequest) -def create_board(body: BoardCreateRequest): - """Create a new board""" - user_id = get_current_user_id() - - board = Board( - name=body.name, - description=body.description, - user_id=user_id, - 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 board.to_dict(), 201 - - -@kanban_bp.route("/boards/", methods=["PUT"]) -@jwt_required() -@load_board_owned -@validate(body=BoardCreateRequest) -def update_board(board_id, board, body: BoardCreateRequest): - """Update a board""" - 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 - - board.date_last_activity = datetime.now(UTC) - - db.session.commit() - - return board.to_dict(), 200 - - -@kanban_bp.route("/boards/", methods=["DELETE"]) -@jwt_required() -@load_board_owned -def delete_board(board_id, board): - """Delete a board""" - db.session.delete(board) - db.session.commit() - - return {"message": "Board deleted"}, 200 - - -# ============================================================================= -# LIST ROUTES -# ============================================================================= - - -@kanban_bp.route("/boards//lists", methods=["POST"]) -@jwt_required() -@load_board_owned -@validate(body=ListCreateRequest) -def create_list(board_id, board, body: ListCreateRequest): - """Create a new list in a board""" - lst = List( - name=body.name, - board_id=board_id, - pos=body.pos, - ) - - db.session.add(lst) - db.session.commit() - - return lst.to_dict(), 201 - - -@kanban_bp.route("/lists/", methods=["PUT"]) -@jwt_required() -@load_list_owned -@validate(body=ListCreateRequest) -def update_list(list_id, lst, body: ListCreateRequest): - """Update a list""" - 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 lst.to_dict(), 200 - - -@kanban_bp.route("/lists/", methods=["DELETE"]) -@jwt_required() -@load_list_owned -def delete_list(list_id, lst): - """Delete a list""" - db.session.delete(lst) - db.session.commit() - - return {"message": "List deleted"}, 200 - - -# ============================================================================= -# CARD ROUTES -# ============================================================================= - - -@kanban_bp.route("/lists//cards", methods=["POST"]) -@jwt_required() -@load_list_owned -@validate(body=CardCreateRequest) -def create_card(list_id, lst, body: CardCreateRequest): - """Create a new card in a list""" - card = Card( - name=body.name, - description=body.description, - board_id=lst.board_id, - list_id=list_id, - pos=body.pos, - due=body.due, - due_complete=body.due_complete, - badges=body.badges, - cover=body.cover, - desc_data=body.desc_data, - ) - - db.session.add(card) - db.session.commit() - - return card.to_dict(), 201 - - -@kanban_bp.route("/cards/", methods=["GET"]) -@jwt_required() -@load_card_owned -def get_card(card_id, card): - """Get a single card with full details""" - card_dict = card.to_dict() - - # Add labels - card_dict["labels"] = [ - label.to_dict() - for label in ( - db.session.query(Label) - .join(CardLabel) - .filter(CardLabel.card_id == card.id) - .all() - ) - ] - - # Add checklists - card_dict["checklists"] = [ - { - **checklist.to_dict(), - "items": [item.to_dict() for item in checklist.check_items.all()], - } - for checklist in card.checklists.all() - ] - - # Add comments - card_dict["comments"] = [] - for comment in card.comments.all(): - comment_dict = comment.to_dict() - user = db.session.get(User, comment.user_id) - comment_dict["user"] = user.to_dict() if user else None - card_dict["comments"].append(comment_dict) - - return jsonify(card_dict), 200 - - -@kanban_bp.route("/cards/", methods=["PUT"]) -@jwt_required() -@load_card_owned -@validate(body=CardCreateRequest) -def update_card(card_id, card, body: CardCreateRequest): - """Update a card""" - 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 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 card.to_dict(), 200 - - -@kanban_bp.route("/cards/", methods=["DELETE"]) -@jwt_required() -@load_card_owned -def delete_card(card_id, card): - """Delete a card""" - db.session.delete(card) - db.session.commit() - - return {"message": "Card deleted"}, 200 - - -# ============================================================================= -# LABEL ROUTES -# ============================================================================= - - -@kanban_bp.route("/boards//labels", methods=["GET"]) -@jwt_required() -@load_board_owned -def get_labels(board_id, board): - """Get all labels for a board""" - labels = board.labels.all() - return [label.to_dict() for label in labels], 200 - - -@kanban_bp.route("/boards//labels", methods=["POST"]) -@jwt_required() -@load_board_owned -@validate(body=LabelCreateRequest) -def create_label(board_id, board, body: LabelCreateRequest): - """Create a new label in a board""" - label = Label( - name=body.name, - color=body.color, - board_id=board_id, - ) - - db.session.add(label) - db.session.commit() - - return label.to_dict(), 201 - - -@kanban_bp.route("/cards//labels", methods=["POST"]) -@jwt_required() -@load_card_owned -def add_label_to_card(card_id, card): - """Add a label to a card""" - data = request.json - label_id = data.get("label_id") - - if not label_id: - 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 {"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 {"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 card_label.to_dict(), 201 - - -@kanban_bp.route("/cards//labels/", methods=["DELETE"]) -@jwt_required() -@load_card_owned -def remove_label_from_card(card_id, card, label_id): - """Remove a label from a card""" - card_label = CardLabel.query.filter_by(card_id=card_id, label_id=label_id).first() - if not card_label: - return {"error": "Label not found on card"}, 404 - - db.session.delete(card_label) - db.session.commit() - - return {"message": "Label removed from card"}, 200 - - -# ============================================================================= -# CHECKLIST ROUTES -# ============================================================================= - - -@kanban_bp.route("/cards//checklists", methods=["POST"]) -@jwt_required() -@load_card_owned -@validate(body=ChecklistCreateRequest) -def create_checklist(card_id, card, body: ChecklistCreateRequest): - """Create a new checklist in a card""" - checklist = Checklist( - name=body.name, - card_id=card_id, - board_id=card.board_id, - pos=body.pos, - ) - - db.session.add(checklist) - db.session.commit() - - return checklist.to_dict(), 201 - - -@kanban_bp.route("/checklists//items", methods=["POST"]) -@jwt_required() -@load_checklist_owned -@validate(body=CheckItemCreateRequest) -def create_check_item(checklist_id, checklist, body: CheckItemCreateRequest): - """Create a new check item in a checklist""" - check_item = CheckItem( - name=body.name, - checklist_id=checklist_id, - pos=body.pos, - state=body.state, - due=body.due, - ) - - db.session.add(check_item) - db.session.commit() - - return check_item.to_dict(), 201 - - -@kanban_bp.route("/check-items/", methods=["PUT"]) -@jwt_required() -@load_check_item_owned -@validate(body=CheckItemCreateRequest) -def update_check_item(item_id, check_item, body: CheckItemCreateRequest): - """Update a check item""" - check_item.name = body.name - check_item.state = body.state - check_item.pos = body.pos - check_item.due = body.due - - db.session.commit() - - return check_item.to_dict(), 200 - - -@kanban_bp.route("/checklists/", methods=["DELETE"]) -@jwt_required() -@load_checklist_owned -def delete_checklist(checklist_id, checklist): - """Delete a checklist""" - db.session.delete(checklist) - db.session.commit() - - return {"message": "Checklist deleted"}, 200 - - -@kanban_bp.route("/check-items/", methods=["DELETE"]) -@jwt_required() -@load_check_item_owned -def delete_check_item(item_id, check_item): - """Delete a check item""" - db.session.delete(check_item) - db.session.commit() - - return {"message": "Check item deleted"}, 200 - - -# ============================================================================= -# COMMENT ROUTES -# ============================================================================= - - -@kanban_bp.route("/cards//comments", methods=["GET"]) -@jwt_required() -@load_card_owned -def get_comments(card_id, card): - """Get all comments for a card""" - comments = [] - for comment in card.comments.order_by(Comment.created_at.desc()).all(): - comment_dict = comment.to_dict() - user = db.session.get(User, comment.user_id) - comment_dict["user"] = user.to_dict() if user else None - comments.append(comment_dict) - - return comments, 200 - - -@kanban_bp.route("/cards//comments", methods=["POST"]) -@jwt_required() -@load_card_owned -@validate(body=CommentCreateRequest) -def create_comment(card_id, card, body: CommentCreateRequest): - """Create a new comment on a card""" - user_id = get_current_user_id() - - comment = Comment( - text=body.text, - card_id=card_id, - user_id=user_id, - ) - - db.session.add(comment) - db.session.commit() - - return comment.to_dict(), 201 - - -@kanban_bp.route("/comments/", methods=["PUT"]) -@jwt_required() -@load_comment_owned -@validate(body=CommentCreateRequest) -def update_comment(comment_id, comment, body: CommentCreateRequest): - """Update a comment""" - comment.text = body.text - - db.session.commit() - - return comment.to_dict(), 200 - - -@kanban_bp.route("/comments/", methods=["DELETE"]) -@jwt_required() -@load_comment_owned -def delete_comment(comment_id, comment): - """Delete a comment""" - db.session.delete(comment) - db.session.commit() - - return {"message": "Comment deleted"}, 200 diff --git a/backend/app/routes/kanban/__init__.py b/backend/app/routes/kanban/__init__.py new file mode 100644 index 0000000..6ec9b8d --- /dev/null +++ b/backend/app/routes/kanban/__init__.py @@ -0,0 +1,14 @@ +from flask import Blueprint + +# Create the kanban blueprint that will be used by all route modules +kanban_bp = Blueprint("kanban", __name__) + +# Import all route modules to register their routes to this blueprint + +# fmt: off +from . import (boards, cards, checklists, comments, labels, # noqa: F401 E402 + lists) + +# fmt: on + +__all__ = ["kanban_bp"] diff --git a/backend/app/routes/kanban/boards.py b/backend/app/routes/kanban/boards.py new file mode 100644 index 0000000..b69ed4e --- /dev/null +++ b/backend/app/routes/kanban/boards.py @@ -0,0 +1,120 @@ +from datetime import UTC, datetime + +from flask import request +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 +from app.models import Board, Card, CardLabel, Label, List +from app.schemas import (BoardCreateRequest, BoardResponse, + BoardWithDetailsResponse) + +from . import kanban_bp + + +@kanban_bp.route("/boards", methods=["GET"]) +@jwt_required() +def get_boards(): + """Get all boards for current user""" + user_id = get_current_user_id() + boards = Board.query.filter_by(user_id=user_id).all() + return [BoardResponse.model_validate(board).model_dump() for board in boards], 200 + + +@kanban_bp.route("/boards/", methods=["GET"]) +@jwt_required() +@load_board_owned +def get_board(board_id, board): + """Get a single board with all its details""" + # Get all lists for this board + lists_data = [] + for lst in board.lists.filter_by(closed=False).order_by(List.pos).all(): + cards_data = [] + for card in lst.cards.filter_by(closed=False).order_by(Card.pos).all(): + card_dict = card.to_dict() + # Add labels for this card + card_dict["labels"] = [ + label.to_dict() + for label in ( + db.session.query(Label) + .join(CardLabel) + .filter(CardLabel.card_id == card.id) + .all() + ) + ] + cards_data.append(card_dict) + + lists_data.append({**lst.to_dict(), "cards": cards_data}) + + board_dict = board.to_dict() + board_dict["lists"] = lists_data + board_dict["labels"] = [label.to_dict() for label in board.labels.all()] + + response = BoardWithDetailsResponse(**board_dict) + return response.model_dump(), 200 + + +@kanban_bp.route("/boards", methods=["POST"]) +@jwt_required() +@validate(body=BoardCreateRequest) +def create_board(body: BoardCreateRequest): + """Create a new board""" + user_id = get_current_user_id() + + board = Board( + name=body.name, + description=body.description, + user_id=user_id, + 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 BoardResponse.model_validate(board).model_dump(), 201 + + +@kanban_bp.route("/boards/", methods=["PUT"]) +@jwt_required() +@load_board_owned +@validate(body=BoardCreateRequest) +def update_board(board_id, board, body: BoardCreateRequest): + """Update a board""" + 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 + + board.date_last_activity = datetime.now(UTC) + + db.session.commit() + + return BoardResponse.model_validate(board).model_dump(), 200 + + +@kanban_bp.route("/boards/", methods=["DELETE"]) +@jwt_required() +@load_board_owned +def delete_board(board_id, board): + """Delete a board""" + db.session.delete(board) + db.session.commit() + + return {"message": "Board deleted"}, 200 diff --git a/backend/app/routes/kanban/cards.py b/backend/app/routes/kanban/cards.py new file mode 100644 index 0000000..97ad7c9 --- /dev/null +++ b/backend/app/routes/kanban/cards.py @@ -0,0 +1,128 @@ +from datetime import UTC, datetime + +from flask import request +from flask_jwt_extended import jwt_required +from flask_pydantic import validate + +from app import db +from app.decorators import load_card_owned, load_list_owned +from app.models import Board, Card, CardLabel, Label, List +from app.schemas import (CardCreateRequest, CardResponse, + CardWithDetailsResponse) + +from . import kanban_bp + + +@kanban_bp.route("/lists//cards", methods=["POST"]) +@jwt_required() +@load_list_owned +@validate(body=CardCreateRequest) +def create_card(list_id, lst, body: CardCreateRequest): + """Create a new card in a list""" + card = Card( + name=body.name, + description=body.description, + board_id=lst.board_id, + list_id=list_id, + pos=body.pos, + due=body.due, + due_complete=body.due_complete, + badges=body.badges, + cover=body.cover, + desc_data=body.desc_data, + ) + + db.session.add(card) + db.session.commit() + + return CardResponse.model_validate(card).model_dump(), 201 + + +@kanban_bp.route("/cards/", methods=["GET"]) +@jwt_required() +@load_card_owned +def get_card(card_id, card): + """Get a single card with full details""" + from app.models import User + + card_dict = card.to_dict() + + # Add labels + card_dict["labels"] = [ + label.to_dict() + for label in ( + db.session.query(Label) + .join(CardLabel) + .filter(CardLabel.card_id == card.id) + .all() + ) + ] + + # Add checklists + + card_dict["checklists"] = [ + { + **checklist.to_dict(), + "items": [item.to_dict() for item in checklist.check_items.all()], + } + for checklist in card.checklists.all() + ] + + # Add comments + card_dict["comments"] = [] + for comment in card.comments.all(): + comment_dict = comment.to_dict() + user = db.session.get(User, comment.user_id) + comment_dict["user"] = user.to_dict() if user else None + card_dict["comments"].append(comment_dict) + + response = CardWithDetailsResponse(**card_dict) + return response.model_dump(), 200 + + +@kanban_bp.route("/cards/", methods=["PUT"]) +@jwt_required() +@load_card_owned +@validate(body=CardCreateRequest) +def update_card(card_id, card, body: CardCreateRequest): + """Update a card""" + 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 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 CardResponse.model_validate(card).model_dump(), 200 + + +@kanban_bp.route("/cards/", methods=["DELETE"]) +@jwt_required() +@load_card_owned +def delete_card(card_id, card): + """Delete a card""" + db.session.delete(card) + db.session.commit() + + return {"message": "Card deleted"}, 200 diff --git a/backend/app/routes/kanban/checklists.py b/backend/app/routes/kanban/checklists.py new file mode 100644 index 0000000..a7689af --- /dev/null +++ b/backend/app/routes/kanban/checklists.py @@ -0,0 +1,87 @@ +from flask_jwt_extended import jwt_required +from flask_pydantic import validate + +from app import db +from app.decorators import (load_card_owned, load_check_item_owned, + load_checklist_owned) +from app.models import CheckItem, Checklist +from app.schemas import CheckItemCreateRequest, ChecklistCreateRequest + +from . import kanban_bp + + +@kanban_bp.route("/cards//checklists", methods=["POST"]) +@jwt_required() +@load_card_owned +@validate(body=ChecklistCreateRequest) +def create_checklist(card_id, card, body: ChecklistCreateRequest): + """Create a new checklist in a card""" + checklist = Checklist( + name=body.name, + card_id=card_id, + board_id=card.board_id, + pos=body.pos, + ) + + db.session.add(checklist) + db.session.commit() + + return checklist.to_dict(), 201 + + +@kanban_bp.route("/checklists//items", methods=["POST"]) +@jwt_required() +@load_checklist_owned +@validate(body=CheckItemCreateRequest) +def create_check_item(checklist_id, checklist, body: CheckItemCreateRequest): + """Create a new check item in a checklist""" + check_item = CheckItem( + name=body.name, + checklist_id=checklist_id, + pos=body.pos, + state=body.state, + due=body.due, + ) + + db.session.add(check_item) + db.session.commit() + + return check_item.to_dict(), 201 + + +@kanban_bp.route("/check-items/", methods=["PUT"]) +@jwt_required() +@load_check_item_owned +@validate(body=CheckItemCreateRequest) +def update_check_item(item_id, check_item, body: CheckItemCreateRequest): + """Update a check item""" + check_item.name = body.name + check_item.state = body.state + check_item.pos = body.pos + check_item.due = body.due + + db.session.commit() + + return check_item.to_dict(), 200 + + +@kanban_bp.route("/checklists/", methods=["DELETE"]) +@jwt_required() +@load_checklist_owned +def delete_checklist(checklist_id, checklist): + """Delete a checklist""" + db.session.delete(checklist) + db.session.commit() + + return {"message": "Checklist deleted"}, 200 + + +@kanban_bp.route("/check-items/", methods=["DELETE"]) +@jwt_required() +@load_check_item_owned +def delete_check_item(item_id, check_item): + """Delete a check item""" + db.session.delete(check_item) + db.session.commit() + + return {"message": "Check item deleted"}, 200 diff --git a/backend/app/routes/kanban/comments.py b/backend/app/routes/kanban/comments.py new file mode 100644 index 0000000..e758baf --- /dev/null +++ b/backend/app/routes/kanban/comments.py @@ -0,0 +1,72 @@ +from flask_jwt_extended import jwt_required +from flask_pydantic import validate + +from app import db +from app.decorators import load_card_owned, load_comment_owned +from app.models import Comment, User +from app.schemas import (CommentCreateRequest, CommentResponse, + CommentWithUserResponse) + +from . import kanban_bp + + +@kanban_bp.route("/cards//comments", methods=["GET"]) +@jwt_required() +@load_card_owned +def get_comments(card_id, card): + """Get all comments for a card""" + comments = [] + for comment in card.comments.order_by(Comment.created_at.desc()).all(): + comment_dict = comment.to_dict() + user = db.session.get(User, comment.user_id) + comment_dict["user"] = user.to_dict() if user else None + response = CommentWithUserResponse(**comment_dict) + comments.append(response.model_dump()) + + return comments, 200 + + +@kanban_bp.route("/cards//comments", methods=["POST"]) +@jwt_required() +@load_card_owned +@validate(body=CommentCreateRequest) +def create_comment(card_id, card, body: CommentCreateRequest): + """Create a new comment on a card""" + from app.decorators import get_current_user_id + + user_id = get_current_user_id() + + comment = Comment( + text=body.text, + card_id=card_id, + user_id=user_id, + ) + + db.session.add(comment) + db.session.commit() + + return CommentResponse.model_validate(comment).model_dump(), 201 + + +@kanban_bp.route("/comments/", methods=["PUT"]) +@jwt_required() +@load_comment_owned +@validate(body=CommentCreateRequest) +def update_comment(comment_id, comment, body: CommentCreateRequest): + """Update a comment""" + comment.text = body.text + + db.session.commit() + + return CommentResponse.model_validate(comment).model_dump(), 200 + + +@kanban_bp.route("/comments/", methods=["DELETE"]) +@jwt_required() +@load_comment_owned +def delete_comment(comment_id, comment): + """Delete a comment""" + db.session.delete(comment) + db.session.commit() + + return {"message": "Comment deleted"}, 200 diff --git a/backend/app/routes/kanban/labels.py b/backend/app/routes/kanban/labels.py new file mode 100644 index 0000000..636bed9 --- /dev/null +++ b/backend/app/routes/kanban/labels.py @@ -0,0 +1,79 @@ +from flask import request +from flask_jwt_extended import jwt_required +from flask_pydantic import validate + +from app import db +from app.decorators import load_board_owned, load_card_owned +from app.models import CardLabel, Label +from app.schemas import LabelCreateRequest + +from . import kanban_bp + + +@kanban_bp.route("/boards//labels", methods=["GET"]) +@jwt_required() +@load_board_owned +def get_labels(board_id, board): + """Get all labels for a board""" + labels = board.labels.all() + return [label.to_dict() for label in labels], 200 + + +@kanban_bp.route("/boards//labels", methods=["POST"]) +@jwt_required() +@load_board_owned +@validate(body=LabelCreateRequest) +def create_label(board_id, board, body: LabelCreateRequest): + """Create a new label in a board""" + label = Label( + name=body.name, + color=body.color, + board_id=board_id, + ) + + db.session.add(label) + db.session.commit() + + return label.to_dict(), 201 + + +@kanban_bp.route("/cards//labels", methods=["POST"]) +@jwt_required() +@load_card_owned +def add_label_to_card(card_id, card): + """Add a label to a card""" + data = request.json + label_id = data.get("label_id") + + if not label_id: + 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 {"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 {"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 card_label.to_dict(), 201 + + +@kanban_bp.route("/cards//labels/", methods=["DELETE"]) +@jwt_required() +@load_card_owned +def remove_label_from_card(card_id, card, label_id): + """Remove a label from a card""" + card_label = CardLabel.query.filter_by(card_id=card_id, label_id=label_id).first() + if not card_label: + return {"error": "Label not found on card"}, 404 + + db.session.delete(card_label) + db.session.commit() + + return {"message": "Label removed from card"}, 200 diff --git a/backend/app/routes/kanban/lists.py b/backend/app/routes/kanban/lists.py new file mode 100644 index 0000000..6e4749c --- /dev/null +++ b/backend/app/routes/kanban/lists.py @@ -0,0 +1,55 @@ +from flask import request +from flask_jwt_extended import jwt_required +from flask_pydantic import validate + +from app import db +from app.decorators import load_board_owned, load_list_owned +from app.models import List +from app.schemas import ListCreateRequest + +from . import kanban_bp + + +@kanban_bp.route("/boards//lists", methods=["POST"]) +@jwt_required() +@load_board_owned +@validate(body=ListCreateRequest) +def create_list(board_id, board, body: ListCreateRequest): + """Create a new list in a board""" + lst = List( + name=body.name, + board_id=board_id, + pos=body.pos, + ) + + db.session.add(lst) + db.session.commit() + + return lst.to_dict(), 201 + + +@kanban_bp.route("/lists/", methods=["PUT"]) +@jwt_required() +@load_list_owned +@validate(body=ListCreateRequest) +def update_list(list_id, lst, body: ListCreateRequest): + """Update a list""" + 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 lst.to_dict(), 200 + + +@kanban_bp.route("/lists/", methods=["DELETE"]) +@jwt_required() +@load_list_owned +def delete_list(list_id, lst): + """Delete a list""" + db.session.delete(lst) + db.session.commit() + + return {"message": "List deleted"}, 200 diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 7978324..59c90a4 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -1,9 +1,12 @@ """Pydantic schemas for request/response validation""" -from app.schemas.board import BoardCreateRequest, BoardResponse -from app.schemas.card import CardCreateRequest, CardResponse +from app.schemas.board import (BoardCreateRequest, BoardResponse, + BoardWithDetailsResponse) +from app.schemas.card import (CardCreateRequest, CardResponse, + CardWithDetailsResponse) 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.comment import (CommentCreateRequest, CommentResponse, + CommentWithUserResponse) from app.schemas.label import LabelCreateRequest, LabelResponse from app.schemas.list_model import ListCreateRequest, ListResponse from app.schemas.product import ProductCreateRequest, ProductResponse @@ -33,4 +36,7 @@ __all__ = [ # Comment schemas "CommentCreateRequest", "CommentResponse", + "BoardWithDetailsResponse", + "CardWithDetailsResponse", + "CommentWithUserResponse", ] diff --git a/backend/app/schemas/board.py b/backend/app/schemas/board.py index aeaaedc..ef98a32 100644 --- a/backend/app/schemas/board.py +++ b/backend/app/schemas/board.py @@ -1,6 +1,6 @@ """Pydantic schemas for Board model""" from datetime import datetime -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional from pydantic import BaseModel, ConfigDict, Field @@ -66,3 +66,42 @@ class BoardResponse(BaseModel): label_names: Optional[Dict[str, str]] = None limits: Optional[Dict[str, Any]] = None date_last_activity: Optional[datetime] = None + + +class BoardWithDetailsResponse(BaseModel): + """Schema for board response with lists, cards, and labels""" + + 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", + "lists": [], + "labels": [], + } + }, + ) + + 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 + lists: List[Dict[str, Any]] = Field(default_factory=list) + labels: List[Dict[str, Any]] = Field(default_factory=list) diff --git a/backend/app/schemas/card.py b/backend/app/schemas/card.py index bf97088..497bff6 100644 --- a/backend/app/schemas/card.py +++ b/backend/app/schemas/card.py @@ -1,6 +1,6 @@ """Pydantic schemas for Card model""" from datetime import datetime -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional from pydantic import BaseModel, ConfigDict, Field @@ -70,3 +70,48 @@ class CardResponse(BaseModel): cover: Optional[str] = None desc_data: Optional[str] = None date_last_activity: Optional[datetime] = None + + +class CardWithDetailsResponse(BaseModel): + """Schema for card response with labels, checklists, and comments""" + + 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", + "labels": [], + "checklists": [], + "comments": [], + } + }, + ) + + 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 + labels: List[Dict[str, Any]] = Field(default_factory=list) + checklists: List[Dict[str, Any]] = Field(default_factory=list) + comments: List[Dict[str, Any]] = Field(default_factory=list) diff --git a/backend/app/schemas/comment.py b/backend/app/schemas/comment.py index b1f1d96..f0a3870 100644 --- a/backend/app/schemas/comment.py +++ b/backend/app/schemas/comment.py @@ -1,6 +1,6 @@ """Pydantic schemas for Comment model""" from datetime import datetime -from typing import Optional +from typing import Any, Dict, Optional from pydantic import BaseModel, ConfigDict, Field @@ -42,3 +42,34 @@ class CommentResponse(BaseModel): user_id: int created_at: Optional[datetime] = None updated_at: Optional[datetime] = None + + +class CommentWithUserResponse(BaseModel): + """Schema for comment response with user information""" + + 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", + "user": { + "id": 1, + "email": "user@example.com", + "username": "johndoe", + }, + } + }, + ) + + id: int + text: str + card_id: int + user_id: int + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + user: Optional[Dict[str, Any]] = None