fix backend card sorting bug

This commit is contained in:
david 2026-02-27 13:54:34 +03:00
parent c53742d470
commit f819ada0f0
5 changed files with 307 additions and 13 deletions

View file

@ -57,17 +57,6 @@ jobs:
cd backend cd backend
flake8 app tests --count --max-complexity=10 --max-line-length=127 --statistics --show-source flake8 app tests --count --max-complexity=10 --max-line-length=127 --statistics --show-source
# - name: Run migrations
# env:
# TEST_DATABASE_URL: postgresql://test:test@postgres:5432/test_db
# DATABASE_URL: postgresql://test:test@postgres:5432/test_db
# SECRET_KEY: test-secret-key
# JWT_SECRET_KEY: test-jwt-secret
# FLASK_ENV: test
# run: |
# cd backend
# flask db upgrade
- name: Run tests - name: Run tests
env: env:
TEST_DATABASE_URL: postgresql://test:test@postgres:5432/test_db TEST_DATABASE_URL: postgresql://test:test@postgres:5432/test_db
@ -78,4 +67,3 @@ jobs:
run: | run: |
cd backend cd backend
pytest --cov=app --cov-report=xml --cov-report=term pytest --cov=app --cov-report=xml --cov-report=term

View file

@ -9,6 +9,7 @@ from app.decorators import load_card_owned, load_list_owned
from app.models import Board, Card, CardLabel, Label, List from app.models import Board, Card, CardLabel, Label, List
from app.schemas import (CardCreateRequest, CardResponse, from app.schemas import (CardCreateRequest, CardResponse,
CardWithDetailsResponse) CardWithDetailsResponse)
from app.services.card_position_service import CardPositionService
from . import kanban_bp from . import kanban_bp
@ -86,12 +87,18 @@ def get_card(card_id, card):
@validate(body=CardCreateRequest) @validate(body=CardCreateRequest)
def update_card(card_id, card, body: CardCreateRequest): def update_card(card_id, card, body: CardCreateRequest):
"""Update a card""" """Update a card"""
# Track if position or list is changing
old_position = card.pos
old_list_id = card.list_id
new_position = body.pos
new_list_id = card.list_id
# Update basic card fields
card.name = body.name card.name = body.name
if body.description is not None: if body.description is not None:
card.description = body.description card.description = body.description
if request.json.get("closed") is not None: if request.json.get("closed") is not None:
card.closed = request.json.get("closed") card.closed = request.json.get("closed")
card.pos = body.pos
card.due = body.due card.due = body.due
card.due_complete = body.due_complete card.due_complete = body.due_complete
if body.badges is not None: if body.badges is not None:
@ -108,6 +115,19 @@ def update_card(card_id, card, body: CardCreateRequest):
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
# Handle position reordering
if old_list_id != new_list_id or old_position != new_position:
if old_list_id != new_list_id:
# Card moved to different list
CardPositionService.reorder_cards_between_lists(
old_list_id, new_list_id, card_id, new_position
)
else:
# Card moved within same list
CardPositionService.reorder_cards_in_list(
new_list_id, card_id, new_position
)
card.date_last_activity = datetime.now(UTC) card.date_last_activity = datetime.now(UTC)
board = db.session.get(Board, card.board_id) board = db.session.get(Board, card.board_id)
board.date_last_activity = datetime.now(UTC) board.date_last_activity = datetime.now(UTC)

View file

