From 5a3b86e8f8e3b6bd6c9ff2ba2d0a2cef492d4f28 Mon Sep 17 00:00:00 2001 From: pooblobb Date: Sat, 31 May 2025 20:07:23 +0800 Subject: [PATCH] Final submission: added authors API, templates, static files, and tests --- README.md | 168 +++++++++++++++++++++++++------------ app/__init__.py | 18 +++- app/author_routes.py | 58 +++++++++++++ app/models.py | 14 +++- app/routes.py | 10 ++- app/static/swagger.json | 134 +++++++++++++++++++++++++++++ app/templates/authors.html | 51 +++++++++++ app/templates/header.html | 8 +- crudapp.py | 6 +- requirements.txt | 14 ++-- test_author_api.py | 106 +++++++++++++++++++++++ 11 files changed, 523 insertions(+), 64 deletions(-) create mode 100644 app/author_routes.py create mode 100644 app/static/swagger.json create mode 100644 app/templates/authors.html create mode 100644 test_author_api.py diff --git a/README.md b/README.md index c1a51f9..f5b6026 100644 --- a/README.md +++ b/README.md @@ -1,52 +1,116 @@ -# Example Python Flask Crud - - Simple example python flask crud app for sqlite. - -## Screenshots - - -![image](screenshots.png) - - -### Installing (for linux) - -open the terminal and follow the white rabbit. - - -``` -git clone https://github.com/gurkanakdeniz/example-flask-crud.git -``` -``` -cd example-flask-crud/ -``` -``` -python3 -m venv venv -``` -``` -source venv/bin/activate -``` -``` -pip install --upgrade pip -``` -``` -pip install -r requirements.txt -``` -``` -export FLASK_APP=crudapp.py -``` -``` -flask db init -``` -``` -flask db migrate -m "entries table" -``` -``` -flask db upgrade -``` -``` -flask run -``` - -## License - -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details +# Flask CRUD Example with Authors API + +## Project Overview + +This is a simple Flask web application demonstrating CRUD (Create, Read, Update, Delete) REST API functionality for managing entries and authors. +It uses Flask, SQLAlchemy for ORM, and Flask-Migrate for database migrations. +Swagger UI is integrated for interactive API documentation. + +--- + +## Features + +- CRUD operations for `Entry` model (title, description, status) +- CRUD REST API for `Author` model (name, bio) implemented using Flask Blueprint +- RESTful API endpoints with JSON request/response format and proper HTTP status codes +- Swagger UI documentation available at `/docs` +- Unit tests covering all API functionality using `pytest` + +--- + +## Installation + +1. Clone the repository: + ```bash + git clone https://github.com/gurkanakdeniz/example-flask-crud.git + cd example-flask-crud + + +2. Create a virtual environment and activate it: + + python -m venv iptvenv + iptvenv\Scripts\activate + +3. Install dependencies: + + pip install -r requirements.txt + +4. Initialize the database: + (You only need to run flask db init once if not done before) + + flask db init + flask db migrate -m "Add Author model" + flask db upgrade + + +5. Run the application: + + python crudapp.py + +--- + +## Usage +1. Access the main page for managing entries: +http://localhost:5000 + +2. Access authors UI: +http://localhost:5000/authors_ui + +3. REST API endpoints are documented and available via Swagger UI: +http://localhost:5000/docs + +REST API Endpoints for Author +Method Endpoint Description +GET /authors List all authors +POST /authors Create new author +GET /authors/ Get author by ID +PUT /authors/ Update author by ID +DELETE /authors/ Delete author by ID + +--- + +## API Documentation +1. Interactive API documentation is available via Swagger UI at: +http://localhost:5000/docs + +(If flask-swagger-ui is not installed, install it using:) + + pip install flask-swagger-ui + +--- + +## Testing +1. If pytest is not installed, install it: + + pip install pytest + +2. Run tests with: + + pytest test_author_api.py + +--- + +## Changes I Made + +1. I added a new feature: the Author Management API. + +2. I created a new `Author` model with fields `name` and `bio` in `models.py`, and added a `to_dict()` method to help serialize author data to JSON. + +3. I implemented a RESTful API for the Author model using a Flask Blueprint in `author_routes.py`. + +4. I developed full CRUD functionality with JSON request and response formats, making sure to use proper HTTP status codes. + +5. I registered the Author blueprint in the app’s `__init__.py` file. + +6. I integrated Swagger UI for API documentation by adding an OpenAPI-compliant `swagger.json` under the static folder and configured it to serve at the `/docs` endpoint. + +7. I wrote unit tests in `test_author_api.py` to cover all CRUD operations, including both positive and negative test cases, and achieved full test coverage using `pytest`. + +8. I added a simple HTML UI for authors accessible at `/authors_ui` as an optional enhancement. + +9. Finally, I updated `requirements.txt` to include `flask-swagger-ui` and `pytest`. + +--- + +## Passed by: Glenn Paul Bryan Dela Cruz +## IPT Final Hands on activity diff --git a/app/__init__.py b/app/__init__.py index d9c344d..49ef761 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -3,9 +3,25 @@ from flask_sqlalchemy import SQLAlchemy from flask_migrate import Migrate -app = Flask(__name__) +app = Flask(__name__) # No need to set static_folder now app.config.from_object(Config) db = SQLAlchemy(app) migrate = Migrate(app, db) from app import routes, models +from app.author_routes import author_bp +app.register_blueprint(author_bp) + +# Swagger UI +from flask_swagger_ui import get_swaggerui_blueprint + +SWAGGER_URL = '/docs' +API_URL = '/static/swagger.json' + +swaggerui_blueprint = get_swaggerui_blueprint( + SWAGGER_URL, API_URL, config={'app_name': "Author API"} +) +app.register_blueprint(swaggerui_blueprint, url_prefix=SWAGGER_URL) + + + diff --git a/app/author_routes.py b/app/author_routes.py new file mode 100644 index 0000000..5d67e5a --- /dev/null +++ b/app/author_routes.py @@ -0,0 +1,58 @@ +from flask import Blueprint, request, jsonify +from app import db +from app.models import Author + +author_bp = Blueprint('authors', __name__, url_prefix='/authors') + +# Create +@author_bp.route('', methods=['POST']) +def create_author(): + data = request.get_json() + name = data.get('name') + bio = data.get('bio') + + if not name: + return jsonify({"error": "Name is required"}), 400 + + new_author = Author(name=name, bio=bio) + db.session.add(new_author) + db.session.commit() + return jsonify(new_author.to_dict()), 201 + +# Read All +@author_bp.route('', methods=['GET']) +def get_authors(): + authors = Author.query.all() + return jsonify([a.to_dict() for a in authors]), 200 + +# Read One by ID +@author_bp.route('/', methods=['GET']) +def get_author(id): + author = Author.query.get(id) + if not author: + return jsonify({"error": "Author not found"}), 404 + return jsonify(author.to_dict()), 200 + +# Update +@author_bp.route('/', methods=['PUT']) +def update_author(id): + author = Author.query.get(id) + if not author: + return jsonify({"error": "Author not found"}), 404 + + data = request.get_json() + author.name = data.get('name', author.name) + author.bio = data.get('bio', author.bio) + db.session.commit() + return jsonify(author.to_dict()), 200 + +# Delete +@author_bp.route('/', methods=['DELETE']) +def delete_author(id): + author = Author.query.get(id) + if not author: + return jsonify({"error": "Author not found"}), 404 + + db.session.delete(author) + db.session.commit() + return jsonify({"message": "Author deleted"}), 200 diff --git a/app/models.py b/app/models.py index 82ebb21..2148f7a 100644 --- a/app/models.py +++ b/app/models.py @@ -4,4 +4,16 @@ class Entry(db.Model): id = db.Column(db.Integer, primary_key=True) title = db.Column(db.String(64), index=True, nullable=False) description = db.Column(db.String(120), index=True, nullable=False) - status = db.Column(db.Boolean, default=False) \ No newline at end of file + status = db.Column(db.Boolean, default=False) + +class Author(db.Model): + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(100), nullable=False) + bio = db.Column(db.Text) + + def to_dict(self): + return { + "id": self.id, + "name": self.name, + "bio": self.bio + } diff --git a/app/routes.py b/app/routes.py index ddc580f..1295f97 100644 --- a/app/routes.py +++ b/app/routes.py @@ -88,4 +88,12 @@ def turn(id): # @app.errorhandler(Exception) # def error_page(e): -# return "of the jedi" \ No newline at end of file +# return "of the jedi" + +from flask import render_template + + + +@app.route('/authors_ui') +def authors_ui(): + return render_template('authors.html') diff --git a/app/static/swagger.json b/app/static/swagger.json new file mode 100644 index 0000000..af042bf --- /dev/null +++ b/app/static/swagger.json @@ -0,0 +1,134 @@ +{ + "swagger": "2.0", + "info": { + "title": "Authors REST API", + "version": "1.0.0", + "description": "A simple CRUD API to manage authors" + }, + "host": "localhost:5000", + "basePath": "/", + "schemes": ["http"], + "consumes": ["application/json"], + "produces": ["application/json"], + "paths": { + "/authors": { + "get": { + "summary": "Get all authors", + "responses": { + "200": { + "description": "List of authors", + "schema": { + "type": "array", + "items": { "$ref": "#/definitions/Author" } + } + } + } + }, + "post": { + "summary": "Create a new author", + "parameters": [ + { + "in": "body", + "name": "author", + "description": "Author object to create", + "required": true, + "schema": { "$ref": "#/definitions/NewAuthor" } + } + ], + "responses": { + "201": { + "description": "Author created successfully", + "schema": { "$ref": "#/definitions/Author" } + }, + "400": { "description": "Invalid input, name is required" } + } + } + }, + "/authors/{id}": { + "get": { + "summary": "Get an author by ID", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of author to fetch", + "required": true, + "type": "integer", + "format": "int64" + } + ], + "responses": { + "200": { + "description": "Author details", + "schema": { "$ref": "#/definitions/Author" } + }, + "404": { "description": "Author not found" } + } + }, + "put": { + "summary": "Update an existing author", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of author to update", + "required": true, + "type": "integer", + "format": "int64" + }, + { + "in": "body", + "name": "author", + "description": "Author object with updated info", + "required": true, + "schema": { "$ref": "#/definitions/NewAuthor" } + } + ], + "responses": { + "200": { + "description": "Author updated", + "schema": { "$ref": "#/definitions/Author" } + }, + "404": { "description": "Author not found" } + } + }, + "delete": { + "summary": "Delete an author", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of author to delete", + "required": true, + "type": "integer", + "format": "int64" + } + ], + "responses": { + "200": { "description": "Author deleted" }, + "404": { "description": "Author not found" } + } + } + } + }, + "definitions": { + "Author": { + "type": "object", + "properties": { + "id": { "type": "integer", "format": "int64" }, + "name": { "type": "string" }, + "bio": { "type": "string" } + }, + "required": ["id", "name"] + }, + "NewAuthor": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "bio": { "type": "string" } + }, + "required": ["name"] + } + } + } + \ No newline at end of file diff --git a/app/templates/authors.html b/app/templates/authors.html new file mode 100644 index 0000000..79bafde --- /dev/null +++ b/app/templates/authors.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} + +{% block content %} +

