Refactor CRUD operations in group, item, and list modules to remove unnecessary transaction context; enhance error handling and improve code readability. Update API endpoint for OCR processing in configuration and add confirmation dialogs for item actions in ListDetailPage.

This commit is contained in:
mohamad 2025-05-08 22:34:07 +02:00
parent 262505c898
commit f52b47f6df
5 changed files with 227 additions and 128 deletions

View File

@ -4,6 +4,7 @@ from sqlalchemy.future import select
from sqlalchemy.orm import selectinload # For eager loading members
from sqlalchemy.exc import SQLAlchemyError, IntegrityError, OperationalError
from typing import Optional, List
from sqlalchemy import func
from app.models import User as UserModel, Group as GroupModel, UserGroup as UserGroupModel
from app.schemas.group import GroupCreate
@ -47,7 +48,6 @@ async def create_group(db: AsyncSession, group_in: GroupCreate, creator_id: int)
async def get_user_groups(db: AsyncSession, user_id: int) -> List[GroupModel]:
"""Gets all groups a user is a member of."""
try:
async with db.begin():
result = await db.execute(
select(GroupModel)
.join(UserGroupModel)
@ -63,7 +63,6 @@ async def get_user_groups(db: AsyncSession, user_id: int) -> List[GroupModel]:
async def get_group_by_id(db: AsyncSession, group_id: int) -> Optional[GroupModel]:
"""Gets a single group by its ID, optionally loading members."""
try:
async with db.begin():
result = await db.execute(
select(GroupModel)
.where(GroupModel.id == group_id)
@ -80,7 +79,6 @@ async def get_group_by_id(db: AsyncSession, group_id: int) -> Optional[GroupMode
async def is_user_member(db: AsyncSession, group_id: int, user_id: int) -> bool:
"""Checks if a user is a member of a specific group."""
try:
async with db.begin():
result = await db.execute(
select(UserGroupModel.id)
.where(UserGroupModel.group_id == group_id, UserGroupModel.user_id == user_id)
@ -95,7 +93,6 @@ async def is_user_member(db: AsyncSession, group_id: int, user_id: int) -> bool:
async def get_user_role_in_group(db: AsyncSession, group_id: int, user_id: int) -> Optional[UserRoleEnum]:
"""Gets the role of a user in a specific group."""
try:
async with db.begin():
result = await db.execute(
select(UserGroupModel.role)
.where(UserGroupModel.group_id == group_id, UserGroupModel.user_id == user_id)
@ -146,7 +143,6 @@ async def remove_user_from_group(db: AsyncSession, group_id: int, user_id: int)
async def get_group_member_count(db: AsyncSession, group_id: int) -> int:
"""Counts the number of members in a group."""
try:
async with db.begin():
result = await db.execute(
select(func.count(UserGroupModel.id)).where(UserGroupModel.group_id == group_id)
)
@ -170,7 +166,6 @@ async def check_group_membership(
GroupMembershipError: If the user_id is not a member of the group.
"""
try:
async with db.begin():
# Check group existence first
group_exists = await db.get(GroupModel, group_id)
if not group_exists:

View File

@ -43,7 +43,6 @@ async def create_item(db: AsyncSession, item_in: ItemCreate, list_id: int, user_
async def get_items_by_list_id(db: AsyncSession, list_id: int) -> PyList[ItemModel]:
"""Gets all items belonging to a specific list, ordered by creation time."""
try:
async with db.begin():
result = await db.execute(
select(ItemModel)
.where(ItemModel.list_id == list_id)
@ -58,7 +57,6 @@ async def get_items_by_list_id(db: AsyncSession, list_id: int) -> PyList[ItemMod
async def get_item_by_id(db: AsyncSession, item_id: int) -> Optional[ItemModel]:
"""Gets a single item by its ID."""
try:
async with db.begin():
result = await db.execute(select(ItemModel).where(ItemModel.id == item_id))
return result.scalars().first()
except OperationalError as e:

View File

@ -45,7 +45,6 @@ async def create_list(db: AsyncSession, list_in: ListCreate, creator_id: int) ->
async def get_lists_for_user(db: AsyncSession, user_id: int) -> PyList[ListModel]:
"""Gets all lists accessible by a user."""
try:
async with db.begin():
group_ids_result = await db.execute(
select(UserGroupModel.group_id).where(UserGroupModel.user_id == user_id)
)
@ -65,14 +64,11 @@ async def get_lists_for_user(db: AsyncSession, user_id: int) -> PyList[ListModel
except OperationalError as e:
raise DatabaseConnectionError(f"Failed to connect to database: {str(e)}")
except SQLAlchemyError as e:
# It would be helpful to log the original error e here for more detailed debugging
# For example: logger.error(f"SQLAlchemyError in get_lists_for_user: {type(e).__name__} - {str(e)}")
raise DatabaseQueryError(f"Failed to query user lists: {str(e)}")
async def get_list_by_id(db: AsyncSession, list_id: int, load_items: bool = False) -> Optional[ListModel]:
"""Gets a single list by ID, optionally loading its items."""
try:
async with db.begin():
query = select(ListModel).where(ListModel.id == list_id)
if load_items:
query = query.options(
@ -139,7 +135,6 @@ async def delete_list(db: AsyncSession, list_db: ListModel) -> None:
async def check_list_permission(db: AsyncSession, list_id: int, user_id: int, require_creator: bool = False) -> ListModel:
"""Fetches a list and verifies user permission."""
try:
async with db.begin():
list_db = await get_list_by_id(db, list_id=list_id, load_items=True)
if not list_db:
raise ListNotFoundError(list_id)
@ -170,7 +165,6 @@ async def check_list_permission(db: AsyncSession, list_id: int, user_id: int, re
async def get_list_status(db: AsyncSession, list_id: int) -> ListStatus:
"""Gets the update timestamps and item count for a list."""
try:
async with db.begin():
list_query = select(ListModel.updated_at).where(ListModel.id == list_id)
list_result = await db.execute(list_query)
list_updated_at = list_result.scalar_one_or_none()

View File

@ -73,7 +73,7 @@ export const API_ENDPOINTS = {
// OCR
OCR: {
PROCESS: '/ocr/process',
PROCESS: '/ocr/extract-items',
STATUS: (jobId: string) => `/ocr/status/${jobId}`,
RESULT: (jobId: string) => `/ocr/result/${jobId}`,
BATCH: '/ocr/batch',

View File

@ -105,6 +105,8 @@
label="Item Name"
:rules="[(val) => !!val || 'Name is required']"
outlined
ref="itemNameInput"
@keydown.enter.prevent="onAddItem"
/>
</div>
<div class="col-12 col-md-4">
@ -114,6 +116,7 @@
label="Quantity (optional)"
outlined
min="1"
@keydown.enter.prevent="onAddItem"
/>
</div>
<div class="col-12 col-md-2">
@ -142,8 +145,9 @@
<q-item-section avatar>
<q-checkbox
v-model="item.is_complete"
@update:model-value="updateItem(item)"
@update:model-value="confirmUpdateItem(item)"
:loading="item.updating"
:disable="item.updating"
/>
</q-item-section>
<q-item-section>
@ -152,9 +156,36 @@
Quantity: {{ item.quantity }}
</q-item-label>
</q-item-section>
<q-item-section side>
<q-btn
flat
round
dense
icon="delete"
color="negative"
@click="confirmDeleteItem(item)"
:loading="item.deleting"
:disable="item.deleting"
/>
</q-item-section>
</q-item>
</q-list>
</template>
<!-- Confirmation Dialog -->
<q-dialog v-model="showConfirmDialog" persistent>
<q-card>
<q-card-section class="row items-center">
<q-avatar icon="warning" color="warning" text-color="white" />
<span class="q-ml-sm">{{ confirmDialogMessage }}</span>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Cancel" color="primary" v-close-popup />
<q-btn flat label="Confirm" color="primary" @click="handleConfirmedAction" />
</q-card-actions>
</q-card>
</q-dialog>
</q-page>
</template>
@ -172,6 +203,7 @@ interface Item {
version: number;
updating?: boolean;
updated_at: string;
deleting?: boolean;
}
interface List {
@ -198,7 +230,7 @@ const list = ref<List>({
const loading = ref(true);
const error = ref<string | null>(null);
const addingItem = ref(false);
const pollingInterval = ref<number | undefined>(undefined);
const pollingInterval = ref<ReturnType<typeof setInterval> | undefined>(undefined);
const lastListUpdate = ref<string | null>(null);
const lastItemUpdate = ref<string | null>(null);
@ -214,6 +246,14 @@ const ocrItems = ref<{ name: string }[]>([]);
const addingOcrItems = ref(false);
const ocrError = ref<string | null>(null);
// Add new refs for confirmation dialog
const showConfirmDialog = ref(false);
const confirmDialogMessage = ref('');
const pendingAction = ref<(() => Promise<void>) | null>(null);
// Add ref for item name input
const itemNameInput = ref<{ focus: () => void } | null>(null);
const fetchListDetails = async () => {
loading.value = true;
error.value = null;
@ -277,7 +317,13 @@ const stopPolling = () => {
};
const onAddItem = async () => {
if (!newItem.value.name) return;
if (!newItem.value.name) {
$q.notify({
type: 'warning',
message: 'Please enter an item name',
});
return;
}
addingItem.value = true;
try {
@ -287,6 +333,8 @@ const onAddItem = async () => {
);
list.value.items.push(response.data as Item);
newItem.value = { name: '' };
// Focus the input for the next item
itemNameInput.value?.focus();
} catch (err: unknown) {
$q.notify({
type: 'negative',
@ -395,14 +443,68 @@ const addOcrItems = async () => {
}
};
// Add keyboard shortcut handler
const handleKeyPress = (event: KeyboardEvent) => {
// Focus item name input when pressing 'n'
if (event.key === 'n' && !event.ctrlKey && !event.metaKey) {
event.preventDefault();
itemNameInput.value?.focus();
}
};
// Add confirmation dialog handlers
const confirmUpdateItem = (item: Item) => {
confirmDialogMessage.value = `Are you sure you want to mark "${item.name}" as ${item.is_complete ? 'complete' : 'incomplete'}?`;
pendingAction.value = () => updateItem(item);
showConfirmDialog.value = true;
};
const confirmDeleteItem = (item: Item) => {
confirmDialogMessage.value = `Are you sure you want to delete "${item.name}"?`;
pendingAction.value = () => deleteItem(item);
showConfirmDialog.value = true;
};
const handleConfirmedAction = async () => {
if (pendingAction.value) {
await pendingAction.value();
pendingAction.value = null;
}
showConfirmDialog.value = false;
};
// Add delete item function
const deleteItem = async (item: Item) => {
item.deleting = true;
try {
await apiClient.delete(
API_ENDPOINTS.LISTS.ITEM(list.value.id.toString(), item.id.toString())
);
const index = list.value.items.findIndex((i) => i.id === item.id);
if (index !== -1) {
list.value.items.splice(index, 1);
}
} catch (err: unknown) {
$q.notify({
type: 'negative',
message: (err as Error).message || 'Failed to delete item',
});
} finally {
item.deleting = false;
}
};
// Add keyboard event listeners
onMounted(() => {
void fetchListDetails().then(() => {
startPolling();
});
window.addEventListener('keydown', handleKeyPress);
});
onUnmounted(() => {
stopPolling();
window.removeEventListener('keydown', handleKeyPress);
});
</script>
@ -411,4 +513,14 @@ onUnmounted(() => {
text-decoration: line-through;
opacity: 0.7;
}
/* Add transition for item updates */
.q-item {
transition: all 0.3s ease;
}
.q-item.updating {
opacity: 0.7;
pointer-events: none;
}
</style>