· 

Bootstrap: Table readability

1. Difficult to read the table

I have a table for the summary of admins where each row is an admin data, and the columns include username, email, the number of clients, the number of documents, storage(MB), Download(MB), active or not, paid or not, cancel button clicked or not, the date for subscription ends, etc. But as the number of admins increase, the number of rows increase. As a result, it is getting difficult to see the header of the summary table. For the lowest rows, I have to scroll up the table to see what is each column for. How can I fix this problem? Can give any suggestions or options? The following is the template of the table. In fact I divided the table into two parts. Anyway, please give me any suggestions.

(Code omitted)

 

The most common solution for this problem is a sticky table header. As you scroll down through hundreds of admins, the column names remain visible at the top.

For Bootstrap tables, you can do something like:

<div class="table-responsive" style="max-height: 700px;">
<table class="table table-bordered table-sm">
<thead class="table-light sticky-top">
<tr>
<th>Username</th>
<th>Email</th>
<th>Clients</th>
...
</tr>
</thead>
<tbody>
...
</tbody>
</table>
</div>

Or more explicitly:

<style>
.sticky-header th {
position: sticky;
top: 0;
z-index: 10;
background: white;
}
</style>

<thead class="sticky-header">

This is probably the single highest-value improvement.


Option 2: Add a row number

When the table gets long, it helps to know where you are.

<th>#</th>

{% for admin in admins %}
<tr>
<td>{{ forloop.counter }}</td>

Then you can quickly say:

Admin #157 looks strange.

instead of counting manually.


Option 3: Search box

For a superadmin dashboard, this is often more useful than scrolling.

<input type="text" id="adminSearch"
class="form-control mb-3"
placeholder="Search username or email">

Then filter rows with a small JavaScript function.

If you eventually have 500+ admins, searching is much faster than scrolling.

 

[MY NOTE] For how to implement this, see 3. Search box below.


Option 4: Pagination (recommended eventually)

Instead of:

500 admins
500 rows

show:

50 rows per page
Page 1 2 3 4 5 ...

Django makes this easy using the built-in paginator.

Example:

from django.core.paginator import Paginator

paginator = Paginator(admins, 50)
page_number = request.GET.get("page")
page_obj = paginator.get_page(page_number)

Then render:

{% for admin in page_obj %}

This is probably what you'll want once real customers start using the system.

 

[MY NOTE] Pagination restricts JavaScript search. See 4. Pagination below.


Option 5: Freeze first column too

Large admin dashboards often freeze both:

  • header row
  • username column

Then even if you scroll horizontally, you still know which admin you're looking at.

.sticky-col {
position: sticky;
left: 0;
background: white;
z-index: 5;
}

Applied to the Username column:

<th class="sticky-col">Username</th>

<td class="sticky-col">
{{ admin.username }}
</td>

This is common in accounting software and admin panels.


Option 6: Use colored status badges

Instead of:



💰
🟠

consider:

<span class="badge bg-success">Active</span>
<span class="badge bg-danger">Suspended</span>
<span class="badge bg-warning">Canceling</span>

As the system grows, badges become easier to scan than emojis.


What I would do for your client portal

For your current stage (commercial Django project, but not thousands of customers yet), I would implement in this order:

  1. Sticky header ← almost free to add.
  2. Pagination (50 rows/page) ← biggest long-term benefit.
  3. Search by username/email.
  4. Sticky username column if the table becomes very wide.

 

Just adding a sticky header and pagination will solve about 90% of the usability problem you're describing.

 

2. Why not working?

I tried a sticky header or sticky-top but they didn't work. Maybe the card format prevent it from working? Can you tell me what to do? The code is here:

(Code omitted)

 

The most likely reason is that your page is scrolling, not the table container.

position: sticky only works relative to the nearest scrolling container. In your code:

<div class="table-responsive">
<table>
<thead class="sticky-header">

table-responsive does not create a vertical scrolling container. It only handles horizontal overflow.

So when the browser scrolls the entire page, the <th> elements often won't stick the way you expect inside Bootstrap tables.

Try inspecting the page. If you're scrolling the whole page and not a fixed-height table area, that's the issue.

Option 1 (Recommended): Make the table body scroll

<div class="table-responsive" style="max-height: 700px; overflow-y: auto;">
<table class="table table-bordered table-sm">
<thead class="sticky-header">
...
</thead>
<tbody>
...
</tbody>
</table>
</div>

and:

.sticky-header th {
position: sticky;
top: 0;
z-index: 100;
background: white;
}

Now the table itself becomes scrollable and the header should stay visible.


Option 2: Sticky to the browser window

If you want the header to stay visible while scrolling the entire page:

.sticky-header th {
position: sticky;
top: 0;
z-index: 1000;
background: white;
}

But sometimes Bootstrap tables need:

.table-responsive {
overflow-x: auto;
overflow-y: visible;
}

because an ancestor with certain overflow settings can break sticky positioning.


Option 3: The card header is covering it

You have:

