diff --git a/server/polar/backoffice/organizations/endpoints.py b/server/polar/backoffice/organizations/endpoints.py index 7a722684e2..05f023d471 100644 --- a/server/polar/backoffice/organizations/endpoints.py +++ b/server/polar/backoffice/organizations/endpoints.py @@ -1312,6 +1312,8 @@ def get_value(self, item: File) -> str | None: async def get( request: Request, id: UUID4, + files_page: int = Query(1, ge=1), + files_limit: int = Query(10, ge=1, le=100), session: AsyncSession = Depends(get_db_session), ) -> Any: repository = OrganizationRepository.from_session(session) @@ -1803,7 +1805,7 @@ async def get( pass # Organization Files Section - with tag.div(classes="mt-8"): + with tag.div(classes="mt-8 flex flex-col gap-4", id="files"): with tag.div(classes="flex items-center gap-4 mb-4"): with tag.h2(classes="text-2xl font-bold"): text("Downloadable Files") @@ -1812,10 +1814,12 @@ async def get( (FileSortProperty.created_at, True) ] file_repository = FileRepository.from_session(session) - files = await file_repository.get_all_by_organization( + files, files_count = await file_repository.paginate_by_organization( organization.id, service=FileServiceTypes.downloadable, sorting=sorting, + limit=files_limit, + page=files_page, ) with datatable.Datatable[File, FileSortProperty]( @@ -1828,6 +1832,11 @@ async def get( ).render(request, files, sorting=sorting): pass + with datatable.pagination( + request, PaginationParams(files_page, files_limit), files_count + ): + pass + @router.get("/{id}/plain_search_url", name="organizations:plain_search_url") async def get_plain_search_url( diff --git a/server/polar/backoffice/organizations_v2/endpoints.py b/server/polar/backoffice/organizations_v2/endpoints.py index 26d435969d..df6867860e 100644 --- a/server/polar/backoffice/organizations_v2/endpoints.py +++ b/server/polar/backoffice/organizations_v2/endpoints.py @@ -315,6 +315,8 @@ async def get_organization_detail( request: Request, organization_id: UUID4, section: str = Query("overview"), + files_page: int = Query(1, ge=1), + files_limit: int = Query(10, ge=1, le=100), session: AsyncSession = Depends(get_db_session), ) -> None: """ @@ -498,17 +500,25 @@ async def get_organization_detail( with account_section.render(request): pass elif section == "files": - # Fetch downloadable files from repository + # Fetch downloadable files from repository with pagination file_sorting: list[Sorting[FileSortProperty]] = [ (FileSortProperty.created_at, True) ] file_repository = FileRepository(session) - files = await file_repository.get_all_by_organization( + files, files_count = await file_repository.paginate_by_organization( organization.id, service=FileServiceTypes.downloadable, sorting=file_sorting, + limit=files_limit, + page=files_page, + ) + files_section = FilesSection( + organization, + files=files, + page=files_page, + limit=files_limit, + total_count=files_count, ) - files_section = FilesSection(organization, files=list(files)) with files_section.render(request): pass elif section == "history": diff --git a/server/polar/backoffice/organizations_v2/views/sections/files_section.py b/server/polar/backoffice/organizations_v2/views/sections/files_section.py index 8b9c4a4019..ef09aa030a 100644 --- a/server/polar/backoffice/organizations_v2/views/sections/files_section.py +++ b/server/polar/backoffice/organizations_v2/views/sections/files_section.py @@ -4,7 +4,8 @@ from collections.abc import Generator from fastapi import Request -from tagflow import tag, text +from starlette.datastructures import URL +from tagflow import attr, tag, text from polar.file.service import file as file_service from polar.models import File, Organization @@ -19,9 +20,15 @@ def __init__( self, organization: Organization, files: list[File] | None = None, + page: int = 1, + limit: int = 10, + total_count: int = 0, ): self.org = organization self.files = files or [] + self.page = page + self.limit = limit + self.total_count = total_count def format_file_size(self, size_bytes: int) -> str: """Format file size in human-readable format.""" @@ -34,18 +41,63 @@ def format_file_size(self, size_bytes: int) -> str: else: return f"{size_bytes / (1024 * 1024 * 1024):.1f} GB" + def _render_pagination(self, request: Request) -> None: + """Render pagination controls for files.""" + start = (self.page - 1) * self.limit + 1 + end = min(self.page * self.limit, self.total_count) + + # Calculate URLs for navigation + next_url: URL | None = None + if end < self.total_count: + next_url = request.url.replace_query_params( + **{**request.query_params, "files_page": self.page + 1} + ).replace(fragment="files") + previous_url: URL | None = None + if start > 1: + previous_url = request.url.replace_query_params( + **{**request.query_params, "files_page": self.page - 1} + ).replace(fragment="files") + + with tag.div(classes="flex justify-between"): + with tag.div(classes="text-sm"): + text("Showing ") + with tag.span(classes="font-bold"): + text(str(start)) + text(" to ") + with tag.span(classes="font-bold"): + text(str(end)) + text(" of ") + with tag.span(classes="font-bold"): + text(str(self.total_count)) + text(" entries") + with tag.div(classes="join grid grid-cols-2"): + with tag.a( + classes="join-item btn", + href=str(previous_url) if previous_url else "", + ): + if previous_url is None: + attr("disabled", True) + text("Previous") + with tag.a( + classes="join-item btn", + href=str(next_url) if next_url else "", + ): + if next_url is None: + attr("disabled", True) + text("Next") + @contextlib.contextmanager def render(self, request: Request) -> Generator[None]: """Render the files section.""" - with tag.div(classes="space-y-6"): + with tag.div(classes="space-y-6", id="files"): # Files list card with card(bordered=True): with tag.div(classes="flex items-center justify-between mb-4"): with tag.h2(classes="text-lg font-bold"): text("Downloadable Files") with tag.div(classes="text-sm text-base-content/60"): - text(f"{len(self.files)} file(s)") + text(f"{self.total_count} file(s)") if self.files: # Files table @@ -104,6 +156,10 @@ def render(self, request: Request) -> Generator[None]: classes="btn btn-sm btn-ghost", ): text("Download") + + # Pagination controls + if self.total_count > self.limit: + self._render_pagination(request) else: with empty_state( "No Files", @@ -117,19 +173,19 @@ def render(self, request: Request) -> Generator[None]: text("Storage Information") with tag.div(classes="space-y-2 text-sm"): - total_size = sum(f.size for f in self.files if f.size) + page_size = sum(f.size for f in self.files if f.size) with tag.div(classes="flex justify-between"): with tag.span(classes="text-base-content/60"): text("Total Files:") with tag.span(classes="font-semibold"): - text(str(len(self.files))) + text(str(self.total_count)) with tag.div(classes="flex justify-between"): with tag.span(classes="text-base-content/60"): - text("Total Size:") + text("Page Size:") with tag.span(classes="font-semibold"): - text(self.format_file_size(total_size)) + text(self.format_file_size(page_size)) yield diff --git a/server/polar/file/repository.py b/server/polar/file/repository.py index 94add5006f..dc03f0ef9e 100644 --- a/server/polar/file/repository.py +++ b/server/polar/file/repository.py @@ -70,6 +70,30 @@ async def get_all_by_organization( return await self.get_all(statement) + async def paginate_by_organization( + self, + organization_id: UUID, + *, + service: FileServiceTypes | None = None, + sorting: list[Sorting[FileSortProperty]] = [ + (FileSortProperty.created_at, True) + ], + limit: int, + page: int, + ) -> tuple[list[File], int]: + """Get paginated files for an organization, optionally filtered by service type.""" + statement = self.get_base_statement().where( + File.organization_id == organization_id, + File.is_uploaded.is_(True), + ) + + if service is not None: + statement = statement.where(File.service == service) + + statement = self.apply_sorting(statement, sorting) + + return await self.paginate(statement, limit=limit, page=page) + def get_sorting_clause(self, property: FileSortProperty) -> SortingClause: match property: case FileSortProperty.created_at: