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
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
env:
TEST_DATABASE_URL: postgresql://test:test@postgres:5432/test_db
@ -78,4 +67,3 @@ jobs:
run: |
cd backend
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.schemas import (CardCreateRequest, CardResponse,
CardWithDetailsResponse)
from app.services.card_position_service import CardPositionService
from . import kanban_bp
@ -86,12 +87,18 @@ def get_card(card_id, card):
@validate(body=CardCreateRequest)
def update_card(card_id, card, body: CardCreateRequest):
"""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
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:
@ -108,6 +115,19 @@ def update_card(card_id, card, body: CardCreateRequest):
if new_list and new_list.board_id == card.board_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)
board = db.session.get(Board, card.board_id)
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
with app.app_context():
print('--------db.create_all()------')
db.create_all()
yield app
# Cleanup after all tests

View file

@ -167,3 +167,151 @@ class TestCardRoutes:
response = client.delete("/api/cards/99999", headers=auth_headers)
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