diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4756ef5 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +MAILGUN_DOMAIN= +MAILGUN_API_KEY= \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..178d4d0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +test/test.py +test/test.db +__pycache__ +.vscode +.idea +/.env +database/data.db +resources/__pycache__ +model/__pycache__ \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/PythonRESTapi.iml b/.idea/PythonRESTapi.iml new file mode 100644 index 0000000..e469c68 --- /dev/null +++ b/.idea/PythonRESTapi.iml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..e7ffb93 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..bb14381 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..2c6c77b --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "python.pythonPath": "/usr/sbin/python3.9", + "python.formatting.provider": "black" +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..7eca575 --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +# REST API application made with flask. + +Contains <> branches + +Final and stable application is in the master branch + +NOTE: Use a virtual environment with python 3.9 to run the application + +## INSATLLATION + +Run the following command to install dependencies: + +``` +pip install -r requirements.txt +``` + +## RUN + +Run the following command to start the application: + +``` +python app.py +``` + +## IMPORTANT + +Make sure to delete the `data.db` file from `database` directory before fresh run. diff --git a/__pycache__/blacklist.cpython-39.pyc b/__pycache__/blacklist.cpython-39.pyc new file mode 100644 index 0000000..1a3d504 Binary files /dev/null and b/__pycache__/blacklist.cpython-39.pyc differ diff --git a/__pycache__/database.cpython-39.pyc b/__pycache__/database.cpython-39.pyc new file mode 100644 index 0000000..eeefcfc Binary files /dev/null and b/__pycache__/database.cpython-39.pyc differ diff --git a/__pycache__/item.cpython-39.pyc b/__pycache__/item.cpython-39.pyc new file mode 100644 index 0000000..19f4cec Binary files /dev/null and b/__pycache__/item.cpython-39.pyc differ diff --git a/__pycache__/security.cpython-39.pyc b/__pycache__/security.cpython-39.pyc new file mode 100644 index 0000000..0289371 Binary files /dev/null and b/__pycache__/security.cpython-39.pyc differ diff --git a/__pycache__/user.cpython-39.pyc b/__pycache__/user.cpython-39.pyc new file mode 100644 index 0000000..5c46dce Binary files /dev/null and b/__pycache__/user.cpython-39.pyc differ diff --git a/app.py b/app.py index 5fbfd70..b2fa87b 100644 --- a/app.py +++ b/app.py @@ -1,10 +1,138 @@ -from flask import Flask -# creating an app from Flask class -app = Flask(__name__) -# letting the application know what requests it will understand -@app.route('/') -def home(): - return "Hello World!" - -# run the app in specified folder -app.run(port=5000) \ No newline at end of file +from flask import Flask, jsonify +from flask_restful import Api +from flask_jwt_extended import JWTManager +from marshmallow import ValidationError + +from ma import ma +from resources.user import ( + UserRegister, + User, + UserLogin, + UserLogout, + TokenRefresh, +) +from resources.item import Item, ItemList +from resources.store import Store, StoreList +from resources.confirmation import Confirmation, ConfirmationByUser +from blacklist import BLACKLIST +from database import db + +app = Flask(__name__) + +app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///database/data.db" + +app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False # turns of flask_sqlalchemy modification tracker +app.config["PROPAGATE_EXCEPTIONS"] = True # if flask_jwt raises an error, the flask app will check the error if this is set to true +app.config["JWT_BLACKLIST_ENABLED"] = True +app.config["JWT_BLACKLIST_TOKEN_CHECKS"] = [ + "access", + "refresh", +] # both access and refresh tokens will be denied for the user ids + +app.secret_key = "komraishumtirkomchuri" +# app.config["JWT_SECRET_KEY"] = "YOUR KEY HERE" + +api = Api(app) + + +@app.errorhandler(ValidationError) +def handle_marshmallow_validation(err): + return jsonify(err.messages), 400 + + +@app.before_first_request +def create_tables(): + db.create_all() + # above function creates all the tables before the 1st request is made + # unless they exist already + + +# JWT() creates a new endpoint: /auth +# we send username and password to /auth +# JWT() gets the username and password, and sends it to authenticate function +# the authenticate function maps the username and checks the password +# if all goes well, the authenticate function returns user +# which is the identity or jwt(or token) +# jwt = JWT(app, authenticate, identity) +jwt = JWTManager(app) # JwtManager links up to the application, doesn't create /auth point + + +# below function returns True, if the token that is sent is in the blacklist +@jwt.token_in_blocklist_loader +def check_if_token_in_blacklist(jwt_header, jwt_data): + print("Log Message:", jwt_data) + return jwt_data["jti"] in BLACKLIST + + +@jwt.additional_claims_loader # modifies the below function, and links it with JWTManager, which in turn is linked with our app +def add_claims_to_jwt(identity): + if identity == 1: # instead of hard-coding this, we should read it from a config file or database + return {"is_admin": True} + + return {"is_admin": False} + + +# JWT Configurations +@jwt.expired_token_loader +def expired_token_callback(): + return ( + jsonify({"description": "The token has expired.", "error": "token_expired"}), + 401, + ) + + +@jwt.invalid_token_loader +def invalid_token_callback(error): + return ( + jsonify({"description": "Signature verification failed.", "error": "invalid_token"}), + 401, + ) + + +@jwt.unauthorized_loader +def missing_token_callback(error): # when no jwt is sent + return ( + jsonify( + { + "description": "Request doesn't contain a access token.", + "error": "authorization_required", + } + ), + 401, + ) + + +@jwt.needs_fresh_token_loader +def token_not_fresh_callback(self, callback): + # print("Log:", callback) + return ( + jsonify({"description": "The token is not fresh.", "error": "fresh_token_required"}), + 401, + ) + + +@jwt.revoked_token_loader +def revoked_token_callback(self, callback): + # print("Log:", callback) + return ( + jsonify({"description": "The token has been revoked.", "error": "token_revoked"}), + 401, + ) + + +api.add_resource(Item, "/item/") +api.add_resource(Store, "/store/") +api.add_resource(ItemList, "/items") +api.add_resource(StoreList, "/stores") +api.add_resource(UserRegister, "/register") +api.add_resource(Confirmation, "/user_confirm/") +api.add_resource(ConfirmationByUser, "/confirmation/user/") +api.add_resource(User, "/user/") +api.add_resource(UserLogin, "/login") +api.add_resource(UserLogout, "/logout") +api.add_resource(TokenRefresh, "/refresh") + +if __name__ == "__main__": + db.init_app(app) + ma.init_app(app) # tells marshmallow that it should be communicating with this flask app + app.run(port=5000, debug=True) diff --git a/blacklist.py b/blacklist.py new file mode 100644 index 0000000..ec72982 --- /dev/null +++ b/blacklist.py @@ -0,0 +1,2 @@ +BLACKLIST = set() # user ids that will be denied access + diff --git a/database.py b/database.py new file mode 100644 index 0000000..d43f174 --- /dev/null +++ b/database.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/libs/__init__.py b/libs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/libs/mailgun.py b/libs/mailgun.py new file mode 100644 index 0000000..5ec6ea6 --- /dev/null +++ b/libs/mailgun.py @@ -0,0 +1,51 @@ +import os +from typing import List +from requests import Response, post + + +FAILED_LOAD_API_KEY = "Falied to load Mailgun API key." +FAILED_LOAD_DOMAIN_NAME = "Failde to load Mailgun domain." +FAILED_SEND_CONFIRMATION_MAIL = "Error in sending confirmation email, user registration failed." + + +class MailgunException(Exception): + def __init__(self, message: str): + super().__init__(message) + + +class Mailgun: + + MAILGUN_DOMAIN = os.environ.get("MAILGUN_DOMAIN") # can be None + MAILGUN_API_KEY = os.environ.get("MAILGUN_API_KEY") # can be None + FROM_TITLE = "Stores RestAPI" + # FROM_EMAIL = "Your Mailgun Email" + FROM_EMAIL = "postmaster@sandboxb3ef8f2c0e20406cb3f834bc39735ab4.mailgun.org" + + # This method will interact with Mailgun API and return the response sent + @classmethod + def send_email(cls, email: List[str], subject: str, text: str, html: str) -> Response: + if cls.MAILGUN_API_KEY is None: + raise MailgunException(FAILED_LOAD_API_KEY) + + if cls.MAILGUN_DOMAIN is None: + raise MailgunException(FAILED_LOAD_DOMAIN_NAME) + + response = post( + f"https://api.mailgun.net/v3/{cls.MAILGUN_DOMAIN}/messages", + auth=( + "api", + cls.MAILGUN_API_KEY, + ), + data={ + "from": f"{cls.FROM_TITLE} <{cls.FROM_EMAIL}>", + "to": email, + "subject": subject, + "text": text, + "html": html, + }, + ) + + if response.status_code != 200: + raise MailgunException(FAILED_SEND_CONFIRMATION_MAIL) + + return response diff --git a/ma.py b/ma.py new file mode 100644 index 0000000..cf7687a --- /dev/null +++ b/ma.py @@ -0,0 +1,3 @@ +from flask_marshmallow import Marshmallow + +ma = Marshmallow() diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/models/__pycache__/__init__.cpython-39.pyc b/models/__pycache__/__init__.cpython-39.pyc new file mode 100644 index 0000000..9c283e0 Binary files /dev/null and b/models/__pycache__/__init__.cpython-39.pyc differ diff --git a/models/__pycache__/item.cpython-39.pyc b/models/__pycache__/item.cpython-39.pyc new file mode 100644 index 0000000..a85569a Binary files /dev/null and b/models/__pycache__/item.cpython-39.pyc differ diff --git a/models/__pycache__/store.cpython-39.pyc b/models/__pycache__/store.cpython-39.pyc new file mode 100644 index 0000000..92a3462 Binary files /dev/null and b/models/__pycache__/store.cpython-39.pyc differ diff --git a/models/__pycache__/user.cpython-39.pyc b/models/__pycache__/user.cpython-39.pyc new file mode 100644 index 0000000..e795248 Binary files /dev/null and b/models/__pycache__/user.cpython-39.pyc differ diff --git a/models/confirmation.py b/models/confirmation.py new file mode 100644 index 0000000..941cd5b --- /dev/null +++ b/models/confirmation.py @@ -0,0 +1,48 @@ +from uuid import uuid4 +from time import time + +from database import db + + +CONFIRMATION_EXPIRATION_DELTA = 1800 # 30minutes + + +class ConfirmationModel(db.Model): + __tablename__ = "confirmations" + + id = db.Column(db.String(50), primary_key=True) + expire_at = db.Column(db.Integer, nullable=False) + confirmed = db.Column(db.Boolean, nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + user = db.relationship("UserModel") + + def __init__(self, user_id: int, **kwargs): + super().__init__(**kwargs) + self.user_id = user_id + self.id = uuid4().hex + self.expire_at = int(time()) + CONFIRMATION_EXPIRATION_DELTA # current time + 30 minutes + self.confirmed = False + + @classmethod + def find_by_id(cls, _id: str) -> "ConfirmationModel": + return cls.query.filter_by(id=_id).first() + + def save_to_database(self) -> None: + db.session.add(self) + db.session.commit() + + def delete_from_database(self) -> None: + db.session.delete(self) + db.session.commit() + + # To check id confirmation has expired + # When a method only returns the status/value of property, it must be given @property decorator + @property + def has_expired(self) -> bool: + return time() > self.expire_at + + # Forcefully expire the confirmation at current time + def force_to_expire(self) -> None: + if not self.has_expired: # has_expired() method is given a propeerty decorator, so can be called as an object + self.expire_at = int(time()) + self.save_to_database() diff --git a/models/item.py b/models/item.py new file mode 100644 index 0000000..0a137cd --- /dev/null +++ b/models/item.py @@ -0,0 +1,36 @@ +from typing import List + +from database import db + + +class ItemModel(db.Model): # tells SQLAlchemy that it is something that will be saved to database and will be retrieved from database + + __tablename__ = "items" + + # Columns + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), nullable=False, unique=True) + price = db.Column(db.Float(precision=2), nullable=False) # precision: numbers after decimal point + + store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) + store = db.relationship("StoreModel", back_populates="items") + + # searches the database for items using name + @classmethod + def find_item_by_name(cls, name: str) -> "ItemModel": + # return cls.query.filter_by(name=name) # SELECT name FROM __tablename__ WHERE name=name + # this function would return a ItemModel object + return cls.query.filter_by(name=name).first() # SELECT name FROM __tablename__ WHERE name=name LIMIT 1 + + @classmethod + def find_all(cls) -> List["ItemModel"]: + return cls.query.all() + + # method to insert or update an item into database + def save_to_database(self) -> None: + db.session.add(self) # session here is a collection of objects that wil be written to database + db.session.commit() + + def delete_from_database(self) -> None: + db.session.delete(self) + db.session.commit() diff --git a/models/store.py b/models/store.py new file mode 100644 index 0000000..deae614 --- /dev/null +++ b/models/store.py @@ -0,0 +1,35 @@ +from typing import List + +from database import db + + +class StoreModel(db.Model): # tells SQLAlchemy that it is something that will be saved to database and will be retrieved from database + + __tablename__ = "stores" + + # Columns + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), nullable=False, unique=True) + + items = db.relationship("ItemModel", lazy="dynamic") # this will allow us to check which item is in store whose id is equal to it's id. + # lazy="dynamic" tells sqlalchemy to not create seperate objects for each item that is created + + # searches the database for items using name + @classmethod + def find_store_by_name(cls, name: str) -> "StoreModel": + # return cls.query.filter_by(name=name) # SELECT name FROM __tablename__ WHERE name=name + # this function would return a StoreModel object + return cls.query.filter_by(name=name).first() # SELECT name FROM __tablename__ WHERE name=name LIMIT 1 + + @classmethod + def find_all(cls) -> List["StoreModel"]: + return cls.query.all() + + # method to insert or update an item into database + def save_to_database(self) -> None: + db.session.add(self) # session here is a collection of objects that wil be written to database + db.session.commit() + + def delete_from_database(self) -> None: + db.session.delete(self) + db.session.commit() diff --git a/models/user.py b/models/user.py new file mode 100644 index 0000000..e6049ab --- /dev/null +++ b/models/user.py @@ -0,0 +1,62 @@ +from requests import Response +from flask import request, url_for + +from database import db +from libs.mailgun import Mailgun +from models.confirmation import ConfirmationModel + + +class UserModel(db.Model): + + __tablename__ = "users" # will be used to tell sqlalchemy the table name for users + + # table columns for users table + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), nullable=False, unique=True) + email = db.Column(db.String(80), nullable=False, unique=True) + password = db.Column(db.String(80), nullable=False) + + confirmation = db.relationship( + "ConfirmationModel", + lazy="dynamic", + cascade="all, delete-orphan", + overlaps="user", + ) + # lazy=dynamic means that when we create a new UserModel, confirmation is not retrieved from the db, + # When we access the confirmation, then it it is retrieved from detabase + + @property + def most_recent_confirmation(self) -> "ConfirmationModel": + return self.confirmation.order_by(db.desc(ConfirmationModel.expire_at)).first() + + @classmethod + def find_by_username(cls, username: str) -> "UserModel": + return cls.query.filter_by(username=username).first() + + @classmethod + def find_by_id(cls, _id: int) -> "UserModel": + return cls.query.filter_by(id=_id).first() + + @classmethod + def find_by_email(cls, email: str) -> "UserModel": + return cls.query.filter_by(email=email).first() + + # This method will interact with Mailgun API and return the response sent + def send_confirmation_email(self) -> Response: + # http://127.0.0.1:5000 - is the 'url_root' + # url_for("userconfirm") - this must mathch the name of user confirmation endpoint + link = request.url_root[:-1] + url_for("confirmation", confirmation_id=self.most_recent_confirmation.id) + + subject = "CONFIRM REGISTRATION" + text = f"Click the link to confirm ragistration: {link}" + html = f'Click the link to confirm registration: {link}' + + return Mailgun.send_email([self.email], subject, text, html) + + def save_to_database(self) -> None: + db.session.add(self) + db.session.commit() + + def delete_from_database(self) -> None: + db.session.delete(self) + db.session.commit() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..40cb649 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +Flask +Flask-RESTful +Flask_-JWT-Extended +Flask-SQLAlchemy +Marshmallow +Flask-Marshmallow +Marshmallow-SQLAlchemy +Python-Dotenv \ No newline at end of file diff --git a/resources/__init__.py b/resources/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/resources/__pycache__/__init__.cpython-39.pyc b/resources/__pycache__/__init__.cpython-39.pyc new file mode 100644 index 0000000..a900378 Binary files /dev/null and b/resources/__pycache__/__init__.cpython-39.pyc differ diff --git a/resources/__pycache__/item.cpython-39.pyc b/resources/__pycache__/item.cpython-39.pyc new file mode 100644 index 0000000..d2d434f Binary files /dev/null and b/resources/__pycache__/item.cpython-39.pyc differ diff --git a/resources/__pycache__/store.cpython-39.pyc b/resources/__pycache__/store.cpython-39.pyc new file mode 100644 index 0000000..d409f59 Binary files /dev/null and b/resources/__pycache__/store.cpython-39.pyc differ diff --git a/resources/__pycache__/user.cpython-39.pyc b/resources/__pycache__/user.cpython-39.pyc new file mode 100644 index 0000000..8ce997a Binary files /dev/null and b/resources/__pycache__/user.cpython-39.pyc differ diff --git a/resources/confirmation.py b/resources/confirmation.py new file mode 100644 index 0000000..be44960 --- /dev/null +++ b/resources/confirmation.py @@ -0,0 +1,82 @@ +from time import time +import traceback +from flask_restful import Resource +from flask import make_response, render_template + +from models.confirmation import ConfirmationModel +from models.user import UserModel +from schemas.confirmation import ConfirmationSchema +from libs.mailgun import MailgunException + +confirmation_schema = ConfirmationSchema() +NOT_FOUND = "Confirmation referrence not found" +EXPIRED = "Confirmation link has expired." +ALREADY_CONFIRMED = "Registration has already been confirmed." +RESEND_FAIL = "Failed to resend confirmation mail." +RESEND_SUCCESSFUL = "Confirmation mail re-send successful" + + +class Confirmation(Resource): + # Returns confirmation HTML page + @classmethod + def get(cls, confirmation_id: str): + confirmation = ConfirmationModel.find_by_id(confirmation_id) + if not confirmation: + return {"message": NOT_FOUND}, 404 + + if confirmation.has_expired: + return {"message": EXPIRED}, 400 + + if confirmation.confirmed: + return {"message": ALREADY_CONFIRMED}, 400 + + confirmation.confirmed = True + confirmation.save_to_database() + + headers = {"Content-Type": "text/html"} + return make_response( + render_template("confirmation_page.html", email=confirmation.user.email), + 200, + headers, + ) + + +class ConfirmationByUser(Resource): + # Return confirmations for given user. Only for test purpose + @classmethod + def get(cls, user_id: int): + user = UserModel.find_by_id(user_id) + if not user: + return {"message": "User not found."}, 404 + + return { + "current_time": int(time()), + "confirmation": [confirmation_schema.dump(each) for each in user.confirmation.order_by(ConfirmationModel.expire_at)], + }, 200 + + # Resend confirmation email + @classmethod + def post(cls, user_id: int): + user = UserModel.find_by_id(user_id) + if not user: + return {"message": "User not found."}, 404 + + try: + confirmation = user.most_recent_confirmation + if confirmation: + if confirmation.confirmed: + return {"message": ALREADY_CONFIRMED}, 400 + + confirmation.force_to_expire() + + new_confirmation = ConfirmationModel(user_id) + new_confirmation.save_to_database() + user.send_confirmation_email() + + return {"message": RESEND_SUCCESSFUL}, 201 + + except MailgunException as err: + return {"message": str(err)}, 500 + except: + traceback.print_exc() + return {"message": RESEND_FAIL}, 500 diff --git a/resources/item.py b/resources/item.py new file mode 100644 index 0000000..14f1c48 --- /dev/null +++ b/resources/item.py @@ -0,0 +1,110 @@ +from typing import Dict +from flask import request +from flask_restful import Resource +from flask_jwt_extended import ( + jwt_required, + get_jwt, + get_jwt_identity, +) + +from models.item import ItemModel +from schemas.item import ItemSchema + +BLANK_ERROR = "'{}' cannot be blank" +ITEM_NOT_FOUND = "item not found." +NAME_ALREADY_EXISTS = "item '{}' already exists" +ERROR_INSERTING = "An error occured while inserting the item." +ITEM_DELETED = "Item deleted" + +item_schema = ItemSchema() +item_list_schema = ItemSchema(many=True) + + +class Item(Resource): + + # TO GET ITEM WITH NAME + @classmethod + @jwt_required() + def get(cls, name: str) -> Dict: + item = ItemModel.find_item_by_name(name) + if item: + return item_schema.dump(item), 200 + + return {"message": ITEM_NOT_FOUND}, 404 + + # TO POST AN ITEM + @classmethod + @jwt_required(fresh=True) + def post(cls, name: str): + # if there already exists an item with "name", show a messege, and donot add the item + if ItemModel.find_item_by_name(name): + return {"messege": NAME_ALREADY_EXISTS.format(name)}, 400 + + item_json = request.get_json() + item_json["name"] = name # this part is necessary to populate the payload with item name + + item = item_schema.load(item_json) + + # TODO: put these in above try except + try: + item.save_to_database() + except: + return {"messege": ERROR_INSERTING}, 500 + + return item_schema.dump(item), 201 # 201 is for CREATED status + + # TO DELETE AN ITEM + @classmethod + @jwt_required() + def delete(cls, name: str): + claims = get_jwt() + + if not claims["is_admin"]: + return {"message": "Admin privilages required"}, 401 + + item = ItemModel.find_item_by_name(name) + if item: + item.delete_from_database() + return {"messege": ITEM_DELETED} + + # if doesn't exist, skip deleting + return {"messege": ITEM_NOT_FOUND}, 400 + + # TO ADD OR UPDATE AN ITEM + @classmethod + def put(cls, name: str): + item_json = request.get_json() + + item = ItemModel.find_item_by_name(name) + + # if item is not available, add it + if item is None: + item_json["name"] = name + item = item_schema.load(item_json) + + # if item exists, update it + else: + item.price = item_json["price"] + item.store_id = item_json["store_id"] + + # whether item is changed or inserted, it has to be saved to db + item.save_to_database() + return item_schema.dump(item), 200 + + +class ItemList(Resource): + @classmethod + @jwt_required(optional=True) + def get(cls): + user_id = get_jwt_identity() + items = [item_list_schema.dump(ItemModel.find_all())] + + # if user id is given, then display full details + if user_id: + return {"items": items}, 200 + + # else display only item name + return { + "items": [item["name"] for item in items], + "message": "Login to view more data.", + }, 200 diff --git a/resources/store.py b/resources/store.py new file mode 100644 index 0000000..5474e42 --- /dev/null +++ b/resources/store.py @@ -0,0 +1,56 @@ +from flask import request +from flask_restful import Resource + +from schemas.store import StoreSchema +from models.store import StoreModel + +NAME_ALREADY_EXISTS = "Store alrady exists." +ERROR_INSERTING = "An error occured while creating the store." +STORE_NOT_FOUND = "Store not found." +STORE_DELETED = "Store deleted." + + +store_schema = StoreSchema() +store_list_schema = StoreSchema(many=True) + + +class Store(Resource): + @classmethod + def get(cls, name: str): + store = StoreModel.find_store_by_name(name) + if store: + return store_schema.dump(store), 200 + + return {"message": STORE_NOT_FOUND}, 404 + + @classmethod + def post(cls, name: str): + if StoreModel.find_store_by_name(name): + return {"message": NAME_ALREADY_EXISTS}, 400 + + store = StoreModel(name=name) # since __init__() method was removed from StoreModel, the name needs to be passed manually as such + # as StoreModel is a model subclass, model class acceps keyword arguements and maps them to columns + # so here, passing name as a keyword argument will map to name key in store model + + try: + store.save_to_database() + except: + return {"message": ERROR_INSERTING}, 500 + + return store_schema.dump(store), 201 + + @classmethod + def delete(cls, name: str): + store = StoreModel.find_store_by_name(name) + if store: + store.delete_from_database() + return {"message": STORE_DELETED} + + return {"message": STORE_NOT_FOUND} + + +class StoreList(Resource): + @classmethod + def get(cls): + # return {"item": list(map(lambda x: x.json(), ItemModel.query.all()))} + return {"stores": [store_list_schema.dump(StoreModel.find_all())]}, 200 diff --git a/resources/user.py b/resources/user.py new file mode 100644 index 0000000..025ae9f --- /dev/null +++ b/resources/user.py @@ -0,0 +1,138 @@ +from flask import request +from flask_restful import Resource +from marshmallow import ValidationError +from werkzeug.security import safe_str_cmp +from flask_jwt_extended import ( + create_access_token, + create_refresh_token, + jwt_required, + get_jwt_identity, + get_jwt, +) +import traceback +from flask import request + + +from models.user import UserModel +from schemas.user import UserSchema +from blacklist import BLACKLIST +from libs.mailgun import MailgunException +from models.confirmation import ConfirmationModel + +user_schema = UserSchema() + +USER_ALREADY_EXISTS = "A user with that username already exists." +EMAIL_ALREADY_EXISTS = "A user with that email already exists." +USER_NOT_FOUND = "User not found." +USER_DELETED = "User deleted." +INVALID_CREDENTIALS = "Invalid credentials!" +USER_LOGGED_OUT = "User successfully logged out." +NOT_CONFIRMED_ERROR = "You have not confirmed registration, please check your email <{}>." +FAILED_TO_CREATE = "Internal server error. Failed to create user." +SUCCESS_REGISTER_MESSAGE = "Account created successfully, an email with an activation link has been sent to your email address, please check." + + +# New user registration class +class UserRegister(Resource): + + # calls to post a new user (new user registration) + @classmethod + def post(cls): + user = user_schema.load(request.get_json()) + + # First check if that user is present or not + if UserModel.find_by_username(user.username): + # if exists, then don't add + return {"message": USER_ALREADY_EXISTS}, 400 + if UserModel.find_by_email(user.email): + # if exists, then don't add + return {"message": EMAIL_ALREADY_EXISTS}, 400 + + # user = UserModel(data["username"], data["password"]) + # user = UserModel(**user_data) # since parser only takes in username and password, only those two will be added. + # flask_marshmallow already creates a user model, so we need not do it manually + try: + user.save_to_database() + confirmation = ConfirmationModel(user.id) + confirmation.save_to_database() + user.send_confirmation_email() + return { + "messege": SUCCESS_REGISTER_MESSAGE, + }, 201 + # Delete user from database in case of any Mailgun error + except MailgunException as e: + user.delete_from_database() + return {"message", str(e)}, 500 + except: + # print(err.messages) + traceback.print_exc() + user.delete_from_database() + return {"message": FAILED_TO_CREATE} + + +class User(Resource): + @classmethod + def get(cls, user_id: int): + + user = UserModel.find_by_id(user_id) + if not user: + return {"message": USER_NOT_FOUND}, 404 + + return user_schema.dump(user), 200 + + @classmethod + def delete(cls, user_id: int): + user = UserModel.find_by_id(user_id) + if not user: + return {"message": USER_NOT_FOUND}, 404 + + user.delete_from_database() + return {"message": USER_DELETED}, 200 + + +class UserLogin(Resource): + @classmethod + def post(cls): + # get data from user to login. Include email to optional field. + user_data = user_schema.load(request.get_json(), partial=("email",)) + + # find user in database + user = UserModel.find_by_username(user_data.username) + + # check password + # this here is what authenticate() function used to do + if user and safe_str_cmp(user.password, user_data.password): + confirmation = user.most_recent_confirmation + print("user resource: ", confirmation.id) + # Check if user is activated + if confirmation and confirmation.confirmed: + # create access and refresh tokens + access_token = create_access_token(identity=user.id, fresh=True) # here, identity=user.id is what identity() used to do previously + refresh_token = create_refresh_token(identity=user.id) + # print("user logged in") + + return {"access_token": access_token, "refresh_token": refresh_token}, 200 + # If user is not activated + return {"message": NOT_CONFIRMED_ERROR} + + return {"message": INVALID_CREDENTIALS}, 401 # Unauthorized + + +class UserLogout(Resource): + # Loggig out requirees jwt as if user is not logged in they cannot log out + @classmethod + @jwt_required() + def post(cls): + jti = get_jwt()["jti"] # jti is JWT ID, unique identifier for a JWT + BLACKLIST.add(jti) + return {"message": USER_LOGGED_OUT.format(jti)}, 200 + + +class TokenRefresh(Resource): + @classmethod + @jwt_required(refresh=True) + def post(cls): + current_user = get_jwt_identity() + new_token = create_access_token(identity=current_user, fresh=False) # fresh=Flase means that user have logged in days ago. + + return {"access_token": new_token}, 200 diff --git a/schemas/confirmation.py b/schemas/confirmation.py new file mode 100644 index 0000000..a417a67 --- /dev/null +++ b/schemas/confirmation.py @@ -0,0 +1,11 @@ +from ma import ma +from models.confirmation import ConfirmationModel + + +class ConfirmationSchema(ma.SQLAlchemyAutoSchema): + class Meta: + model = ConfirmationModel + load_instance = True + load_only = ("user",) + dump_only = ("id", "expired_at", "confirmed") + include_fk = True diff --git a/schemas/item.py b/schemas/item.py new file mode 100644 index 0000000..8df7cc0 --- /dev/null +++ b/schemas/item.py @@ -0,0 +1,16 @@ +from xml.etree.ElementInclude import include +from ma import ma +from models.item import ItemModel +from models.store import StoreModel + + +class ItemSchema(ma.SQLAlchemyAutoSchema): + # If the below Meta class is excluded, while fetching user information, we also fetch + # the user password. So, password is included in the load_only tuple so that password field + # is only loaded and not displayed. + class Meta: + model = ItemModel + load_instance = True + load_only = ("store",) # makes 'store' field load_only. store_id will be displayed + dump_only = ("id",) # makes 'id' field dump_only. + include_fk = True diff --git a/schemas/store.py b/schemas/store.py new file mode 100644 index 0000000..a6a7444 --- /dev/null +++ b/schemas/store.py @@ -0,0 +1,14 @@ +from ma import ma +from models.store import StoreModel +from models.item import ItemModel +from schemas.item import ItemSchema + + +class StoreSchema(ma.SQLAlchemyAutoSchema): + items = ma.Nested(ItemSchema, many=True) + + class Meta: + model = StoreModel + load_instance = True + dump_only = ("id",) # makes 'id' field dump_only. + include_fk = True diff --git a/schemas/user.py b/schemas/user.py new file mode 100644 index 0000000..dfe871f --- /dev/null +++ b/schemas/user.py @@ -0,0 +1,23 @@ +from ma import ma +from marshmallow import pre_dump +from models.user import UserModel + + +# use SQLAlchemyAutoSchema. ModelSchema is deprecated +class UserSchema(ma.SQLAlchemyAutoSchema): + # If the below Meta class is excluded, while fetching user information, we also fetch + # the user password. So, password is included in the load_only tuple so that password field + # is only loaded and not displayed. + class Meta: + model = UserModel + load_instance = True + load_only = ("password",) # makes 'password' field load_only + dump_only = ( + "id", + "confirmation", + ) # makes 'id' field dump_only. + + @pre_dump + def _pre_dump(self, user: UserModel, **kwargs): # Here user is the user that will be turned to json + user.confirmation = [user.most_recent_confirmation] + return user diff --git a/static/confirmation_page.css b/static/confirmation_page.css new file mode 100644 index 0000000..5a74cf6 --- /dev/null +++ b/static/confirmation_page.css @@ -0,0 +1,5 @@ +.full-height { + height: 80vh; + margin: 10px; + background-color: aquamarine; +} diff --git a/templates/confirmation_page.html b/templates/confirmation_page.html new file mode 100644 index 0000000..4eb558e --- /dev/null +++ b/templates/confirmation_page.html @@ -0,0 +1,31 @@ + + + + + Registration Confirmation + + + + + + + + Thank You + Your registration has been confirmed through < {{ email }} >. + + + + + + + \ No newline at end of file
Your registration has been confirmed through < {{ email }} >.