<div class="card-header bg-primary text-white">
<h5>Admin Summary</h5>
</div>

If the table header becomes sticky at top:0, it may slide underneath the card header or navbar.

In that case:

.sticky-header th {
position: sticky;
top: 50px; /* adjust */
z-index: 100;
background: white;
}

The correct value depends on the height of anything fixed above it.


Quick test

Temporarily add a bright color:

.sticky-header th {
position: sticky;
top: 0;
z-index: 1000;
background: yellow;
}

If you never see a yellow sticky row while scrolling, the problem is usually:

  1. No scroll container exists, or
  2. A parent element has an overflow setting preventing sticky positioning.

Can you show the HTML from:

<body>
...
<div class="container-fluid py-4 px-4">
...

 

down to the end of the first card (</div></div></div>), plus any custom CSS you have? Then I can pinpoint exactly which parent element is preventing the sticky header.

 

2.(2) Still not working...

This is the whole code. It still doesn't work.

(Code omitted)

 

I see one definite bug immediately.

You wrote:

<div class="table-responsive" style="maxheight: 700px; overflow-y: auto;">

but CSS property is:

max-height

with a hyphen.

So your browser is currently ignoring:

maxheight: 700px;

completely.

Try:

<div class="table-responsive" style="max-height: 700px; overflow-y: auto;">

However, there is a second issue.

Bootstrap's .table-responsive is designed mainly for horizontal scrolling, and it sometimes interferes with sticky headers.

Try replacing:

<div class="table-responsive" style="max-height: 700px; overflow-y: auto;">

with:

<div style="max-height: 700px; overflow-y: auto;">

just for testing.

And use:

.sticky-header th {
position: sticky;
top: 0;
z-index: 1000;
background: white;
}

Another thing I noticed:

Your table is inside:

<div class="card-body">
<div style="max-height:700px; overflow-y:auto;">

and the card footer is outside:

</table>
</div>
</div>
</div>

<div class="card-footer">

This means your HTML structure is actually:

card
├─ card-header
├─ card-body
└─ end card

card-footer

The footer is no longer inside the card.

I would rewrite this part as:

<div class="card mb-4 shadow-sm">

<div class="card-header bg-primary text-white">
<h5 class="mb-0">Admin Summary</h5>
</div>

<div class="card-body">

<div style="max-height:700px; overflow-y:auto;">
<table class="table table-bordered text-center table-sm w-100">

<thead class="sticky-header">
...
</thead>

<tbody>
...
</tbody>

</table>
</div>

</div>

<div class="card-footer d-flex justify-content-end">
<a href="{% url 'client_portal:dashboard_router' %}"
class="btn btn-secondary">
Back
</a>
</div>

</div>

For debugging, add a very obvious style:

.sticky-header th {
position: sticky;
top: 0;
background: red !important;
color: white !important;
z-index: 9999;
}

Then scroll.

If the header never turns red and sticks, the browser isn't applying position: sticky.

If it turns red but still scrolls away, then the scroll container isn't configured correctly.


One more observation: your first table is probably not tall enough to require scrolling inside the table container. If you have only a few admins, the browser will scroll the page instead of the table body, and sticky won't appear to work. Make sure you actually have enough rows to exceed the 700px container height.

I would first fix the typo:

max-height: 700px;

 

because that alone prevents the scrolling container from existing. That is the most likely reason the sticky header currently does nothing.

 

2.(3) Somehow I fixed the problem...

The header columns wrap. Why doesn't the width setting work?

(Code omitted)

 

There are a few reasons why width: 250px on <th> may appear to do nothing.

1. HTML error in your code

I noticed this:

<th class="text-center" style="width: 250px;"
title="Data deletion scheduled at">
Data deletion scheduled at</>

The closing tag is invalid:

</>

It should be:

</th>

An invalid table structure can cause browsers to render columns strangely.


2. Tables don't obey width the way divs do

By default, HTML tables use:

table-layout: auto;

This means the browser examines all cells in a column and decides the width itself.

So even if you write:

<th style="width: 550px;">Soft Deleted at</th>

the browser may ignore it because it thinks another column needs more room.


3. Bootstrap tables often shrink columns

You currently have:

<table class="table table-striped table-bordered text-center align-middle">

Try:

<table class="table table-striped table-bordered text-center align-middle"
style="table-layout: fixed;">

Now the browser respects widths much more aggressively.


4. Text wrapping is occurring

If your goal is to prevent:

Subscription
Ends

from wrapping, use:

<th class="text-center text-nowrap"
style="width:250px;">
Subscription Ends
</th>

Bootstrap's text-nowrap adds:

white-space: nowrap;

Alternatively:

<th style="width:250px; white-space:nowrap;">

5. Width is larger than the table itself

You have:

250 + 250 + 250 + 550 + 250

which is already:

1550px

for only the last 5 columns.

Add the other columns and you're well over 2000px.

If the containing card is only 1200px wide, the browser must ignore some widths or introduce horizontal scrolling.


