A learning-focused Django REST Framework backend for uploading, reviewing, approving, rejecting, and publicly verifying documents.
This project is designed to teach real backend engineering patterns, not just basic CRUD. The goal is to build a small but realistic API like something used by schools, recruitment agencies, government offices, or companies that need to verify whether submitted documents are genuine.
Organizations often receive documents such as CVs, certificates, transcripts, licenses, IDs, and contracts through manual channels like email. These documents can be forged, altered, duplicated, hard to track, or difficult to verify later.
The API will allow users to upload documents, officers to review them, and public visitors to verify approved documents using a unique verification code.
Document uploaded
-> Pending verification
-> Officer reviews
-> Approved or rejected
-> Public verification link becomes usable
Example public verification URL:
https://verify.company.com/doc/ABC123XYZ
Phase 3 authentication is complete.
Current files:
requirements.txt
README.md
manage.py
config/
accounts/
authentication/
documents/
audits/
Installed packages:
Django 5.2.15
Django REST Framework 3.17.1
djangorestframework-simplejwt 5.5.1
drf-spectacular 0.29.0
psycopg 3.3.4
dj-database-url 3.1.2
python-decouple 3.8
Phase 4 document management is complete. The project is now being moved from SQLite to PostgreSQL for local development.
Use this path when setting up the project on a new machine.
Python 3.12+
PostgreSQL
Git
make
git clone <repo-url>
cd secure-docs-apipython3 -m venv venv
source venv/bin/activatepip install -r requirements.txtcp .env.example .envUpdate at least these values:
SECRET_KEY=change-this-local-secret-key
JWT_SIGNING_KEY=change-this-to-a-long-random-jwt-signing-key
DATABASE_NAME=secure_docs_db_local
DATABASE_USER=postgres
DATABASE_PASSWORD=If your local PostgreSQL user can create databases:
createdb secure_docs_db_localOr from psql:
CREATE DATABASE secure_docs_db_local;make migratemake createsuperusermake verifymake runOpen Swagger:
http://localhost:4000/api/docs/
make run
make test
make test-app APP=documents
make makemigrations
make migrate
make schema
make verifyPostgreSQL is not running -> start PostgreSQL, then run make migrate
database does not exist -> create secure_docs_db_local
missing .env -> cp .env.example .env
port 4000 already in use -> make run PORT=8000
unapplied migrations -> make migrate
dependencies missing -> activate venv and run pip install -r requirements.txt
This project will cover:
- Authentication
- Authorization
- PostgreSQL
- File uploads
- Role-based access control
- Audit logs
- Email notifications
- Search and filtering
- Pagination
- Docker
- Testing
- Deployment
- Production security
If you are coming from Spring Boot, read the dedicated DRF guide:
docs/spring-boot-to-drf-guide.md
It explains this project from start to end by mapping Spring Boot concepts like Controller, Entity, Repository, DTO, Spring Security, ResponseEntity, ControllerAdvice, Flyway, and Swagger to their Django REST Framework equivalents.
Can manage users, manage officers, view all documents, and view audit logs.
Can review documents, approve documents, reject documents, and add review remarks.
Can register, upload documents, view document status, and download their own documents.
Can verify document authenticity without logging in.
Implemented endpoints:
POST /api/auth/register/
POST /api/auth/login/
POST /api/auth/refresh/
POST /api/auth/logout/
GET /api/accounts/profile/
Refresh request:
POST /api/auth/refresh/
Authorization: Bearer <refresh_token>In Swagger, use the normal Authorize button for this endpoint, but paste the refresh token instead of the access token.
JWT behavior:
Access tokens expire quickly.
Refresh tokens last longer and rotate on refresh.
Refresh endpoint reads the refresh token from Authorization: Bearer <refresh_token>.
Old refresh tokens are blacklisted after rotation.
Logout blacklists the submitted refresh token.
JWT_SIGNING_KEY is read from the environment.
Protected endpoints validate the token and load the current user from the database.
Deleted, inactive, or invalid-role users cannot use old access tokens.
Authorization decisions use the database user role, not a role claim from the token.
Account-state authentication failures return one generic message and log the internal reason server-side.
Swagger/OpenAPI documentation is available through drf-spectacular.
GET /api/schema/
GET /api/docs/
All JSON API responses are wrapped in one consistent envelope, similar to the response DTO style commonly used in NestJS or Spring Boot controllers.
Successful response:
{
"success": true,
"message": "Document fetched successfully",
"data": {
"id": 1,
"title": "Degree Certificate",
"status": "PENDING"
}
}Business error response:
{
"success": false,
"message": "Only admins and officers can review documents",
"status": 403,
"error": "DOCUMENT_REVIEW_FORBIDDEN",
"data": null
}Validation error response:
{
"success": false,
"message": "email: This field is required.",
"status": 400,
"error": null,
"data": null,
"errors": {
"email": ["This field is required."]
}
}In this project:
common/renderers.py -> wraps normal successful API responses
common/exceptions.py -> wraps validation, permission, auth, and not-found errors
view.response_message -> sets the success message for a simple view
view.response_messages -> sets success messages per ViewSet action
Implemented endpoints:
POST /api/documents/
GET /api/documents/
GET /api/documents/{id}/
PATCH /api/documents/{id}/
DELETE /api/documents/{id}/
Document create/update endpoints accept multipart/form-data so files can be uploaded from Swagger.
Document edits use PATCH; full PUT replacement is disabled.
Form-data fields:
title
file
description
Document statuses:
PENDING
UNDER_REVIEW
APPROVED
REJECTED
Officer review endpoint:
POST /api/documents/{id}/review/
Request examples:
{
"action": "START_REVIEW",
"review_notes": "Initial review started."
}{
"action": "APPROVE",
"review_notes": "Document details match official records."
}{
"action": "REJECT",
"review_notes": "Certificate number could not be verified."
}Review metadata stored on each document:
reviewed_by
reviewed_at
review_notes
Implemented endpoint:
GET /api/verify/{verification_code}/
Example response:
{
"success": true,
"message": "Document verified successfully",
"data": {
"verified": true,
"title": "Degree Certificate",
"document_type": "Certificate",
"verification_code": "AB12CD34E",
"status": "APPROVED",
"reviewed_at": "2026-06-16T10:30:00Z"
}
}Public verification does not expose file URLs, user emails, reviewer emails, review notes, or internal user ids.
Actions to track:
DOCUMENT_UPLOADED
DOCUMENT_REVIEW_STARTED
DOCUMENT_APPROVED
DOCUMENT_REJECTED
DOCUMENT_DELETED
DOCUMENT_VERIFICATION_SUCCEEDED
DOCUMENT_VERIFICATION_FAILED
id
email
first_name
last_name
role
is_active
created_at
id
title
file
description
verification_code
status
uploaded_by
created_at
updated_at
id
document
officer
decision
remarks
reviewed_at
id
user
action
entity
entity_id
metadata
created_at
| Spring Boot | Django REST Framework |
|---|---|
| Entity | Model |
| Repository | ORM Manager |
| DTO | Serializer |
| Controller | ViewSet or APIView |
| Spring Security | DRF Permissions |
| JWT Filter | SimpleJWT |
| Swagger / springdoc-openapi | drf-spectacular |
| application.properties | settings.py and .env |
| JPA | Django ORM |
| Flyway | Migrations |
- Python
- Virtual environment
- Django
- Django REST Framework
- Git
- PostgreSQL
- Django project creation
- App structure
- Custom user model
- Environment variables
- Settings separation
- Completed
- JWT
- Registration
- Login
- Refresh tokens
- Profile endpoint
- Permissions
- Completed
- Models
- Serializers
- ViewSets
- File uploads
- Validation
- Completed
- Approval process
- Rejection process
- Status transitions
- Business rules
- Audit events for document upload, review, approval, rejection, and deletion
- Completed
- Verification codes
- Public endpoint
- Safe public response shape
- Audit logs for public verification attempts
- Completed
- Audit model
- Admin visibility
- Optional admin-only audit API
- Optional middleware for request-level activity tracking
- Activity tracking
- Unit tests
- API tests
- Permission tests
- Workflow tests
- Dockerfile
- Docker Compose
- PostgreSQL container
- Local development environment
- Gunicorn
- Nginx
- Render or Railway
- CI/CD
- Production security checks
Phase 2 is about setting up the shape of the project before building features. A good Django REST Framework project becomes much easier to understand when each app has a clear responsibility.
Make sure the virtual environment is active and Django is available.
source venv/bin/activate
python --version
django-admin --version
pip show djangorestframeworkSuccess means:
Python 3.12.x
Django 5.2.15
djangorestframework installed
Create the main Django project in the current folder.
django-admin startproject config .Why config?
config/ will hold project-level settings, URLs, ASGI, and WSGI files.
Feature code will live in separate apps.
Expected structure:
manage.py
config/
__init__.py
asgi.py
settings.py
urls.py
wsgi.py
Create separate apps for the main business areas.
python manage.py startapp accounts
python manage.py startapp authentication
python manage.py startapp documents
python manage.py startapp auditsApp responsibilities:
authentication/ -> register, login, refresh-token workflows
accounts/ -> users, roles, profile, permissions
documents/ -> uploads, document metadata, verification workflow
audits/ -> audit trail and activity history
Add DRF and the local apps to INSTALLED_APPS inside config/settings.py.
Planned apps:
rest_framework
accounts
documents
audits
Success means Django can detect all project apps without errors.
python manage.py checkDjango projects should define a custom user model at the beginning, before the first migration.
Planned user direction:
Use email as the login field.
Add a role field for ADMIN, OFFICER, and USER.
Keep is_active for account control.
Why this matters:
Changing the user model later is painful after migrations and data exist.
The actual model implementation belongs to Phase 3, but Phase 2 must prepare for it.
For now, keep config/settings.py simple. Later, split settings when the project grows.
Future structure:
config/settings/
__init__.py
base.py
local.py
production.py
Do not split too early unless the project needs it. First learn the normal Django settings file, then refactor with understanding.
Create an environment strategy before production settings.
Variables this project will eventually need:
SECRET_KEY
DEBUG
ALLOWED_HOSTS
DATABASE_URL
EMAIL_HOST
EMAIL_PORT
EMAIL_HOST_USER
EMAIL_HOST_PASSWORD
Recommended future package:
python-decouple
This can wait until database and deployment work begin.
Project-level URLs should route into app-level URLs.
Target structure:
config/urls.py
authentication/urls.py
accounts/urls.py
documents/urls.py
audits/urls.py
Planned URL prefixes:
/api/auth/
/api/accounts/
/api/documents/
/api/verify/
/api/audits/
Documents will require file uploads, so the architecture needs media settings.
Planned local settings:
MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"
Later, production should use external storage instead of keeping uploaded files only on the server disk.
After creating the project and apps, run:
python manage.py check
python manage.py runserverSuccess means:
Django starts without errors.
The local server opens.
The app structure is ready for custom user work.
Do not run the first database migration until the custom user model is created in Phase 3.
- Django project created as
config manage.pyexistsaccounts,documents, andauditsapps exist- Apps are registered in
INSTALLED_APPS python manage.py checkpasses- Initial migrations were deferred until the custom user model existed
- URL strategy is clear
- Media upload strategy is clear
- Custom user model direction is decided before Phase 3
Phase 3 turns the project into a real API with users, roles, and token-based authentication. The most important rule is to create the custom user model before running the first migration.
Run the Django system check before editing authentication code.
source venv/bin/activate
python manage.py checkSuccess means:
System check identified no issues
Add SimpleJWT for access and refresh tokens.
pip install djangorestframework-simplejwt
pip freeze > requirements.txtWhy:
DRF handles API structure.
SimpleJWT handles token creation, refresh, and authentication.
Update accounts/models.py with a custom user model.
Planned fields:
email
first_name
last_name
role
is_active
is_staff
date_joined
Planned roles:
ADMIN
OFFICER
USER
Design decision:
email will be the login field.
username will not be used.
Create a custom manager for user creation.
Required methods:
create_user(email, password, **extra_fields)
create_superuser(email, password, **extra_fields)
Why:
Django needs a manager that knows how to create normal users and admin users when email replaces username.
Add this to config/settings.py:
AUTH_USER_MODEL = "accounts.User"This must happen before the first migration.
Add REST framework authentication settings in config/settings.py.
Planned configuration:
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": (
"rest_framework_simplejwt.authentication.JWTAuthentication",
),
"DEFAULT_PERMISSION_CLASSES": (
"rest_framework.permissions.IsAuthenticated",
),
}Why:
Most API endpoints should require login by default.
Public endpoints will explicitly allow anonymous access later.
Create authentication/serializers.py and accounts/serializers.py.
Planned serializers:
authentication.RegisterSerializer -> validate registration input and create users
accounts.UserSerializer -> return safe profile data
Do not return password hashes in API responses.
Update authentication/views.py and accounts/views.py.
Planned views:
authentication.RegisterView
authentication.LoginView
authentication.RefreshTokenView
accounts.ProfileView
Login inherits from SimpleJWT. Refresh uses a custom view so the refresh token can be read from the Bearer header.
Update authentication/urls.py and accounts/urls.py.
Planned endpoints:
POST /api/auth/register/
POST /api/auth/login/
POST /api/auth/refresh/
GET /api/accounts/profile/
Mapping:
authentication/register/ -> custom RegisterView
authentication/login/ -> SimpleJWT LoginView
authentication/refresh/ -> custom RefreshTokenView with SimpleJWT refresh serializer
accounts/profile/ -> custom ProfileView
Create accounts/permissions.py.
Planned permissions:
IsAdmin
IsOfficer
IsDocumentOwner
These permissions will be used heavily in later phases when documents and verification workflows are built.
Only run migrations after the custom user model and AUTH_USER_MODEL are in place.
python manage.py makemigrations
python manage.py migrateSuccess means:
accounts migration is created.
Database tables are created.
No custom user model errors appear.
Create the first admin user.
python manage.py createsuperuserExpected login field:
Email
If Django asks for Username, the custom user setup is not correct yet.
Run the server.
python manage.py runserverManual checks:
Register a user.
Log in and receive access plus refresh tokens.
Use the access token to call profile.
Refresh the token successfully.
Confirm the refresh response returns a new refresh token.
Log out and confirm the refresh token cannot be reused.
Update authentication/tests.py and accounts/tests.py.
Minimum tests:
User can register.
User can log in.
Refresh token rotation returns a new refresh token.
Old refresh token cannot be reused after rotation.
Authenticated user can log out.
Logged-out refresh token cannot be reused.
Anonymous user cannot log out.
Authenticated user can view profile.
Anonymous user cannot view profile.
Superuser is created with ADMIN role.
- SimpleJWT installed and saved in
requirements.txt - Custom email-based user model exists
- Custom user manager exists
AUTH_USER_MODELis configured- DRF uses JWT authentication
- Register endpoint works
- Login endpoint returns tokens
- Refresh endpoint returns a new access token and rotated refresh token
- Old refresh tokens are blacklisted after rotation
- Logout endpoint blacklists refresh tokens
- Profile endpoint requires authentication
- Role permission classes are started
- First migrations are created and applied
- Superuser creation uses email
- Authentication tests pass
Phase 4 adds the main business object of the system: documents. This phase teaches file uploads, ownership rules, serializers, viewsets, filtering, and API tests.
Run the existing checks before adding document code.
source venv/bin/activate
python manage.py check
python manage.py testSuccess means:
The authentication tests pass.
Django reports no system check issues.
Update documents/models.py.
Planned fields:
title
file
description
verification_code
status
uploaded_by
created_at
updated_at
Initial statuses:
PENDING
UNDER_REVIEW
APPROVED
REJECTED
Important model rules:
uploaded_by links to the custom user model.
verification_code must be unique.
new documents start as PENDING.
review fields are set by admins/officers during verification.
file uploads are stored under a documents/ media folder.
Add automatic verification code generation when a document is first created.
Code requirements:
unique
hard to guess
short enough to share
safe for URLs
Example:
ABC123XYZ
After the model is ready:
python manage.py makemigrations documents
python manage.py migrateSuccess means:
documents migration is created.
Document table exists in the database.
No auth model migration errors appear.
Update documents/admin.py.
Admin should show useful fields:
title
status
verification_code
uploaded_by
created_at
updated_at
Useful admin filters:
status
created_at
updated_at
Create documents/serializers.py.
Planned serializers:
DocumentSerializer
DocumentCreateSerializer
Serializer rules:
uploaded_by is read-only.
verification_code is read-only.
status is read-only for normal users.
file is required on create.
Create documents/permissions.py.
Planned permission behavior:
Admins can view all documents.
Officers can view documents for review.
Users can view and manage only their own documents.
Public visitors cannot access document management endpoints.
This builds on the IsAdmin, IsOfficer, and IsDocumentOwner concepts from Phase 3.
Update documents/views.py.
Use a DRF ModelViewSet for:
list
create
retrieve
partial_update
destroy
Behavior rules:
normal users see only their own documents.
admins and officers can see all documents.
new documents are assigned to request.user.
Update documents/urls.py.
Use a DRF router.
Target endpoints:
POST /api/documents/
GET /api/documents/
GET /api/documents/{id}/
PATCH /api/documents/{id}/
DELETE /api/documents/{id}/
Test document upload through:
Swagger UI file picker
DRF APIClient
manual multipart request
Validation rules to consider:
file is required.
title is required.
only authenticated users can upload.
uploaded files are saved under media/documents/.
Add basic query features.
Planned support:
search by title
filter by status
order by created_at
Likely DRF tools:
SearchFilter
OrderingFilter
Configure default pagination for document lists.
Planned behavior:
document lists return paginated results.
page size stays small for development.
This prepares the API for real-world document volumes.
Update documents/tests.py.
Minimum tests:
authenticated user can upload a document.
anonymous user cannot upload a document.
user can list only their own documents.
admin can list all documents.
document receives a verification code.
document starts as PENDING.
user can retrieve own document.
user cannot retrieve another user's document.
After the document endpoints are wired, confirm Swagger shows file upload fields.
python manage.py spectacular --file /tmp/secure-docs-schema.yml
python manage.py runserverOpen:
http://127.0.0.1:8000/api/docs/
Expected Swagger behavior:
POST /api/documents/ uses multipart/form-data.
file is shown as a binary upload field.
Swagger UI shows a file picker.
Documentmodel exists- verification code generation works
- document migration is created and applied
- document appears in Django admin
- document serializers exist
- ownership permissions exist
- document viewset exists
- document URLs are wired
- authenticated users can upload files
- users can only manage their own documents
- admins and officers can review all documents
- list endpoint supports search, filtering, ordering, and pagination
- document tests pass
- Swagger shows document endpoints with a file picker for uploads
Run the development server:
python manage.py runserverOpen Swagger UI:
http://127.0.0.1:8000/api/docs/
Open the raw OpenAPI schema:
http://127.0.0.1:8000/api/schema/
Generate the schema from the command line:
python manage.py spectacular --file /tmp/secure-docs-schema.ymlThe project includes a Makefile for common local commands:
make help
make run
make check
make test
make test-app APP=authentication
make schema
make migration-check
make verify
make migrate
make createsuperuserBy default, make run starts the server on port 4000.
You can override it:
make run PORT=8000The project now reads database settings from environment variables.
Create a local .env file from the example:
cp .env.example .envRecommended local .env shape:
PORT=4000
DEBUG=True
SECRET_KEY=change-this-local-secret-key
ALLOWED_HOSTS=localhost,127.0.0.1,testserver
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_NAME=secure_docs_db_local
DATABASE_USER=postgres
DATABASE_PASSWORD=
LOCAL_SERVER=http://localhost:4000
STAGING_SERVER=
PRODUCTION_SERVER=
SARTIFY_SERVER=If your postgres user has a password, put it in DATABASE_PASSWORD.
You can also use a single URL:
DATABASE_URL=postgresql://postgres:mypassword%40123@localhost:5432/secure_docs_db_localIf a password contains @, write it as %40 inside DATABASE_URL.
Run migrations after PostgreSQL is reachable:
python manage.py migrate
python manage.py createsuperuserRun the development server on the same style of port you use in other projects:
python manage.py runserver 0.0.0.0:4000Activate the virtual environment:
source venv/bin/activateCheck installed versions:
python --version
django-admin --version
pip show djangorestframework
pip show djangorestframework-simplejwt
pip show drf-spectacular
pip show psycopg
pip show dj-database-url
pip show python-decoupleInstall dependencies from requirements.txt if needed:
pip install -r requirements.txtFinish the local PostgreSQL switch:
Create or update .env
Confirm PostgreSQL is running
Run migrations on secure_docs_db_local
Create a new PostgreSQL-backed superuser
After that, the next major milestone will be the verification workflow.