Add position attribute to Item model for reordering functionality

- Introduced a new 'position' column in the Item model to facilitate item ordering.
- Updated the List model's relationship to order items by position and creation date.
- Enhanced CRUD operations to handle item creation and updates with position management.
- Implemented drag-and-drop reordering in the frontend, ensuring proper position updates on item movement.
- Adjusted item update logic to accommodate reordering and version control.
This commit is contained in:
mohamad 2025-06-07 15:04:49 +02:00
parent b9aace0c4e
commit fc09848a33
6 changed files with 199 additions and 12 deletions

View File

@ -0,0 +1,133 @@
"""Add position to Item model for reordering
Revision ID: 91d00c100f5b
Revises: 0001_initial_schema
Create Date: 2025-06-07 14:59:48.761124
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '91d00c100f5b'
down_revision: Union[str, None] = '0001_initial_schema'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index('ix_apscheduler_jobs_next_run_time', table_name='apscheduler_jobs')
op.drop_table('apscheduler_jobs')
op.alter_column('chore_assignments', 'is_complete',
existing_type=sa.BOOLEAN(),
server_default=None,
existing_nullable=False)
op.alter_column('expenses', 'currency',
existing_type=sa.VARCHAR(),
server_default=None,
existing_nullable=False)
op.alter_column('expenses', 'is_recurring',
existing_type=sa.BOOLEAN(),
server_default=None,
existing_nullable=False)
op.drop_index('ix_expenses_recurring_next_occurrence', table_name='expenses', postgresql_where='(is_recurring = true)')
op.alter_column('invites', 'is_active',
existing_type=sa.BOOLEAN(),
server_default=None,
existing_nullable=False)
op.add_column('items', sa.Column('position', sa.Integer(), server_default='0', nullable=False))
op.alter_column('items', 'is_complete',
existing_type=sa.BOOLEAN(),
server_default=None,
existing_nullable=False)
op.create_index('ix_items_list_id_position', 'items', ['list_id', 'position'], unique=False)
op.alter_column('lists', 'is_complete',
existing_type=sa.BOOLEAN(),
server_default=None,
existing_nullable=False)
op.alter_column('recurrence_patterns', 'interval',
existing_type=sa.INTEGER(),
server_default=None,
existing_nullable=False)
op.create_index(op.f('ix_settlement_activities_id'), 'settlement_activities', ['id'], unique=False)
op.create_index('ix_settlement_activity_created_by_user_id', 'settlement_activities', ['created_by_user_id'], unique=False)
op.create_index('ix_settlement_activity_expense_split_id', 'settlement_activities', ['expense_split_id'], unique=False)
op.create_index('ix_settlement_activity_paid_by_user_id', 'settlement_activities', ['paid_by_user_id'], unique=False)
op.alter_column('users', 'is_active',
existing_type=sa.BOOLEAN(),
server_default=None,
existing_nullable=False)
op.alter_column('users', 'is_superuser',
existing_type=sa.BOOLEAN(),
server_default=None,
existing_nullable=False)
op.alter_column('users', 'is_verified',
existing_type=sa.BOOLEAN(),
server_default=None,
existing_nullable=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('users', 'is_verified',
existing_type=sa.BOOLEAN(),
server_default=sa.text('false'),
existing_nullable=False)
op.alter_column('users', 'is_superuser',
existing_type=sa.BOOLEAN(),
server_default=sa.text('false'),
existing_nullable=False)
op.alter_column('users', 'is_active',
existing_type=sa.BOOLEAN(),
server_default=sa.text('true'),
existing_nullable=False)
op.drop_index('ix_settlement_activity_paid_by_user_id', table_name='settlement_activities')
op.drop_index('ix_settlement_activity_expense_split_id', table_name='settlement_activities')
op.drop_index('ix_settlement_activity_created_by_user_id', table_name='settlement_activities')
op.drop_index(op.f('ix_settlement_activities_id'), table_name='settlement_activities')
op.alter_column('recurrence_patterns', 'interval',
existing_type=sa.INTEGER(),
server_default=sa.text('1'),
existing_nullable=False)
op.alter_column('lists', 'is_complete',
existing_type=sa.BOOLEAN(),
server_default=sa.text('false'),
existing_nullable=False)
op.drop_index('ix_items_list_id_position', table_name='items')
op.alter_column('items', 'is_complete',
existing_type=sa.BOOLEAN(),
server_default=sa.text('false'),
existing_nullable=False)
op.drop_column('items', 'position')
op.alter_column('invites', 'is_active',
existing_type=sa.BOOLEAN(),
server_default=sa.text('true'),
existing_nullable=False)
op.create_index('ix_expenses_recurring_next_occurrence', 'expenses', ['is_recurring', 'next_occurrence'], unique=False, postgresql_where='(is_recurring = true)')
op.alter_column('expenses', 'is_recurring',
existing_type=sa.BOOLEAN(),
server_default=sa.text('false'),
existing_nullable=False)
op.alter_column('expenses', 'currency',
existing_type=sa.VARCHAR(),
server_default=sa.text("'USD'::character varying"),
existing_nullable=False)
op.alter_column('chore_assignments', 'is_complete',
existing_type=sa.BOOLEAN(),
server_default=sa.text('false'),
existing_nullable=False)
op.create_table('apscheduler_jobs',
sa.Column('id', sa.VARCHAR(length=191), autoincrement=False, nullable=False),
sa.Column('next_run_time', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=True),
sa.Column('job_state', postgresql.BYTEA(), autoincrement=False, nullable=False),
sa.PrimaryKeyConstraint('id', name='apscheduler_jobs_pkey')
)
op.create_index('ix_apscheduler_jobs_next_run_time', 'apscheduler_jobs', ['next_run_time'], unique=False)
# ### end Alembic commands ###

View File

@ -7,6 +7,7 @@ from sqlalchemy.exc import SQLAlchemyError, IntegrityError, OperationalError
from typing import Optional, List as PyList from typing import Optional, List as PyList
from datetime import datetime, timezone from datetime import datetime, timezone
import logging # Add logging import import logging # Add logging import
from sqlalchemy import func
from app.models import Item as ItemModel, User as UserModel # Import UserModel for type hints if needed for selectinload from app.models import Item as ItemModel, User as UserModel # Import UserModel for type hints if needed for selectinload
from app.schemas.item import ItemCreate, ItemUpdate from app.schemas.item import ItemCreate, ItemUpdate
@ -23,15 +24,21 @@ from app.core.exceptions import (
logger = logging.getLogger(__name__) # Initialize logger logger = logging.getLogger(__name__) # Initialize logger
async def create_item(db: AsyncSession, item_in: ItemCreate, list_id: int, user_id: int) -> ItemModel: async def create_item(db: AsyncSession, item_in: ItemCreate, list_id: int, user_id: int) -> ItemModel:
"""Creates a new item record for a specific list.""" """Creates a new item record for a specific list, setting its position."""
try: try:
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction: async with db.begin_nested() if db.in_transaction() else db.begin() as transaction:
# Get the current max position in the list
max_pos_stmt = select(func.max(ItemModel.position)).where(ItemModel.list_id == list_id)
max_pos_result = await db.execute(max_pos_stmt)
max_pos = max_pos_result.scalar_one_or_none() or 0
db_item = ItemModel( db_item = ItemModel(
name=item_in.name, name=item_in.name,
quantity=item_in.quantity, quantity=item_in.quantity,
list_id=list_id, list_id=list_id,
added_by_id=user_id, added_by_id=user_id,
is_complete=False is_complete=False,
position=max_pos + 1 # Set the new position
) )
db.add(db_item) db.add(db_item)
await db.flush() # Assigns ID await db.flush() # Assigns ID
@ -104,18 +111,41 @@ async def get_item_by_id(db: AsyncSession, item_id: int) -> Optional[ItemModel]:
raise DatabaseQueryError(f"Failed to query item: {str(e)}") raise DatabaseQueryError(f"Failed to query item: {str(e)}")
async def update_item(db: AsyncSession, item_db: ItemModel, item_in: ItemUpdate, user_id: int) -> ItemModel: async def update_item(db: AsyncSession, item_db: ItemModel, item_in: ItemUpdate, user_id: int) -> ItemModel:
"""Updates an existing item record, checking for version conflicts.""" """Updates an existing item record, checking for version conflicts and handling reordering."""
try: try:
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction: async with db.begin_nested() if db.in_transaction() else db.begin() as transaction:
if item_db.version != item_in.version: if item_db.version != item_in.version:
# No need to rollback here, as the transaction hasn't committed.
# The context manager will handle rollback if an exception is raised.
raise ConflictError( raise ConflictError(
f"Item '{item_db.name}' (ID: {item_db.id}) has been modified. " f"Item '{item_db.name}' (ID: {item_db.id}) has been modified. "
f"Your version is {item_in.version}, current version is {item_db.version}. Please refresh." f"Your version is {item_in.version}, current version is {item_db.version}. Please refresh."
) )
update_data = item_in.model_dump(exclude_unset=True, exclude={'version'}) update_data = item_in.model_dump(exclude_unset=True, exclude={'version'})
# --- Handle Reordering ---
if 'position' in update_data:
new_position = update_data.pop('position') # Remove from update_data to handle separately
# We need the full list to reorder, making sure it's loaded and ordered
list_id = item_db.list_id
stmt = select(ItemModel).where(ItemModel.list_id == list_id).order_by(ItemModel.position.asc(), ItemModel.created_at.asc())
result = await db.execute(stmt)
items_in_list = result.scalars().all()
# Find the item to move
item_to_move = next((it for it in items_in_list if it.id == item_db.id), None)
if item_to_move:
items_in_list.remove(item_to_move)
# Insert at the new position (adjust for 1-based index from frontend)
# Clamp position to be within bounds
insert_pos = max(0, min(new_position - 1, len(items_in_list)))
items_in_list.insert(insert_pos, item_to_move)
# Re-assign positions
for i, item in enumerate(items_in_list):
item.position = i + 1
# --- End Handle Reordering ---
if 'is_complete' in update_data: if 'is_complete' in update_data:
if update_data['is_complete'] is True: if update_data['is_complete'] is True:

View File

@ -189,7 +189,12 @@ class List(Base):
# --- Relationships --- # --- Relationships ---
creator = relationship("User", back_populates="created_lists") # Link to User.created_lists creator = relationship("User", back_populates="created_lists") # Link to User.created_lists
group = relationship("Group", back_populates="lists") # Link to Group.lists group = relationship("Group", back_populates="lists") # Link to Group.lists
items = relationship("Item", back_populates="list", cascade="all, delete-orphan", order_by="Item.created_at") # Link to Item.list, cascade deletes items = relationship(
"Item",
back_populates="list",
cascade="all, delete-orphan",
order_by="Item.position.asc(), Item.created_at.asc()" # Default order by position, then creation
)
# --- Relationships for Cost Splitting --- # --- Relationships for Cost Splitting ---
expenses = relationship("Expense", foreign_keys="Expense.list_id", back_populates="list", cascade="all, delete-orphan") expenses = relationship("Expense", foreign_keys="Expense.list_id", back_populates="list", cascade="all, delete-orphan")
@ -199,6 +204,9 @@ class List(Base):
# === NEW: Item Model === # === NEW: Item Model ===
class Item(Base): class Item(Base):
__tablename__ = "items" __tablename__ = "items"
__table_args__ = (
Index('ix_items_list_id_position', 'list_id', 'position'),
)
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
list_id = Column(Integer, ForeignKey("lists.id", ondelete="CASCADE"), nullable=False) # Belongs to which list list_id = Column(Integer, ForeignKey("lists.id", ondelete="CASCADE"), nullable=False) # Belongs to which list
@ -206,6 +214,7 @@ class Item(Base):
quantity = Column(String, nullable=True) # Flexible quantity (e.g., "1", "2 lbs", "a bunch") quantity = Column(String, nullable=True) # Flexible quantity (e.g., "1", "2 lbs", "a bunch")
is_complete = Column(Boolean, default=False, nullable=False) is_complete = Column(Boolean, default=False, nullable=False)
price = Column(Numeric(10, 2), nullable=True) # For cost splitting later (e.g., 12345678.99) price = Column(Numeric(10, 2), nullable=True) # For cost splitting later (e.g., 12345678.99)
position = Column(Integer, nullable=False, server_default='0') # For ordering
added_by_id = Column(Integer, ForeignKey("users.id"), nullable=False) # Who added this item added_by_id = Column(Integer, ForeignKey("users.id"), nullable=False) # Who added this item
completed_by_id = Column(Integer, ForeignKey("users.id"), nullable=True) # Who marked it complete completed_by_id = Column(Integer, ForeignKey("users.id"), nullable=True) # Who marked it complete
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)

View File

@ -32,5 +32,6 @@ class ItemUpdate(BaseModel):
quantity: Optional[str] = None quantity: Optional[str] = None
is_complete: Optional[bool] = None is_complete: Optional[bool] = None
price: Optional[Decimal] = None # Price added here for update price: Optional[Decimal] = None # Price added here for update
position: Optional[int] = None # For reordering
version: int version: int
# completed_by_id will be set internally if is_complete is true # completed_by_id will be set internally if is_complete is true

View File

@ -190,7 +190,6 @@ const handleLogout = async () => {
.page-container { .page-container {
flex-grow: 1; flex-grow: 1;
padding: 1rem; // Add some default padding
padding-bottom: calc(var(--footer-height) + 1rem); // Space for fixed footer padding-bottom: calc(var(--footer-height) + 1rem); // Space for fixed footer
} }

View File

@ -346,9 +346,9 @@
<template #footer> <template #footer>
<VButton variant="neutral" @click="closeSettleShareModal">{{ <VButton variant="neutral" @click="closeSettleShareModal">{{
$t('listDetailPage.settleShareModal.cancelButton') $t('listDetailPage.settleShareModal.cancelButton')
}}</VButton> }}</VButton>
<VButton variant="primary" @click="handleConfirmSettle">{{ $t('listDetailPage.settleShareModal.confirmButton') <VButton variant="primary" @click="handleConfirmSettle">{{ $t('listDetailPage.settleShareModal.confirmButton')
}}</VButton> }}</VButton>
</template> </template>
</VModal> </VModal>
@ -1248,22 +1248,28 @@ const handleCheckboxChange = (item: ItemWithUI, event: Event) => {
const handleDragEnd = async (evt: any) => { const handleDragEnd = async (evt: any) => {
if (!list.value || evt.oldIndex === evt.newIndex) return; if (!list.value || evt.oldIndex === evt.newIndex) return;
const originalList = [...list.value.items]; // Store original order
const item = list.value.items[evt.newIndex]; const item = list.value.items[evt.newIndex];
const newPosition = evt.newIndex + 1; // Assuming backend uses 1-based indexing const newPosition = evt.newIndex + 1; // Assuming backend uses 1-based indexing for position
try { try {
// The v-model on draggable has already updated the list.value.items order optimistically.
await apiClient.put( await apiClient.put(
API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id)), API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id)),
{ position: newPosition, version: item.version } { position: newPosition, version: item.version }
); );
item.version++; // On success, we need to update the version of the moved item
const updatedItemInList = list.value.items.find(i => i.id === item.id);
if (updatedItemInList) {
updatedItemInList.version++;
}
notificationStore.addNotification({ notificationStore.addNotification({
message: t('listDetailPage.notifications.itemReorderedSuccess'), message: t('listDetailPage.notifications.itemReorderedSuccess'),
type: 'success' type: 'success'
}); });
} catch (err) { } catch (err) {
// Revert the order on error // Revert the order on error
list.value.items = [...list.value.items]; list.value.items = originalList;
notificationStore.addNotification({ notificationStore.addNotification({
message: getApiErrorMessage(err, 'listDetailPage.errors.reorderItemFailed'), message: getApiErrorMessage(err, 'listDetailPage.errors.reorderItemFailed'),
type: 'error' type: 'error'
@ -1391,6 +1397,7 @@ const handleDragEnd = async (evt: any) => {
.page-padding { .page-padding {
padding: 1rem; padding: 1rem;
padding-inline: 0;
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
} }
@ -1810,12 +1817,20 @@ const handleDragEnd = async (evt: any) => {
} }
/* New item input styling */ /* New item input styling */
.new-item-input-container {
list-style: none !important;
padding-inline: 3rem;
padding-bottom: 1.2rem;
}
.new-item-input-container .neo-checkbox-label { .new-item-input-container .neo-checkbox-label {
width: 100%; width: 100%;
} }
.neo-new-item-input { .neo-new-item-input {
all: unset; all: unset;
height: 100%;
width: 100%; width: 100%;
font-size: 1.05rem; font-size: 1.05rem;
font-weight: 500; font-weight: 500;