diff --git a/Project1_Simple_REST_API/Python_flask/flask_simple_restapi/app/app.py b/Project1_Simple_REST_API/Python_flask/flask_simple_restapi/app/app.py index a3cd633..81cba01 100755 --- a/Project1_Simple_REST_API/Python_flask/flask_simple_restapi/app/app.py +++ b/Project1_Simple_REST_API/Python_flask/flask_simple_restapi/app/app.py @@ -1,10 +1,12 @@ from app_init.app_factory import create_app from flask import jsonify,current_app,request -from app.models import Users -from app.serializer import UserSchema,UpdateSchema +from app.models import Users,Todo +from app.serializer import UserSchema,UpdateSchema,TodoUpdateSchema,TodoSchema +from flask_jwt_extended import jwt_required,create_access_token,get_jwt_identity,get_jwt_claims,create_refresh_token,jwt_refresh_token_required +from marshmallow import ValidationError +from app.utils import verify_secret import os import warnings -from marshmallow import ValidationError warnings.simplefilter("ignore") @@ -26,6 +28,7 @@ def create_user(): return UserSchema().jsonify(user) + @app.route("/api/v1/users/",methods=["GET"]) def get_user(id): user = Users.query.filter_by(id=id).first() @@ -44,7 +47,8 @@ def update_user(id): schema = UpdateSchema() user_info = schema.load(user_info) user = user.update(**user_info) - return UserSchema().jsonify(user) + + return UserSchema(load_only=['password']).jsonify(user) except ValidationError as err: return jsonify(err.messages) @@ -54,7 +58,7 @@ def update_user(id): @app.route("/api/v1/users",methods=["GET"]) def get_users(): users = Users.query.all() - return UserSchema().jsonify(users,many=True) + return UserSchema(load_only=['password']).jsonify(users,many=True) @app.route("/api/v1/users/",methods=["DELETE"]) @@ -62,4 +66,101 @@ def delete_user(id): user = Users.query.get(id) if user.delete(): return jsonify({"result": True}) - return jsonify({'message': "User not found"}),404 \ No newline at end of file + return jsonify({'message': "User not found"}),404 + + + +@app.route("/api/v1/users/login",methods=["POST"]) +def login_user(): + user_data = request.get_json() + user = Users.query.filter_by(email=user_data.get("email")).first() + + if not user: + return jsonify({'message': "User not found"}),404 + + if verify_secret(user_data.get("password"),user.password): + schema = UserSchema() + user = schema.dump(user) + access_token = create_access_token(identity=user.get("id")) + refresh_token = create_refresh_token(identity=user.get("id")) + user.update(access_token=access_token,refresh_token=refresh_token) + return jsonify(user) + return jsonify({'message': "User not found"}),404 + + + +@app.route("/api/v1/users/todos",methods=["POST"]) +@jwt_required +def create_todo(): + user_id = get_jwt_identity() + user = Users.query.get(user_id) + if not user: + return jsonify({'message': "User not found"}),404 + todo_data = request.get_json() + schema = TodoSchema() + todo = schema.load(todo_data) + todo.user_id = user.id + todo.save_db() + + + return TodoSchema().jsonify(todo) + + + + +@app.route("/api/v1/users/todos",methods=["GET"]) +@jwt_required +def get_todos(): + user_id = get_jwt_identity() + todos = Todo.query.filter_by(user_id= user_id).all() + return TodoSchema().jsonify(todos,many=True) + + +@app.route("/api/v1/users/todos/",methods=["GET"]) +@jwt_required +def get_todo(todo_id): + user_id = get_jwt_identity() + todos = Todo.query.filter_by(user_id = user_id,id = todo_id).first() + return TodoSchema().jsonify(todos) + + + +@app.route("/api/v1/users/todos/",methods=["PUT"]) +@jwt_required +def update_todo(todo_id): + user_id = get_jwt_identity() + todos = Todo.query.filter_by(user_id = user_id,id = todo_id).first() + if not todos: + return jsonify({'message': "Todo not found"}),404 + + todo = TodoUpdateSchema().load(request.get_json()) + todos.update(**todo) + return TodoSchema().jsonify(todos) + + +@app.route("/api/v1/users/todos/",methods=["DELETE"]) +def delete_todo(todo_id): + user_id = get_jwt_identity() + todos = Todo.query.filter_by(user_id = user_id,id = todo_id).first() + user_claims = get_jwt_claims() + + if not todos: + return jsonify({'message': "Todo not found"}),404 + + result = todos.delete() + return jsonify({'message': result}) + + + + +@app.route("/api/v1/users/token/refresh",methods=["POST"]) +@jwt_refresh_token_required +def refresh_user_token(): + identity = get_jwt_identity() + + user = Users.query.get(identity) + if not user: + return jsonify({"result": "Oops..."}),401 + + access_token = create_access_token(identity=identity) + return jsonify(access_token=access_token) \ No newline at end of file diff --git a/Project1_Simple_REST_API/Python_flask/flask_simple_restapi/app/models.py b/Project1_Simple_REST_API/Python_flask/flask_simple_restapi/app/models.py index f953fa0..309b5c3 100755 --- a/Project1_Simple_REST_API/Python_flask/flask_simple_restapi/app/models.py +++ b/Project1_Simple_REST_API/Python_flask/flask_simple_restapi/app/models.py @@ -1,8 +1,9 @@ -from extensions.extension import Model,String,Integer,Column,DateTime +from extensions.extension import Model,String,Integer,Column,DateTime,ForeignKey,relationship from extensions.extension import db from sqlalchemy.sql import func + class Users(Model): __tablename__ = "users" @@ -10,10 +11,38 @@ class Users(Model): first_name = Column(String(),nullable=False) last_name = Column(String(),nullable=False) user_name = Column(String(),nullable=False) - email = Column(String(),nullable=False) + email = Column(String(),nullable=False,unique=True) password = Column(String(),nullable=False) + todos = relationship("Todo") + created = Column(DateTime(timezone=True), default=func.now()) + updated = Column(DateTime(timezone=True), onupdate=func.now(),nullable=True) + + def save_db(self): + db.session.add(self) + db.session.commit() + return self + + def update(self,**kwargs): + for key,val in kwargs.items(): + setattr(self,key,val) + + return self.save_db() + + def delete(self): + db.session.delete(self) + db.session.commit() + + return True + +class Todo(Model): + __tablename__ = "todos" + + id = Column(Integer(),autoincrement=True,primary_key=True) + title = Column(String(),nullable=False) + description = Column(String()) created = Column(DateTime(timezone=True), default=func.now()) updated = Column(DateTime(timezone=True), onupdate=func.now(),nullable=True) + user_id = Column(Integer(),ForeignKey("users.id",use_alter=True,ondelete="SET NULL")) def save_db(self): db.session.add(self) diff --git a/Project1_Simple_REST_API/Python_flask/flask_simple_restapi/app/serializer.py b/Project1_Simple_REST_API/Python_flask/flask_simple_restapi/app/serializer.py index ed34b02..6060da5 100644 --- a/Project1_Simple_REST_API/Python_flask/flask_simple_restapi/app/serializer.py +++ b/Project1_Simple_REST_API/Python_flask/flask_simple_restapi/app/serializer.py @@ -1,5 +1,5 @@ from extensions.extension import ma,fields,validate,validates_schema -from app.models import Users +from app.models import Users,Todo from app.utils import get_secret_hash class UserSchema(ma.ModelSchema): @@ -8,7 +8,7 @@ class UserSchema(ma.ModelSchema): last_name = fields.Str(required=True,validate=[validate.Length(min=2,max=250)]) user_name = fields.Str(required=True,validate=[validate.Length(min=2,max=250)]) email = fields.Email(required=True) - password = fields.Str(required=True,validate=[validate.Regexp(r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d@$!%*?#&]{8,}$")]) + password = fields.Str(load_only=True,required=True,validate=[validate.Regexp(r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d@$!%*?#&]{8,}$")]) @validates_schema(skip_on_field_errors=True) def hash_pass(self,data,**kwargs): @@ -17,6 +17,8 @@ def hash_pass(self,data,**kwargs): class Meta: model = Users + + todos = fields.Nested('TodoSchema', many=True) class UpdateSchema(ma.Schema): @@ -34,4 +36,19 @@ def hash_pass(self,data,**kwargs): - +class TodoSchema(ma.ModelSchema): + + title = fields.Str(required=True,validate=[validate.Length(min=2,max=250)]) + description = fields.Str(validate=[validate.Length(min=2,max=250)]) + + class Meta: + model = Todo + include_fk = True + + + +class TodoUpdateSchema(ma.Schema): + + title = fields.Str(validate=[validate.Length(min=2,max=250)]) + description = fields.Str(validate=[validate.Length(min=2,max=250)]) + user_id = fields.Int() \ No newline at end of file diff --git a/Project1_Simple_REST_API/Python_flask/flask_simple_restapi/app_init/app_factory.py b/Project1_Simple_REST_API/Python_flask/flask_simple_restapi/app_init/app_factory.py index c1d670d..d98dacd 100755 --- a/Project1_Simple_REST_API/Python_flask/flask_simple_restapi/app_init/app_factory.py +++ b/Project1_Simple_REST_API/Python_flask/flask_simple_restapi/app_init/app_factory.py @@ -1,6 +1,6 @@ from flask import Flask import os, logging,sys -from extensions.extension import db,ma,migrate +from extensions.extension import db,ma,migrate,jwt from logging.config import dictConfig @@ -41,5 +41,6 @@ def create_app(settings_name): db.init_app(app) ma.init_app(app) migrate.init_app(app,db) + jwt.init_app(app) return app \ No newline at end of file diff --git a/Project1_Simple_REST_API/Python_flask/flask_simple_restapi/extensions/extension.py b/Project1_Simple_REST_API/Python_flask/flask_simple_restapi/extensions/extension.py index c0110f8..fd40d9a 100755 --- a/Project1_Simple_REST_API/Python_flask/flask_simple_restapi/extensions/extension.py +++ b/Project1_Simple_REST_API/Python_flask/flask_simple_restapi/extensions/extension.py @@ -3,10 +3,12 @@ from marshmallow import validate,fields,validates_schema from flask_migrate import Migrate from passlib.context import CryptContext +from flask_jwt_extended import JWTManager pwd_context = CryptContext(schemes="sha256_crypt") db = SQLAlchemy() ma = Marshmallow() migrate = Migrate() +jwt = JWTManager() -Model,Column,String,Integer,DateTime = db.Model,db.Column,db.String,db.Integer,db.DateTime \ No newline at end of file +Model,Column,String,Integer,DateTime,ForeignKey,relationship = db.Model,db.Column,db.String,db.Integer,db.DateTime,db.ForeignKey,db.relationship \ No newline at end of file diff --git a/Project1_Simple_REST_API/Python_flask/flask_simple_restapi/migrations/README b/Project1_Simple_REST_API/Python_flask/flask_simple_restapi/migrations/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/Project1_Simple_REST_API/Python_flask/flask_simple_restapi/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/Project1_Simple_REST_API/Python_flask/flask_simple_restapi/migrations/alembic.ini b/Project1_Simple_REST_API/Python_flask/flask_simple_restapi/migrations/alembic.ini new file mode 100644 index 0000000..f8ed480 --- /dev/null +++ b/Project1_Simple_REST_API/Python_flask/flask_simple_restapi/migrations/alembic.ini @@ -0,0 +1,45 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/Project1_Simple_REST_API/Python_flask/flask_simple_restapi/migrations/env.py b/Project1_Simple_REST_API/Python_flask/flask_simple_restapi/migrations/env.py new file mode 100644 index 0000000..9452179 --- /dev/null +++ b/Project1_Simple_REST_API/Python_flask/flask_simple_restapi/migrations/env.py @@ -0,0 +1,96 @@ +from __future__ import with_statement + +import logging +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +from flask import current_app +config.set_main_option( + 'sqlalchemy.url', + str(current_app.extensions['migrate'].db.engine.url).replace('%', '%%')) +target_metadata = current_app.extensions['migrate'].db.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, target_metadata=target_metadata, literal_binds=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + connectable = engine_from_config( + config.get_section(config.config_ini_section), + prefix='sqlalchemy.', + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + process_revision_directives=process_revision_directives, + **current_app.extensions['migrate'].configure_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/Project1_Simple_REST_API/Python_flask/flask_simple_restapi/migrations/script.py.mako b/Project1_Simple_REST_API/Python_flask/flask_simple_restapi/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/Project1_Simple_REST_API/Python_flask/flask_simple_restapi/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/Project1_Simple_REST_API/Python_flask/flask_simple_restapi/migrations/versions/3aeec46ace04_.py b/Project1_Simple_REST_API/Python_flask/flask_simple_restapi/migrations/versions/3aeec46ace04_.py new file mode 100644 index 0000000..11f8674 --- /dev/null +++ b/Project1_Simple_REST_API/Python_flask/flask_simple_restapi/migrations/versions/3aeec46ace04_.py @@ -0,0 +1,37 @@ +"""empty message + +Revision ID: 3aeec46ace04 +Revises: b8c5a49bf5d7 +Create Date: 2020-05-10 16:10:08.884465 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '3aeec46ace04' +down_revision = 'b8c5a49bf5d7' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('todos', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('title', sa.String(), nullable=False), + sa.Column('description', sa.String(), nullable=True), + sa.Column('created', sa.DateTime(timezone=True), nullable=True), + sa.Column('updated', sa.DateTime(timezone=True), nullable=True), + sa.Column('user_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='SET NULL', use_alter=True), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('todos') + # ### end Alembic commands ### diff --git a/Project1_Simple_REST_API/Python_flask/flask_simple_restapi/migrations/versions/89692811365a_.py b/Project1_Simple_REST_API/Python_flask/flask_simple_restapi/migrations/versions/89692811365a_.py new file mode 100644 index 0000000..c7093af --- /dev/null +++ b/Project1_Simple_REST_API/Python_flask/flask_simple_restapi/migrations/versions/89692811365a_.py @@ -0,0 +1,38 @@ +"""empty message + +Revision ID: 89692811365a +Revises: +Create Date: 2020-05-10 12:10:33.658858 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '89692811365a' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('users', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('first_name', sa.String(), nullable=False), + sa.Column('last_name', sa.String(), nullable=False), + sa.Column('user_name', sa.String(), nullable=False), + sa.Column('email', sa.String(), nullable=False), + sa.Column('password', sa.String(), nullable=False), + sa.Column('created', sa.DateTime(timezone=True), nullable=True), + sa.Column('updated', sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('users') + # ### end Alembic commands ### diff --git a/Project1_Simple_REST_API/Python_flask/flask_simple_restapi/migrations/versions/b8c5a49bf5d7_.py b/Project1_Simple_REST_API/Python_flask/flask_simple_restapi/migrations/versions/b8c5a49bf5d7_.py new file mode 100644 index 0000000..1723bc6 --- /dev/null +++ b/Project1_Simple_REST_API/Python_flask/flask_simple_restapi/migrations/versions/b8c5a49bf5d7_.py @@ -0,0 +1,28 @@ +"""empty message + +Revision ID: b8c5a49bf5d7 +Revises: 89692811365a +Create Date: 2020-05-10 12:17:05.913350 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'b8c5a49bf5d7' +down_revision = '89692811365a' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_unique_constraint(None, 'users', ['email']) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'users', type_='unique') + # ### end Alembic commands ### diff --git a/Project1_Simple_REST_API/Python_flask/flask_simple_restapi/settings/devsettings.py b/Project1_Simple_REST_API/Python_flask/flask_simple_restapi/settings/devsettings.py index c90723c..9b4790f 100755 --- a/Project1_Simple_REST_API/Python_flask/flask_simple_restapi/settings/devsettings.py +++ b/Project1_Simple_REST_API/Python_flask/flask_simple_restapi/settings/devsettings.py @@ -1,4 +1,5 @@ from settings.settings import BaseSettings +from datetime import timedelta import os class DevelopSettings(BaseSettings): @@ -6,3 +7,5 @@ class DevelopSettings(BaseSettings): DEBUG=True ENV="development" SQLALCHEMY_DATABASE_URI = f"postgresql:///testdb" + JWT_SECRET_KEY = os.urandom(32) + JWT_ACCESS_TOKEN_EXPIRES = timedelta(minutes=3)