# 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! 🚀