
1. 项目概述从零开始搭一个真正能用的 Django 博客我带过十几期 Python 后端训练营每次开课前都会问学员一个问题“你写过一个能自己访问、自己增删改查、自己看到数据变化的完整 Web 应用吗”——超过七成的人会摇头。不是学不会而是教程太碎有的讲完路由就停了有的配好数据库就收尾还有的直接甩出一整套现成代码连 manage.py 是干啥的都没解释清楚。这篇内容就是为那些卡在“写了半天浏览器里只有一行 ‘It worked!’” 的人写的。我们不碰 React、不聊 Vue、不堆 CSS 框架就用 Django 自带的模板引擎、SQLite 默认数据库、admin 后台和原生 Python 逻辑从空文件夹开始一步步搭出一个真实可运行、可管理、可扩展的博客系统。它支持创建文章、列表展示、点击查看详情、编辑更新、一键删除——也就是完整的 CRUD 流程。重点在于每一步你都能在终端里敲出来每一行代码你都能在浏览器里看到效果每一个报错你都能准确定位到是哪一行、为什么错。比如python manage.py runserver启动后打不开 localhost:8000不是网络问题大概率是你没激活虚拟环境或者终端当前路径不在项目根目录下。这种细节我会在实操环节掰开揉碎讲透。这个项目不需要你精通 JavaScript 或前端工程化但要求你熟悉 Python 基础语法知道def怎么定义函数、class怎么声明类、for循环怎么遍历列表、字典怎么用键取值。如果你能写一个带if-else判断的计算器脚本那你就能跟上这篇内容。它适合两类人一是刚学完 Python 基础、想立刻做出点“看得见摸得着”东西的新手二是已经会写 Flask 小应用、想系统理解 Django “约定优于配置”设计哲学的进阶者。它不教你如何部署到云服务器但会把本地开发环境的每个螺丝钉都拧紧——因为线上问题八成源于本地环境没理顺。2. 整体架构与设计思路拆解为什么 Django 要这样组织项目2.1 Django 的“三层洋葱模型”从外到内看懂项目结构很多新手第一次django-admin startproject mysite后面对生成的七八个文件和嵌套目录直接懵掉。其实 Django 的项目结构不是随意堆砌而是一个精心设计的“三层洋葱模型”每一层解决一类问题且严格隔离最外层Project 层对应django_blog/目录它是一个容器负责全局配置。settings.py是它的大脑管数据库连接、静态文件路径、安全密钥、已安装应用列表urls.py是它的门卫决定所有 HTTP 请求该分发给谁manage.py是它的总控台所有命令行操作都从这里发起。它不处理具体业务逻辑只做统筹调度。中间层App 层对应blog/目录它是功能单元。Django 强调“一个 App 做好一件事”比如blog只管文章users只管用户comments只管评论。每个 App 内部有清晰分工models.py定义数据结构就像 Excel 表头views.py处理用户请求就像客服接电话urls.py规划 App 内部路线就像商场楼层导览图admin.py配置后台管理就像物业办公室。这种拆分让项目像乐高一样可插拔——今天加个搜索 App明天换掉评论 App都不影响博客主体。最内层Template Static 层对应templates/和static/目录它是用户界面。templates/blog/下的 HTML 文件不是纯静态页面而是 Django 模板里面嵌入{% for post in blog_list %}这样的逻辑标签能动态渲染数据static/下放 CSS、JS、图片等资源由 Django 在运行时自动拼接 URL。关键点在于模板里绝对不写 Python 代码只写极简的控制流和变量输出——这是 Django 的铁律保证前后端关注点分离。这个结构不是为了炫技而是为了解决真实痛点。比如你发现博客首页加载慢排查方向就很明确先看views.py里的查询是否用了.select_related()避免 N1 查询再看templates/blog_list.html里有没有循环中嵌套数据库查询这是新手常踩的坑最后才去优化 CSS 文件大小。结构即约束约束即效率。2.2 为什么选 SQLite 而非 MySQL/PostgreSQL教程里直接用sqlite3常被质疑“太假生产环境谁用 SQLite”。这话对了一半。SQLite 确实不适合高并发网站但它在开发阶段有不可替代的优势零配置启动MySQL 要装服务、建库、设用户、配权限PostgreSQL 要初始化集群、改配置文件。而 SQLite 就是一个.db文件Django 创建项目时自动生成migrate命令一跑表结构全建好。你省下的这半小时足够写三个视图函数。完全隔离的测试沙盒每个 Django 项目配一个独立的db.sqlite3文件。你删库重来rm db.sqlite3 python manage.py migrate两行命令搞定。而 MySQL 里删库可能误伤其他项目还得手动备份。我在带团队时强制要求新人用 SQLite 开发就是为了让他们敢试错——改坏模型删库重来写错迁移python manage.py migrate blog zero回滚到初始状态。ORM 抽象层的真实检验场Django ORM 的强大在于它能屏蔽底层数据库差异。你在 SQLite 上写的Post.objects.filter(title__icontainsDjango)换到 PostgreSQL 里几乎不用改。但如果一开始就用 MySQL遇到JSONField兼容性问题、fulltext search语法差异反而会干扰你对 Django 核心机制的理解。就像学开车先在空旷操场练熟离合油门再去高速路实战。当然当项目要上线切换数据库只需三步改settings.py里的DATABASES配置装对应驱动pip install psycopg2-binary再跑一次migrate。这个过程本身就是理解 Django 架构的好机会。2.3 为什么坚持“纯后端”路线不碰前端框架的深层考量教程明确说“不使用 JavaScript、CSS 等前端技术”这不是偷懒而是刻意为之的设计选择聚焦核心抽象能力新手最大的认知负担不是语法而是“数据如何从数据库流到浏览器”。用原生 HTML 模板你能清晰看到views.py里context {blog_list: posts}把数据打包 →render()函数把包塞进blog_list.html→ 模板里{{ post.title }}解包显示。如果引入 Vue这个链条就变成Python 返回 JSON → JS 接收 → Vue 渲染 DOM中间多了一层黑箱出问题时你不知道是后端没传对数据还是前端解析错了格式。避免“前端焦虑”干扰学习主线我见过太多人卡在“怎么让 CSS 样式居中”上花三天研究 Flexbox却忘了models.py里CharField(max_length200)的max_length是干嘛的。Django 的价值在于它帮你把重复劳动用户认证、表单验证、URL 路由封装成一行代码而不是让你成为全栈超人。先把Post.objects.create(titleHello, contentWorld)这行 Python 玩明白比折腾 Tailwind CSS 配置重要十倍。为后续扩展留足接口这个纯后端博客天然就是一个 REST API 基座。你只需要在views.py里把render()换成JsonResponse()把blog_list.html换成序列化逻辑它立刻变成供 React/Vue 调用的数据源。我在实际项目中常先用 Django 搭好数据核心和 admin 后台再让前端团队基于/api/posts/接口开发效率极高。所以这不是一个“简陋版”博客而是一个经过深思熟虑的“最小可行教学模型”。它剔除所有干扰项只保留 Django 最硬核的骨架模型定义数据、视图处理请求、模板渲染结果、URL 连接一切。3. 核心细节解析与实操要点每个命令背后的原理与陷阱3.1 虚拟环境为什么venv不是可选项而是必选项python -m venv env这条命令新手常忽略其重量。它创建的不是一个文件夹而是一个Python 运行时宇宙。让我用一个真实案例说明去年有个学员本地装了 Python 3.9用pip install django4.2装了新版 Django。两周后他想跑一个老项目需要 Django 3.2于是pip install django3.2——结果整个系统崩了因为pip直接覆盖了全局安装。他不得不重装 Python。虚拟环境就是为解决这个问题。venv的本质是复制一份 Python 解释器及其标准库再新建一个独立的site-packages目录存放第三方包。执行source env/bin/activateMac/Linux或env\Scripts\activate.batWindows后终端提示符前会出现(env)这意味着which python指向env/bin/python而非系统/usr/bin/pythonpip list显示的只有env里装的包import django加载的是env/lib/python3.9/site-packages/django/提示永远不要在未激活虚拟环境时运行pip install。我养成的习惯是打开终端第一件事cd到项目目录然后source env/bin/activate。如果提示Command not found说明你没装 Python 或路径不对这时别硬试先python --version确认基础环境。还有一个隐藏陷阱Windows 用户用 Git Bash 运行source env/Scripts/activate会失败因为 Bash 无法执行 Windows 的.bat文件。正确做法是要么用 Windows Terminal 运行env\Scripts\activate.bat要么在 Git Bash 中用source env/Scripts/activate注意是正斜杠。我在训练营里专门录了 3 分钟视频演示不同终端的激活方式因为 90% 的环境问题都出在这里。3.2manage.py与django-admin两个命令行工具的本质区别新手常混淆django-admin startproject和python manage.py startapp。它们看似相似实则定位完全不同django-admin是 Django 的全局命令行工具随 Django 安装到系统 PATH。它只做两件事startproject创建新项目和check检查项目配置。它不依赖任何项目就像工厂的总装线只负责产出初始框架。manage.py是 Django 为每个项目量身定制的管理脚本由django-admin startproject自动生成。它内部做了三件关键事1) 设置DJANGO_SETTINGS_MODULE环境变量指向你的settings.py2) 将项目根目录加入sys.path让 Python 能 import 你的 App3) 加载 Django 设置使所有命令如runserver,migrate能读取数据库配置。没有manage.pypython manage.py migrate就是一句无效的 Python 代码。所以startproject必须用django-admin因为此时项目还不存在而startapp、runserver、migrate等所有后续操作必须用manage.py因为它承载了项目的上下文。我教学生一个记忆法“admin是建楼的设计师manage是管楼的物业经理”。3.3makemigrations与migrate数据库演化的双阶段模型python manage.py makemigrations和python manage.py migrate是 Django 数据库操作的灵魂但很多人只知其然不知其所以然。它们构成一个严谨的“双阶段演化模型”第一阶段makemigrations—— 生成迁移计划书当你修改models.py比如给Post加个pub_date models.DateTimeField()字段Django 并不直接改数据库。它先扫描所有模型对比当前代码与上次迁移记录migrations/0001_initial.py生成一个可执行的 Python 脚本存入migrations/0002_add_pub_date.py。这个脚本里写着“在blog_post表加一列pub_date类型是 datetime允许为空”。它之所以是 Python 而非 SQL是为了跨数据库兼容——同一份迁移脚本在 SQLite、PostgreSQL、MySQL 上都能运行。第二阶段migrate—— 执行迁移计划书migrate命令读取migrations/目录下的所有脚本按数字顺序0001, 0002...执行。它先检查数据库里django_migrations表确认哪些脚本已执行然后只运行未执行的脚本。执行时Django 根据当前数据库后端将 Python 脚本翻译成对应 SQL如 SQLite 的ALTER TABLE blog_post ADD COLUMN pub_date datetime;再发送给数据库。注意makemigrations生成的脚本必须提交到 Git它是数据库版本的唯一真相源。如果同事拉代码后没运行migrate他的数据库就和代码不一致必然报错。我在团队里强制规定git commit前必须git status确认migrations/下有新增文件。一个经典错误场景你改了模型makemigrations生成了0002.py但还没migrate就直接runserver。这时 Django 会报django.core.exceptions.ImproperlyConfigured: Requested setting DATABASES, but settings are not configured.——因为 ORM 尝试加载模型时发现数据库表结构和代码不匹配。解决方案永远是先migrate再runserver。3.4admin.site.register(Post)后台管理的魔法开关原理admin.py里短短一行admin.site.register(Post)背后是 Django 最精妙的设计之一。它不是简单地把模型“暴露”给后台而是触发了一整套自动化流程元数据提取Django 通过 Python 的inspect模块读取Post类的属性title,content,created_at自动推断字段类型CharField→ 文本框TextField→ 多行文本框DateTimeField→ 日期时间选择器。CRUD 视图注入register调用后Django 动态生成四个视图函数post_add创建、post_change编辑、post_changelist列表、post_delete删除。这些视图已内置权限校验、表单验证、日志记录。URL 自动注册admin.site.urls在项目启动时会扫描所有已注册模型为每个模型生成 URL 模式如/admin/blog/post/列表页、/admin/blog/post/add/创建页。你无需在urls.py里手动添加。但这也带来一个常见误区有人以为register后就能直接用结果访问/admin/看不到Post。原因通常是1)INSTALLED_APPS里没加blogDjango 根本没加载blog.admin模块2)Post模型定义在models.py里但admin.py的from .models import Post导入失败比如models.py有语法错误。我的排查口诀是“先看INSTALLED_APPS再看admin.py导入最后看models.py语法”。4. 实操过程与核心环节实现手把手完成博客全流程4.1 环境搭建与项目初始化从零到第一个 It worked!我们从最干净的状态开始。假设你已安装 Python 3.8python --version确认现在打开终端Git Bash / Terminal / CMD# 1. 创建项目目录并进入 mkdir ~/Desktop/project-blog cd ~/Desktop/project-blog # 2. 创建虚拟环境注意env 是环境名可自定义 python -m venv env # 3. 激活虚拟环境 # Mac/Linux: source env/bin/activate # Windows (CMD/PowerShell): env\Scripts\activate.bat # Windows (Git Bash): source env/Scripts/activate # 4. 升级 pip避免旧版 pip 安装包失败 pip install --upgrade pip # 5. 安装 Django指定稳定版本避免新特性导致教程不兼容 pip install Django4.2,5.0提示pip install Django4.2,5.0用引号包裹防止 shell 把当作重定向符号。这是 Windows Git Bash 用户的高频坑点。激活后终端提示符应显示(env)。现在创建 Django 项目# 6. 创建项目django_blog 是项目名可改 django-admin startproject django_blog . # 注意最后的点.它让文件生成在当前目录而非子目录 # 此时目录结构project-blog/ - manage.py, django_blog/验证项目结构ls -la # 应看到manage.py django_blog/ env/ ls django_blog/ # 应看到__init__.py asgi.py settings.py urls.py wsgi.py启动开发服务器# 7. 运行服务器确保在 project-blog/ 目录下 python manage.py runserver打开浏览器访问http://127.0.0.1:8000或http://localhost:8000。看到 “The install worked successfully! Congratulations!” 页面说明环境搭建成功。此时不要关闭终端CtrlC可停止服务器。注意如果报错ModuleNotFoundError: No module named django一定是虚拟环境没激活如果报错Error: That port is already in use说明另一个程序占用了 8000 端口加端口号python manage.py runserver 8001换个端口。4.2 创建 Blog App 与模型定义让数据落地生根项目骨架有了现在创建业务模块# 确保在 project-blog/ 目录且虚拟环境已激活 python manage.py startapp blog此时目录新增blog/文件夹。现在要告诉 Django“这个项目要用 blog 这个 App”。编辑django_blog/settings.py找到INSTALLED_APPS列表在末尾添加blog# django_blog/settings.py INSTALLED_APPS [ django.contrib.admin, django.contrib.auth, django.contrib.contenttypes, django.contrib.sessions, django.contrib.messages, django.contrib.staticfiles, blog, # ← 新增这一行注意逗号 ]提示INSTALLED_APPS里的字符串必须是 App 名blog不是文件夹路径./blog或 Python 模块名blog.apps.BlogConfig。新手常在此处拼写错误比如写成Blog首字母大写或myblog名字不一致。接下来定义数据模型。编辑blog/models.py# blog/models.py from django.db import models class Post(models.Model): title models.CharField(max_length200) # 标题最长200字符 content models.TextField() # 正文无长度限制 created_at models.DateTimeField(auto_now_addTrue) # 创建时间自动填入 def __str__(self): return self.title # 在 admin 后台显示标题而非 Post object (1)关键点解析CharField(max_length200)max_length是必填参数SQLite 要求显式声明长度。TextField()适合长文本无需max_length。auto_now_addTrue对象首次保存时自动设置为当前时间之后不再更新。__str__方法Python 类的“字符串表示”让 admin 后台和调试时显示有意义的名字。4.3 数据库迁移与 Admin 后台让数据可管理模型写好了但数据库里还没有blog_post表。执行迁移# 生成迁移文件 python manage.py makemigrations # 输出Migrations for blog: blog/migrations/0001_initial.py # 应用迁移创建表 python manage.py migrate查看数据库文件SQLitels -la # 应看到 db.sqlite3 文件约 20KB现在注册模型到 admin 后台。编辑blog/admin.py# blog/admin.py from django.contrib import admin from .models import Post # ← 导入模型 admin.register(Post) # ← 使用装饰器注册推荐写法 class PostAdmin(admin.ModelAdmin): list_display (title, created_at) # 后台列表页显示哪些字段 list_filter (created_at,) # 右侧筛选栏按时间过滤 search_fields (title, content) # 顶部搜索框支持标题和正文搜索提示admin.register(Post)比老式admin.site.register(Post)更灵活可直接配置显示字段。list_display里不能写方法名如get_title除非该方法有short_description属性。创建超级用户admin 登录账号python manage.py createsuperuser # 按提示输入用户名、邮箱可选、密码不显示启动服务器访问http://127.0.0.1:8000/admin/用刚创建的账号登录。左侧应看到 “Blog” 分组和 “Posts” 链接。点击 “ADD POST”填写标题和内容点击 “SAVE”。刷新列表页新文章出现——数据已成功写入数据库。4.4 视图、URL 与模板让数据在浏览器里活起来现在数据有了但用户无法访问。我们需要“视图”处理请求“URL” 定义路径“模板” 渲染页面。第一步编写视图函数编辑blog/views.py# blog/views.py from django.shortcuts import render from .models import Post def blog_list(request): 博客列表页视图 posts Post.objects.all().order_by(-created_at) # 按时间倒序 context {blog_list: posts} # 键名 blog_list 将在模板中使用 return render(request, blog/blog_list.html, context) def blog_detail(request, id): 博客详情页视图 post Post.objects.get(idid) # 获取指定 ID 的文章 context {post: post} return render(request, blog/blog_detail.html, context) def blog_delete(request, id): 删除博客视图 post Post.objects.get(idid) post.delete() return redirect(/posts/) # 删除后跳转回列表页注意redirect需要导入所以在文件开头加from django.shortcuts import render, redirect from django.http import HttpResponse from .models import Post第二步配置 App 内部 URL在blog/目录下创建urls.py# blog/urls.py from django.urls import path from . import views urlpatterns [ path(, views.blog_list, nameblog_list), # /posts/ path(int:id/, views.blog_detail, nameblog_detail), # /posts/1/ path(int:id/delete/, views.blog_delete, nameblog_delete), # /posts/1/delete/ ]关键点int:id表示 URL 中的id必须是整数Django 会自动转换为int类型传给视图函数。第三步配置项目主 URL编辑django_blog/urls.py导入include并添加blog路由# django_blog/urls.py from django.contrib import admin from django.urls import path, include # ← 添加 include urlpatterns [ path(admin/, admin.site.urls), path(posts/, include(blog.urls)), # ← 新增这一行 ]第四步创建模板文件在项目根目录project-blog/创建templates/文件夹再建templates/blog/mkdir -p templates/blog创建templates/blog/blog_list.html!-- templates/blog/blog_list.html -- !DOCTYPE html html head title我的博客/title /head body h1博客列表/h1 ul {% for post in blog_list %} li a href/posts/{{ post.id }}/{{ post.title }}/a small{{ post.created_at|date:Y-m-d H:i }}/small /li {% endfor %} /ul pa href/admin/blog/post/add/添加新文章/a/p /body /html创建templates/blog/blog_detail.html!-- templates/blog/blog_detail.html -- !DOCTYPE html html head title{{ post.title }}/title /head body h1{{ post.title }}/h1 p{{ post.content|linebreaks }}/p psmall发布于 {{ post.created_at|date:Y年m月d日 H:i }}/small/p pa href/posts/返回列表/a | a href/admin/blog/post/{{ post.id }}/change/编辑/a | a href/posts/{{ post.id }}/delete/删除/a/p /body /html关键点{{ post.title }}输出变量值{% for %}是模板循环语法|date:Y-m-d H:i是 Django 过滤器格式化时间|linebreaks将换行符转为br让正文换行生效第五步配置模板路径编辑django_blog/settings.py找到TEMPLATES配置修改DIRS# django_blog/settings.py TEMPLATES [ { BACKEND: django.template.backends.django.DjangoTemplates, DIRS: [BASE_DIR / templates], # ← 添加这一行指向 templates/ 文件夹 APP_DIRS: True, OPTIONS: { context_processors: [ django.template.context_processors.debug, django.template.context_processors.request, django.contrib.auth.context_processors.auth, django.contrib.messages.context_processors.messages, ], }, }, ]启动服务器访问http://127.0.0.1:8000/posts/应看到博客列表点击标题进入详情页。至此CRUD 中的 CCreate、RRead、DDelete已实现。UUpdate已在 admin 后台支持/admin/blog/post/1/change/。5. 常见问题与排查技巧实录那些年踩过的坑与速查方案5.1 终端报错速查表从现象定位根本原因现象可能原因排查步骤解决方案Command django-admin not foundDjango 未安装或 PATH 未配置pip list | grep Djangopip install Django重启终端ModuleNotFoundError: No module named blogINSTALLED_APPS未添加blog或拼写错误cat django_blog/settings.py | grep blog检查INSTALLED_APPS是否含blog注意引号和逗号ImportError: cannot import name Post from blog.modelsmodels.py有语法错误如少括号、冒号python -m py_compile blog/models.py修复models.py语法python manage.py check验证no such table: blog_postmigrate未执行或执行失败ls db.sqlite3python manage.py showmigrationspython manage.py migrate确认blog显示[X]Reverse for blog_detail not foundURL 名称不匹配nameblog_detail未定义或拼错grep -r blog_detail blog/urls.py检查blog/urls.py中path(..., nameblog_detail)是否存在Page not found (404)访问/posts/django_blog/urls.py未include(blog.urls)cat django_blog/urls.py确认path(posts/, include(blog.urls))存在TemplateDoesNotExist at /posts/templates/路径未在settings.py中配置cat django_blog/settings.py | grep DIRS确认TEMPLATES[0][DIRS]包含BASE_DIR / templatesInvalid HTTP_HOST headerALLOWED_HOSTS未配置Django 3.1 安全要求cat django_blog/settings.py | grep ALLOWED_HOSTSALLOWED_HOSTS [127.0.0.1, localhost]5.2 实操中的独家避坑技巧技巧一python manage.py check是你的第一道防线在每次修改settings.py、models.py或urls.py后运行python manage.py check。它会扫描整个项目报告配置错误、模型关系问题、URL 冲突等。比如你忘了在INSTALLED_APPS加blog它会直接提示?: (models.E001) blog.Post has no field named title——虽然提示有点绕但比等到runserver报错再排查快十倍。技巧二用shell实时验证模型操作别总靠admin后台测试数据。启动 Django Shellpython manage.py shell然后直接写 Python 代码 from blog.models import Post Post.objects.all() # 查所有文章 Post.objects.create(titleTest, contentHello World) # 创建新文章 Post.objects.filter(title__startswithTest) # 模糊查询Shell 是 Django 的“交互式实验室”所有 ORM 操作在这里秒级反馈比写视图、启服务、刷网页高效得多。技巧三迁移冲突的黄金处理法多人协作时A 和 B 同时改了模型makemigrations生成了0002.py和0002_another.pymigrate会报错。正确做法python manage.py migrate blog zero回滚到初始状态rm blog/migrations/0002_*.py删除冲突的迁移文件python manage.py makemigrations重新生成一个合并后的0002.pypython manage.py migrate执行注意此操作仅限开发环境。生产环境必须用--fake-initial等高级选项切勿删迁移文件。技巧四DEBUGFalse下的静态文件救星教程中没配静态文件但当你加 CSS 时DEBUGFalse会导致样式丢失。临时解决方案仅开发在django_blog/urls.py末尾加from django.conf import settings from django.conf.urls.static import static if settings.DEBUG: urlpatterns static(settings.STATIC_URL, document_rootsettings.STATIC_ROOT)这行代码让 Django 开发服务器能直接提供static/文件避免配置 Nginx 的复杂度。5.3 从“能跑”到“好用”的三个关键升级完成基础 CRUD 后这三个小升级能让博客立刻专业起来升级一添加分页避免列表页加载过慢编辑blog/views.pyfrom django.core.paginator import Paginator def blog_list(request): posts Post.objects.all().order_by(-created_at) paginator Paginator(posts, 5) # 每页5篇 page_number request.GET.get(page) page_obj paginator.get_page(page_number) return render(request, blog/blog_list.html, {page_obj: page_obj})修改templates/blog/blog_list.html{% for post in page_obj %} lia href/posts/{{ post.id }}/{{ post.title }}/a/li {% endfor %} div classpagination span classpage-links {% if page_obj.has_previous %} a href?page1laquo; first/a a href?page{{ page_obj.previous_page_number }}previous/a {% endif %} span classcurrentPage {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}./span {% if page_obj.has_next %} a href?page{{ page_obj.next_page_number