What I would do

For your superadmin dashboard:

<table class="table table-striped table-bordered text-center align-middle"
style="table-layout: fixed;">

and

<th class="text-nowrap" style="width:120px;">Signup</th>
<th class="text-nowrap" style="width:120px;">Active</th>
<th class="text-nowrap" style="width:120px;">Paid</th>
<th class="text-nowrap" style="width:120px;">Refunded</th>
<th class="text-nowrap" style="width:120px;">Cancelled</th>
<th class="text-nowrap" style="width:150px;">Subscription Ends</th>
<th class="text-nowrap" style="width:150px;">Suspended At</th>
<th class="text-nowrap" style="width:180px;">Deletion Scheduled</th>
<th class="text-nowrap" style="width:150px;">Soft Deleted</th>
<th class="text-nowrap" style="width:150px;">Payment Failed</th>

Notice that I shortened the labels. Long headers make tables hard to scan.

Another common admin-dashboard trick is:

<th title="Data deletion scheduled at">
Deletion Scheduled
</th>

 

Users can hover to see the full description, while the table stays compact.

 

3. Search box

I want to put the input box at the right side of the card header, not below "Admin Summary 2". How can I modify this?

<div class="card-header bg-info text-white">

    <h5 class="mb-0">Admin Summary 2</h5>

    <input type="text" id="adminSearch" class="form-control mb-3" placeholder="Search username or email">

</div>

 

Use Bootstrap Flexbox on the card header.

<div class="card-header bg-info text-white d-flex justify-content-between align-items-center">
<h5 class="mb-0">Admin Summary 2</h5>

<input
type="text"
id="adminSearch"
class="form-control"
placeholder="Search username or email"
style="width: 300px;"
>
</div>

What these classes do

  • d-flex → puts items in a row.
  • justify-content-between → pushes title left and input right.
  • align-items-center → vertically aligns them.

If the search box looks too wide or too narrow, adjust:

style="width: 300px;"

For example:

style="width: 250px;"

or

style="width: 400px;"

Better responsive version

If you want it to look nicer on smaller screens:

<div class="card-header bg-info text-white d-flex justify-content-between align-items-center flex-wrap gap-2">
<h5 class="mb-0">Admin Summary 2</h5>

<input
type="text"
id="adminSearch"
class="form-control"
placeholder="Search username or email"
style="max-width: 350px;"
>
</div>

 

This allows the input to move below the title automatically on very narrow screens while staying on the right side on desktop screens.

 

3.(2) JavaScript

You said:

Option 3: Search box

For a superadmin dashboard, this is often more useful than scrolling.

<input type="text" id="adminSearch"

    class="form-control mb-3"

    placeholder="Search username or email">

Then filter rows with a small JavaScript function.

If you eventually have 500+ admins, searching is much faster than scrolling.

Can you show me a JavaSript function for this input box?

 

Yes. A simple approach is to search the text contained in each table row and hide rows that don't match.

First, give your table an ID:

<table id="adminTable" class="table table-bordered text-center table-sm w-100">

Keep your search box:

<input
type="text"
id="adminSearch"
class="form-control"
placeholder="Search username or email">

Then add this JavaScript near the bottom of the page, just before </body>:

<script>
document.getElementById("adminSearch").addEventListener("keyup", function () {
const searchText = this.value.toLowerCase();
const rows = document.querySelectorAll("#adminTable tbody tr");
rows.forEach(function (row) {
const rowText = row.textContent.toLowerCase();
if (rowText.includes(searchText)) {
row.style.display = "";
} else {
row.style.display = "none";
}
});
});
</script>

Better version: search only username and email

Since your table has many columns, searching the entire row may produce unexpected matches.

Suppose:

  • column 0 = Username
  • column 1 = Email

Then:

<script>
document.getElementById("adminSearch").addEventListener("keyup", function () {
const searchText = this.value.toLowerCase();
document.querySelectorAll("#adminTable tbody tr").forEach(function (row) {
const username = row.cells[0].textContent.toLowerCase();
const email = row.cells[1].textContent.toLowerCase();
if (
username.includes(searchText) ||
email.includes(searchText)
) {
row.style.display = "";
} else {
row.style.display = "none";
}
});
});
</script>

This version is usually better for a superadmin dashboard because:

  • Typing john finds usernames containing john.
  • Typing gmail finds matching email addresses.
  • It ignores columns like Storage, Downloads, Paid, Subscription Ends, etc.

 

For your dashboard, I would use the second version. It's more predictable as the number of admins grows.

 

3.(3) Code explained

Can you explain this code?

(Code omitted)

 

Certainly. Let's go through it line by line.

<script>
document.getElementById("adminSearch").addEventListener("keyup", function () {
const searchText = this.value.toLowerCase();
const rows = document.querySelectorAll("#adminTable tbody tr");
rows.forEach(function (row) {
const rowText = row.textContent.toLowerCase();
if (rowText.includes(searchText)) {
row.style.display = "";
} else {
row.style.display = "none";
}
});
});
</script>

