├── directory ├── static │ └── .gitkeep ├── apps │ ├── sites_app │ │ ├── __init__.py │ │ ├── models.py │ │ └── views.py │ └── users_app │ │ ├── __init__.py │ │ ├── models.py │ │ └── views.py ├── config.py ├── utils │ └── request.py └── __init__.py ├── app.py ├── .env.example ├── .dockerignore ├── migrations ├── README ├── script.py.mako ├── alembic.ini ├── versions │ ├── 6be745619778_.py │ └── 1dd786b9642b_.py └── env.py ├── docker-entry.sh ├── .gitignore ├── Dockerfile ├── fandogh.yml ├── requirements.txt └── README.md /directory/static/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | from directory import app -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | SQLALCHEMY_DATABASE_URI= 2 | SECRET_KEY= -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | .venv/ 3 | .vscode/ 4 | .env -------------------------------------------------------------------------------- /migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /docker-entry.sh: -------------------------------------------------------------------------------- 1 | flask db upgrade 2 | gunicorn -b 0.0.0.0:80 -w 2 app:app -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | .venv/ 3 | .vscode/ 4 | .env 5 | directory/static/* 6 | !directory/static/.gitkeep 7 | .fandogh/ -------------------------------------------------------------------------------- /directory/apps/sites_app/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | sites = Blueprint('sites', __name__, url_prefix='/sites/') 4 | 5 | from . import views -------------------------------------------------------------------------------- /directory/apps/users_app/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | users = Blueprint('users', __name__, url_prefix='/users/') 4 | 5 | from . import views -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.7-slim-stretch 2 | 3 | COPY ./requirements.txt /app/requirements.txt 4 | WORKDIR /app 5 | 6 | RUN apt update && apt install -qy libmariadbclient-dev gcc 7 | RUN pip install -r requirements.txt 8 | RUN pip install gunicorn 9 | 10 | COPY . /app 11 | 12 | CMD ["sh", "docker-entry.sh"] 13 | -------------------------------------------------------------------------------- /directory/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | class Base(object): 5 | SQLALCHEMY_DATABASE_URI = os.getenv('SQLALCHEMY_DATABASE_URI') 6 | SQLALCHEMY_TRACK_MODIFICATIONS = False 7 | SECRET_KEY = os.getenv('SECRET_KEY') 8 | 9 | 10 | class Development(Base): 11 | pass 12 | 13 | 14 | class Production(Base): 15 | pass -------------------------------------------------------------------------------- /fandogh.yml: -------------------------------------------------------------------------------- 1 | kind: ExternalService 2 | name: rest-directory 3 | spec: 4 | image: rest_directory:0.2 5 | port: 80 6 | env: 7 | - name: SQLALCHEMY_DATABASE_URI 8 | value: mysql+mysqldb://root:${DB_PASSWORD}@rest-dir-db/rest_directory 9 | - name: SECRET_KEY 10 | value: ${SECRET_KEY} 11 | static_path: /static/ 12 | -------------------------------------------------------------------------------- /directory/utils/request.py: -------------------------------------------------------------------------------- 1 | from flask import request 2 | from functools import wraps 3 | 4 | 5 | def json_only(function): 6 | @wraps(function) 7 | def decorator(*args, **kwargs): 8 | if not request.is_json: 9 | return {'error': 'JSON Only!'}, 400 10 | return function(*args, **kwargs) 11 | return decorator -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | alembic==1.3.2 2 | Click==7.0 3 | Flask==1.1.1 4 | Flask-JWT-Extended==3.24.1 5 | Flask-Migrate==2.5.2 6 | Flask-SQLAlchemy==2.4.1 7 | itsdangerous==1.1.0 8 | Jinja2==2.10.3 9 | Mako==1.2.2 10 | MarkupSafe==1.1.1 11 | mysqlclient==1.4.6 12 | PyJWT==1.7.1 13 | python-dateutil==2.8.1 14 | python-dotenv==0.10.3 15 | python-editor==1.0.4 16 | six==1.13.0 17 | SQLAlchemy==1.3.12 18 | Werkzeug==0.16.0 19 | -------------------------------------------------------------------------------- /migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /directory/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | from flask_sqlalchemy import SQLAlchemy 3 | from flask_migrate import Migrate 4 | from flask_jwt_extended import JWTManager 5 | 6 | from directory.config import Development 7 | 8 | app = Flask(__name__) 9 | app.config.from_object(Development) 10 | 11 | db = SQLAlchemy(app) 12 | migrate = Migrate(app, db) 13 | jwt_manager = JWTManager(app) 14 | 15 | @app.route('/') 16 | def home(): 17 | return {'message': 'Hello World'} 18 | 19 | 20 | from directory.apps.users_app import users 21 | from directory.apps.sites_app import sites 22 | 23 | app.register_blueprint(users) 24 | app.register_blueprint(sites) -------------------------------------------------------------------------------- /migrations/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # template used to generate migration files 5 | # file_template = %%(rev)s_%%(slug)s 6 | 7 | # set to 'true' to run the environment during 8 | # the 'revision' command, regardless of autogenerate 9 | # revision_environment = false 10 | 11 | 12 | # Logging configuration 13 | [loggers] 14 | keys = root,sqlalchemy,alembic 15 | 16 | [handlers] 17 | keys = console 18 | 19 | [formatters] 20 | keys = generic 21 | 22 | [logger_root] 23 | level = WARN 24 | handlers = console 25 | qualname = 26 | 27 | [logger_sqlalchemy] 28 | level = WARN 29 | handlers = 30 | qualname = sqlalchemy.engine 31 | 32 | [logger_alembic] 33 | level = INFO 34 | handlers = 35 | qualname = alembic 36 | 37 | [handler_console] 38 | class = StreamHandler 39 | args = (sys.stderr,) 40 | level = NOTSET 41 | formatter = generic 42 | 43 | [formatter_generic] 44 | format = %(levelname)-5.5s [%(name)s] %(message)s 45 | datefmt = %H:%M:%S 46 | -------------------------------------------------------------------------------- /migrations/versions/6be745619778_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 6be745619778 4 | Revises: 5 | Create Date: 2020-01-15 17:07:55.903708 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '6be745619778' 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('users', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('username', sa.String(length=32), nullable=False), 24 | sa.Column('password', sa.String(length=128), nullable=False), 25 | sa.PrimaryKeyConstraint('id'), 26 | sa.UniqueConstraint('username') 27 | ) 28 | # ### end Alembic commands ### 29 | 30 | 31 | def downgrade(): 32 | # ### commands auto generated by Alembic - please adjust! ### 33 | op.drop_table('users') 34 | # ### end Alembic commands ### 35 | -------------------------------------------------------------------------------- /migrations/versions/1dd786b9642b_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 1dd786b9642b 4 | Revises: 6be745619778 5 | Create Date: 2020-02-04 08:34:23.792757 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '1dd786b9642b' 14 | down_revision = '6be745619778' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('sites', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('create_date', sa.DateTime(), nullable=False), 24 | sa.Column('name', sa.String(length=128), nullable=False), 25 | sa.Column('description', sa.Text(), nullable=True), 26 | sa.Column('address', sa.String(length=128), nullable=False), 27 | sa.Column('icon', sa.String(length=256), nullable=True), 28 | sa.PrimaryKeyConstraint('id'), 29 | sa.UniqueConstraint('address') 30 | ) 31 | # ### end Alembic commands ### 32 | 33 | 34 | def downgrade(): 35 | # ### commands auto generated by Alembic - please adjust! ### 36 | op.drop_table('sites') 37 | # ### end Alembic commands ### 38 | -------------------------------------------------------------------------------- /directory/apps/users_app/models.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, Integer, String 2 | from sqlalchemy.orm import validates 3 | from werkzeug.security import generate_password_hash, check_password_hash 4 | 5 | from directory import db 6 | 7 | 8 | class User(db.Model): 9 | __tablename__ = 'users' 10 | 11 | id = Column(Integer(), primary_key=True) 12 | username = Column(String(32), unique=True, nullable=False) 13 | password = Column(String(128), unique=False, nullable=False) 14 | 15 | @validates('password') 16 | def validate_password(self, key, value): 17 | if value is None: 18 | raise ValueError('Password can\'t be null') 19 | if len(value) < 6: 20 | raise ValueError('Password should be atleast 6 characters.') 21 | return generate_password_hash(value) 22 | 23 | @validates('username') 24 | def validate_username(self, key, value): 25 | if value is None: 26 | raise ValueError('Username can\'t be null') 27 | if not value.isidentifier(): 28 | raise ValueError('Username is invalid.') 29 | return value 30 | 31 | def check_password(self, password): 32 | return check_password_hash(self.password, password) -------------------------------------------------------------------------------- /directory/apps/sites_app/models.py: -------------------------------------------------------------------------------- 1 | import re 2 | import datetime as dt 3 | from sqlalchemy import Column, Integer, DateTime, String, Text 4 | from sqlalchemy.orm import validates 5 | from directory import db 6 | 7 | 8 | class Site(db.Model): 9 | __tablename__ = 'sites' 10 | 11 | id = Column(Integer(), primary_key=True) 12 | create_date = Column(DateTime(), nullable=False, unique=False, default=dt.datetime.utcnow) 13 | name = Column(String(128), nullable=False, unique=False) 14 | description = Column(Text(), nullable=True, unique=False) 15 | address = Column(String(128), nullable=False, unique=True) 16 | icon = Column(String(256), nullable=True, unique=False) 17 | 18 | @validates('name') 19 | def validate_name(self, key, value): 20 | if value is None: 21 | raise ValueError('Name can\'t be null') 22 | if len(value) > 128: 23 | raise ValueError('Name cant be longer than 128 characters.') 24 | return value 25 | 26 | @validates('address') 27 | def validate_address(self, key, value): 28 | if value is None: 29 | raise ValueError('Address can\'t be null') 30 | pattern = re.compile(r'^(http|https):\/\/[a-zA-Z0-9][a-zA-Z0-9\.\-]+\.[a-zA-Z]{2,}$') 31 | if not pattern.match(value): 32 | raise ValueError('Address is not valid') 33 | if len(value) > 128: 34 | raise ValueError('Address cant be longer than 128 characters.') 35 | return value 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ReSTFul Web Directory 2 | The second project of my Flask Tuts Series in Persian! 3 | We are going to develop a ReSTFul Web Directory using Flask, Each episode, has it's own branch 4 | 5 | ## Episodes 6 | 0. Store and Validate Data | [Branch on Github](https://github.com/DarkSuniuM/ReST-Directory/tree/00-Store_and_Validate_Data) | [Video On YouTube](https://youtu.be/iTGYOw2obes) 7 | 0. Authentication | [Branch on Github](https://github.com/DarkSuniuM/ReST-Directory/tree/01-Authentication) | [Video On YouTube](https://youtu.be/ULht9NjvI9M) 8 | 0. Upload files | [Branch on Github](https://github.com/DarkSuniuM/ReST-Directory/tree/02-Upload_files) | [Video On YouTube](https://youtu.be/Z9vxnfUpiOM) 9 | 0. Update/Delete Data | [Branch on Github](https://github.com/DarkSuniuM/ReST-Directory/tree/03-Update_Delete_Data) | [Video On YouTube](https://youtu.be/GCRfVOqJpk4) 10 | 0. **Deploy on Fandogh | [Branch on Github](https://github.com/DarkSuniuM/ReST-Directory/tree/04-Deploy_on_Fandogh) | [Video On YouTube](https://youtu.be/wOp_eU43rcQ)** 11 | 12 | 13 | 14 | ## Setup and Run 15 | 0. Clone the repo by `$ git clone https://github.com/DarkSuniuM/ReST-Directory.git` 16 | 0. Go to cloned directory and create a virtual environment `$ python3 -m virtualenv venv` or `py -3 -m virtualenv venv` if you are using Windows! 17 | 0. Activate the virtual environment using `$ ./venv/bin/activate` or `$ .\venv\Scripts\activate.bat` if you are using Windows! 18 | 0. Install the requirements using `$ pip install -r requirements.txt` 19 | 0. Copy `.env.example` to `.env` and fill in the keys. 20 | 0. Run the migrations by `$ flask db upgrade` 21 | 0. Run the project using `$ flask run` 22 | 23 | ## The Serie 24 | **[Playlist on YouTube](https://www.youtube.com/playlist?list=PLdUn5H7OTUk1WYCrDJpNGpJ2GFWd7yZaw)** 25 | 26 | Ask your questions on the comments section in YouTube, I try to answer the ones I can! 27 | -------------------------------------------------------------------------------- /directory/apps/users_app/views.py: -------------------------------------------------------------------------------- 1 | from flask import abort, request 2 | from flask_jwt_extended import (create_access_token, create_refresh_token, 3 | get_jwt_identity, jwt_refresh_token_required, 4 | jwt_required) 5 | from sqlalchemy.exc import IntegrityError 6 | 7 | from directory import db 8 | from directory.utils.request import json_only 9 | 10 | from . import users 11 | from .models import User 12 | 13 | 14 | @users.route('/', methods=['POST']) 15 | @json_only 16 | def create_user(): 17 | args = request.get_json() 18 | 19 | try: 20 | new_user = User() 21 | new_user.username = args.get('username') 22 | new_user.password = args.get('password') 23 | db.session.add(new_user) 24 | db.session.commit() 25 | except ValueError as e: 26 | db.session.rollback() 27 | return {'error': f'{e}'}, 400 28 | except IntegrityError: 29 | db.session.rollback() 30 | return {'error': 'Username is duplicated.'}, 400 31 | 32 | return {'message': 'Account created successfully'}, 201 33 | 34 | 35 | @users.route('/auth/', methods=['POST']) 36 | @json_only 37 | def login(): 38 | args = request.get_json() 39 | 40 | username = args.get('username') 41 | password = args.get('password') 42 | 43 | user = User.query.filter(User.username.ilike(username)).first() 44 | if not user: 45 | return {'error': 'Username/Password does not match.'}, 403 46 | 47 | if not user.check_password(password): 48 | return {'error': 'Username/Password does not match.'}, 403 49 | 50 | access_token = create_access_token(identity=user.username, fresh=True, ) 51 | refresh_token = create_refresh_token(identity=user.username) 52 | 53 | return {'access_token': access_token, 'refresh_token': refresh_token}, 200 54 | 55 | 56 | @users.route('/auth/', methods=['PUT']) 57 | @jwt_refresh_token_required 58 | def get_new_access_token(): 59 | identity = get_jwt_identity() 60 | return {'access_token': create_access_token(identity=identity)} 61 | 62 | 63 | @users.route('/', methods=['GET']) 64 | @jwt_required 65 | def get_user(): 66 | identity = get_jwt_identity() 67 | user = User.query.filter(User.username.ilike(identity)).first() 68 | return {'username': user.username} 69 | 70 | 71 | @users.route('/', methods=['PATCH']) 72 | @jwt_required 73 | @json_only 74 | def modify_user(): 75 | args = request.get_json() 76 | 77 | identity = get_jwt_identity() 78 | user = User.query.filter(User.username.ilike(identity)).first() 79 | 80 | new_password = args.get('password') 81 | try: 82 | user.password = new_password 83 | db.session.commit() 84 | except ValueError as e: 85 | db.session.rollback() 86 | return {'error': f'{e}'}, 400 87 | return {}, 204 88 | -------------------------------------------------------------------------------- /migrations/env.py: -------------------------------------------------------------------------------- 1 | from __future__ import with_statement 2 | 3 | import logging 4 | from logging.config import fileConfig 5 | 6 | from sqlalchemy import engine_from_config 7 | from sqlalchemy import pool 8 | 9 | from alembic import context 10 | 11 | # this is the Alembic Config object, which provides 12 | # access to the values within the .ini file in use. 13 | config = context.config 14 | 15 | # Interpret the config file for Python logging. 16 | # This line sets up loggers basically. 17 | fileConfig(config.config_file_name) 18 | logger = logging.getLogger('alembic.env') 19 | 20 | # add your model's MetaData object here 21 | # for 'autogenerate' support 22 | # from myapp import mymodel 23 | # target_metadata = mymodel.Base.metadata 24 | from flask import current_app 25 | config.set_main_option( 26 | 'sqlalchemy.url', current_app.config.get( 27 | 'SQLALCHEMY_DATABASE_URI').replace('%', '%%')) 28 | target_metadata = current_app.extensions['migrate'].db.metadata 29 | 30 | # other values from the config, defined by the needs of env.py, 31 | # can be acquired: 32 | # my_important_option = config.get_main_option("my_important_option") 33 | # ... etc. 34 | 35 | 36 | def run_migrations_offline(): 37 | """Run migrations in 'offline' mode. 38 | 39 | This configures the context with just a URL 40 | and not an Engine, though an Engine is acceptable 41 | here as well. By skipping the Engine creation 42 | we don't even need a DBAPI to be available. 43 | 44 | Calls to context.execute() here emit the given string to the 45 | script output. 46 | 47 | """ 48 | url = config.get_main_option("sqlalchemy.url") 49 | context.configure( 50 | url=url, target_metadata=target_metadata, literal_binds=True 51 | ) 52 | 53 | with context.begin_transaction(): 54 | context.run_migrations() 55 | 56 | 57 | def run_migrations_online(): 58 | """Run migrations in 'online' mode. 59 | 60 | In this scenario we need to create an Engine 61 | and associate a connection with the context. 62 | 63 | """ 64 | 65 | # this callback is used to prevent an auto-migration from being generated 66 | # when there are no changes to the schema 67 | # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html 68 | def process_revision_directives(context, revision, directives): 69 | if getattr(config.cmd_opts, 'autogenerate', False): 70 | script = directives[0] 71 | if script.upgrade_ops.is_empty(): 72 | directives[:] = [] 73 | logger.info('No changes in schema detected.') 74 | 75 | connectable = engine_from_config( 76 | config.get_section(config.config_ini_section), 77 | prefix='sqlalchemy.', 78 | poolclass=pool.NullPool, 79 | ) 80 | 81 | with connectable.connect() as connection: 82 | context.configure( 83 | connection=connection, 84 | target_metadata=target_metadata, 85 | process_revision_directives=process_revision_directives, 86 | **current_app.extensions['migrate'].configure_args 87 | ) 88 | 89 | with context.begin_transaction(): 90 | context.run_migrations() 91 | 92 | 93 | if context.is_offline_mode(): 94 | run_migrations_offline() 95 | else: 96 | run_migrations_online() 97 | -------------------------------------------------------------------------------- /directory/apps/sites_app/views.py: -------------------------------------------------------------------------------- 1 | from flask import request, jsonify, url_for 2 | from flask_jwt_extended import jwt_required 3 | from werkzeug.utils import secure_filename 4 | 5 | from directory import db 6 | from directory.utils.request import json_only 7 | 8 | from . import sites 9 | from .models import Site 10 | 11 | 12 | @sites.route('/', methods=['POST']) 13 | @json_only 14 | @jwt_required 15 | def create_site(): 16 | args = request.get_json() 17 | try: 18 | new_site = Site() 19 | new_site.name = args.get('name') 20 | new_site.description = args.get('description') 21 | new_site.address = args.get('address') 22 | db.session.add(new_site) 23 | db.session.commit() 24 | except ValueError as e: 25 | db.session.rollback() 26 | return {'error': f'{e}'}, 400 27 | except IntegrityError: 28 | db.session.rollback() 29 | return {'error': 'Address is duplicated.'}, 400 30 | return {'message': 'Site added successfully', 'id': new_site.id}, 201 31 | 32 | 33 | @sites.route('/', methods=['GET']) 34 | def read_sites(): 35 | sites = Site.query.all() 36 | sites = [ 37 | { 38 | 'id': site.id, 39 | 'name': site.name, 40 | 'address': site.address, 41 | 'site': url_for('static', filename=site.icon, _external=True) if site.icon else None 42 | } for site in sites 43 | ] 44 | return jsonify(sites), 200 45 | 46 | 47 | @sites.route('//', methods=['GET']) 48 | def read_site(site_id): 49 | site = Site.query.get(site_id) 50 | if not site: 51 | return {'error': 'Site with given ID not found!'}, 404 52 | return {'id': site.id, 53 | 'create_date': site.create_date, 54 | 'name': site.name, 55 | 'description': site.description, 56 | 'address': site.address, 57 | 'icon': url_for('static', filename=site.icon, _external=True) if site.icon else None}, 200 58 | 59 | 60 | @sites.route('//icon', methods=['PATCH']) 61 | @jwt_required 62 | def modify_thumbnail(site_id): 63 | site = Site.query.get(site_id) 64 | if not site: 65 | return {'error': 'Site with given ID not found!'}, 404 66 | file = request.files.get('file') 67 | if not file: 68 | return {'error': 'File cant be null!'}, 400 69 | filename = secure_filename(file.filename) 70 | file.save(f'directory/static/{filename}') 71 | site.icon = filename 72 | db.session.commit() 73 | return {}, 204 74 | 75 | 76 | @sites.route('//', methods=['PATCH']) 77 | @jwt_required 78 | def modify_site(site_id): 79 | args = request.get_json() 80 | site = Site.query.get(site_id) 81 | if not site: 82 | return {'error': 'Site with given ID not found!'}, 404 83 | 84 | try: 85 | site.name = args.get('name') if args.get('name') else site.name 86 | site.description = args.get('description') if args.get('description') else site.description 87 | site.address = args.get('address') if args.get('address') else site.address 88 | db.session.commit() 89 | except ValueError as e: 90 | db.session.rollback() 91 | return {'error': f'{e}'}, 400 92 | 93 | return {}, 204 94 | 95 | 96 | @sites.route('//', methods=['DELETE']) 97 | @jwt_required 98 | def delete_site(site_id): 99 | site = Site.query.get(site_id) 100 | if not site: 101 | return {'error': 'Site with given ID not found!'}, 404 102 | 103 | db.session.delete(site) 104 | db.session.commit() 105 | return {}, 204 106 | --------------------------------------------------------------------------------