Create New Author

+
+ +

+
+

+ +
+ +

Authors List

+
    + + +{% endblock %} diff --git a/app/templates/header.html b/app/templates/header.html index 9224ea4..513f7d4 100644 --- a/app/templates/header.html +++ b/app/templates/header.html @@ -1,2 +1,6 @@ - Home -
    header
    \ No newline at end of file + +
    header
    + \ No newline at end of file diff --git a/crudapp.py b/crudapp.py index e524e69..0b835ac 100644 --- a/crudapp.py +++ b/crudapp.py @@ -1 +1,5 @@ -from app import app \ No newline at end of file +from app import app + + +if __name__ == '__main__': + app.run(debug=True) diff --git a/requirements.txt b/requirements.txt index 0406c19..322a058 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,16 +1,18 @@ -alembic==1.7.7 +alembic>=1.9.0,<2.0 click==8.1.3 -Flask==2.1.2 -Flask-Migrate==3.1.0 -Flask-SQLAlchemy==2.5.1 +Flask==2.3.2 +Flask-Migrate==4.0.4 +Flask-SQLAlchemy==3.0.3 Flask-WTF==1.0.1 +flask-swagger-ui==3.36.0 itsdangerous==2.1.2 Jinja2==3.1.2 Mako==1.2.0 MarkupSafe==2.1.1 python-dateutil==2.8.2 python-editor==1.0.4 +pytest==7.4.0 six==1.16.0 SQLAlchemy==1.4.36 -Werkzeug==2.1.2 -WTForms==3.0.1 \ No newline at end of file +Werkzeug==2.3.6 +WTForms==3.0.1 diff --git a/test_author_api.py b/test_author_api.py new file mode 100644 index 0000000..bcfe649 --- /dev/null +++ b/test_author_api.py @@ -0,0 +1,106 @@ +import pytest +from app import app, db +from app.models import Author + +@pytest.fixture +def client(): + app.config['TESTING'] = True + app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:' # In-memory DB for test + with app.test_client() as client: + with app.app_context(): + db.create_all() + yield client + with app.app_context(): + db.session.remove() + db.drop_all() + +# -------------------- +# POST /authors +# -------------------- +def test_create_author_success(client): + response = client.post('/authors', json={ + 'name': 'George Lucas', + 'bio': 'Created Star Wars' + }) + assert response.status_code == 201 + data = response.get_json() + assert data['name'] == 'George Lucas' + assert data['bio'] == 'Created Star Wars' + +def test_create_author_missing_name(client): + response = client.post('/authors', json={'bio': 'No name'}) + assert response.status_code == 400 + assert response.get_json()['error'] == 'Name is required' + +# -------------------- +# GET /authors +# -------------------- +def test_get_all_authors(client): + with app.app_context(): + author = Author(name='John Doe', bio='Writer') + db.session.add(author) + db.session.commit() + response = client.get('/authors') + assert response.status_code == 200 + data = response.get_json() + assert isinstance(data, list) + assert len(data) == 1 + +# -------------------- +# GET /authors/ +# -------------------- +def test_get_author_success(client): + with app.app_context(): + author = Author(name='Jane Austen', bio='Novelist') + db.session.add(author) + db.session.commit() + author_id = author.id + response = client.get(f'/authors/{author_id}') + assert response.status_code == 200 + assert response.get_json()['name'] == 'Jane Austen' + +def test_get_author_not_found(client): + response = client.get('/authors/999') + assert response.status_code == 404 + assert response.get_json()['error'] == 'Author not found' + +# -------------------- +# PUT /authors/ +# -------------------- +def test_update_author_success(client): + with app.app_context(): + author = Author(name='Old Name', bio='Old Bio') + db.session.add(author) + db.session.commit() + author_id = author.id + response = client.put(f'/authors/{author_id}', json={ + 'name': 'New Name', + 'bio': 'Updated Bio' + }) + assert response.status_code == 200 + data = response.get_json() + assert data['name'] == 'New Name' + assert data['bio'] == 'Updated Bio' + +def test_update_author_not_found(client): + response = client.put('/authors/999', json={'name': 'Ghost'}) + assert response.status_code == 404 + assert response.get_json()['error'] == 'Author not found' + +# -------------------- +# DELETE /authors/ +# -------------------- +def test_delete_author_success(client): + with app.app_context(): + author = Author(name='To Delete', bio='Bye') + db.session.add(author) + db.session.commit() + author_id = author.id + response = client.delete(f'/authors/{author_id}') + assert response.status_code == 200 + assert response.get_json()['message'] == 'Author deleted' + +def test_delete_author_not_found(client): + response = client.delete('/authors/999') + assert response.status_code == 404 + assert response.get_json()['error'] == 'Author not found'