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:
parent
262505c898
commit
f52b47f6df
@ -4,6 +4,7 @@ from sqlalchemy.future import select
|
|||||||
from sqlalchemy.orm import selectinload # For eager loading members
|
from sqlalchemy.orm import selectinload # For eager loading members
|
||||||
from sqlalchemy.exc import SQLAlchemyError, IntegrityError, OperationalError
|
from sqlalchemy.exc import SQLAlchemyError, IntegrityError, OperationalError
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
from app.models import User as UserModel, Group as GroupModel, UserGroup as UserGroupModel
|
from app.models import User as UserModel, Group as GroupModel, UserGroup as UserGroupModel
|
||||||
from app.schemas.group import GroupCreate
|
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]:
|
async def get_user_groups(db: AsyncSession, user_id: int) -> List[GroupModel]:
|
||||||
"""Gets all groups a user is a member of."""
|
"""Gets all groups a user is a member of."""
|
||||||
try:
|
try:
|
||||||
async with db.begin():
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(GroupModel)
|
select(GroupModel)
|
||||||
.join(UserGroupModel)
|
.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]:
|
async def get_group_by_id(db: AsyncSession, group_id: int) -> Optional[GroupModel]:
|
||||||
"""Gets a single group by its ID, optionally loading members."""
|
"""Gets a single group by its ID, optionally loading members."""
|
||||||
try:
|
try:
|
||||||
async with db.begin():
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(GroupModel)
|
select(GroupModel)
|
||||||
.where(GroupModel.id == group_id)
|
.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:
|
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."""
|
"""Checks if a user is a member of a specific group."""
|
||||||
try:
|
try:
|
||||||
async with db.begin():
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(UserGroupModel.id)
|
select(UserGroupModel.id)
|
||||||
.where(UserGroupModel.group_id == group_id, UserGroupModel.user_id == user_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]:
|
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."""
|
"""Gets the role of a user in a specific group."""
|
||||||
try:
|
try:
|
||||||
async with db.begin():
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(UserGroupModel.role)
|
select(UserGroupModel.role)
|
||||||
.where(UserGroupModel.group_id == group_id, UserGroupModel.user_id == user_id)
|
.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:
|
async def get_group_member_count(db: AsyncSession, group_id: int) -> int:
|
||||||
"""Counts the number of members in a group."""
|
"""Counts the number of members in a group."""
|
||||||
try:
|
try:
|
||||||
async with db.begin():
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(func.count(UserGroupModel.id)).where(UserGroupModel.group_id == group_id)
|
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.
|
GroupMembershipError: If the user_id is not a member of the group.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
async with db.begin():
|
|
||||||
# Check group existence first
|
# Check group existence first
|
||||||
group_exists = await db.get(GroupModel, group_id)
|
group_exists = await db.get(GroupModel, group_id)
|
||||||
if not group_exists:
|
if not group_exists:
|
||||||
|
@ -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]:
|
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."""
|
"""Gets all items belonging to a specific list, ordered by creation time."""
|
||||||
try:
|
try:
|
||||||
async with db.begin():
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(ItemModel)
|
select(ItemModel)
|
||||||
.where(ItemModel.list_id == list_id)
|
.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]:
|
async def get_item_by_id(db: AsyncSession, item_id: int) -> Optional[ItemModel]:
|
||||||
"""Gets a single item by its ID."""
|
"""Gets a single item by its ID."""
|
||||||
try:
|
try:
|
||||||
async with db.begin():
|
|
||||||
result = await db.execute(select(ItemModel).where(ItemModel.id == item_id))
|
result = await db.execute(select(ItemModel).where(ItemModel.id == item_id))
|
||||||
return result.scalars().first()
|
return result.scalars().first()
|
||||||
except OperationalError as e:
|
except OperationalError as e:
|
||||||
|
@ -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]:
|
async def get_lists_for_user(db: AsyncSession, user_id: int) -> PyList[ListModel]:
|
||||||
"""Gets all lists accessible by a user."""
|
"""Gets all lists accessible by a user."""
|
||||||
try:
|
try:
|
||||||
async with db.begin():
|
|
||||||
group_ids_result = await db.execute(
|
group_ids_result = await db.execute(
|
||||||
select(UserGroupModel.group_id).where(UserGroupModel.user_id == user_id)
|
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:
|
except OperationalError as e:
|
||||||
raise DatabaseConnectionError(f"Failed to connect to database: {str(e)}")
|
raise DatabaseConnectionError(f"Failed to connect to database: {str(e)}")
|
||||||
except SQLAlchemyError as 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)}")
|
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]:
|
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."""
|
"""Gets a single list by ID, optionally loading its items."""
|
||||||
try:
|
try:
|
||||||
async with db.begin():
|
|
||||||
query = select(ListModel).where(ListModel.id == list_id)
|
query = select(ListModel).where(ListModel.id == list_id)
|
||||||
if load_items:
|
if load_items:
|
||||||
query = query.options(
|
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:
|
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."""
|
"""Fetches a list and verifies user permission."""
|
||||||
try:
|
try:
|
||||||
async with db.begin():
|
|
||||||
list_db = await get_list_by_id(db, list_id=list_id, load_items=True)
|
list_db = await get_list_by_id(db, list_id=list_id, load_items=True)
|
||||||
if not list_db:
|
if not list_db:
|
||||||
raise ListNotFoundError(list_id)
|
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:
|
async def get_list_status(db: AsyncSession, list_id: int) -> ListStatus:
|
||||||
"""Gets the update timestamps and item count for a list."""
|
"""Gets the update timestamps and item count for a list."""
|
||||||
try:
|
try:
|
||||||
async with db.begin():
|
|
||||||
list_query = select(ListModel.updated_at).where(ListModel.id == list_id)
|
list_query = select(ListModel.updated_at).where(ListModel.id == list_id)
|
||||||
list_result = await db.execute(list_query)
|
list_result = await db.execute(list_query)
|
||||||
list_updated_at = list_result.scalar_one_or_none()
|
list_updated_at = list_result.scalar_one_or_none()
|
||||||
|
@ -73,7 +73,7 @@ export const API_ENDPOINTS = {
|
|||||||
|
|
||||||
// OCR
|
// OCR
|
||||||
OCR: {
|
OCR: {
|
||||||
PROCESS: '/ocr/process',
|
PROCESS: '/ocr/extract-items',
|
||||||
STATUS: (jobId: string) => `/ocr/status/${jobId}`,
|
STATUS: (jobId: string) => `/ocr/status/${jobId}`,
|
||||||
RESULT: (jobId: string) => `/ocr/result/${jobId}`,
|
RESULT: (jobId: string) => `/ocr/result/${jobId}`,
|
||||||
BATCH: '/ocr/batch',
|
BATCH: '/ocr/batch',
|
||||||
|
@ -105,6 +105,8 @@
|
|||||||
label="Item Name"
|
label="Item Name"
|
||||||
:rules="[(val) => !!val || 'Name is required']"
|
:rules="[(val) => !!val || 'Name is required']"
|
||||||
outlined
|
outlined
|
||||||
|
ref="itemNameInput"
|
||||||
|
@keydown.enter.prevent="onAddItem"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-md-4">
|
<div class="col-12 col-md-4">
|
||||||
@ -114,6 +116,7 @@
|
|||||||
label="Quantity (optional)"
|
label="Quantity (optional)"
|
||||||
outlined
|
outlined
|
||||||
min="1"
|
min="1"
|
||||||
|
@keydown.enter.prevent="onAddItem"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-md-2">
|
<div class="col-12 col-md-2">
|
||||||
@ -142,8 +145,9 @@
|
|||||||
<q-item-section avatar>
|
<q-item-section avatar>
|
||||||
<q-checkbox
|
<q-checkbox
|
||||||
v-model="item.is_complete"
|
v-model="item.is_complete"
|
||||||
@update:model-value="updateItem(item)"
|
@update:model-value="confirmUpdateItem(item)"
|
||||||
:loading="item.updating"
|
:loading="item.updating"
|
||||||
|
:disable="item.updating"
|
||||||
/>
|
/>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
<q-item-section>
|
<q-item-section>
|
||||||
@ -152,9 +156,36 @@
|
|||||||
Quantity: {{ item.quantity }}
|
Quantity: {{ item.quantity }}
|
||||||
</q-item-label>
|
</q-item-label>
|
||||||
</q-item-section>
|
</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-item>
|
||||||
</q-list>
|
</q-list>
|
||||||
</template>
|
</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>
|
</q-page>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -172,6 +203,7 @@ interface Item {
|
|||||||
version: number;
|
version: number;
|
||||||
updating?: boolean;
|
updating?: boolean;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
deleting?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface List {
|
interface List {
|
||||||
@ -198,7 +230,7 @@ const list = ref<List>({
|
|||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
const addingItem = ref(false);
|
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 lastListUpdate = ref<string | null>(null);
|
||||||
const lastItemUpdate = ref<string | null>(null);
|
const lastItemUpdate = ref<string | null>(null);
|
||||||
|
|
||||||
@ -214,6 +246,14 @@ const ocrItems = ref<{ name: string }[]>([]);
|
|||||||
const addingOcrItems = ref(false);
|
const addingOcrItems = ref(false);
|
||||||
const ocrError = ref<string | null>(null);
|
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 () => {
|
const fetchListDetails = async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
@ -277,7 +317,13 @@ const stopPolling = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onAddItem = async () => {
|
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;
|
addingItem.value = true;
|
||||||
try {
|
try {
|
||||||
@ -287,6 +333,8 @@ const onAddItem = async () => {
|
|||||||
);
|
);
|
||||||
list.value.items.push(response.data as Item);
|
list.value.items.push(response.data as Item);
|
||||||
newItem.value = { name: '' };
|
newItem.value = { name: '' };
|
||||||
|
// Focus the input for the next item
|
||||||
|
itemNameInput.value?.focus();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
$q.notify({
|
$q.notify({
|
||||||
type: 'negative',
|
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(() => {
|
onMounted(() => {
|
||||||
void fetchListDetails().then(() => {
|
void fetchListDetails().then(() => {
|
||||||
startPolling();
|
startPolling();
|
||||||
});
|
});
|
||||||
|
window.addEventListener('keydown', handleKeyPress);
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
stopPolling();
|
stopPolling();
|
||||||
|
window.removeEventListener('keydown', handleKeyPress);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -411,4 +513,14 @@ onUnmounted(() => {
|
|||||||
text-decoration: line-through;
|
text-decoration: line-through;
|
||||||
opacity: 0.7;
|
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>
|
</style>
|
||||||
|
Loading…
Reference in New Issue
Block a user