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:
parent
b9aace0c4e
commit
fc09848a33
@ -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 ###
|
@ -7,6 +7,7 @@ from sqlalchemy.exc import SQLAlchemyError, IntegrityError, OperationalError
|
||||
from typing import Optional, List as PyList
|
||||
from datetime import datetime, timezone
|
||||
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.schemas.item import ItemCreate, ItemUpdate
|
||||
@ -23,15 +24,21 @@ from app.core.exceptions import (
|
||||
logger = logging.getLogger(__name__) # Initialize logger
|
||||
|
||||
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:
|
||||
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(
|
||||
name=item_in.name,
|
||||
quantity=item_in.quantity,
|
||||
list_id=list_id,
|
||||
added_by_id=user_id,
|
||||
is_complete=False
|
||||
is_complete=False,
|
||||
position=max_pos + 1 # Set the new position
|
||||
)
|
||||
db.add(db_item)
|
||||
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)}")
|
||||
|
||||
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:
|
||||
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction:
|
||||
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(
|
||||
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."
|
||||
)
|
||||
|
||||
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 update_data['is_complete'] is True:
|
||||
|
@ -189,7 +189,12 @@ class List(Base):
|
||||
# --- Relationships ---
|
||||
creator = relationship("User", back_populates="created_lists") # Link to User.created_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 ---
|
||||
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 ===
|
||||
class Item(Base):
|
||||
__tablename__ = "items"
|
||||
__table_args__ = (
|
||||
Index('ix_items_list_id_position', 'list_id', 'position'),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
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")
|
||||
is_complete = Column(Boolean, default=False, nullable=False)
|
||||
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
|
||||
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)
|
||||
|
@ -32,5 +32,6 @@ class ItemUpdate(BaseModel):
|
||||
quantity: Optional[str] = None
|
||||
is_complete: Optional[bool] = None
|
||||
price: Optional[Decimal] = None # Price added here for update
|
||||
position: Optional[int] = None # For reordering
|
||||
version: int
|
||||
# completed_by_id will be set internally if is_complete is true
|
@ -190,7 +190,6 @@ const handleLogout = async () => {
|
||||
|
||||
.page-container {
|
||||
flex-grow: 1;
|
||||
padding: 1rem; // Add some default padding
|
||||
padding-bottom: calc(var(--footer-height) + 1rem); // Space for fixed footer
|
||||
}
|
||||
|
||||
|
@ -346,9 +346,9 @@
|
||||
<template #footer>
|
||||
<VButton variant="neutral" @click="closeSettleShareModal">{{
|
||||
$t('listDetailPage.settleShareModal.cancelButton')
|
||||
}}</VButton>
|
||||
}}</VButton>
|
||||
<VButton variant="primary" @click="handleConfirmSettle">{{ $t('listDetailPage.settleShareModal.confirmButton')
|
||||
}}</VButton>
|
||||
}}</VButton>
|
||||
</template>
|
||||
</VModal>
|
||||
|
||||
@ -1248,22 +1248,28 @@ const handleCheckboxChange = (item: ItemWithUI, event: Event) => {
|
||||
const handleDragEnd = async (evt: any) => {
|
||||
if (!list.value || evt.oldIndex === evt.newIndex) return;
|
||||
|
||||
const originalList = [...list.value.items]; // Store original order
|
||||
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 {
|
||||
// The v-model on draggable has already updated the list.value.items order optimistically.
|
||||
await apiClient.put(
|
||||
API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id)),
|
||||
{ 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({
|
||||
message: t('listDetailPage.notifications.itemReorderedSuccess'),
|
||||
type: 'success'
|
||||
});
|
||||
} catch (err) {
|
||||
// Revert the order on error
|
||||
list.value.items = [...list.value.items];
|
||||
list.value.items = originalList;
|
||||
notificationStore.addNotification({
|
||||
message: getApiErrorMessage(err, 'listDetailPage.errors.reorderItemFailed'),
|
||||
type: 'error'
|
||||
@ -1391,6 +1397,7 @@ const handleDragEnd = async (evt: any) => {
|
||||
|
||||
.page-padding {
|
||||
padding: 1rem;
|
||||
padding-inline: 0;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
@ -1810,12 +1817,20 @@ const handleDragEnd = async (evt: any) => {
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.neo-new-item-input {
|
||||
all: unset;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 500;
|
||||
|
Loading…
Reference in New Issue
Block a user