diff --git a/src/govoplan_addresses/backend/router.py b/src/govoplan_addresses/backend/router.py index 3193b0a..43e53cb 100644 --- a/src/govoplan_addresses/backend/router.py +++ b/src/govoplan_addresses/backend/router.py @@ -80,6 +80,7 @@ from govoplan_addresses.backend.service import ( create_carddav_sync_source, create_contact, create_sync_source, + count_contacts, delete_address_book, delete_address_list, delete_address_list_entry, @@ -430,16 +431,41 @@ def api_restore_address_book( @router.get("/contacts", response_model=ContactListResponse) def api_list_contacts( address_book_id: str | None = Query(default=None), + address_list_id: str | None = Query(default=None), query: str | None = Query(default=None), limit: int = Query(default=200, ge=1, le=500), + offset: int = Query(default=0, ge=0), include_deleted: bool = Query(default=False), principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _require_scope(principal, "addresses:contact:read") try: - contacts = list_contacts(session, principal, address_book_id=address_book_id, query=query, limit=limit, include_deleted=include_deleted) - return ContactListResponse(contacts=[_contact_response(contact) for contact in contacts]) + contacts = list_contacts( + session, + principal, + address_book_id=address_book_id, + address_list_id=address_list_id, + query=query, + limit=limit, + offset=offset, + include_deleted=include_deleted, + ) + total = count_contacts( + session, + principal, + address_book_id=address_book_id, + address_list_id=address_list_id, + query=query, + include_deleted=include_deleted, + ) + return ContactListResponse( + contacts=[_contact_response(contact) for contact in contacts], + total=total, + offset=offset, + limit=limit, + has_more=offset + len(contacts) < total, + ) except AddressBookError as exc: raise _error(exc) from exc diff --git a/src/govoplan_addresses/backend/schemas.py b/src/govoplan_addresses/backend/schemas.py index 7f70b36..6e18f00 100644 --- a/src/govoplan_addresses/backend/schemas.py +++ b/src/govoplan_addresses/backend/schemas.py @@ -221,6 +221,10 @@ class ContactResponse(BaseModel): class ContactListResponse(BaseModel): contacts: list[ContactResponse] + total: int + offset: int + limit: int + has_more: bool class AddressLookupResponse(BaseModel): diff --git a/src/govoplan_addresses/backend/service.py b/src/govoplan_addresses/backend/service.py index 68a103e..3fd24dd 100644 --- a/src/govoplan_addresses/backend/service.py +++ b/src/govoplan_addresses/backend/service.py @@ -2421,14 +2421,79 @@ def list_contacts( principal: ApiPrincipal, *, address_book_id: str | None = None, + address_list_id: str | None = None, query: str | None = None, limit: int = 200, + offset: int = 0, include_deleted: bool = False, ) -> list[Contact]: + contact_query = _filtered_contact_query( + session, + principal, + address_book_id=address_book_id, + address_list_id=address_list_id, + query=query, + include_deleted=include_deleted, + ) + return ( + contact_query.order_by(Contact.display_name.asc(), Contact.id.asc()) + .offset(max(0, offset)) + .limit(max(1, min(limit, 500))) + .all() + ) + + +def count_contacts( + session: Session, + principal: ApiPrincipal, + *, + address_book_id: str | None = None, + address_list_id: str | None = None, + query: str | None = None, + include_deleted: bool = False, +) -> int: + contact_query = _filtered_contact_query( + session, + principal, + address_book_id=address_book_id, + address_list_id=address_list_id, + query=query, + include_deleted=include_deleted, + ) + return int( + contact_query.order_by(None) + .with_entities(func.count(func.distinct(Contact.id))) + .scalar() + or 0 + ) + + +def _filtered_contact_query( + session: Session, + principal: ApiPrincipal, + *, + address_book_id: str | None, + address_list_id: str | None, + query: str | None, + include_deleted: bool, +): contact_query = _visible_contact_query(session, principal, include_deleted=include_deleted, include_deleted_books=include_deleted) if address_book_id: get_visible_address_book(session, principal, address_book_id, include_deleted=include_deleted) contact_query = contact_query.filter(Contact.address_book_id == address_book_id) + if address_list_id: + address_list = get_visible_address_list( + session, + principal, + address_list_id, + include_deleted=include_deleted, + ) + if address_book_id and address_list.address_book_id != address_book_id: + raise AddressBookError("Address list does not belong to the selected address book.") + contact_query = contact_query.join( + AddressListEntry, + AddressListEntry.contact_id == Contact.id, + ).filter(AddressListEntry.address_list_id == address_list_id) normalized_query = _trim(query) if normalized_query: pattern = f"%{normalized_query.lower()}%" @@ -2439,7 +2504,7 @@ def list_contacts( func.lower(ContactEmail.email).like(pattern), ) ) - return contact_query.order_by(Contact.display_name.asc(), Contact.id.asc()).limit(max(1, min(limit, 500))).all() + return contact_query.distinct() def get_visible_contact(session: Session, principal: ApiPrincipal, contact_id: str, *, include_deleted: bool = False) -> Contact: diff --git a/tests/test_addresses_service.py b/tests/test_addresses_service.py index 11909a4..afb2748 100644 --- a/tests/test_addresses_service.py +++ b/tests/test_addresses_service.py @@ -67,6 +67,7 @@ from govoplan_addresses.backend.service import ( create_carddav_sync_source, create_contact, create_sync_source, + count_contacts, delete_address_list_entry, delete_contact, delete_sync_source, @@ -252,6 +253,50 @@ class AddressServiceTest(unittest.TestCase): self.session.commit() self.assertEqual([item.id for item in list_contacts(self.session, self.principal, address_book_id=book.id)], [contact.id]) + def test_contact_windows_report_exact_totals(self) -> None: + book = create_address_book( + self.session, + self.principal, + AddressBookCreateRequest(scope_type="user", name="Paged"), + ) + self.session.flush() + contacts = [ + create_contact( + self.session, + self.principal, + book.id, + ContactCreateRequest( + display_name=name, + emails=[ContactEmailPayload(email=f"{name.lower()}@example.local")], + ), + ) + for name in ("Ada", "Barbara", "Claude", "Dorothy", "Edsger") + ] + self.session.commit() + + page = list_contacts( + self.session, + self.principal, + address_book_id=book.id, + limit=2, + offset=2, + ) + + self.assertEqual([contact.id for contact in page], [contacts[2].id, contacts[3].id]) + self.assertEqual( + count_contacts(self.session, self.principal, address_book_id=book.id), + 5, + ) + self.assertEqual( + count_contacts( + self.session, + self.principal, + address_book_id=book.id, + query="example.local", + ), + 5, + ) + def test_vcard_import_and_export_preserves_common_fields(self) -> None: book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Imported")) self.session.commit() @@ -470,6 +515,27 @@ END:VCARD self.assertEqual(entries[0].contact_email.email, "ada.private@example.local") self.assertEqual(entries[1].target_kind, "postal_address") self.assertEqual(entries[1].contact_postal_address.locality, "Berlin") + self.assertEqual( + [ + item.id + for item in list_contacts( + self.session, + self.principal, + address_book_id=book.id, + address_list_id=address_list.id, + ) + ], + [contact.id], + ) + self.assertEqual( + count_contacts( + self.session, + self.principal, + address_book_id=book.id, + address_list_id=address_list.id, + ), + 1, + ) with self.assertRaisesRegex(ValueError, "same address book"): create_address_list_entry( diff --git a/webui/src/api/addresses.ts b/webui/src/api/addresses.ts index 47144d5..40ccfc4 100644 --- a/webui/src/api/addresses.ts +++ b/webui/src/api/addresses.ts @@ -168,8 +168,12 @@ type AddressListEntryListResponse = { entries: AddressListEntry[]; }; -type ContactListResponse = { +export type ContactListResponse = { contacts: Contact[]; + total: number; + offset: number; + limit: number; + has_more: boolean; }; type AddressBookWriteTargetsResponse = { @@ -588,11 +592,15 @@ export function resolveAddressSyncConflict( }); } -export async function listContacts(settings: ApiSettings, options: {addressBookId?: string | null;query?: string | null;limit?: number;includeDeleted?: boolean;} = {}): Promise { - const response = await apiFetch( +export function listContactsPage(settings: ApiSettings, options: {addressBookId?: string | null;addressListId?: string | null;query?: string | null;limit?: number;offset?: number;includeDeleted?: boolean;} = {}): Promise { + return apiFetch( settings, - `/api/v1/addresses/contacts${queryString({ address_book_id: options.addressBookId, query: options.query, limit: options.limit, include_deleted: options.includeDeleted ? "true" : null })}` + `/api/v1/addresses/contacts${queryString({ address_book_id: options.addressBookId, address_list_id: options.addressListId, query: options.query, limit: options.limit, offset: options.offset, include_deleted: options.includeDeleted ? "true" : null })}` ); +} + +export async function listContacts(settings: ApiSettings, options: {addressBookId?: string | null;addressListId?: string | null;query?: string | null;limit?: number;offset?: number;includeDeleted?: boolean;} = {}): Promise { + const response = await listContactsPage(settings, options); return response.contacts; } diff --git a/webui/src/features/addressbook/AddressBookPage.tsx b/webui/src/features/addressbook/AddressBookPage.tsx index 350788f..ee7ec25 100644 --- a/webui/src/features/addressbook/AddressBookPage.tsx +++ b/webui/src/features/addressbook/AddressBookPage.tsx @@ -4,6 +4,7 @@ import { ApiError, Button, ConfirmDialog, + DataGridPaginationBar, Dialog, DismissibleAlert, ExplorerTree, @@ -47,6 +48,7 @@ import { listAddressSyncSources, listAddressSyncTombstones, listContacts, + listContactsPage, previewAddressSyncSource, restoreAddressBook, restoreAddressList, @@ -621,6 +623,9 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props) const [addressListEntries, setAddressListEntries] = useState([]); const [syncSources, setSyncSources] = useState([]); const [contacts, setContacts] = useState([]); + const [contactTotal, setContactTotal] = useState(0); + const [contactPage, setContactPage] = useState(1); + const [contactPageSize, setContactPageSize] = useState(50); const [selectedBookId, setSelectedBookId] = useState(""); const [selectedListId, setSelectedListId] = useState(""); const [selectedContactId, setSelectedContactId] = useState(""); @@ -718,10 +723,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props) const addressTreeNodes = useMemo(() => buildAddressTree(books, addressLists), [addressLists, books]); const selectedListContactIds = useMemo(() => listEntryContactIds(addressListEntries), [addressListEntries]); const selectedListEntryKeys = useMemo(() => new Set(addressListEntries.map(addressListEntryKey)), [addressListEntries]); - const visibleContacts = useMemo( - () => selectedList ? contacts.filter((contact) => selectedListContactIds.has(contact.id)) : contacts, - [contacts, selectedList, selectedListContactIds] - ); + const visibleContacts = contacts; const memberCandidateContacts = useMemo(() => { const normalizedQuery = memberQuery.trim().toLowerCase(); return memberCandidates @@ -985,10 +987,25 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props) const refreshContacts = useCallback(async (bookId: string, search: string) => { if (!bookId) { setContacts([]); + setContactTotal(0); return; } - setContacts(await listContacts(settings, { addressBookId: bookId, query: search, limit: 500, includeDeleted: showArchived })); - }, [settings, showArchived]); + const response = await listContactsPage(settings, { + addressBookId: bookId, + addressListId: selectedListId || undefined, + query: search, + limit: contactPageSize, + offset: (contactPage - 1) * contactPageSize, + includeDeleted: showArchived + }); + const pageCount = Math.max(1, Math.ceil(response.total / contactPageSize)); + if (contactPage > pageCount) { + setContactPage(pageCount); + return; + } + setContacts(response.contacts); + setContactTotal(response.total); + }, [contactPage, contactPageSize, selectedListId, settings, showArchived]); const refreshListEntries = useCallback(async (listId: string) => { if (!listId) { @@ -1163,12 +1180,14 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props) function openTreeNode(node: AddressTreeNode) { if (node.kind === "book" && node.book) { + setContactPage(1); setSelectedBookId(node.book.id); setSelectedListId(""); setSelectedContactId(""); return; } if (node.kind === "list" && node.list) { + setContactPage(1); setSelectedBookId(node.list.address_book_id); setSelectedListId(node.list.id); setSelectedContactId(""); @@ -1984,7 +2003,10 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props) label="Show archived" checked={showArchived} disabled={Boolean(toggleArchiveReason)} - onChange={() => setShowArchived((current) => !current)} + onChange={() => { + setContactPage(1); + setShowArchived((current) => !current); + }} help="Include archived address books, lists, and contacts." /> @@ -2018,7 +2040,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)

{selectedList ? selectedList.name : selectedBook ? selectedBook.name : "Contacts"}

- {visibleContacts.length} contact{visibleContacts.length === 1 ? "" : "s"} + {contactTotal} contact{contactTotal === 1 ? "" : "s"} {selectedList ? " in selected list" : selectedBook ? " in selected book" : ""}

@@ -2030,7 +2052,13 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
- setQuery(event.target.value)} placeholder="Search contacts" /> + { + setContactPage(1); + setQuery(event.target.value); + }} + placeholder="Search contacts" /> {selectedBook?.read_only && } {selectedBook?.deleted_at && }
@@ -2042,6 +2070,19 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props) } + { + setContactPageSize(pageSize); + setContactPage(1); + }} />
diff --git a/webui/src/styles/addresses.css b/webui/src/styles/addresses.css index 4755925..e7d50b8 100644 --- a/webui/src/styles/addresses.css +++ b/webui/src/styles/addresses.css @@ -308,6 +308,11 @@ align-content: start; } +.address-contact-pagination { + border-top: var(--border-line); + flex: 0 0 auto; +} + .address-contact-selection-list { gap: 2px; }