@ -0,0 +1,137 @@
"""Service for managing card positioning and reordering"""
from typing import List, Optional
from app import db
from app.models import Card
class CardPositionService:
"""Service for handling card position management"""
@staticmethod
def reorder_cards_in_list(list_id: int, moved_card_id: int, new_position: float) -> None:
"""
Reorder all cards in a list when one card is moved to a new position.
Args:
list_id: The ID of the list containing the cards
moved_card_id: The ID of the card being moved
new_position: The new position for the moved card
"""
# Get all cards in the list, ordered by their current position
all_cards = (
Card.query.filter_by(list_id=list_id)
.order_by(Card.pos)
.all()
)
# Find the moved card in the list
moved_card = None
other_cards = []
for card in all_cards:
if card.id == moved_card_id:
moved_card = card
else:
other_cards.append(card)
if not moved_card:
return # Card not found in this list
# Remove the moved card from other_cards (already done above)
# Insert the moved card at the new position in other_cards
other_cards.insert(int(new_position), moved_card)
# Update positions for all cards to ensure unique, sequential positions
for index, card in enumerate(other_cards):
card.pos = float(index)
db.session.commit()
@staticmethod
def reorder_cards_between_lists(
from_list_id: int,
to_list_id: int,
moved_card_id: int,
new_position: float
) -> None:
"""
Reorder cards when moving a card from one list to another.
Args:
from_list_id: The source list ID
to_list_id: The destination list ID
moved_card_id: The ID of the card being moved
new_position: The new position in the destination list
"""
# Reorder source list (remove the card and compact positions)
source_cards = (
Card.query.filter_by(list_id=from_list_id)
.filter(Card.id != moved_card_id)
.order_by(Card.pos)
.all()
)
for index, card in enumerate(source_cards):
card.pos = float(index)
# Reorder destination list (insert the card at new position)
dest_cards = (
Card.query.filter_by(list_id=to_list_id)
.order_by(Card.pos)
.all()
)
# Insert moved card at the specified position
dest_cards.insert(int(new_position), None) # Placeholder for moved card
for index, card in enumerate(dest_cards):
if card is None:
# This is where our moved card should go
moved_card = Card.query.get(moved_card_id)
if moved_card:
moved_card.pos = float(index)
else:
card.pos = float(index)
db.session.commit()
@staticmethod
def get_next_position(list_id: int) -> float:
"""
Get the next available position in a list.
Args:
list_id: The ID of the list
Returns:
The next available position (float)
"""
last_card = (
Card.query.filter_by(list_id=list_id)
.order_by(Card.pos.desc())
.first()
)
return float(last_card.pos + 1) if last_card else 0.0
@staticmethod
def ensure_unique_positions(list_id: int) -> None:
"""
Ensure all cards in a list have unique, sequential positions.
Useful for data cleanup.
Args:
list_id: The ID of the list to fix
"""
cards = (
Card.query.filter_by(list_id=list_id)
.order_by(Card.pos)
.all()
)
for index, card in enumerate(cards):
card.pos = float(index)
db.session.commit()

View file

@ -29,6 +29,7 @@ def app():
# Create tables once per session # Create tables once per session
with app.app_context(): with app.app_context():
print('--------db.create_all()------')
db.create_all() db.create_all()
yield app yield app
# Cleanup after all tests # Cleanup after all tests

View file

