Blog tags: add tags to a post, creating the new ones
Python · Django · intermediate · modification
Adds the tagging endpoint for posts. The body is validated first (list size, printable text, slug length), then one `transaction.atomic()` block locks the post row, checks authorship, finds or creates each tag by slug with `get_or_create`, links them with `post.tags.add()` and returns the post's tags. Slugs are processed in sorted order. A `Tag` model with a unique constraint on `slug` and the `Post.tags` many-to-many field come with their migration, and the blog URLs are included under `/api/`. I ran the view against PostgreSQL with concurrent requests, including requests that list the same new tags in opposite orders.
Django 5.1 on PostgreSQL 16, `USE_TZ = True`, session auth (the frontend sends the CSRF token in `X-CSRFToken`), `DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"`, default `READ COMMITTED` isolation. The `blog` app already exists with `Post` and its `0001_initial` migration, which the new migration builds on; `config/urls.py` is the project's root URLconf. Tags are only created by this endpoint (the admin does not register `Tag`), and nothing else writes to the `Tag` table. Post authors are the only users who tag posts, and a post's author never changes. Request bodies are capped at Django's default `DATA_UPLOAD_MAX_MEMORY_SIZE` of 2.5 MB. Error reporting does not capture frame locals.
Requirements
- Add `POST /api/posts/<post_id>/tags/` for signed-in users. The body is JSON `{"tags": ["<name>", ...]}`: a list of 1 to 5 strings. A name is trimmed of surrounding whitespace and must be printable text of at most 50 characters whose slug (Django's `slugify`) is non-empty and at most 50 characters. Responses: `200 {"tags": [{"name": ..., "slug": ...}, ...]}` with all the post's tags ordered by slug (also when nothing new was added); `401` when not signed in; `400` for any body that does not meet the rules above (checked before the post is looked up); `404` for an unknown post; `403` when the signed-in user is not the post's author; `405` for other methods.
- Tags are shared by all posts and identified by slug: `Python`, `python` and ` PYTHON ` are the same tag, which keeps the spelling it was first created with. A tag name is never changed by later requests. Two names in one request with the same slug add one tag.
- Adding is idempotent: tagging a post with a tag it already has changes nothing. Concurrent requests must never create two tags with the same slug, and a request that adds valid tags must not fail with a 5xx because another request is adding the same tags at the same time (posts, tags and the post-tag links are on PostgreSQL 16, default isolation, `ATOMIC_REQUESTS = False`).
- A request is all-or-nothing for the post: if it fails, none of its tags are linked to the post.
- There is no cap on the number of tags per post, and tags are never deleted by this endpoint.
Files touched
- config/urls.py
- blog/models.py
- blog/migrations/0002_tag_post_tags.py
- blog/urls.py
- blog/views.py
--- config/urls.py
from django.contrib import admin
-from django.urls import path
+from django.urls import include, path
urlpatterns = [
path("admin/", admin.site.urls),
+ path("api/", include("blog.urls")),
]
--- blog/models.py
from django.conf import settings
from django.db import models
+
+
+class Tag(models.Model):
+ name = models.CharField(max_length=50)
+ slug = models.CharField(max_length=50)
+
+ class Meta:
+ constraints = [
+ models.UniqueConstraint(fields=["slug"], name="unique_tag_slug"),
+ ]
+
+ def __str__(self):
+ return self.name
body = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)