Backend for Coderr, a freelancer marketplace where business users
publish offers and customer users order them and write reviews. Business
users create tiered offers (basic / standard / premium); customers place
orders from an offer's detail tiers and leave one review per business.
This repository contains the backend only; the frontend is a separate,
delivered static application served on http://127.0.0.1:5500.
- Stack: Python, Django, Django REST Framework, Django ORM, SQLite.
- Auth: DRF
TokenAuthentication. Login usesusername+password(never email). No JWT. - API base URL:
http://127.0.0.1:8000/api/locally,https://yannick-oetelshoven.developerakademie.org/api/in production (see Production deployment).
- Python 3.12
pipand the ability to create a virtual environment- Git
# 1. Clone the repository
git clone <repository-url>
cd Coderr-Backend
# 2. Create and activate a virtual environment
python3.12 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
# 3. Install dependencies
pip install -r requirements.txt
# 4. Create the environment file from the template
cp .env.example .env
# Then generate a real secret key and paste it into .env:
python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"
# 5. Apply database migrations
python manage.py migrate
# 6. Create an administrator account
python manage.py createsuperuser
# 7. Run the development server
python manage.py runserver # http://127.0.0.1:8000The database (db.sqlite3) is not in the repository, so a fresh
clone starts with an empty database. Create data via the API (see
Demo accounts below) or the Django admin.
Configuration is loaded from a local .env file via python-dotenv.
See .env.example for the required keys. Secrets are never committed.
| Variable | Description | Example |
|---|---|---|
SECRET_KEY |
Django secret key (required, no default) | (generated value) |
DEBUG |
Debug mode, True or False |
True |
ALLOWED_HOSTS |
Comma-separated list of allowed hosts | 127.0.0.1,localhost |
CSRF_TRUSTED_ORIGINS |
Comma-separated origins with scheme, empty by default | https://your-domain.example |
CORS_ALLOWED_ORIGINS |
Comma-separated origins with scheme, dev origins by default | http://127.0.0.1:5500 |
The application refuses to start if SECRET_KEY is missing — there is no
hardcoded fallback.
All authenticated requests carry a DRF token in the Authorization
header, using the literal word Token and a single space:
Authorization: Token 83bf098723b08f7b23429u0fv8274
Tokens are issued on login and registration, which both return
token, username, email and user_id. Login is performed with
username and password; email is never used as a credential.
Base URL: http://127.0.0.1:8000/api/
| Method | Path | Who may call it |
|---|---|---|
| POST | /api/registration/ |
Anyone |
| POST | /api/login/ |
Anyone |
| GET | /api/profile/{pk}/ |
Any authenticated user |
| PATCH | /api/profile/{pk}/ |
The profile's owner |
| GET | /api/profiles/business/ |
Any authenticated user |
| GET | /api/profiles/customer/ |
Any authenticated user |
| GET | /api/offers/ |
Anyone (public, paginated) |
| POST | /api/offers/ |
Authenticated business users |
| GET | /api/offers/{id}/ |
Any authenticated user |
| PATCH | /api/offers/{id}/ |
The offer's owner |
| DELETE | /api/offers/{id}/ |
The offer's owner |
| GET | /api/offerdetails/{id}/ |
Any authenticated user |
| GET | /api/orders/ |
Any authenticated user (own orders) |
| POST | /api/orders/ |
Authenticated customer users |
| PATCH | /api/orders/{id}/ |
The order's assigned business user |
| DELETE | /api/orders/{id}/ |
Staff (admin) users |
| GET | /api/order-count/{business_user_id}/ |
Any authenticated user |
| GET | /api/completed-order-count/{business_user_id}/ |
Any authenticated user |
| GET | /api/reviews/ |
Any authenticated user |
| POST | /api/reviews/ |
Authenticated customer users |
| PATCH | /api/reviews/{id}/ |
The review's author |
| DELETE | /api/reviews/{id}/ |
The review's author |
| GET | /api/base-info/ |
Anyone (public) |
- Pagination is applied only to the offer list, with page size 6.
GET /api/offers/returns{count, next, previous, results}; every other list endpoint (profiles/business/,profiles/customer/,orders/,reviews/) returns a bare array. The project does not setDEFAULT_PAGINATION_CLASSinREST_FRAMEWORK; doing so would wrap those bare-array responses and break the frontend, which assigns them directly to arrays. - Profile text fields serialize as empty strings, never
null.first_name,last_name,location,tel,descriptionandworking_hoursare always strings; the frontend renders them directly. - Orders store a snapshot of the offer detail. At creation an order copies the title, revisions, delivery time, price, features and offer type; it holds no foreign key to the offer detail, so later edits or deletion of the source offer leave existing orders unchanged.
- The database is not in the repository. A fresh clone starts with an empty database; there are no fixtures.
- Filtering is configured per view, not through a global
DEFAULT_FILTER_BACKENDS. - Media files are served by Django only while
DEBUGisTrue, viaMEDIA_URLandMEDIA_ROOT, because the frontend prefixes relative media paths withhttp://127.0.0.1:8000/. - CORS defaults to the frontend dev origins
http://127.0.0.1:5500andhttp://localhost:5500, and is replaced entirely byCORS_ALLOWED_ORIGINSwhen that variable is set. - Four views declare permissions through
get_permissions()rather than apermission_classesattribute —OfferListCreateView,OrderListCreateView,OrderStatusUpdateDeleteViewandReviewListCreateView. On those paths the HTTP methods (e.g. GET vs POST) require different permissions, which a singlepermission_classeslist cannot express. Every method returns an explicit permission list; no view inherits the global default silently. - The offer list drives one extra request per offer detail, by
design. The delivered frontend fetches each detail separately, so a
page of six offers produces eighteen additional requests to
/api/offerdetails/{id}/. This is client behaviour and is not fixed in the backend; eachofferdetails/{id}/call is kept to a single query. - The delivered frontend redirects away from login.html and registration.html whenever any token is present in localStorage, without checking its validity (login.js:7, registration.js:7). A client holding a foreign or expired token therefore cannot reach the login form until localStorage is cleared. The backend keeps all public endpoints reachable in that state; the client-side guard itself is frontend behaviour and is not modified.
The delivered frontend hardcodes two guest logins in its config.js:
| Type | Username | Password |
|---|---|---|
| customer | andrey |
asdasd |
| business | kevin |
asdasd24 |
These accounts do not exist in a fresh database — the frontend only
sends their credentials to POST /api/login/; it never creates them.
Recreate them once against a running server with two registration calls
(any email works):
curl -X POST http://127.0.0.1:8000/api/registration/ \
-H "Content-Type: application/json" \
-d '{"username": "andrey", "email": "andrey@example.com", "password": "asdasd", "repeated_password": "asdasd", "type": "customer"}'
curl -X POST http://127.0.0.1:8000/api/registration/ \
-H "Content-Type: application/json" \
-d '{"username": "kevin", "email": "kevin@example.com", "password": "asdasd24", "repeated_password": "asdasd24", "type": "business"}'The kevin business account should own at least one offer (create one
with POST /api/offers/ while logged in as kevin), otherwise the page
looks empty after a business guest login.
source .venv/bin/activate
python manage.py runserver
python manage.py makemigrations
python manage.py migrate
python manage.py createsuperuser
ruff format . && ruff check .
python manage.py test
coverage run manage.py test && coverage report -mThe backend runs live at https://yannick-oetelshoven.developerakademie.org.
Nginx terminates TLS and proxies to Gunicorn over plain HTTP on
127.0.0.1:8000; Supervisor keeps Gunicorn alive. The frontend and the
API are served from the same origin — the frontend is a plain static
site under /, the API lives under /api/ on that same host — so the
browser never issues a cross-origin request and CORS plays no part in
this deployment.
| Path | Contents |
|---|---|
/var/www/coderr/backend |
This repository |
/var/www/coderr/backend/env |
Virtual environment |
/var/www/coderr/frontend |
Delivered static frontend |
/var/log/coderr/ |
Gunicorn stdout and stderr logs |
The virtual environment is called env on the server, not .venv as in
local development:
source /var/www/coderr/backend/env/bin/activateSupervisor runs Gunicorn as the program coderr_gunicorn, defined in
/etc/supervisor/conf.d/coderr-gunicorn.conf, bound to 127.0.0.1:8000
and writing its logs to /var/log/coderr/.
sudo supervisorctl status coderr_gunicorn
sudo supervisorctl restart coderr_gunicorn
sudo tail -f /var/log/coderr/gunicorn-stderr.logAfter editing the Supervisor config itself, reload it with
sudo supervisorctl reread && sudo supervisorctl update.
Gunicorn runs with 3 workers rather than the (2 * cores) + 1 the
course material prescribes — see DEVIATIONS.md.
The site is defined in /etc/nginx/sites-available/coderr (symlinked
into sites-enabled/) and routes by prefix:
| URL prefix | Handled by |
|---|---|
/ |
Static frontend from /var/www/coderr/frontend |
/api/ |
Proxied to Gunicorn on 127.0.0.1:8000 |
/admin/ |
Proxied to Gunicorn on 127.0.0.1:8000 |
/static/ |
Served from disk (STATIC_ROOT) |
/media/ |
Served from disk (MEDIA_ROOT) |
Nginx also performs the HTTP→HTTPS redirect. Django deliberately does
not: SECURE_SSL_REDIRECT and the HSTS settings stay unset, because a
missing forwarded header would turn them into a redirect loop. This is
why manage.py check --deploy still reports security.W004 and
security.W008 — both are expected here and are recorded in
DEVIATIONS.md.
sudo nginx -t && sudo systemctl reload nginxSECURE_PROXY_SSL_HEADER makes Django trust the forwarded scheme, so
the proxied locations must always set it — otherwise DRF builds http://
URLs for uploaded images:
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;Setting the header unconditionally also overwrites any value a client sends, which is what keeps it trustworthy.
The server .env lives at /var/www/coderr/backend/.env and is never
committed.
| Variable | Production value |
|---|---|
SECRET_KEY |
A freshly generated key, never the dev one |
DEBUG |
False |
ALLOWED_HOSTS |
yannick-oetelshoven.developerakademie.org |
CSRF_TRUSTED_ORIGINS |
https://yannick-oetelshoven.developerakademie.org |
CORS_ALLOWED_ORIGINS |
(present but empty) |
DEBUG=Falseis what marks the session and CSRF cookiesSecure.CSRF_TRUSTED_ORIGINSneeds the scheme, and thehttps://form, because the browser only ever sees the TLS origin.CORS_ALLOWED_ORIGINSmust be present and empty — a single-origin deployment needs no allowed origin at all. Omitting the key entirely is not the same thing: the setting then falls back to the two localhost development origins.
With DEBUG = False Django serves neither static nor media files.
collectstatic must run on every release, and Nginx serves both
directories directly from disk:
| URL prefix | Directory on the server | Setting |
|---|---|---|
/static/ |
/var/www/coderr/backend/staticfiles |
STATIC_ROOT |
/media/ |
/var/www/coderr/backend/media |
MEDIA_ROOT |
Nginx needs read access to both; uploaded media must stay writable for the Gunicorn user.
Run in this order, from /var/www/coderr/backend with the virtual
environment activated:
cd /var/www/coderr/backend
source env/bin/activate
git pull
pip install -r requirements.txt
python manage.py migrate
python manage.py collectstatic --noinput
sudo supervisorctl restart coderr_gunicornOnly the last step interrupts the service. collectstatic runs before
the restart so the new static files are already in place when the new
workers come up.
This deployment keeps SQLite, deliberately. It is a course demo with a single application server, one Gunicorn process group and no concurrent write load; SQLite handles that comfortably and keeps the deployment reproducible. PostgreSQL is explicitly out of scope for this project — a real multi-writer workload would be the point at which that decision has to be revisited.
The database file lives at /var/www/coderr/backend/db.sqlite3, is not
in the repository, and — like media/ — survives a deploy untouched.
Both are worth backing up before a migration.