@ -167,3 +167,151 @@ class TestCardRoutes:
response = client.delete("/api/cards/99999", headers=auth_headers) response = client.delete("/api/cards/99999", headers=auth_headers)
assert response.status_code == 404 assert response.status_code == 404
def test_update_card_position_within_same_list(self, client, db_session, regular_user, auth_headers):
"""Test updating card position within the same list reorders other cards"""
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
lst = List(name="To Do", board_id=board.id, pos=0)
db_session.add(lst)
db_session.flush()
# Create 3 cards in sequential positions
card1 = Card(name="Card 1", board_id=board.id, list_id=lst.id, pos=0)
card2 = Card(name="Card 2", board_id=board.id, list_id=lst.id, pos=1)
card3 = Card(name="Card 3", board_id=board.id, list_id=lst.id, pos=2)
db_session.add(card1)
db_session.add(card2)
db_session.add(card3)
db_session.commit()
# Move card3 from position 2 to position 0 (top)
response = client.put(
f"/api/cards/{card3.id}",
headers=auth_headers,
json={"name": "Card 3", "pos": 0},
)
assert response.status_code == 200
# Verify all cards have unique, sequential positions
updated_cards = Card.query.filter_by(list_id=lst.id).order_by(Card.pos).all()
assert len(updated_cards) == 3
assert updated_cards[0].id == card3.id
assert updated_cards[0].pos == 0.0
assert updated_cards[1].id == card1.id
assert updated_cards[1].pos == 1.0
assert updated_cards[2].id == card2.id
assert updated_cards[2].pos == 2.0
def test_update_card_position_between_lists(self, client, db_session, regular_user, auth_headers):
"""Test moving card between lists reorders both lists"""
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
list1 = List(name="To Do", board_id=board.id, pos=0)
list2 = List(name="Done", board_id=board.id, pos=1)
db_session.add(list1)
db_session.add(list2)
db_session.flush()
# Create cards in both lists
card1 = Card(name="Card 1", board_id=board.id, list_id=list1.id, pos=0)
card2 = Card(name="Card 2", board_id=board.id, list_id=list1.id, pos=1)
card3 = Card(name="Card 3", board_id=board.id, list_id=list2.id, pos=0)
db_session.add(card1)
db_session.add(card2)
db_session.add(card3)
db_session.commit()
# Move card1 from list1 to list2 at position 0
response = client.put(
f"/api/cards/{card1.id}",
headers=auth_headers,
json={"name": "Card 1", "list_id": list2.id, "pos": 0},
)
assert response.status_code == 200
# Verify list1 now has only card2 at position 0
list1_cards = Card.query.filter_by(list_id=list1.id).order_by(Card.pos).all()
assert len(list1_cards) == 1
assert list1_cards[0].id == card2.id
assert list1_cards[0].pos == 0.0
# Verify list2 now has card1 at position 0 and card3 at position 1
list2_cards = Card.query.filter_by(list_id=list2.id).order_by(Card.pos).all()
assert len(list2_cards) == 2
assert list2_cards[0].id == card1.id
assert list2_cards[0].pos == 0.0
assert list2_cards[1].id == card3.id
assert list2_cards[1].pos == 1.0
def test_update_card_position_no_change(self, client, db_session, regular_user, auth_headers):
"""Test updating card with same position doesn't reorder others"""
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
lst = List(name="To Do", board_id=board.id, pos=0)
db_session.add(lst)
db_session.flush()
card1 = Card(name="Card 1", board_id=board.id, list_id=lst.id, pos=0)
card2 = Card(name="Card 2", board_id=board.id, list_id=lst.id, pos=1)
db_session.add(card1)
db_session.add(card2)
db_session.commit()
original_pos1 = card1.pos
original_pos2 = card2.pos
# Update card2 but keep same position
response = client.put(
f"/api/cards/{card2.id}",
headers=auth_headers,
json={"name": "Updated Card 2", "pos": original_pos2},
)
assert response.status_code == 200
# Verify positions unchanged
updated_card1 = db.session.get(Card, card1.id)
updated_card2 = db.session.get(Card, card2.id)
assert updated_card1.pos == original_pos1
assert updated_card2.pos == original_pos2
def test_create_card_with_position(self, client, db_session, regular_user, auth_headers):
"""Test creating card with specific position reorders existing cards"""
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
lst = List(name="To Do", board_id=board.id, pos=0)
db_session.add(lst)
db_session.flush()
# Create existing cards
card1 = Card(name="Card 1", board_id=board.id, list_id=lst.id, pos=0)
card2 = Card(name="Card 2", board_id=board.id, list_id=lst.id, pos=1)
db_session.add(card1)
db_session.add(card2)
db_session.commit()
# Create new card at position 0 (should push others down)
response = client.post(
f"/api/lists/{lst.id}/cards",
headers=auth_headers,
json={"name": "New Card", "pos": 0},
)
assert response.status_code == 201
# Note: create_card endpoint doesn't use CardPositionService yet
# This test documents current behavior - positions may not be unique after creation
# The reordering happens when cards are moved, not when created
all_cards = Card.query.filter_by(list_id=lst.id).order_by(Card.pos).all()
assert len(all_cards) == 3