Repository: jupyterhub/jupyterhub

Issue:
/hub/api/users?include_stopped_servers doesn't include ?include_stopped_servers in the next_url when paginating
### Bug description

/hub/api/users?include_stopped_servers doesn't include ?include_stopped_servers in the next_url when paginating. This means the first page has stopped servers, but no subsequent pages do. I'd assume that all query params aside from offset should be replicated to the next_url.

#### Expected behaviour

?include_stopped_servers should be in the pagination url

#### Actual behaviour

?include_stopped_servers is not in the url so only the first page has stopped servers

### How to reproduce

1. On 2 different users create 2 stopped named servers.
2. Make an API Request: /hub/api/users?include_stopped_servers&limit=1
3. Follow the pagination url: /hub/api/users?limit=1&offset=1
4. No servers are listed for second user

### Your personal set up

Zero 2 jupyterhub 2.0.


Discussion:
You're using an old version of JupyterHub/Z2JH. Can you check if this bug is still present in the latest version of JupyterHub (4.0.1), which is included in Z2JH `3.0.0-beta.3` and later?
https://hub.jupyter.org/helm-chart/#development-releases-jupyterhub
I'm waiting for the stable release of the chart. Will let you know when it comes out and I upgrade.
@manics this is confirmed still a bug on 4.0.2 using the 3.0.2 helm chart.

<img width="586" alt="image" src="https://github.com/jupyterhub/jupyterhub/assets/301208/f3f21583-5ffb-4dd2-9282-44fd4692007b">


Resolving patch:
diff --git a/jupyterhub/apihandlers/base.py b/jupyterhub/apihandlers/base.py
--- a/jupyterhub/apihandlers/base.py
+++ b/jupyterhub/apihandlers/base.py
@@ -532,7 +532,7 @@ def paginated_model(self, items, offset, limit, total_count):
         if next_offset < total_count:
             # if there's a next page
             next_url_parsed = urlparse(self.request.full_url())
-            query = parse_qs(next_url_parsed.query)
+            query = parse_qs(next_url_parsed.query, keep_blank_values=True)
             query['offset'] = [next_offset]
             query['limit'] = [limit]
             next_url_parsed = next_url_parsed._replace(


Test patch:
diff --git a/jupyterhub/tests/test_api.py b/jupyterhub/tests/test_api.py
--- a/jupyterhub/tests/test_api.py
+++ b/jupyterhub/tests/test_api.py
@@ -7,7 +7,7 @@
 from copy import deepcopy
 from datetime import datetime, timedelta
 from unittest import mock
-from urllib.parse import quote, urlparse
+from urllib.parse import parse_qs, quote, urlparse
 
 import pytest
 from pytest import fixture, mark
@@ -268,20 +268,22 @@ def max_page_limit(app):
 @mark.user
 @mark.role
 @mark.parametrize(
-    "n, offset, limit, accepts_pagination, expected_count",
+    "n, offset, limit, accepts_pagination, expected_count, include_stopped_servers",
     [
-        (10, None, None, False, 10),
-        (10, None, None, True, 10),
-        (10, 5, None, True, 5),
-        (10, 5, None, False, 5),
-        (10, 5, 1, True, 1),
-        (10, 10, 10, True, 0),
+        (10, None, None, False, 10, False),
+        (10, None, None, True, 10, False),
+        (10, 5, None, True, 5, False),
+        (10, 5, None, False, 5, False),
+        (10, None, 5, True, 5, True),
+        (10, 5, 1, True, 1, True),
+        (10, 10, 10, True, 0, False),
         (  # default page limit, pagination expected
             30,
             None,
             None,
             True,
             'default',
+            False,
         ),
         (
             # default max page limit, pagination not expected
@@ -290,6 +292,7 @@ def max_page_limit(app):
             None,
             False,
             'max',
+            False,
         ),
         (
             # limit exceeded
@@ -298,6 +301,7 @@ def max_page_limit(app):
             500,
             False,
             'max',
+            False,
         ),
     ],
 )
@@ -310,6 +314,7 @@ async def test_get_users_pagination(
     expected_count,
     default_page_limit,
     max_page_limit,
+    include_stopped_servers,
 ):
     db = app.db
 
@@ -336,6 +341,11 @@ async def test_get_users_pagination(
     if limit:
         params['limit'] = limit
     url = url_concat(url, params)
+    if include_stopped_servers:
+        # assumes limit is set. There doesn't seem to be a way to set valueless query
+        # params using url_cat
+        url += "&include_stopped_servers"
+
     headers = auth_header(db, 'admin')
     if accepts_pagination:
         headers['Accept'] = PAGINATION_MEDIA_TYPE
@@ -348,6 +358,11 @@ async def test_get_users_pagination(
             "_pagination",
         }
         pagination = response["_pagination"]
+        if include_stopped_servers and pagination["next"]:
+            next_query = parse_qs(
+                urlparse(pagination["next"]["url"]).query, keep_blank_values=True
+            )
+            assert "include_stopped_servers" in next_query
         users = response["items"]
     else:
         users = response