# Session 2 – Full Backend CRUD with Flask + MySQL + SQLAlchemy (ORM)
> **Goal:** build a production-style **backend-only** Task Manager API (no frontend) using **Flask**, **MySQL**, and **SQLAlchemy** with clear explanations for every key line. Everything below is ready to copy into files.
---
## 🧭 What You’ll Build
A REST API that supports:
- `GET /tasks` – list tasks
- `POST /tasks` – create task
- `PUT /tasks/<id>` – update task
- `DELETE /tasks/<id>` – delete task
Data is stored in a **MySQL** database via **SQLAlchemy** ORM models.
---
## ⚙️ Environment Setup (Anaconda)
```bash
# 1) Create & activate environment
conda create -n flask_crud python=3.11 -y
conda activate flask_crud
# 2) Install dependencies
pip install flask flask_sqlalchemy pymysql python-dotenv
# (Optional) useful during dev:
pip install ipython
```
**Why these packages?**
- `flask` – the web framework
- `flask_sqlalchemy` – ORM integration
- `pymysql` – pure-Python MySQL driver (easier to install than mysqlclient)
- `python-dotenv` – load DB credentials from a `.env` file instead of hardcoding
---
## 🗂 Project Structure
```
flask_task_manager/
│── app.py # Flask app & routes (endpoints)
│── config.py # Loads environment variables & builds DB URI
│── models.py # SQLAlchemy models (Task)
│── db_setup.py # One-time DB/table initialization script
│── .env # Local secrets (DB credentials)
│── requirements.txt # (optional) pinned deps for reproducible installs
```
> You’ll create each file using the code blocks below.
---
## 🔐 1) `.env` – Local Credentials (never commit to Git)
```dotenv
# .env
DB_USER=root
DB_PASSWORD=your_mysql_password
DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=task_db
FLASK_ENV=development
SECRET_KEY=change-this-in-production
```
**Explanation**
- We avoid hardcoding secrets in code. `python-dotenv` will load these values into environment variables so `config.py` can read them.
- Replace `your_mysql_password` with your real MySQL password.
- Make sure the database **exists** (see next step).
---
## 🗄 2) Create the MySQL Database
Open your MySQL client and run:
```sql
CREATE DATABASE IF NOT EXISTS task_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
```
**Explanation**
- Creates a database named `task_db` if it doesn’t exist.
- `utf8mb4` supports full Unicode (including emojis).
---
## ⚙️ 3) `config.py` – Central Configuration
```python
# config.py
import os
from dotenv import load_dotenv
# Load variables from .env into process environment
load_dotenv()
# Read values with defaults for safety
DB_USER = os.getenv("DB_USER", "root")
DB_PASSWORD = os.getenv("DB_PASSWORD", "")
DB_HOST = os.getenv("DB_HOST", "127.0.0.1")
DB_PORT = os.getenv("DB_PORT", "3306")
DB_NAME = os.getenv("DB_NAME", "task_db")
SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret")
# Build a SQLAlchemy-compatible connection URI using PyMySQL driver
SQLALCHEMY_DATABASE_URI = (
f"mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
)
# Disable event system overhead we don’t use
SQLALCHEMY_TRACK_MODIFICATIONS = False
```
**Explanation (line-by-line highlights)**
- `load_dotenv()` reads `.env` so we can access env vars via `os.getenv`.
- `SQLALCHEMY_DATABASE_URI` uses the **PyMySQL** driver (`mysql+pymysql://…`).
- `SECRET_KEY` is needed by Flask for securely signing sessions (even if you don’t use sessions now).
---
## 🧱 4) `models.py` – SQLAlchemy Models
```python
# models.py
from flask_sqlalchemy import SQLAlchemy
from datetime import datetime
db = SQLAlchemy()
class Task(db.Model):
__tablename__ = "tasks"
id = db.Column(db.Integer, primary_key=True)
content = db.Column(db.String(200), nullable=False)
done = db.Column(db.Boolean, default=False, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
def to_dict(self):
return {
"id": self.id,
"content": self.content,
"done": self.done,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
}
```
**Explanation**
- `db = SQLAlchemy()` – global DB object the app will initialize.
- `Task` represents a table named `tasks`.
- `content` cannot be null (`nullable=False`). This is a **business rule** enforced at DB level.
- `created_at` and `updated_at` auto-manage timestamps:
- `default=datetime.utcnow` when the row is inserted
- `onupdate=datetime.utcnow` updates timestamp on any change
- `.to_dict()` serializes the model into a JSON-friendly shape for API responses.
---
## 🧰 5) `app.py` – Flask App & REST Endpoints
```python
# app.py
from flask import Flask, request, jsonify
from models import db, Task
import config
def create_app():
app = Flask(__name__)
# Load configuration
app.config["SQLALCHEMY_DATABASE_URI"] = config.SQLALCHEMY_DATABASE_URI
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = config.SQLALCHEMY_TRACK_MODIFICATIONS
app.config["SECRET_KEY"] = config.SECRET_KEY
# Initialize database with app context
db.init_app(app)
# ---------- Health & Root ----------
@app.route("/")
def root():
return jsonify({"message": "Task Manager API (Flask + MySQL + SQLAlchemy)"}), 200
@app.route("/healthz")
def health():
# Lightweight health check
return jsonify({"status": "ok"}), 200
# ---------- CRUD: Tasks ----------
@app.route("/tasks", methods=["GET"])
def list_tasks():
"""List all tasks."""
tasks = Task.query.order_by(Task.created_at.desc()).all()
return jsonify([t.to_dict() for t in tasks]), 200
@app.route("/tasks/<int:task_id>", methods=["GET"])
def get_task(task_id):
"""Get a single task by id."""
task = Task.query.get(task_id)
if not task:
return jsonify({"error": "Task not found"}), 404
return jsonify(task.to_dict()), 200
@app.route("/tasks", methods=["POST"])
def create_task():
"""Create a new task."""
payload = request.get_json(silent=True) or {}
content = payload.get("content", "").strip()
if not content:
return jsonify({"error": "Field 'content' is required and cannot be empty."}), 400
task = Task(content=content, done=bool(payload.get("done", False)))
db.session.add(task)
db.session.commit()
return jsonify(task.to_dict()), 201
@app.route("/tasks/<int:task_id>", methods=["PUT"])
def update_task(task_id):
"""Update content/done for an existing task."""
task = Task.query.get(task_id)
if not task:
return jsonify({"error": "Task not found"}), 404
payload = request.get_json(silent=True) or {}
# Only update provided fields
if "content" in payload:
new_content = str(payload["content"]).strip()
if not new_content:
return jsonify({"error": "Field 'content' cannot be empty."}), 400
task.content = new_content
if "done" in payload:
task.done = bool(payload["done"])
db.session.commit()
return jsonify(task.to_dict()), 200
@app.route("/tasks/<int:task_id>", methods=["DELETE"])
def delete_task(task_id):
"""Delete a task by id."""
task = Task.query.get(task_id)
if not task:
return jsonify({"error": "Task not found"}), 404
db.session.delete(task)
db.session.commit()
return jsonify({"message": "Task deleted"}), 200
# ---------- Convenience Filters ----------
@app.route("/tasks/done", methods=["GET"])
def list_done():
tasks = Task.query.filter_by(done=True).order_by(Task.updated_at.desc()).all()
return jsonify([t.to_dict() for t in tasks]), 200
@app.route("/tasks/pending", methods=["GET"])
def list_pending():
tasks = Task.query.filter_by(done=False).order_by(Task.created_at.desc()).all()
return jsonify([t.to_dict() for t in tasks]), 200
return app
# Dev entrypoint
if __name__ == "__main__":
app = create_app()
app.run(debug=True)
```
**Explanation (highlights)**
- `create_app()` pattern makes the app **factory-based** (helps testing and deployment).
- `db.init_app(app)` binds SQLAlchemy to this Flask instance.
- Each endpoint returns JSON + an appropriate **HTTP status code**.
- Validation rules:
- `POST /tasks` requires non-empty `content`.
- `PUT /tasks/<id>` only updates fields provided (partial update).
- Filter endpoints demonstrate ORM **querying** with `filter_by(...)` and `order_by(...)`.
---
## 🧪 6) `db_setup.py` – Initialize Tables
```python
# db_setup.py
from app import create_app
from models import db
app = create_app()
with app.app_context():
db.create_all()
print("✅ Database tables created (or already exist).")
```
**Explanation**
- Imports the Flask app factory, enters an app context, then calls `db.create_all()` to create tables defined in `models.py`.
> Run it once after configuring `.env` and creating the database:
>
> ```bash
> python db_setup.py
> ```
---
## 📦 7) (Optional) `requirements.txt`
```txt
flask==3.0.3
flask_sqlalchemy==3.1.1
pymysql==1.1.1
python-dotenv==1.0.1
```
**Explanation**
- Helps pin versions so teammates install the same dependencies:
```bash
pip install -r requirements.txt
```
---
## ▶️ Run the API
```bash
# Ensure DB exists and tables are created
python db_setup.py
# Start the API
python app.py
```
The server starts at `http://127.0.0.1:5000`.
---
## 🧪 Test with cURL
### Create a task
```bash
curl -X POST http://127.0.0.1:5000/tasks -H "Content-Type: application/json" -d '{"content": "Write Flask + MySQL API"}'
```
### List all tasks
```bash
curl http://127.0.0.1:5000/tasks
```
### Get a single task
```bash
curl http://127.0.0.1:5000/tasks/1
```
### Update a task
```bash
curl -X PUT http://127.0.0.1:5000/tasks/1 -H "Content-Type: application/json" -d '{"done": true, "content":"Write Flask + MySQL API (updated)"}'
```
### Delete a task
```bash
curl -X DELETE http://127.0.0.1:5000/tasks/1
```
### List done / pending
```bash
curl http://127.0.0.1:5000/tasks/done
curl http://127.0.0.1:5000/tasks/pending
```
---
## 🧠 How ORM Maps to SQL (Conceptual)
- **Create**
```python
task = Task(content="Example")
db.session.add(task)
db.session.commit()
```
Behind the scenes SQL:
```sql
INSERT INTO tasks (content, done, created_at, updated_at)
VALUES ('Example', 0, NOW(), NOW());
```
- **Read**
```python
Task.query.filter_by(done=False).all()
```
Rough SQL:
```sql
SELECT * FROM tasks WHERE done = 0;
```
- **Update**
```python
task.done = True
db.session.commit()
```
Rough SQL:
```sql
UPDATE tasks SET done = 1, updated_at = NOW() WHERE id = ?;
```
- **Delete**
```python
db.session.delete(task)
db.session.commit()
```
Rough SQL:
```sql
DELETE FROM tasks WHERE id = ?;
```
---
## 🛟 Troubleshooting
- **`ModuleNotFoundError: No module named 'pymysql'`**
Run `pip install pymysql` inside the `flask_crud` environment.
- **`(pymysql.err.OperationalError) (1045, "Access denied…")`**
Wrong credentials. Check `.env` values (user/password/host/port).
- **`Can't connect to MySQL server`**
Ensure MySQL is running and listening on `DB_HOST:DB_PORT`. On local dev, that’s usually `127.0.0.1:3306`.
- **`db.create_all()` but no tables**
Confirm you’re connecting to the *expected* database (`DB_NAME`). Print `config.SQLALCHEMY_DATABASE_URI` to verify.
- **Unicode issues**
Ensure DB uses `utf8mb4` character set (the `CREATE DATABASE` above does).
---
## 🧪 Stretch Goals (Homework)
1. Add **pagination** to `GET /tasks` using `?page=1&limit=20`.
2. Add **search**: `GET /tasks?query=<text>` filters by content.
3. Add **soft delete** (a `deleted_at` column instead of hard delete).
4. Add **SQLAlchemy migrations** (e.g., with Flask-Migrate) when your schema evolves.
---
Happy building! 🚀