diff --git a/进阶_Django/README.md b/进阶_Django/README.md new file mode 100644 index 0000000..6498bba --- /dev/null +++ b/进阶_Django/README.md @@ -0,0 +1,123 @@ +# 进阶任务 · 设计一个真正的 Django 小应用(可选) + +> **本任务不做要求**,是给学有余力的同学的挑战。 +> 开学前能把这个完整做出来,已经非常厉害了。 +> 完成后**主动找学长验收**——验收时会围绕你的代码**提问**(为什么这么设计、某个写法是什么原理), +> 答得上来才算通过,所以请确保每一行都是你自己写出来的。 + +**本任务规则**: + +1. **前端**(模板 HTML / CSS / JS)**允许使用 AI 辅助** +2. **后端**(models / views / urls / ORM 查询 / 业务逻辑)**必须自己手搓**,不允许 AI 代写 +3. 完成后**找学长验收**:验收 = 现场回答问题,不能只看跑不跑得通 + +> 前提:你应该已经跟着教程跑通了 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//` → `article_detail` + - 提交评论 `/article//comment/` → `add_comment` + - 作者文章页 `/author//` → `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 给文章作者发通知…… +> 每加一个功能,都是一次新的练习。 diff --git a/进阶_Django/参考解答.md b/进阶_Django/参考解答.md new file mode 100644 index 0000000..038efe4 --- /dev/null +++ b/进阶_Django/参考解答.md @@ -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//", views.article_detail, name="article_detail"), + path("article//comment/", views.add_comment, name="add_comment"), + path("author//", 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//")`,把 `pk=3` 提取出来并调用对应的**视图函数** +> `article_detail(request, pk=3)`。视图内执行 **ORM 查询**(`get_object_or_404` 对应 +> 一条 SELECT),拿到文章对象后调用 **`render`** 把数据填入模板、生成 HTML, +> 最终打包成 **`HttpResponse`** 返回给浏览器渲染。 + +--- + +## 5. 进阶:index.html 模板片段 + +```html +{% if articles %} +
    + {% for a in articles %} +
  • + {{ a.title }} + {{ a.created_at|date:"Y-m-d H:i" }} + · 作者:{{ a.author.nickname }} +
  • + {% endfor %} +
+{% else %} +

暂无文章,快来发布第一篇吧!

+{% endif %} +``` + +--- + +## 验收清单 + +- [ ] `python manage.py makemigrations blog` 与 `migrate` 无报错 +- [ ] `runserver` 后,列表页 / 详情页 / 评论提交 / 作者页全部可访问 +- [ ] 删掉一个 Member 后,他的文章和评论也一起消失(验证 CASCADE) +- [ ] 能口头讲清楚:两个用户同时点文章,浏览量不会"丢访问"