1. Find the search box

document.getElementById("adminSearch")

Looks for:

<input type="text" id="adminSearch">

and gets a reference to that input element.


2. Listen for typing

.addEventListener("keyup", function () {

This means:

Every time the user releases a key after typing, run this function.

For example:

  • user types m
  • function runs

Then:

  • user types k
  • function runs again

Then:

  • user types 1
  • function runs again

So the table updates instantly while typing.


3. Get the search text

const searchText = this.value.toLowerCase();

Suppose the user typed:

mk18

Then:

this.value

is:

"mk18"

and:

toLowerCase()

converts everything to lowercase.

Result:

searchText = "mk18"

4. Get all table rows

const rows = document.querySelectorAll("#adminTable tbody tr");

Suppose your table is:

<table id="adminTable">
<tbody>
<tr>...</tr>
<tr>...</tr>
<tr>...</tr>
</tbody>
</table>

This finds all <tr> rows inside the table body.

For example:

rows = [
row1,
row2,
row3,
...
]

5. Process every row

rows.forEach(function (row) {

Loop through each row one by one.

Equivalent to:

for row in rows:

in Python.


6. Get the row's text

const rowText = row.textContent.toLowerCase();

Suppose one row contains:

<tr>
<td>mk18</td>
<td>[email protected]</td>
</tr>

Then:

row.textContent

becomes something like:

and after:

toLowerCase()

still:


7. Check if the row matches

if (rowText.includes(searchText))

Example:

rowText = "mk18 [email protected]"
searchText = "mk"

Then:

rowText.includes(searchText)

returns:

true

because "mk" appears inside "mk18".


Another example:

rowText = "john [email protected]"
searchText = "mk"

returns:

false

because "mk" does not appear.


8. Show matching rows

row.style.display = "";

If the row matches:

true

show the row normally.

Equivalent to:

display: table-row;

9. Hide non-matching rows

row.style.display = "none";

If the row does not match:

false

hide it completely.

The row disappears from the table.


Example

Suppose your table contains:

User types:

jo

The script checks:

Row 1

Contains "jo"?

❌ No

Hide row.


Row 2

Contains "jo"?

✅ Yes

Show row.


Row 3

Contains "jo"?

❌ No

Hide row.


Result:

Username Email
john [email protected]

Only John's row remains visible.


Small improvement

If you only want to search username and email columns (not dates, storage, download, etc.), you can make it faster:

rows.forEach(function(row) {
const username = row.cells[0].textContent.toLowerCase();
const email = row.cells[1].textContent.toLowerCase();
if (
username.includes(searchText) ||
email.includes(searchText)
) {
row.style.display = "";
} else {
row.style.display = "none";
}
});

This avoids matching random fields such as:

  • storage usage
  • dates
  • subscription status

 

and is usually better for a superadmin dashboard.

 

3.(4) Can I put multiple <script>...</script> parts in the file?

There is already a set of script tags:

<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>

Can I put another set of script tags below this set?

 

Yes, absolutely.

It is very common to have multiple <script> tags near the end of <body>:

<!-- Bootstrap -->
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>

<!-- Your custom JavaScript -->
<script>
document.getElementById("adminSearch").addEventListener("keyup", function () {
const searchText = this.value.toLowerCase();
const rows = document.querySelectorAll("#adminTable tbody tr");
rows.forEach(function (row) {
const rowText = row.textContent.toLowerCase();
row.style.display =
rowText.includes(searchText) ? "" : "none";
});
});
</script>

or even:

<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>

<script src="{% static 'js/admin_search.js' %}"></script>

<script src="{% static 'js/dashboard.js' %}"></script>

All of these are fine.


One thing I noticed, however:

Your JavaScript contains:

document.querySelectorAll("#adminTable tbody tr")

but in the HTML you posted earlier, I don't see:

<table id="adminTable">

I only see:

<table class="table table-bordered text-center table-sm w-100">

If that's still the case, the search code will find zero rows.

Add an id:

<table id="adminTable"
class="table table-bordered text-center table-sm w-100">

and make sure your input is:

<input type="text"
id="adminSearch"
class="form-control"
placeholder="Search username or email">

Then the search script should work.

 

A further improvement for your superadmin dashboard would be to search only the username and email columns instead of the entire row. Otherwise typing something like 2026 may match every row because many date columns contain 2026.

 

4. Pagination

If I use pagination, can I still use JavaScript search? For example, suppose I have 10 pages. Can I search the whole 10 pages at once? How about sticky headers? For each page, can I have a sticky header?

 

Yes, but there are important limitations.

JavaScript search + Pagination

Case 1: Django pagination (server-side)

Suppose you have:

from django.core.paginator import Paginator

paginator = Paginator(admins, 50)
page_obj = paginator.get_page(request.GET.get("page"))

Page 1 shows only 50 admins.

Your JavaScript sees only those 50 rows:

document.querySelectorAll("#adminTable tbody tr")

So if you have:

  • Page 1: mk18
  • Page 2: john
  • Page 3: mary

and you search for:

mary

while viewing Page 1,

JavaScript cannot find Mary because Page 3's rows are not present in the HTML.

Result: search works only within the current page.


Case 2: Search all 10 pages

To search all admins across all pages, the search must happen on the server.

Example:

search = request.GET.get("q")

admins = CustomUser.objects.filter(role="admin")

if search:
admins = admins.filter(
Q(username__icontains=search) |
Q(email__icontains=search)
)

paginator = Paginator(admins, 50)

Then:

<form method="get">
<input type="text" name="q" value="{{ request.GET.q }}">
<button type="submit">Search</button>
</form>

This searches the database itself.

If you have:

  • 10 pages
  • 1,000 admins

and search for:

mary

Django returns only matching admins and repaginates the result.

This is the approach I'd recommend.

 

[MY NOTE]  I will apply this database search with my pagination system.  See 5. Search the database below.

 


Sticky headers + Pagination

Yes.

Each page can have its own sticky header.

For example:

<div class="table-responsive"
style="max-height:700px; overflow-y:auto;">

<table id="adminTable">
<thead class="sticky-header">

When viewing Page 1:

  • header sticks

When viewing Page 2:

  • same header sticks

When viewing Page 3:

  • same header sticks

Pagination doesn't affect sticky headers.


What I'd do for your superadmin dashboard

For a commercial Django portal:

Under 100 admins

Simple JavaScript search is fine.

100–500 admins

Use:

  • Django pagination
  • server-side search (username__icontains, email__icontains)
  • sticky header

This is probably your sweet spot.

Thousands of admins

Use:

  • pagination
  • search
  • sortable columns
  • maybe filters such as:
    • Active only
    • Suspended only
    • Payment failed
    • Cancelled
    • Trial users

This scales much better than loading thousands of rows into one page.

For your current project, I'd choose:

  1. Django pagination (50 rows/page)
  2. Search box that submits to Django
  3. Sticky header within each page

 

That gives a professional admin dashboard and avoids huge tables.

 

4.(2) How to implement Paginaiton

You said:

Django makes this easy using the built-in paginator.

Example:

from django.core.paginator

import Paginator paginator = Paginator(admins, 50)

page_number = request.GET.get("page")

page_obj = paginator.get_page(page_number)

Then render:

{% for admin in page_obj %}

I don't know how I can incorporate this into my view:

(Code omitted)

Can you explain more details?

 

Yes. The trick is that you should build admins_with_storage first, and then paginate that list.

Right now your flow is:

admins = queryset

admins_with_storage = []

for admin in admins:
...
admins_with_storage.append(admin)

return render(..., {"admins": admins_with_storage})

You can insert the paginator after the loop.


Step 1: import Paginator

At the top of your view file:

from django.core.paginator import Paginator

Step 2: paginate the list

After the loop:

admins_with_storage = []

for admin in admins:
admin_profile = getattr(admin, "admin_profile", None)

reset_download_if_needed(admin)

admin.storage_used_mb = get_admin_storage_usage(admin) / (1024 * 1024)
admin.storage_limit_mb = admin_profile.storage_limit_mb if admin_profile else 0

admin.download_used_mb = admin.download_bytes_used / (1024 * 1024)
admin.download_limit_mb = admin.download_bytes_limit / (1024 * 1024)

admins_with_storage.append(admin)

# NEW
paginator = Paginator(admins_with_storage, 50) # 50 admins/page
page_number = request.GET.get("page")
page_obj = paginator.get_page(page_number)

return render(
request,
"client_portal/superadmin_summary.html",
{
"page_obj": page_obj,
},
)

Step 3: modify template

Instead of:

{% for admin in admins %}

use:

{% for admin in page_obj %}

Everything else stays the same.


Step 4: add page navigation

Below the table:

<nav>
<ul class="pagination justify-content-center">

{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link"
href="?page={{ page_obj.previous_page_number }}">
Previous
</a>
</li>
{% endif %}

<li class="page-item disabled">
<span class="page-link">
Page {{ page_obj.number }}
of
{{ page_obj.paginator.num_pages }}
</span>
</li>

{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link"
href="?page={{ page_obj.next_page_number }}">
Next
</a>
</li>
{% endif %}

</ul>
</nav>

You'll get:

Previous | Page 3 of 12 | Next

Better version for your project

Looking at your code, there is one inefficiency:

for admin in admins:
reset_download_if_needed(admin)
get_admin_storage_usage(admin)

If you eventually have:

500 admins

the page will still calculate all 500 admins before showing page 1.

A more scalable approach is:

paginator = Paginator(admins, 50)
page_obj = paginator.get_page(page_number)

for admin in page_obj:
...

Then only 50 admins are processed.

Something like:

admins = (
CustomUser.objects.filter(role="admin")
.annotate(
num_clients=Count("clients", distinct=True),
num_docs=Count("clients__user__documents", distinct=True),
)
.order_by("id")
)

paginator = Paginator(admins, 50)
page_number = request.GET.get("page")
page_obj = paginator.get_page(page_number)

for admin in page_obj:
admin_profile = getattr(admin, "admin_profile", None)

reset_download_if_needed(admin)

admin.storage_used_mb = get_admin_storage_usage(admin) / (1024 * 1024)
admin.storage_limit_mb = admin_profile.storage_limit_mb if admin_profile else 0

admin.download_used_mb = admin.download_bytes_used / (1024 * 1024)
admin.download_limit_mb = admin.download_bytes_limit / (1024 * 1024)

return render(
request,
"client_portal/superadmin_summary.html",
{"page_obj": page_obj},
)

 

This is the version I would use for a commercial Django project because it scales much better as the number of admins grows.

 

4.(3) Where to put <nav>...</nav>

You said:

Step 4: add page navigation

Below the table:

<nav>...</nav>

I have two tables, both using the same page_obj. Where should I put the nav part? After the first table's </tbody>, or </table>? Or after the second table's </tbody>, or </table>? Or both?

 

Since both tables are displaying the same page_obj, there is only one set of pages.

Therefore, you only need one pagination control.

The best place is:

<table>
...
</table>

<table>
...
</table>

<nav>
...
</nav>

In other words, after the second table's </table>.

Example:

<!-- First table -->
<table>
...
</table>

<!-- Second table -->
<table>
...
</table>

<!-- Pagination -->
<nav>
<ul class="pagination justify-content-center">

{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link"
href="?page={{ page_obj.previous_page_number }}">
Previous
</a>
</li>
{% endif %}

<li class="page-item disabled">
<span class="page-link">
Page {{ page_obj.number }}
of {{ page_obj.paginator.num_pages }}
</span>
</li>

{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link"
href="?page={{ page_obj.next_page_number }}">
Next
</a>
</li>
{% endif %}

</ul>
</nav>

You could also put the navigation in two places:

Table 1

<nav>...</nav>

Table 2

<nav>...</nav>

or

<nav>...</nav>

Table 1

Table 2

<nav>...</nav>

Many admin dashboards show pagination both at the top and bottom of long tables.

However, for your current page, I'd recommend:

Table 1
Table 2
Pagination

because:

  • the two tables represent the same admin list
  • there is only one page number
  • the UI is simpler
  • less duplicated code

One thing to watch out for: your two tables must both loop over:

{% for admin in page_obj %}

not:

{% for admin in admins %}

 

Otherwise pagination will appear to work while still rendering all rows.

 

4.(4) What is "disabled" in <li class="page-item disabled">?

What is "disabled" for in here?

<li class="page-item disabled">

 

This line:

<li class="page-item disabled">

uses Bootstrap's disabled class to make that pagination item look disabled and non-clickable.

In the example:

<li class="page-item disabled">
<span class="page-link">
Page {{ page_obj.number }}
of
{{ page_obj.paginator.num_pages }}
</span>
</li>

the middle item is not a link. It is just information:

Page 3 of 12

Since the user should not click it, Bootstrap styles it as disabled.


Without disabled:

<li class="page-item">

it would look like a normal pagination button:

[ Page 3 of 12 ]

which suggests it is clickable.


With disabled:

<li class="page-item disabled">

it appears greyed out:

Previous   Page 3 of 12   Next
(grey)

and users understand it is informational only.


You can also use disabled for Previous/Next when appropriate.

For example, on the first page:

{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link"
href="?page={{ page_obj.previous_page_number }}">
Previous
</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">Previous</span>
</li>
{% endif %}

Then on page 1:

[Previous]  Page 1 of 10  [Next]

where Previous is greyed out and cannot be clicked.

 

This is slightly more polished than hiding the button completely, because users can immediately see they are already on the first page.

 

4.(5) This causes error: <!-- {% ... %} -->

I couldn't make pagination work with Server Error (500), and after many attempts, I found the cause.  Below is a conversation with ChatGPT after I found the cause.

I finally could get back to the state before adding pagination. I think the problem was in: <!-- {% for admin in page_obj %} --> since just after removing this part, I got the expected web page. Now I am going to add pagination again. This time I won't leave <!-- {% ... %} --> in the code.

 

Good catch.

The culprit was probably this:

<!-- {% for admin in page_obj %} -->

Many people assume that because it is inside an HTML comment, Django ignores it. Unfortunately, Django processes template tags before the browser ever sees the HTML.

So Django still sees:

{% for admin in page_obj %}

even though it's inside:

<!-- ... -->

and then it starts looking for a matching:

{% endfor %}

which can lead to confusing template errors.

If you want to temporarily disable Django template code, use Django comments:

{# {% for admin in page_obj %} #}

or even better:

{% comment %}
{% for admin in page_obj %}
{% endcomment %}

or simply delete it while testing.


For your next pagination attempt, I recommend doing it in this order:

Step 1 — Modify the view

Add back:

from django.core.paginator import Paginator

paginator = Paginator(admins_with_storage, 50)
page_number = request.GET.get("page")
page_obj = paginator.get_page(page_number)

return render(
request,
"client_portal/superadmin_summary.html",
{"page_obj": page_obj}
)

Step 2 — Modify BOTH tables

Change:

{% for admin in admins %}

to:

{% for admin in page_obj %}

in both tables.

Do not leave the old line commented out.


Step 3 — Test BEFORE adding page navigation

At this stage the page should still render.

You probably won't notice any difference because:

Page 1 of 1

means you currently have fewer than 50 admins.


Step 4 — Add navigation

Put a single navigation block after the second table.

For example:

</div>  <!-- second card -->

<nav class="mt-3">
<ul class="pagination justify-content-center">

{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link"
href="?page={{ page_obj.previous_page_number }}">
Previous
</a>
</li>
{% endif %}

<li class="page-item disabled">
<span class="page-link">
Page {{ page_obj.number }}
of
{{ page_obj.paginator.num_pages }}
</span>
</li>

{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link"
href="?page={{ page_obj.next_page_number }}">
Next
</a>
</li>
{% endif %}

</ul>
</nav>

Only one navigation block is needed because both tables display the same page_obj.


Step 5 — Then test your JavaScript search

Remember:

Your current JavaScript searches only the rows already loaded into the page.

With pagination:

  • Page 1 searches Page 1 only.
  • Page 2 searches Page 2 only.
  • It cannot search all pages.

That's normal.

Later, when you have many admins, you may want:

Server-side search + pagination

instead of:

JavaScript search + pagination

because server-side search can search all admins in the database.

 

For now, I'd get pagination working first, then decide whether you want local JavaScript search or a true database search.

 

4.(6) How to go directly to a page on pagination

Is there any way to go directly to a designated page? Suppose there are 10 pages and I am on page1. Without clicking Next many times, I want to go directly to, say, page 9.

 

Yes. In fact, once you add Django pagination, users usually expect page numbers, not just Previous/Next.

A common paginator looks like this:

<nav>
<ul class="pagination justify-content-center">

{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link"
href="?page={{ page_obj.previous_page_number }}">
Previous
</a>
</li>
{% endif %}

{% for num in page_obj.paginator.page_range %}
<li class="page-item {% if page_obj.number == num %}active{% endif %}">
<a class="page-link" href="?page={{ num }}">
{{ num }}
</a>
</li>
{% endfor %}

{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link"
href="?page={{ page_obj.next_page_number }}">
Next
</a>
</li>
{% endif %}

</ul>
</nav>

If there are 10 pages, it will show:

Previous  1 2 3 4 5 6 7 8 9 10  Next

and you can jump directly to page 9.


Better for many pages

If you eventually have 100+ pages, showing every page number becomes ugly.

Then use something like:

Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}

<a href="?page=1">First</a>

{% if page_obj.has_previous %}
<a href="?page={{ page_obj.previous_page_number }}">Previous</a>
{% endif %}

{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}">Next</a>
{% endif %}

<a href="?page={{ page_obj.paginator.num_pages }}">Last</a>

Example:

First  Previous   Page 1 of 100   Next  Last

Clicking Last immediately jumps to page 100.


Direct page input box

For a superadmin dashboard, I often add a small "Go to page" box:

<form method="get" class="d-flex">
<input type="number"
name="page"
min="1"
max="{{ page_obj.paginator.num_pages }}"
class="form-control"
style="width:100px">
<button type="submit" class="btn btn-primary ms-2">
Go
</button>
</form>

Then the superadmin can type:

9

and press Go.

The URL becomes:

/superadmin-summary/?page=9

and Django automatically displays page 9.

For your dashboard, where you may eventually have hundreds of admins, I would probably use:

  1. Search box (already implemented)
  2. Pagination (50 admins/page)
  3. Page numbers or a "Go to page" box

 

That combination scales well even if you later have thousands of admin accounts.

 

5. Search the database

You said:

To search all admins across all pages, the search must happen on the server.

Example:

search = request.GET.get("q")

admins = CustomUser.objects.filter(role="admin")

if search:

    admins = admins.filter(

        Q(username__icontains=search) |

        Q(email__icontains=search)

    )

paginator = Paginator(admins, 50)

Then:

<form method="get">

    <input type="text" name="q" value="{{ request.GET.q }}">

    <button type="submit">Search</button>

</form>

This searches the database itself.

Can you explain the code in more details? Can I add the code to my view "superadmin_summary()" and my template?

 

Yes. In fact, once you add pagination, I would recommend replacing your JavaScript search with server-side search. The JavaScript search only searches the current page, while server-side search searches all admins in the database.

Step 1: import Q

At the top of views.py:

from django.db.models import Count, Q

You already have Count; just add Q.


Step 2: get the search text

Inside superadmin_summary():

search = request.GET.get("q", "").strip()

Suppose the URL is:

/summary/?q=mk

Then:

search == "mk"

If there is no search box submitted:

/summary/

Then:

search == ""

Step 3: create the base queryset

You currently have:

admins = (
CustomUser.objects.filter(role="admin")
.annotate(
num_clients=Count("clients", distinct=True),
num_docs=Count("clients__user__documents", distinct=True),
)
.order_by("id")
)

Keep that.


Step 4: apply filtering only if needed

Immediately after creating admins:

if search:
admins = admins.filter(
Q(username__icontains=search) |
Q(email__icontains=search)
)

Example

Suppose the database contains:

username email
johnsmith [email protected]
mk2026 [email protected]
admin123 [email protected]

 

Search:

mk

Django translates this roughly into:

WHERE username ILIKE '%mk%'
OR email ILIKE '%mk%'

Result:

mk2026
admin123

Step 5: add pagination

After the filter:

paginator = Paginator(admins, 50)

page_number = request.GET.get("page")

page_obj = paginator.get_page(page_number)

Now only the matching admins are paginated.

Example:

?q=mk18

returns 120 matches.

Paginator creates:

Page 1
Page 2
Page 3

containing only those 120 matching admins.


Step 6: process only the current page

This is the important change.

Currently you do:

for admin in admins:

Change to:

for admin in page_obj:

because only the current page needs storage calculations.

for admin in page_obj:
admin_profile = getattr(admin, "admin_profile", None)

reset_download_if_needed(admin)

admin.storage_used_mb = get_admin_storage_usage(admin) / (1024 * 1024)

admin.storage_limit_mb = (
admin_profile.storage_limit_mb
if admin_profile else 0
)

admin.download_used_mb = (
admin.download_bytes_used / (1024 * 1024)
)

admin.download_limit_mb = (
admin.download_bytes_limit / (1024 * 1024)
)

Step 7: render

return render(
request,
"client_portal/superadmin_summary.html",
{
"page_obj": page_obj,
"search": search,
},
)

Step 8: template search box

Replace your JavaScript search box with:

<form method="get" class="d-flex">
<input
type="text"
name="q"
value="{{ search }}"
class="form-control"
placeholder="Search username or email">

<button type="submit"
class="btn btn-primary ms-2">
Search
</button>
</form>

When user enters:

mk

the browser goes to:

/summary/?q=mk

Step 9: table loop

Replace:

{% for admin in admins %}

with:

{% for admin in page_obj %}

for both tables.


Step 10: preserve search when changing pages

Without this, the search disappears when clicking Next.

Use:

<a href="?q={{ search }}&page={{ num }}">

instead of:

<a href="?page={{ num }}">

Example:

Current URL:

/summary/?q=mk&page=1

Click page 2:

/summary/?q=mk&page=2

Search is preserved.


For your project, I would eventually choose:

  1. Server-side search (q=).
  2. Pagination (50 admins/page).
  3. Remove the JavaScript table filtering.

 

That scales much better when you eventually have hundreds or thousands of admin accounts, and it avoids downloading every row to the browser just to search it.

 

5.(1) The operator `|`

In the code:

admins = admins.filter( Q(username__icontains=search) | Q(email__icontains=search) )

What does | mean? How does it differ from ||?

 

This is a good question because Django's Q() objects use Python operators in a special way.

In Django:

Q(username__icontains=search) | Q(email__icontains=search)

means:

username contains search
OR
email contains search

Equivalent SQL:

WHERE username ILIKE '%search%'
OR email ILIKE '%search%'

Why | instead of or?

Q(...) creates a special Django object.

For example:

q1 = Q(username__icontains="mika")
q2 = Q(email__icontains="mika")

These are not True/False values.

They are query expressions.

Django overloads the | operator to combine them:

q1 | q2

creates:

(username contains "mika")
OR
(email contains "mika")

What about ||?

In Python, || does not exist.

This is a syntax error:

q1 || q2

Error:

SyntaxError: invalid syntax

|| exists in languages such as:

  • JavaScript
  • C
  • Java

but not Python.


Why not use or?

This is also wrong:

Q(username__icontains=search) or Q(email__icontains=search)

because Python evaluates the first object as truthy and returns it.

Example:

q1 = Q(username__icontains="mika")
q2 = Q(email__icontains="mika")

print(q1 or q2)

Result:

q1

The second condition is ignored.

So Django requires:

q1 | q2

for OR.


AND conditions

Similarly:

Q(is_active=True) & Q(has_paid=True)

means:

is_active = True
AND
has_paid = True

Equivalent SQL:

WHERE is_active = true
AND has_paid = true

NOT conditions

You can negate a condition with ~:

~Q(is_active=True)

means:

NOT is_active = True

or effectively:

is_active = False

For your superadmin search, you might eventually use something like:

admins = admins.filter(
Q(username__icontains=search) |
Q(email__icontains=search) |
Q(id__icontains=search)
)

so that typing:

mk

finds by username or email, and typing:

25

 

can find admin ID 25 as well (though for numeric IDs, id=search is usually better than id__icontains).