Update 进阶_Django: 前置要求改为完成Day_15后可挑战
This commit is contained in:
@@ -0,0 +1,124 @@
|
|||||||
|
# 进阶任务 · 设计一个真正的 Django 小应用(可选)
|
||||||
|
|
||||||
|
> **本任务不做要求**,是给学有余力的同学的挑战。
|
||||||
|
> 开学前能把这个完整做出来,已经非常厉害了。
|
||||||
|
> 完成后**主动找学长验收**——验收时会围绕你的代码**提问**(为什么这么设计、某个写法是什么原理),
|
||||||
|
> 答得上来才算通过,所以请确保每一行都是你自己写出来的。
|
||||||
|
|
||||||
|
**本任务规则**:
|
||||||
|
|
||||||
|
1. **前端**(模板 HTML / CSS / JS)**允许使用 AI 辅助**
|
||||||
|
2. **后端**(models / views / urls / ORM 查询 / 业务逻辑)**必须自己手搓**,不允许 AI 代写
|
||||||
|
3. 完成后**找学长验收**:验收 = 现场回答问题,不能只看跑不跑得通
|
||||||
|
|
||||||
|
> 前提:**建议先完成 Day_15**(能跑通 Django 的 Hello, Django!)再挑战本任务。
|
||||||
|
> 你应该已经跑通 Django 的安装,执行过
|
||||||
|
> `django-admin startproject mysite` 和 `python manage.py startapp blog`,
|
||||||
|
> 知道 `settings.py` / `urls.py` / `views.py` / `models.py` 大概长什么样。
|
||||||
|
> 这不是"照着教程敲一遍",而是"**先想清楚,再动手设计**"。
|
||||||
|
> 题目里每一步都问了为什么——能回答上,才算真的懂了。
|
||||||
|
|
||||||
|
## 今日目标
|
||||||
|
|
||||||
|
做一个**社团招新管理系统**:成员(Member)发布文章(Article),成员可以评论(Comment)。
|
||||||
|
覆盖:模型设计、ORM 查询、视图、URL 路由、模板、以及"从请求到响应"的完整链路。
|
||||||
|
|
||||||
|
## 环境准备
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install django
|
||||||
|
django-admin startproject mysite
|
||||||
|
cd mysite
|
||||||
|
python manage.py startapp blog
|
||||||
|
# 记得把 'blog' 加进 settings.py 的 INSTALLED_APPS
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 题目一(必做):设计数据模型 models.py
|
||||||
|
|
||||||
|
**背景**:一个博客/招新系统,核心数据是"文章",文章有作者,作者是社团成员,文章下面有评论。
|
||||||
|
|
||||||
|
**要求**:在 `blog/models.py` 里写出完整代码,包含三个模型:
|
||||||
|
|
||||||
|
1. **Member**(成员):昵称 `nickname`、邮箱 `email`(要**唯一**)、
|
||||||
|
加入时间 `joined_at`(自动记录创建时间)、简介 `bio`(允许为空)
|
||||||
|
2. **Article**(文章):标题 `title`、正文 `content`、作者 `author`
|
||||||
|
(外键 → Member,作者被删时文章级联删除)、是否发布 `is_published`(默认 False)、
|
||||||
|
发布时间 `created_at`(自动记录)、浏览量 `views`(默认 0)
|
||||||
|
3. **Comment**(评论):正文 `content`、所属文章 `article`
|
||||||
|
(外键,`related_name="comments"`)、评论者 `commenter`(外键 → Member)
|
||||||
|
|
||||||
|
四个要求:
|
||||||
|
|
||||||
|
- 三个模型都要有合理的 `__str__`(Article 返回标题,Comment 返回"评论者 + 内容前 20 字",Member 返回昵称)
|
||||||
|
- 用 `Meta` 设置排序:Article 按 `created_at` 倒序,Comment 按 `id` 正序
|
||||||
|
- 字段参数用对:`max_length`、`default`、`null=True`/`blank=True` 该加的地方要加
|
||||||
|
- 思考题写进注释里(各一两句即可)
|
||||||
|
|
||||||
|
**思考题(写进注释)**:
|
||||||
|
|
||||||
|
1. 为什么外键要用 `on_delete=models.CASCADE`?如果改成 `SET_NULL` 会发生什么、需要什么前提?
|
||||||
|
2. `related_name="comments"` 是干什么的?(提示:`article.comments.all()` 是什么?)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 题目二(必做):写视图 + ORM 查询 views.py
|
||||||
|
|
||||||
|
**要求**:在 `blog/views.py` 里写四个视图函数:
|
||||||
|
|
||||||
|
1. **`index(request)`**:查出所有**已发布**的文章(`is_published=True`),按时间倒序,
|
||||||
|
渲染 `index.html`,把文章列表传进模板
|
||||||
|
2. **`article_detail(request, pk)`**:用 `get()` 取单篇文章,**不存在时抛 `Http404`**;
|
||||||
|
浏览量 +1 并保存;渲染 `detail.html`
|
||||||
|
3. **`add_comment(request, pk)`**:POST 请求时读取 `request.POST` 里的 `content`,
|
||||||
|
创建一条评论(评论者先用 `Member.objects.first()` 随便取一个,我们还没做登录),
|
||||||
|
完成后**重定向**回文章详情页;GET 请求也直接重定向回详情页
|
||||||
|
4. **(进阶)`author_articles(request, nickname)`**:按作者昵称查该作者的全部文章,
|
||||||
|
并且**用 ORM 统计每篇文章的评论数**(提示:`annotate` + `Count`,配合 `related_name`),
|
||||||
|
渲染 `author.html`。想不出 `annotate` 就先用 Python 算,但要写一句注释说明两种做法的区别
|
||||||
|
|
||||||
|
**思考题(写进注释)**:
|
||||||
|
|
||||||
|
- 浏览量 +1 有两种写法:
|
||||||
|
a) `article.views += 1; article.save()`
|
||||||
|
b) `Article.objects.filter(pk=pk).update(views=F("views") + 1)`
|
||||||
|
两个请求同时点击文章时,a 会出什么问题?b 为什么没问题?(提示:并发 / 原子性)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 题目三(必做):URL 路由 + 完整链路说明
|
||||||
|
|
||||||
|
**要求**:
|
||||||
|
|
||||||
|
1. 写出项目级 `urls.py`(含 `include`)和应用级 `blog/urls.py`,路由表覆盖:
|
||||||
|
- 列表页 `""` → `index`
|
||||||
|
- 文章详情 `/article/<int:pk>/` → `article_detail`
|
||||||
|
- 提交评论 `/article/<int:pk>/comment/` → `add_comment`
|
||||||
|
- 作者文章页 `/author/<str:nickname>/` → `author_articles`
|
||||||
|
每个路由都要起 `name`。
|
||||||
|
2. **写一段话(150 字内)**:用户在浏览器输入 `/article/3/` 回车后,
|
||||||
|
从"请求到响应"经历了哪些环节?必须提到:URL 匹配、视图函数、
|
||||||
|
ORM 查询、`render`、模板、`HttpResponse` 这几个词。
|
||||||
|
3. **(进阶)** 给出 `index.html` 的模板片段:
|
||||||
|
- 用 `{% for %}` 循环展示文章标题和"查看详情"链接
|
||||||
|
- 用模板过滤器把 `created_at` 格式化成 `Y-m-d H:i`
|
||||||
|
- 空列表时显示"暂无文章"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 完成标准
|
||||||
|
|
||||||
|
- [ ] 题目一:三个模型写全,思考题有自己的答案(不必和参考一致,能自圆其说就行)
|
||||||
|
- [ ] 题目二:四个视图能跑通,`python manage.py runserver` 后列表页、详情页、评论、作者页都能访问
|
||||||
|
- [ ] 题目三:路由表写全 + 链路说明写出来(进阶则 +1)
|
||||||
|
|
||||||
|
**验收动作(强烈建议)**:跑起来 `runserver`,在浏览器里真的走一遍
|
||||||
|
"打开列表页 → 点进文章 → 提交评论 → 回到详情页看到评论"。
|
||||||
|
能跑通,再带着对每道思考题的理解去找学长验收。
|
||||||
|
|
||||||
|
完成后打开 `参考解答.md` 对照。
|
||||||
|
|
||||||
|
> 进度提示:做完这个进阶任务,你就完成了"基础语法 → 面向对象 → 框架实战"的完整闭环。
|
||||||
|
> 下一步可以自己加功能:分页、搜索、`@login_required`、用 Celery 给文章作者发通知……
|
||||||
|
> 每加一个功能,都是一次新的练习。
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
# 进阶任务参考解答
|
||||||
|
|
||||||
|
> 说明:这里是完整参考实现,按文件拆成四块。
|
||||||
|
> 建议先自己写,卡住了再来看对应文件;看完后尝试不参考、重写一遍 views.py。
|
||||||
|
> 提醒:后端请务必自己手写,验收时会围绕这些代码提问。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. blog/models.py
|
||||||
|
|
||||||
|
```python
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Member(models.Model):
|
||||||
|
"""社团成员"""
|
||||||
|
nickname = models.CharField(max_length=50)
|
||||||
|
email = models.EmailField(unique=True) # 邮箱唯一
|
||||||
|
joined_at = models.DateTimeField(auto_now_add=True) # 创建时自动记录
|
||||||
|
bio = models.TextField(blank=True, null=True) # 允许为空
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.nickname
|
||||||
|
|
||||||
|
|
||||||
|
class Article(models.Model):
|
||||||
|
"""文章"""
|
||||||
|
title = models.CharField(max_length=100)
|
||||||
|
content = models.TextField()
|
||||||
|
author = models.ForeignKey(
|
||||||
|
Member,
|
||||||
|
on_delete=models.CASCADE, # 作者被删 => 文章一起删
|
||||||
|
related_name="articles", # member.articles.all() 能取到该作者全部文章
|
||||||
|
)
|
||||||
|
is_published = models.BooleanField(default=False)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
views = models.PositiveIntegerField(default=0) # 浏览量不会为负,用 PositiveIntegerField
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created_at"] # 新文章在前
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.title
|
||||||
|
|
||||||
|
|
||||||
|
class Comment(models.Model):
|
||||||
|
"""评论"""
|
||||||
|
content = models.TextField()
|
||||||
|
article = models.ForeignKey(
|
||||||
|
Article,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="comments", # article.comments.all() 能取到该文章全部评论
|
||||||
|
)
|
||||||
|
commenter = models.ForeignKey(Member, on_delete=models.CASCADE)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["id"] # 按评论先后排序
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"评论({self.commenter}):{self.content[:20]}"
|
||||||
|
```
|
||||||
|
|
||||||
|
**思考题答案(供对照)**:
|
||||||
|
|
||||||
|
1. `on_delete=models.CASCADE`:作者被删除时,他写的文章(以及文章下的评论)**级联删除**,
|
||||||
|
不会留下指向不存在作者的孤儿数据。若改成 `SET_NULL`,删作者时文章的 `author`
|
||||||
|
会被置为 `NULL`——但前提是外键字段允许为空,即
|
||||||
|
`author = models.ForeignKey(Member, null=True, blank=True, on_delete=models.SET_NULL)`。
|
||||||
|
2. `related_name="comments"`:定义**反向查询**的名字。有了它,从文章查评论就是
|
||||||
|
`article.comments.all()`(不需要自己再写 `Comment.objects.filter(article=...)`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. blog/views.py
|
||||||
|
|
||||||
|
```python
|
||||||
|
from django.db.models import Count, F
|
||||||
|
from django.shortcuts import render, get_object_or_404, redirect
|
||||||
|
from django.http import Http404
|
||||||
|
|
||||||
|
from .models import Article, Comment, Member
|
||||||
|
|
||||||
|
|
||||||
|
def index(request):
|
||||||
|
# 只要已发布文章,按时间倒序(Meta.ordering 已生效,order_by 可省略,写上更明确)
|
||||||
|
articles = Article.objects.filter(is_published=True).order_by("-created_at")
|
||||||
|
return render(request, "blog/index.html", {"articles": articles})
|
||||||
|
|
||||||
|
|
||||||
|
def article_detail(request, pk):
|
||||||
|
# get_object_or_404 = get() + 捕获 DoesNotExist 后抛 Http404
|
||||||
|
article = get_object_or_404(Article, pk=pk)
|
||||||
|
|
||||||
|
# 浏览量 +1。为什么不用 article.views += 1; article.save()?
|
||||||
|
# 两个用户同时点进来:两人都读到 views=10,各自 +1 后都写回 11,
|
||||||
|
# 丢了 1 次访问——这就是"并发丢失更新"。
|
||||||
|
# 用 F("views") + 1 是在数据库层完成"读旧值+1再写回",
|
||||||
|
# 原子操作,不会丢更新。
|
||||||
|
Article.objects.filter(pk=pk).update(views=F("views") + 1)
|
||||||
|
|
||||||
|
return render(request, "blog/detail.html", {"article": article})
|
||||||
|
|
||||||
|
|
||||||
|
def add_comment(request, pk):
|
||||||
|
article = get_object_or_404(Article, pk=pk)
|
||||||
|
if request.method == "POST":
|
||||||
|
content = request.POST.get("content", "").strip()
|
||||||
|
if content:
|
||||||
|
member = Member.objects.first() # 简化:还没做登录,先随便取一个成员
|
||||||
|
Comment.objects.create(
|
||||||
|
content=content,
|
||||||
|
article=article,
|
||||||
|
commenter=member,
|
||||||
|
)
|
||||||
|
return redirect("article_detail", pk=pk) # POST 后重定向,避免刷新重复提交
|
||||||
|
|
||||||
|
|
||||||
|
def author_articles(request, nickname):
|
||||||
|
author = get_object_or_404(Member, nickname=nickname)
|
||||||
|
|
||||||
|
# 进阶:annotate 在数据库层完成"按文章分组数评论数",
|
||||||
|
# 返回的每篇文章都带一个动态属性 comment_count。
|
||||||
|
articles = (
|
||||||
|
Article.objects.filter(author=author)
|
||||||
|
.annotate(comment_count=Count("comments")) # 反向查询名直接拿来用
|
||||||
|
.order_by("-created_at")
|
||||||
|
)
|
||||||
|
return render(request, "blog/author.html", {"author": author, "articles": articles})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 路由
|
||||||
|
|
||||||
|
**项目级 mysite/urls.py**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.urls import path, include
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("admin/", admin.site.urls),
|
||||||
|
path("", include("blog.urls")), # 把 / 开头的请求交给 blog 应用处理
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**应用级 blog/urls.py**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from django.urls import path
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
app_name = "blog" # 配合 name 使用:模板里 {% url 'blog:article_detail' a.pk %}
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("", views.index, name="index"),
|
||||||
|
path("article/<int:pk>/", views.article_detail, name="article_detail"),
|
||||||
|
path("article/<int:pk>/comment/", views.add_comment, name="add_comment"),
|
||||||
|
path("author/<str:nickname>/", views.author_articles, name="author_articles"),
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**路由命名与反向解析**:`name` 给路由起了名字,模板和视图里用
|
||||||
|
`{% url 'blog:article_detail' a.pk %}` / `redirect("article_detail", pk=pk)` 引用,
|
||||||
|
而不是手写死 URL 字符串——这样以后改路径,代码不用跟着改。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 完整链路说明(题目三第 2 问示例答案)
|
||||||
|
|
||||||
|
> 用户在浏览器输入 `/article/3/` 回车后:Django 首先在 **URL 路由表**中逐条匹配,
|
||||||
|
> 命中 `path("article/<int:pk>/")`,把 `pk=3` 提取出来并调用对应的**视图函数**
|
||||||
|
> `article_detail(request, pk=3)`。视图内执行 **ORM 查询**(`get_object_or_404` 对应
|
||||||
|
> 一条 SELECT),拿到文章对象后调用 **`render`** 把数据填入模板、生成 HTML,
|
||||||
|
> 最终打包成 **`HttpResponse`** 返回给浏览器渲染。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 进阶:index.html 模板片段
|
||||||
|
|
||||||
|
```html
|
||||||
|
{% if articles %}
|
||||||
|
<ul>
|
||||||
|
{% for a in articles %}
|
||||||
|
<li>
|
||||||
|
<a href="{% url 'blog:article_detail' a.pk %}">{{ a.title }}</a>
|
||||||
|
<span>{{ a.created_at|date:"Y-m-d H:i" }}</span>
|
||||||
|
· 作者:{{ a.author.nickname }}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p>暂无文章,快来发布第一篇吧!</p>
|
||||||
|
{% endif %}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 验收清单
|
||||||
|
|
||||||
|
- [ ] `python manage.py makemigrations blog` 与 `migrate` 无报错
|
||||||
|
- [ ] `runserver` 后,列表页 / 详情页 / 评论提交 / 作者页全部可访问
|
||||||
|
- [ ] 删掉一个 Member 后,他的文章和评论也一起消失(验证 CASCADE)
|
||||||
|
- [ ] 能口头讲清楚:两个用户同时点文章,浏览量不会"丢访问"
|
||||||
Reference in New Issue
Block a user