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:
- Sticky header ← almost free to add.
- Pagination (50 rows/page) ← biggest long-term benefit.
- Search by username/email.
- 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:
- No scroll container exists, or
- 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
johnfinds usernames containingjohn. - Typing
gmailfinds 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:
"mk18 [email protected]"
and after:
toLowerCase()
still:
"mk18 [email protected]"
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:
| Username | |
|---|---|
| mk18 | [email protected] |
| john | [email protected] |
| mary | [email protected] |
User types:
jo
The script checks:
Row 1
mk18 [email protected]
Contains "jo"?
❌ No
Hide row.
Row 2
john [email protected]
Contains "jo"?
✅ Yes
Show row.
Row 3
mary [email protected]
Contains "jo"?
❌ No
Hide row.
Result:
| Username | |
|---|---|
| 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:
- Django pagination (50 rows/page)
- Search box that submits to Django
- 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:
- Search box (already implemented)
- Pagination (50 admins/page)
- 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 | |
|---|---|
| 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:
- Server-side search (
q=). - Pagination (50 admins/page).
- 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).
