├── _config.yml ├── .env.sample ├── app.py ├── __init__.py ├── config.py ├── items ├── urls.py ├── controllers.py └── models.py ├── accounts ├── urls.py ├── controllers.py └── models.py ├── .gitignore └── README.md /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-slate 2 | 3 | title: Flask, SQLAlchemy, and PostgreSQL 4 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | # Configuration Mode => development, testing, staging, or production 2 | CONFIG_MODE = development 3 | 4 | # => 'postgresql+psycopg2://user:password@host/database' 5 | DEVELOPMENT_DATABASE_URL = 'postgresql+psycopg2://testuser:testpass@localhost:5432/flask_sqlalchemy_postgresql' 6 | TEST_DATABASE_URL = 7 | STAGING_DATABASE_URL = 8 | PRODUCTION_DATABASE_URL = 9 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | # App Initialization 4 | from . import create_app # from __init__ file 5 | app = create_app(os.getenv("CONFIG_MODE")) 6 | 7 | # ----------------------------------------------- # 8 | 9 | # Hello World! 10 | @app.route('/') 11 | def hello(): 12 | return "Hello World!" 13 | 14 | # Applications Routes 15 | from .accounts import urls 16 | from .items import urls 17 | 18 | # ----------------------------------------------- # 19 | 20 | if __name__ == "__main__": 21 | # To Run the Server in Terminal => flask run -h localhost -p 5000 22 | # To Run the Server with Automatic Restart When Changes Occurred => FLASK_DEBUG=1 flask run -h localhost -p 5000 23 | 24 | app.run() -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | from flask_sqlalchemy import SQLAlchemy 3 | from flask_migrate import Migrate 4 | 5 | from .config import config 6 | 7 | # ----------------------------------------------- # 8 | 9 | db = SQLAlchemy() 10 | migrate = Migrate() 11 | 12 | def create_app(config_mode): 13 | app = Flask(__name__) 14 | app.config.from_object(config[config_mode]) 15 | 16 | db.init_app(app) 17 | migrate.init_app(app, db) 18 | 19 | return app 20 | 21 | # ----------------------------------------------- # 22 | 23 | # Migrate Commands: 24 | # flask db init 25 | # flask db migrate 26 | # flask db upgrade 27 | # ERROR [flask_migrate] Error: Can't locate revision identified by 'ID' => flask db revision --rev-id ID 28 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | class Config: 4 | SQLALCHEMY_TRACK_MODIFICATIONS = True 5 | 6 | class DevelopmentConfig(Config): 7 | DEVELOPMENT = True 8 | DEBUG = True 9 | SQLALCHEMY_DATABASE_URI = os.getenv("DEVELOPMENT_DATABASE_URL") 10 | 11 | class TestingConfig(Config): 12 | TESTING = True 13 | SQLALCHEMY_DATABASE_URI = os.getenv("TEST_DATABASE_URL") 14 | 15 | class StagingConfig(Config): 16 | DEVELOPMENT = True 17 | DEBUG = True 18 | SQLALCHEMY_DATABASE_URI = os.getenv("STAGING_DATABASE_URL") 19 | 20 | class ProductionConfig(Config): 21 | DEBUG = False 22 | SQLALCHEMY_DATABASE_URI = os.getenv("PRODUCTION_DATABASE_URL") 23 | 24 | config = { 25 | "development": DevelopmentConfig, 26 | "testing": TestingConfig, 27 | "staging": StagingConfig, 28 | "production": ProductionConfig 29 | } 30 | -------------------------------------------------------------------------------- /items/urls.py: -------------------------------------------------------------------------------- 1 | from flask import request 2 | 3 | from ..app import app 4 | from .controllers import list_all_items_controller, create_item_controller, retrieve_item_controller, update_item_controller, delete_item_controller 5 | 6 | @app.route("/items", methods=['GET', 'POST']) 7 | def list_create_items(): 8 | if request.method == 'GET': return list_all_items_controller() 9 | if request.method == 'POST': return create_item_controller() 10 | else: return 'Method is Not Allowed' 11 | 12 | @app.route("/items/", methods=['GET', 'PUT', 'DELETE']) 13 | def retrieve_update_destroy_item(item_id): 14 | if request.method == 'GET': return retrieve_item_controller(item_id) 15 | if request.method == 'PUT': return update_item_controller(item_id) 16 | if request.method == 'DELETE': return delete_item_controller(item_id) 17 | else: return 'Method is Not Allowed' 18 | -------------------------------------------------------------------------------- /accounts/urls.py: -------------------------------------------------------------------------------- 1 | from flask import request 2 | 3 | from ..app import app 4 | from .controllers import list_all_accounts_controller, create_account_controller, retrieve_account_controller, update_account_controller, delete_account_controller 5 | 6 | @app.route("/accounts", methods=['GET', 'POST']) 7 | def list_create_accounts(): 8 | if request.method == 'GET': return list_all_accounts_controller() 9 | if request.method == 'POST': return create_account_controller() 10 | else: return 'Method is Not Allowed' 11 | 12 | @app.route("/accounts/", methods=['GET', 'PUT', 'DELETE']) 13 | def retrieve_update_destroy_account(account_id): 14 | if request.method == 'GET': return retrieve_account_controller(account_id) 15 | if request.method == 'PUT': return update_account_controller(account_id) 16 | if request.method == 'DELETE': return delete_account_controller(account_id) 17 | else: return 'Method is Not Allowed' 18 | -------------------------------------------------------------------------------- /items/controllers.py: -------------------------------------------------------------------------------- 1 | from flask import request, jsonify 2 | import uuid 3 | 4 | from .. import db 5 | from .models import Item 6 | 7 | # ----------------------------------------------- # 8 | 9 | # Query Object Methods => https://docs.sqlalchemy.org/en/14/orm/query.html#sqlalchemy.orm.Query 10 | # Session Object Methods => https://docs.sqlalchemy.org/en/14/orm/session_api.html#sqlalchemy.orm.Session 11 | # How to serialize SqlAlchemy PostgreSQL Query to JSON => https://stackoverflow.com/a/46180522 12 | 13 | def list_all_items_controller(): 14 | items = Item.query.all() 15 | response = [] 16 | for item in items: response.append(item.toDict()) 17 | return jsonify(response) 18 | 19 | def create_item_controller(): 20 | request_form = request.form.to_dict() 21 | 22 | id = str(uuid.uuid4()) 23 | new_item = Item( 24 | id = id, 25 | name = request_form['name'], 26 | price = float(request_form['price']), 27 | description = request_form['description'], 28 | image_link = request_form['image_link'], 29 | account_id = request_form['account_id'], 30 | ) 31 | db.session.add(new_item) 32 | db.session.commit() 33 | 34 | response = Item.query.get(id).toDict() 35 | return jsonify(response) 36 | 37 | def retrieve_item_controller(item_id): 38 | response = Item.query.get(item_id).toDict() 39 | return jsonify(response) 40 | 41 | def update_item_controller(item_id): 42 | request_form = request.form.to_dict() 43 | item = Item.query.get(item_id) 44 | 45 | item.name = request_form['name'] 46 | item.price = float(request_form['price']) 47 | item.description = request_form['description'] 48 | item.image_link = request_form['image_link'] 49 | item.account_id = request_form['account_id'] 50 | db.session.commit() 51 | 52 | response = Item.query.get(item_id).toDict() 53 | return jsonify(response) 54 | 55 | def delete_item_controller(item_id): 56 | Item.query.filter_by(id=item_id).delete() 57 | db.session.commit() 58 | 59 | return ('Item with Id "{}" deleted successfully!').format(item_id) 60 | -------------------------------------------------------------------------------- /accounts/controllers.py: -------------------------------------------------------------------------------- 1 | from flask import request, jsonify 2 | import uuid 3 | 4 | from .. import db 5 | from .models import Account 6 | 7 | # ----------------------------------------------- # 8 | 9 | # Query Object Methods => https://docs.sqlalchemy.org/en/14/orm/query.html#sqlalchemy.orm.Query 10 | # Session Object Methods => https://docs.sqlalchemy.org/en/14/orm/session_api.html#sqlalchemy.orm.Session 11 | # How to serialize SqlAlchemy PostgreSQL Query to JSON => https://stackoverflow.com/a/46180522 12 | 13 | def list_all_accounts_controller(): 14 | accounts = Account.query.all() 15 | response = [] 16 | for account in accounts: response.append(account.toDict()) 17 | return jsonify(response) 18 | 19 | def create_account_controller(): 20 | request_form = request.form.to_dict() 21 | 22 | id = str(uuid.uuid4()) 23 | new_account = Account( 24 | id = id, 25 | email = request_form['email'], 26 | username = request_form['username'], 27 | dob = request_form['dob'], 28 | country = request_form['country'], 29 | phone_number = request_form['phone_number'], 30 | ) 31 | db.session.add(new_account) 32 | db.session.commit() 33 | 34 | response = Account.query.get(id).toDict() 35 | return jsonify(response) 36 | 37 | def retrieve_account_controller(account_id): 38 | response = Account.query.get(account_id).toDict() 39 | return jsonify(response) 40 | 41 | def update_account_controller(account_id): 42 | request_form = request.form.to_dict() 43 | account = Account.query.get(account_id) 44 | 45 | account.email = request_form['email'] 46 | account.username = request_form['username'] 47 | account.dob = request_form['dob'] 48 | account.country = request_form['country'] 49 | account.phone_number = request_form['phone_number'] 50 | db.session.commit() 51 | 52 | response = Account.query.get(account_id).toDict() 53 | return jsonify(response) 54 | 55 | def delete_account_controller(account_id): 56 | Account.query.filter_by(id=account_id).delete() 57 | db.session.commit() 58 | 59 | return ('Account with Id "{}" deleted successfully!').format(account_id) 60 | -------------------------------------------------------------------------------- /items/models.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import inspect 2 | from datetime import datetime 3 | from flask_validator import ValidateString, ValidateNumber, ValidateURL 4 | from sqlalchemy.orm import validates 5 | 6 | from .. import db # from __init__.py 7 | 8 | # ----------------------------------------------- # 9 | 10 | # SQL Datatype Objects => https://docs.sqlalchemy.org/en/14/core/types.html 11 | class Item(db.Model): 12 | # Auto Generated Fields: 13 | id = db.Column(db.String(50), primary_key=True, nullable=False, unique=True) 14 | created = db.Column(db.DateTime(timezone=True), default=datetime.now) # The Date of the Instance Creation => Created one Time when Instantiation 15 | updated = db.Column(db.DateTime(timezone=True), default=datetime.now, onupdate=datetime.now) # The Date of the Instance Update => Changed with Every Update 16 | 17 | # Input by User Fields: 18 | name = db.Column(db.String(50), nullable=False) 19 | price = db.Column(db.Float(precision=2), nullable=False, default=0.00) 20 | description = db.Column(db.Text()) 21 | image_link = db.Column(db.String(1000), nullable=False) 22 | 23 | # Relations: SQLAlchemy Basic Relationship Patterns => https://docs.sqlalchemy.org/en/14/orm/basic_relationships.html 24 | account = db.relationship("Account", back_populates="items") 25 | account_id = db.Column(db.String(100), db.ForeignKey("account.id")) 26 | 27 | 28 | # Validations => https://flask-validator.readthedocs.io/en/latest/index.html 29 | @classmethod 30 | def __declare_last__(cls): 31 | ValidateString(Item.name, False, True, "The name type must be string") 32 | ValidateNumber(Item.price, True, "The price type must be number") 33 | ValidateURL(Item.image_link, True, True, "The image link is not valid") 34 | 35 | # Set an empty string to null for name field => https://stackoverflow.com/a/57294872 36 | @validates('name') 37 | def empty_string_to_null(self, key, value): 38 | if isinstance(value, str) and value == '': return None 39 | else: return value 40 | 41 | 42 | # How to serialize SqlAlchemy PostgreSQL Query to JSON => https://stackoverflow.com/a/46180522 43 | def toDict(self): 44 | return { c.key: getattr(self, c.key) for c in inspect(self).mapper.column_attrs } 45 | 46 | def __repr__(self): 47 | return "<%r>" % self.email 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /accounts/models.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import inspect 2 | from datetime import datetime 3 | from flask_validator import ValidateEmail, ValidateString, ValidateCountry 4 | from sqlalchemy.orm import validates 5 | 6 | from .. import db # from __init__.py 7 | 8 | # ----------------------------------------------- # 9 | 10 | # SQL Datatype Objects => https://docs.sqlalchemy.org/en/14/core/types.html 11 | class Account(db.Model): 12 | # Auto Generated Fields: 13 | id = db.Column(db.String(50), primary_key=True, nullable=False, unique=True) 14 | created = db.Column(db.DateTime(timezone=True), default=datetime.now) # The Date of the Instance Creation => Created one Time when Instantiation 15 | updated = db.Column(db.DateTime(timezone=True), default=datetime.now, onupdate=datetime.now) # The Date of the Instance Update => Changed with Every Update 16 | 17 | # Input by User Fields: 18 | email = db.Column(db.String(100), nullable=False, unique=True) 19 | username = db.Column(db.String(100), nullable=False) 20 | dob = db.Column(db.Date) 21 | country = db.Column(db.String(100)) 22 | phone_number = db.Column(db.String(20)) 23 | 24 | # Relations: SQLAlchemy Basic Relationship Patterns => https://docs.sqlalchemy.org/en/14/orm/basic_relationships.html 25 | items = db.relationship("Item", back_populates='account') # Account May Own Many Items => One to Many 26 | 27 | 28 | # Validations => https://flask-validator.readthedocs.io/en/latest/index.html 29 | @classmethod 30 | def __declare_last__(cls): 31 | ValidateEmail(Account.email, True, True, "The email is not valid. Please check it") # True => Allow internationalized addresses, True => Check domain name resolution. 32 | ValidateString(Account.username, True, True, "The username type must be string") 33 | ValidateCountry(Account.country, True, True, "The country is not valid") 34 | 35 | # Set an empty string to null for username field => https://stackoverflow.com/a/57294872 36 | @validates('username') 37 | def empty_string_to_null(self, key, value): 38 | if isinstance(value, str) and value == '': return None 39 | else: return value 40 | 41 | 42 | # How to serialize SqlAlchemy PostgreSQL Query to JSON => https://stackoverflow.com/a/46180522 43 | def toDict(self): 44 | return { c.key: getattr(self, c.key) for c in inspect(self).mapper.column_attrs } 45 | 46 | def __repr__(self): 47 | return "<%r>" % self.email 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # How to build a CRUD API using Python Flask and SQLAlchemy ORM with PostgreSQL 4 | 5 | In this tutorial, you will learn how to build a simple CRUD API using **Flask**, **SQLAlchemy**, and **PostgreSQL**. 6 | 7 |
8 | Flask, SQLAlchemy, and PostgreSQL 9 |
10 | 11 |   12 | 13 | ## Table of Contents 14 | 15 | - [How to build a CRUD API using Python Flask and SQLAlchemy ORM with PostgreSQL](#how-to-build-a-crud-api-using-python-flask-and-sqlalchemy-orm-with-postgresql) 16 | - [Table of Contents](#table-of-contents) 17 | - [Introduction](#introduction) 18 | - [Tutorial Result](#tutorial-result) 19 | - [Tutorial Steps](#tutorial-steps) 20 | - [Definitions](#definitions) 21 | - [Prerequisites](#prerequisites) 22 | - [Project Setup](#project-setup) 23 | - [#1 Create PostgreSQL Database](#1-create-postgresql-database) 24 | - [#2 Initialize the Virtual Environment](#2-initialize-the-virtual-environment) 25 | - [#3 Install the Project Dependencies](#3-install-the-project-dependencies) 26 | - [Writing the Project Code](#writing-the-project-code) 27 | - [#1 Getting Started with the Main Files "`app`, `__init__`, `config`, `env`"](#1-getting-started-with-the-main-files-app-__init__-config-env) 28 | - [#2 Getting Started with the Applications Files](#2-getting-started-with-the-applications-files) 29 | - [#3 Send Requests Using Postman](#3-send-requests-using-postman) 30 | - [Get Started with SQLAlchemy Basic Relationships](#get-started-with-sqlalchemy-basic-relationships) 31 | - [Conclusion](#conclusion) 32 | 33 |   34 | 35 | ## Introduction 36 | 37 | **CRUD** refers to the four basic operations that a software application must be able to perform: **Create**, **Read**, **Update**, and **Delete**. 38 | 39 | > 📝 _Note: This is a shallow app with the best practice for file structuring, to get the idea and start learning the framework!_ 40 | 41 | Flask Vs Django: Which Python Framework to Choose? You can find the detailed differences between Django and Flask in this [article](https://www.interviewbit.com/blog/flask-vs-django). 42 | 43 | ### Tutorial Result 44 | 45 | This tutorial will create a Flask CRUD application that allows users to create, read, update, and delete database entries using an API. The API will be able to: 46 | 47 | - **List all instances of object** 48 | - **Post a new instance** 49 | - **Get a specific instance** 50 | - **Put a specific instance** 51 | - **Delete a specific instance** 52 | 53 | ### Tutorial Steps 54 | 55 | 1. Project Setup: 56 | 57 | - Create PostgreSQL Database 58 | - Initialize the Virtual Environment 59 | - Install the Project Dependencies 60 | 61 | 2. Writing the Project Code: 62 | 63 | - Writing the Main Files 64 | - Writing the Applications Files 65 | - Send Requests Using Postman 66 | 67 | ### Definitions 68 | 69 | > 💡 _Tip: Skip these definitions at the first reading time!_ 70 | 71 | - **What is Flask?** 72 | 73 | > Flask is what is known as a **WSGI framework**. Which stands for **Web Server Gateway Interface**. Essentially, this is a way for web servers to pass requests to web applications or frameworks. 74 | 75 | > Flask is used for developing web applications using Python. Advantages of using Flask framework: 76 | > 77 | > - Lightweight framework. 78 | > - Use **MVC** design pattern. 79 | > - Has a built-in development server. 80 | > - Fast debugger is provided. 81 | 82 | - **What is SQLAlchemy?** 83 | 84 | > SQLAlchemy provides a nice “**Pythonic**” way of interacting with databases. 85 | 86 | > SQLAlchemy is a library that facilitates the communication between Python programs and databases. Most of the time this library is used as an **Object Relational Mapper** (**ORM**) tool that translates Python classes to tables in relational databases and automatically converts function calls to SQL statements. 87 | 88 | - **What is Alembic?** 89 | 90 | > Alembic is a lightweight database migration tool for usage with the SQLAlchemy Database Toolkit for Python. 91 | 92 | > Alembic is a very useful library which is widely used for **database migration**. It can be used to create tables, insert data or even migrate functions from one schema to another. To be able to do all these tasks, the library uses SQLAlchemy, an ORM that is suited for working with PostgreSQL and other relational databases. 93 | 94 | - **MVC Design Pattern** 95 | 96 | > The Model-View-Controller (MVC) is an architectural pattern that separates an application into three main groups of components: **Models**, **Views**, and **Controllers**. 97 | 98 | > MVC (Model-View-Controller) is a pattern in software design commonly used to implement user interfaces, data, and controlling logic. It emphasizes the separation between the software's business logic and display. This "separation of concerns" provides for a better division of labor and improved maintenance. 99 | 100 |
101 | MVC Diagram 102 |
103 | 104 |   105 | 106 | ## Prerequisites 107 | 108 | - [Windows Terminal](https://learn.microsoft.com/en-us/windows/terminal/install) 109 | - Text Editor like [VSCode](https://code.visualstudio.com) 110 | - [Postman](https://www.postman.com/downloads) 111 | - [Python Interpreter](https://realpython.com/installing-python) 112 | - [PostgreSQL Server](https://www.postgresql.org/download) 113 | - [pgAdmin](https://www.pgadmin.org/download) _"optional"_ 114 | 115 |   116 | 117 | ## Project Setup 118 | 119 | ### #1 Create PostgreSQL Database 120 | 121 | **Target**: Create a new database with a new user. 122 | 123 | > 💡 _Tip: First create a test database with the same names & passwords below, then you can create a real database with the names & passwords you want!_ 124 | 125 | We will create a database called "**testdb**" and user "**testuser**" with password "**testpass**". 126 | 127 | 1. In Windows Terminal, Run the PostgreSQL Server 128 | 129 | ```bash 130 | ~ sudo service postgresql start 131 | ➜ * Starting PostgreSQL 14 database server 132 | # 14 is the PostgreSQL Server Version 133 | ``` 134 | 135 | > 📝 _Important Note: We need to run the PostgreSQL server every time we start coding!_ 136 | 137 | 2. Activate the PostgreSQL Shell 138 | 139 | ```bash 140 | ~ sudo -u postgres psql 141 | ➜ postgres=# 142 | ``` 143 | 144 | 3. Create a New Database 145 | 146 | ```postgres 147 | 148 | postgres=# create database testdb; 149 | ➜ CREATE DATABASE 150 | ``` 151 | 152 | 4. Create a Database User, then Grant Privileges to it 153 | 154 | ```postgres 155 | 156 | postgres=# create user testuser with encrypted password 'testpass'; 157 | ➜ CREATE ROLE 158 | 159 | 160 | postgres=# grant all privileges on database testdb to testuser; 161 | ➜ GRANT 162 | ``` 163 | 164 | 5. Exit the Shell 165 | 166 | ```postgres 167 | postgres=# \q 168 | ``` 169 | 170 | 6. Connect to the New Database 171 | 172 | ```bash 173 | ~ psql -U testuser -h 127.0.0.1 -d testdb 174 | Password for user testuser: testpass 175 | ➜ testdb=> 176 | ``` 177 | 178 | 7. Check the Connection 179 | 180 | ```postgres 181 | testdb=> \conninfo 182 | ➜ You are connected to database "testdb" as user "testuser" on host "127.0.0.1" at port "5432". 183 | 184 | ``` 185 | 186 | Now that our new PostgreSQL database is up and running, let's move on to the next step! 187 | 188 | ### #2 Initialize the Virtual Environment 189 | 190 | - **What is the Virtual Environment?** 191 | 192 | > A virtual environment is a tool that helps separate dependencies required by different projects by creating isolated python virtual environments for them. This is one of the most important tools that most Python developers use. 193 | 194 | > virtualenv is used to manage Python packages for different projects. Using virtualenv allows you to avoid installing Python packages globally which could break system tools or other projects. 195 | 196 | We'll create a virtual environment and activate it using the following commands 197 | 198 | ```bash 199 | # virtualenv -p python3 ProjectName 200 | ~ virtualenv -p python3 Flask-SQLAlchemy-PostgreSQL 201 | ➜ created virtual environment 202 | 203 | cd Flask-SQLAlchemy-PostgreSQL 204 | 205 | source bin/activate 206 | ``` 207 | 208 | ### #3 Install the Project Dependencies 209 | 210 | After creating and activating the virtualenv, let's start with installing the project's dependencies 211 | 212 | ```bash 213 | pip install python-dotenv flask flask-sqlalchemy Flask-Migrate flask_validator psycopg2-binary 214 | ``` 215 | 216 | Then make a folder called src which will contain the project codes 217 | 218 | ```bash 219 | mkdir src && cd $_ 220 | ``` 221 | 222 | The Last step before starting with the code, create a requirements file using this command: 223 | 224 | ```bash 225 | python -m pip freeze > requirements.txt 226 | ``` 227 | 228 |   229 | 230 | ## Writing the Project Code 231 | 232 | > 📝 _Note: In Flask, you can structure and name the files however you like, but we will learn the best practices for the naming and files structuring._ 233 | 234 | ```bash 235 | ├── bin 236 | ├── include 237 | ├── lib 238 | ├── pyvenv.cfg 239 | └── src 240 | ├── config.py 241 | ├── .env 242 | ├── .env.sample 243 | ├── __init__.py 244 | ├── app.py 245 | ├── accounts 246 | │ ├── controllers.py 247 | │ ├── models.py 248 | │ └── urls.py 249 | ├── items 250 | │ ├── controllers.py 251 | │ ├── models.py 252 | │ └── urls.py 253 | ├── requirements.txt 254 | └── README.md 255 | ``` 256 | 257 | ### #1 Getting Started with the Main Files "`app`, `__init__`, `config`, `env`" 258 | 259 | In most Flask tutorials, you'll notice that they only have the `app.py` file, which works. However, it is better to have multiple files, which makes the code clean and file management much easier, especially in large projects. 260 | 261 | So, let's create the 4 main files with this command: 262 | 263 | ```bash 264 | touch app.py __init__.py config.py .env 265 | ``` 266 | 267 | Now let's start diving deeper into each file: 268 | 269 | > _Unpopular opinion: Better to start with **`config.py`** than **`app.py`**_ 270 | 271 | - _**`config.py`**_ 272 | 273 | Let's assume that we have 4 configuration modes: **Development**, **Testing**, **Staging**, and **Production**. We will create a class for each one with the configuration values, you can check the [Configuration — Flask-SQLAlchemy Documentation](https://flask-sqlalchemy.pallet'sprojects.com/en/2.x/config). The most important one is `SQLALCHEMY_DATABASE_URI` which is equal to the PostgreSQL database connection link. 274 | 275 | ```python 276 | import os 277 | 278 | class Config: 279 | SQLALCHEMY_TRACK_MODIFICATIONS = True 280 | 281 | class DevelopmentConfig(Config): 282 | DEVELOPMENT = True 283 | DEBUG = True 284 | SQLALCHEMY_DATABASE_URI = os.getenv("DEVELOPMENT_DATABASE_URL") 285 | 286 | class TestingConfig(Config): 287 | TESTING = True 288 | SQLALCHEMY_DATABASE_URI = os.getenv("TEST_DATABASE_URL") 289 | 290 | class StagingConfig(Config): 291 | DEVELOPMENT = True 292 | DEBUG = True 293 | SQLALCHEMY_DATABASE_URI = os.getenv("STAGING_DATABASE_URL") 294 | 295 | class ProductionConfig(Config): 296 | DEBUG = False 297 | SQLALCHEMY_DATABASE_URI = os.getenv("PRODUCTION_DATABASE_URL") 298 | 299 | config = { 300 | "development": DevelopmentConfig, 301 | "testing": TestingConfig, 302 | "staging": StagingConfig, 303 | "production": ProductionConfig 304 | } 305 | ``` 306 | 307 | - _**`.env`**_ 308 | 309 | Create the environment variables for the config mode and the database URL for each mode. 310 | 311 | ```python 312 | # Configuration Mode => development, testing, staging, or production 313 | CONFIG_MODE = development 314 | 315 | # POSTGRESQL_DATABASE_URI => 'postgresql+psycopg2://user:password@host:port/database' 316 | DEVELOPMENT_DATABASE_URL = 'postgresql+psycopg2://testuser:testpass@localhost:5432/testdb' 317 | TEST_DATABASE_URL = 318 | STAGING_DATABASE_URL = 319 | PRODUCTION_DATABASE_URL = 320 | ``` 321 | 322 | PostgreSQL database connection URL format `postgresql+psycopg2://user:password@host:port/database`. This information can be obtained using `\conninfo` command in the psql shell. 323 | 324 | - _**`__init__.py`**_ 325 | 326 | ```python 327 | from flask import Flask 328 | from flask_sqlalchemy import SQLAlchemy 329 | from flask_migrate import Migrate 330 | 331 | from .config import config 332 | 333 | db = SQLAlchemy() 334 | migrate = Migrate() 335 | 336 | def create_app(config_mode): 337 | app = Flask(__name__) 338 | app.config.from_object(config[config_mode]) 339 | 340 | db.init_app(app) 341 | migrate.init_app(app, db) 342 | 343 | return app 344 | ``` 345 | 346 | `create_app` is a function that instantiates: 347 | 348 | - **app** from the Flask class with the configs from the `config.py` file we created. 349 | - **db** from SQLAlchemy class imported from flask_sqlalchemy. 350 | - **migrate** from Migrate class imported from flask_migrate. 351 | 352 | - _**`app.py`**_ 353 | 354 | ```python 355 | import os 356 | 357 | # App Initialization 358 | from . import create_app # from __init__ file 359 | app = create_app(os.getenv("CONFIG_MODE")) 360 | 361 | # Hello World! 362 | @app.route('/') 363 | def hello(): 364 | return "Hello World!" 365 | 366 | if __name__ == "__main__": 367 | app.run() 368 | ``` 369 | 370 | Now our basic app is ready to go! We can run the server in the terminal by using one of the following commands: 371 | 372 | ```bash 373 | # To Run the Server in Terminal 374 | flask run 375 | 376 | # To Run the Server with specific host and port 377 | # flask run -h HOSTNAME -p PORTNUMBER 378 | flask run -h 127.0.0.2 -p 5001 379 | 380 | # To Run the Server with Automatic Restart When Changes Occur 381 | FLASK_DEBUG=1 flask run 382 | ``` 383 | 384 | You can open your browser at and see the result! 385 | 386 | ### #2 Getting Started with the Applications Files 387 | 388 | All the pains and headaches above are for the first time starting the project; most code is written inside the files of the applications. 389 | 390 | > 💡 _Tip: It is a best practice to have each app in a separate folder._ 391 | 392 | Each app should have its own **models**, **urls**, and **controllers**. 393 | 394 | Let's start by creating an app called Accounts with this command: 395 | 396 | ```bash 397 | mkdir accounts && touch $_/models.py $_/urls.py $_/controllers.py 398 | ``` 399 | 400 | Now, let's break down all these files: 401 | 402 | > 💡 _Tip: Always start with building the models classes_ 403 | 404 | - **`models.py`** 405 | 406 | ```python 407 | from sqlalchemy import inspect 408 | from datetime import datetime 409 | from flask_validator import ValidateEmail, ValidateString, ValidateCountry 410 | from sqlalchemy.orm import validates 411 | 412 | from .. import db # from __init__.py 413 | 414 | # ----------------------------------------------- # 415 | 416 | # SQL Datatype Objects => https://docs.sqlalchemy.org/en/14/core/types.html 417 | class Account(db.Model): 418 | # Auto Generated Fields: 419 | id = db.Column(db.String(50), primary_key=True, nullable=False, unique=True) 420 | created = db.Column(db.DateTime(timezone=True), default=datetime.now) # The Date of the Instance Creation => Created one Time when Instantiation 421 | updated = db.Column(db.DateTime(timezone=True), default=datetime.now, onupdate=datetime.now) # The Date of the Instance Update => Changed with Every Update 422 | 423 | # Input by User Fields: 424 | email = db.Column(db.String(100), nullable=False, unique=True) 425 | username = db.Column(db.String(100), nullable=False) 426 | dob = db.Column(db.Date) 427 | country = db.Column(db.String(100)) 428 | phone_number = db.Column(db.String(20)) 429 | 430 | # Validations => https://flask-validator.readthedocs.io/en/latest/index.html 431 | @classmethod 432 | def __declare_last__(cls): 433 | ValidateEmail(Account.email, True, True, "The email is not valid. Please check it") # True => Allow internationalized addresses, True => Check domain name resolution. 434 | ValidateString(Account.username, True, True, "The username type must be string") 435 | ValidateCountry(Account.country, True, True, "The country is not valid") 436 | 437 | # Set an empty string to null for username field => https://stackoverflow.com/a/57294872 438 | @validates('username') 439 | def empty_string_to_null(self, key, value): 440 | if isinstance(value, str) and value == '': return None 441 | else: return value 442 | 443 | # How to serialize SqlAlchemy PostgreSQL Query to JSON => https://stackoverflow.com/a/46180522 444 | def toDict(self): 445 | return { c.key: getattr(self, c.key) for c in inspect(self).mapper.column_attrs } 446 | 447 | def __repr__(self): 448 | return "<%r>" % self.email 449 | 450 | ``` 451 | 452 | - **`controllers.py`** 453 | 454 | The general CRUD requests are: 455 | 456 | - List all instances 457 | - Post a new instance 458 | - Get a specific instance 459 | - Put a specific instance 460 | - Delete a specific instance 461 | 462 | Each of these operations must have its own logical function in the `controllers.py` file: 463 | 464 | ```python 465 | from flask import request, jsonify 466 | import uuid 467 | 468 | from .. import db 469 | from .models import Account 470 | 471 | # ----------------------------------------------- # 472 | 473 | # Query Object Methods => https://docs.sqlalchemy.org/en/14/orm/query.html#sqlalchemy.orm.Query 474 | # Session Object Methods => https://docs.sqlalchemy.org/en/14/orm/session_api.html#sqlalchemy.orm.Session 475 | # How to serialize SqlAlchemy PostgreSQL Query to JSON => https://stackoverflow.com/a/46180522 476 | 477 | def list_all_accounts_controller(): 478 | accounts = Account.query.all() 479 | response = [] 480 | for account in accounts: response.append(account.toDict()) 481 | return jsonify(response) 482 | 483 | def create_account_controller(): 484 | request_form = request.form.to_dict() 485 | 486 | id = str(uuid.uuid4()) 487 | new_account = Account( 488 | id = id, 489 | email = request_form['email'], 490 | username = request_form['username'], 491 | dob = request_form['dob'], 492 | country = request_form['country'], 493 | phone_number = request_form['phone_number'], 494 | ) 495 | db.session.add(new_account) 496 | db.session.commit() 497 | 498 | response = Account.query.get(id).toDict() 499 | return jsonify(response) 500 | 501 | def retrieve_account_controller(account_id): 502 | response = Account.query.get(account_id).toDict() 503 | return jsonify(response) 504 | 505 | def update_account_controller(account_id): 506 | request_form = request.form.to_dict() 507 | account = Account.query.get(account_id) 508 | 509 | account.email = request_form['email'] 510 | account.username = request_form['username'] 511 | account.dob = request_form['dob'] 512 | account.country = request_form['country'] 513 | account.phone_number = request_form['phone_number'] 514 | db.session.commit() 515 | 516 | response = Account.query.get(account_id).toDict() 517 | return jsonify(response) 518 | 519 | def delete_account_controller(account_id): 520 | Account.query.filter_by(id=account_id).delete() 521 | db.session.commit() 522 | 523 | return ('Account with Id "{}" deleted successfully!').format(account_id) 524 | 525 | ``` 526 | 527 | Let's break down the logical functions for CRUD operations: 528 | 529 | - **List all instances**: 530 | 531 | 1. Get all queries using **query.all()** method 532 | 2. Loop through the result to save the instances in a list of dictionaries 533 | 3. Jsonify the list 534 | 535 | - **Post new instance**: 536 | 537 | 1. Get the request data sent in the request form and convert it into dictionary 538 | 2. Create a unique id from uuid library => [https://docs.python.org/3/library/uuid.html](https://docs.python.org/3/library/uuid.html) 539 | 3. Create a new instance of the class with the request form data 540 | 4. Add then Commit the session to save the new instance in our database 541 | 5. Retrieve the new instance by **id** using **query.get()** method 542 | 6. Convert the result into dictionary then Jsonify it 543 | 544 | - **Get a specific instance**: 545 | 546 | 1. Retrieve the instance by the **provided id** using **query.get()** method 547 | 2. Convert the result into dictionary then Jsonify it 548 | 549 | - **Put a specific instance**: 550 | 551 | 1. Get the request data sent in the request form and convert it into dictionary 552 | 2. Retrieve the instance by the **provided id** using **query.get()** method 553 | 3. Update the instance fields with the request form data 554 | 4. Commit the session to save the instance with the new data in our database 555 | 5. Retrieve the instance by the **provided id** using **query.get()** method 556 | 6. Convert the result into dictionary then Jsonify it 557 | 558 | - **Delete a specific instance**: 559 | 560 | 1. Retrieve the instance by the **provided id** using **query.filter_by()** method 561 | 2. Commit the session to take action in our database 562 | 3. Return with a message to notify the user with the result 563 | 564 | - **`urls.py`** 565 | 566 | The five general operations can be combined into two URLs like this: 567 | 568 | ```python 569 | from flask import request 570 | 571 | from ..app import app 572 | from .controllers import list_all_accounts_controller, create_account_controller, retrieve_account_controller, update_account_controller, delete_account_controller 573 | 574 | @app.route("/accounts", methods=['GET', 'POST']) 575 | def list_create_accounts(): 576 | if request.method == 'GET': return list_all_accounts_controller() 577 | if request.method == 'POST': return create_account_controller() 578 | else: return 'Method is Not Allowed' 579 | 580 | @app.route("/accounts/", methods=['GET', 'PUT', 'DELETE']) 581 | def retrieve_update_destroy_accounts(account_id): 582 | if request.method == 'GET': return retrieve_account_controller(account_id) 583 | if request.method == 'PUT': return update_account_controller(account_id) 584 | if request.method == 'DELETE': return delete_account_controller(account_id) 585 | else: return 'Method is Not Allowed' 586 | 587 | ``` 588 | 589 |   590 | 591 | Now, two steps are required to get our accounts app ready to go: 592 | 593 | 1. Import the `urls` file in the `app.py` 594 | 595 | The final shape of the `app.py` file should look like this: 596 | 597 | ```python 598 | import os 599 | 600 | # App Initialization 601 | from . import create_app # from __init__ file 602 | app = create_app(os.getenv("CONFIG_MODE")) 603 | 604 | # ----------------------------------------------- # 605 | 606 | # Hello World! 607 | @app.route('/') 608 | def hello(): 609 | return "Hello World!" 610 | 611 | # Applications Routes 612 | from .accounts import urls 613 | 614 | # ----------------------------------------------- # 615 | 616 | if __name__ == "__main__": 617 | # To Run the Server in Terminal => flask run -h localhost -p 5000 618 | # To Run the Server with Automatic Restart When Changes Occurred => FLASK_DEBUG=1 flask run -h localhost -p 5000 619 | 620 | app.run() 621 | ``` 622 | 623 | 2. Migrate the new database models with these commands: 624 | 625 | ```bash 626 | flask db init 627 | flask db migrate 628 | flask db upgrade 629 | ``` 630 | 631 | If you face this error: AttributeError: `'_FakeStack'` object has no attribute `'__ident_func__'`, then fix it with these commands: 632 | 633 | ```bash 634 | python -m pip uninstall flask-sqlalchemy 635 | python -m pip install flask-sqlalchemy 636 | ``` 637 | 638 | You can learn more about the Flask-Migrate library from [https://flask-migrate.readthedocs.io/en/latest](https://flask-migrate.readthedocs.io/en/latest) 639 | 640 | ### #3 Send Requests Using Postman 641 | 642 | In this section, we will use Postman to test all of the CRUD operations we created. 643 | 644 | **What is Postman?** 645 | 646 | > Postman is an application that allows us to do API testing. It's like a browser that doesn't render HTML. In the browser, we can hit only GET HTTP requests but here we can hit GET, POST, PUT, DELETE, and many more HTTP requests in API. 647 | 648 | > Postman is the world's largest public API hub. It's an API platform for developers to design, build, test, and iterate their own APIs. 649 | 650 | - **Post New Account**: 651 | 652 | - Request Method: **POST** 653 | - Request Link: **[http://localhost:5000/accounts](http://localhost:5000/accounts)** 654 | - Body Data in form-data: 655 | - email 656 | - username 657 | - dob 658 | - country 659 | - phone_number 660 | 661 |
662 | Post New Account 663 |
664 | 665 | - **List All Accounts**: 666 | 667 | - Request Method: **GET** 668 | - Request Link: **[http://localhost:5000/accounts](http://localhost:5000/accounts)** 669 | 670 |
671 | List All Accounts 672 |
673 | 674 | - **Get a Specific Account**: 675 | 676 | - Request Method: **GET** 677 | - Request Link: **[http://localhost:5000/accounts/ACCOUNT_ID](http://localhost:5000/accounts/ACCOUNT_ID)** 678 | 679 |
680 | Get a Specific Account 681 |
682 | 683 | - **Put a Specific Account**: 684 | 685 | - Request Method: **PUT** 686 | - Request Link: **[http://localhost:5000/accounts/ACCOUNT_ID](http://localhost:5000/accounts/ACCOUNT_ID)** 687 | - Body Data in form-data: 688 | - email 689 | - username 690 | - dob 691 | - country 692 | - phone_number 693 | 694 |
695 | Put a Specific Account 696 |
697 | 698 | - **Delete a Specific Account**: 699 | 700 | - Request Method: **DELETE** 701 | - Request Link: **[http://localhost:5000/accounts/ACCOUNT_ID](http://localhost:5000/accounts/ACCOUNT_ID)** 702 | 703 |
704 | Delete a Specific Account 705 |
706 | 707 | ### Get Started with SQLAlchemy Basic Relationships 708 | 709 | Let's say we have multiple applications like **Accounts** & **Items** and we need to establish a relationship between their models! 710 | 711 | > 📝 _Note: This is a short summary of the model's relationships, we'll go deeper into their CRUD operations in another article!_ 712 | 713 | 1. **[One to Many Relationship](https://docs.sqlalchemy.org/en/14/orm/basic_relationships.html#one-to-many)** 714 | 715 | The Account may own many Items, but the Item is owned by one Account! 716 | 717 | > 💡 _Tip: Use **`ForeignKey`** in the **many** side!_ 718 | 719 | ```python 720 | class Account(db.Model): 721 | id = db.Column(db.String(50), primary_key=True, nullable=False, unique=True) 722 | . 723 | . 724 | . 725 | 726 | # Relations: 727 | items = db.relationship("Item", back_populates='account') 728 | ``` 729 | 730 | ```python 731 | class Item(db.Model): 732 | id = db.Column(db.String(50), primary_key=True, nullable=False, unique=True) 733 | . 734 | . 735 | . 736 | 737 | # Relations: 738 | account_id = db.Column(db.String(100), db.ForeignKey("account.id")) 739 | account = db.relationship("Account", back_populates="items") 740 | ``` 741 | 742 | 2. **[Many to One Relationship](https://docs.sqlalchemy.org/en/14/orm/basic_relationships.html#many-to-one)** 743 | 744 | The Item may be owned by many Accounts, but the Account has only one Item! 745 | 746 | > 💡 _Tip: Use **`ForeignKey`** in the **many** side!_ 747 | 748 | ```python 749 | class Account(db.Model): 750 | id = db.Column(db.String(50), primary_key=True, nullable=False, unique=True) 751 | . 752 | . 753 | . 754 | 755 | # Relations: 756 | item = db.relationship("Item", back_populates="accounts") 757 | item_id = db.Column(db.String(100), db.ForeignKey("item.id")) 758 | ``` 759 | 760 | ```python 761 | class Item(db.Model): 762 | id = db.Column(db.String(50), primary_key=True, nullable=False, 763 | . 764 | . 765 | . 766 | 767 | # Relations: 768 | accounts = db.relationship("Account", back_populates='item') 769 | ``` 770 | 771 | 3. **[One to One Relationship](https://docs.sqlalchemy.org/en/14/orm/basic_relationships.html#one-to-one)** 772 | 773 | The Account can own one Item, and the Item owned by one Account! 774 | 775 | > 💡 _Tip: Use **`uselist=False`** in one side & **`ForeignKey`** in the other side!_ 776 | 777 | ```python 778 | class Account(db.Model): 779 | id = db.Column(db.String(50), primary_key=True, nullable=False, unique=True) 780 | . 781 | . 782 | . 783 | 784 | # Relations: 785 | item = db.relationship("Item", back_populates='account', uselist=False) 786 | ``` 787 | 788 | ```python 789 | class Item(db.Model): 790 | id = db.Column(db.String(50), primary_key=True, nullable=False, unique=True) 791 | . 792 | . 793 | . 794 | 795 | # Relations: 796 | account = db.relationship("Account", back_populates='item') 797 | account_id = db.Column(db.String(100), db.ForeignKey("account.id"), unique=True) 798 | ``` 799 | 800 | 4. **[Many to Many Relationship](https://docs.sqlalchemy.org/en/14/orm/basic_relationships.html#many-to-many)** 801 | 802 | The Account may own many Items, and the Item may be owned by many Accounts! 803 | 804 | > 💡 _Tip: Use **`Association`** class with multi **`ForeignKey`**!_ 805 | 806 | ```python 807 | class Association(db.Model): 808 | item = db.relationship("Item", back_populates="accounts") 809 | account = db.relationship("Account", back_populates="items") 810 | item_id = db.Column('item_id', db.String, db.ForeignKey('item.id'), primary_key=True) 811 | account_id = db.Column('account_id', db.String, db.ForeignKey('account.id'), primary_key=True) 812 | 813 | def toDict(self): 814 | return { c.key: getattr(self, c.key) for c in inspect(self).mapper.column_attrs } 815 | 816 | class Account(db.Model): 817 | id = db.Column(db.String(50), primary_key=True, nullable=False, unique=True) 818 | . 819 | . 820 | . 821 | 822 | # Relations: 823 | items = db.relationship("Association", back_populates='account') 824 | ``` 825 | 826 | ```python 827 | class Item(db.Model): 828 | id = db.Column(db.String(50), primary_key=True, nullable=False, unique=True) 829 | . 830 | . 831 | . 832 | 833 | # Relations: 834 | accounts = db.relationship("Association", back_populates="item") 835 | ``` 836 | 837 | Check out the Concept of **backref** and **back_populate** in SQLalchemy from [this Stack Overflow Answer](https://stackoverflow.com/a/59920780). 838 | 839 |   840 | 841 | ## Conclusion 842 | 843 | In this post, we have introduced ORMs, specifically the SQLAlchemy ORM. Using Flask and Flask-SQLAlchemy, we've created a simple API that displays and manipulates data in a PostgreSQL database. Finally, we introduce the basic relationships of SQLAlchemy. 844 | 845 | > _The source code for the project in this post can be found on [GitHub](https://github.com/yahiaqous/Flask-SQLAlchemy-PostgreSQL)._ 846 | > 847 | > _Article on [Hashnode](https://yahiaqous.hashnode.dev/crud-api-python-flask-sqlalchemy-postgresql), [Medium](https://medium.com/@yahiaqous/how-to-build-a-crud-api-using-python-flask-and-sqlalchemy-orm-with-postgresql-7869517f8930), [DEV Community](https://dev.to/yahiaqous/how-to-build-a-crud-api-using-python-flask-and-sqlalchemy-orm-with-postgresql-2jjj), and [GitHub Pages](https://yahiaqous.github.io/Flask-SQLAlchemy-PostgreSQL/)_ 848 | --------------------------------------------------------------------------------