diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..01c887e --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +MAILGUN_DOMAIN= +MAILGUN_API_KEY= +JWT_SECRET_KEY="secret-key-here" +APPLICATION_SETTINGS=default_config.py +DATABASE_URL=database-uri-here +APP_SECRET_KEY="komraishumtirkomchuri" \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..322dde3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +test/test.py +test/test.db +__pycache__ +.vscode +.idea +/.env +database/data.db +resources/__pycache__ +model/__pycache__ +static/images +migrations \ 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..d678217 --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# 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. + +## Steps to database migration + +1. ``` + flask db init + ``` + + To initiate migration process. + +2. Edit the `alembic.ini` and `env.py` according to requirements. +3. ``` + flask db migrate + ``` + To create migration instance. +4. Edit the version file accordingly. +5. ``` + flask db upgrade + ``` + To commit the upgrade. 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..3b4a453 100644 --- a/app.py +++ b/app.py @@ -1,10 +1,142 @@ -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 flask_uploads import configure_uploads, patch_request_class +from flask_migrate import Migrate +from marshmallow import ValidationError +from dotenv import load_dotenv + +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 resources.image import ImageUpload, Image, AvatarUpload, Avatar +from libs.image_helper import IMAGE_SET +from blacklist import BLACKLIST +from database import db + +app = Flask(__name__) +load_dotenv(".env", verbose=True) +# .env has to be loaded manually here beacuse it is loaded automatically when app runs. +# But here it will not load automatically as the app has not started at the point +app.config.from_object("default_config") # Loads the default_config.py file +app.config.from_envvar("APPLICATION_SETTINGS") +patch_request_class(app, 10 * 1024 * 1024) # Used to limit the max size of image that can be uploaded, here: 10mb +configure_uploads(app, IMAGE_SET) +api = Api(app) +migrate = Migrate(app, db) # Establishes a link between app and the remote database + + +@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(header, data): + 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(ImageUpload, "/upload/image/") +api.add_resource(Image, "/image/") +api.add_resource(AvatarUpload, "/upload/avatar") +api.add_resource(Avatar, "/avatar/") +api.add_resource(User, "/user/") +api.add_resource(UserLogin, "/login") +api.add_resource(UserLogout, "/logout") +api.add_resource(TokenRefresh, "/refresh") + +db.init_app(app) + +if __name__ == "__main__": + 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/config.py b/config.py new file mode 100644 index 0000000..8882869 --- /dev/null +++ b/config.py @@ -0,0 +1,5 @@ +import os + +# When app will be deployed to server, app will use config.py +DEBUG = False +SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL", "sqlite:///database/data.db") 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/default_config.py b/default_config.py new file mode 100644 index 0000000..24872c9 --- /dev/null +++ b/default_config.py @@ -0,0 +1,12 @@ +import os + +DEBUG = True +# SQLALCHEMY_DATABASE_URI = "sqlite:///database/data.db" +SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL", "sqlite:///database/data.db") +SQLALCHEMY_TRACK_MODIFICATIONS = False +PROPAGATE_EXCEPTIONS = True +JWT_SECRET_KEY = os.environ["JWT_SECRET_KEY"] # If there is no secret key set, app would crash +JWT_BLACKLIST_ENABLED = True +JWT_BLACKLIST_TOKEN_CHECKS = ["access", "refresh"] +# APP_SECRET_KEY = os.environ["APP_SECRET_KEY"] +UPLOADED_IMAGES_DEST = os.path.join("static", "images") diff --git a/libs/__init__.py b/libs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/libs/image_helper.py b/libs/image_helper.py new file mode 100644 index 0000000..968445c --- /dev/null +++ b/libs/image_helper.py @@ -0,0 +1,61 @@ +import os +import re +from typing import Union +from werkzeug.datastructures import FileStorage + +from flask_uploads import UploadSet, IMAGES + +IMAGE_SET = UploadSet("images", IMAGES) # Set name and allowed extensions + + +def save_image(image: FileStorage, folder: str = None, name: str = None) -> str: + # Takes filestorage and saves it to a folder + return IMAGE_SET.save(image, folder, name) # images will be saved in static/images + + +def get_path(filename: str = None, folder: str = None) -> str: + # Takes an image and returns its full path + return IMAGE_SET.path(filename, folder) + + +def find_image_any_format(filename: str, folder: str) -> Union[str, None]: + # Takes filename and reutrns an image on any of the accepted formats + for _format in IMAGES: + image = f"{filename}.{_format}" + image_path = IMAGE_SET.path(filename=image, folder=folder) + if os.path.isfile(image_path): + return image_path + + return None + + +def _retrieve_filename(file: Union[str, FileStorage]) -> str: + # Take a file storage and return the filename. + # Allows our functions to call with both filename or FileStorages + # and always gets back filename + # If it is a file it gives the filename + if isinstance(file, FileStorage): + return file.filename + return file # Else it gives the filename + + +def is_filename_safe(file: Union[str, FileStorage]) -> bool: + # Check our regex and returns whether the string matches or not + filename = _retrieve_filename(file) + + allowed_format = "|".join(IMAGES) # jpg|jpeg|png|jpe + regex = f"^[a-zA-Z0-9][a-zA-Z0-9_()-\.]*\.({allowed_format})$" + return re.match(regex, filename) is not None + + +def get_basename(file: Union[str, FileStorage]) -> str: + # Returns fullname of image in path + # get_basename("some/folder/path/image.jpg") -> returns "image.jpg" + filename = _retrieve_filename(file) + return os.path.split(filename)[1] # for /static/images/image.png -> image.png + + +def get_extension(file: Union[str, FileStorage]) -> str: + # Returns file extension + filename = _retrieve_filename(file) + return os.path.splitext(filename)[1] # for image.png -> png 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..bcbf1a9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +Flask +Flask-RESTful +Flask-JWT-Extended +Flask-SQLAlchemy +Marshmallow +Flask-Marshmallow +Marshmallow-SQLAlchemy +Python-Dotenv +Flask-Uploads +Flask-Migrate +psycopg2-binary \ 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/image.py b/resources/image.py new file mode 100644 index 0000000..04bb56b --- /dev/null +++ b/resources/image.py @@ -0,0 +1,113 @@ +from flask_restful import Resource +from flask_uploads import UploadNotAllowed +from flask import request, send_file +from flask_jwt_extended import jwt_required, get_jwt_identity +import traceback +import os + +from libs import image_helper +from schemas.image import ImageSchema + +image_schema = ImageSchema() +IMAGE_UPLOADED = "Image '{}' uploaded" +IMAGE_ILLEGAL_EXTENSION = "Extension '{}' is not allowed." +IMAGE_ILLEGAL_FILENAME = "Illegal filename '{}' requested." +IMAGE_NOT_FOUND = "Image '{}' not found." +IMAGE_REMOVED = "Image file '{}' has been removed." +AVATAR_DELETE_FAILED = "Internal server error. Failed to delete avatar." +AVATAR_UPLOADED = "Avatar '{}' uploaded." +AVATAR_ILLEGAL_EXTENSION = "Extension '{}' is not allowed." +AVATAR_NOT_FOUND = "Avatar '{}' not found." + + +class ImageUpload(Resource): + @classmethod + @jwt_required() + def post(cls): + # To upload image file + # Uses JWT to retrieve user information and saves the image to user's folder + # If there is a filename conflict, it appends a number at the end + data = image_schema.load(request.files) # {"image": FileStorage} + # request.files is a dictionary that has the key of the filename to the data of the file + user_id = get_jwt_identity() + folder = f"user_{user_id}" # folder where the image will be stored eg: static/images/user_1 + + try: + image_path = image_helper.save_image(data["image"], folder=folder) + basename = image_helper.get_basename(image_path) + return {"message": IMAGE_UPLOADED.format(basename)}, 201 + except UploadNotAllowed: + extension = image_helper.get_extension(data["image"]) + return {"message": IMAGE_ILLEGAL_EXTENSION.format(extension)}, 400 + + +class Image(Resource): + # Returns the requested user's image. Looks up inside the logged in user's folder + @classmethod + @jwt_required() + def get(cls, filename: str): + user_id = get_jwt_identity() + folder = f"user_{user_id}" + if not image_helper.is_filename_safe(filename): + return {"message": IMAGE_ILLEGAL_FILENAME.format(filename)}, 400 + try: + return send_file(image_helper.get_path(filename, folder=folder)) + except FileNotFoundError: + return {"message": IMAGE_NOT_FOUND.format(filename)}, 404 + + @classmethod + @jwt_required() + def delete(cls, filename: str): + user_id = get_jwt_identity() + folder = f"user_{user_id}" + + if not image_helper.is_filename_safe(filename): + return {"message": IMAGE_ILLEGAL_FILENAME.format(filename)}, 400 + try: + os.remove(image_helper.get_path(filename, folder=folder)) + return {"message": IMAGE_REMOVED.format(filename)}, 200 + except FileNotFoundError: + return {"message": IMAGE_NOT_FOUND.format(filename)}, 404 + except: + traceback.print_exc() + return {"message": "Internal server error. Failed to delete image."}, 500 + + +class AvatarUpload(Resource): + @classmethod + @jwt_required() + def put(cls): + # This is used to upload user avatars + # All avatars have a name after the user's ID. + # eg: user_1.png + # Uploading a new avatar overwrites the existing one. + data = image_schema.load(request.files) + filename = f"user_{get_jwt_identity()}" + folder = "avatars" + avatar_path = image_helper.find_image_any_format(filename, folder) + if avatar_path: + try: + os.remove(avatar_path) + except: + return {"message": AVATAR_DELETE_FAILED}, 500 + + try: + ext = image_helper.get_extension(data["image"].filename) + avatar = filename + ext + avatar_path = image_helper.save_image(data["image"], folder=folder, name=avatar) + basename = image_helper.get_basename(avatar_path) + return {"message": AVATAR_UPLOADED.format(avatar)}, 200 + except UploadNotAllowed: + extension = image_helper.get_extension(data["image"]) + return {"message": AVATAR_ILLEGAL_EXTENSION.format(extension)}, 400 + + +class Avatar(Resource): + @classmethod + def get(cls, user_id: int): + folder = "avatars" + filename = f"user_{user_id}" + avatar = image_helper.find_image_any_format(filename, folder=folder) + if avatar: + return send_file(avatar) + return {"message": AVATAR_NOT_FOUND.format(avatar)}, 404 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..0524e2f --- /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/image.py b/schemas/image.py new file mode 100644 index 0000000..479a79a --- /dev/null +++ b/schemas/image.py @@ -0,0 +1,22 @@ +from marshmallow import Schema, fields +from werkzeug.datastructures import FileStorage + + +class FileStorageField(fields.Field): + default_error_messages = { + "invalid": "Not a valid image.", + } + + def _deserialize(self, value, attr, data, **kwargs) -> FileStorage: + if value is None: + return None + + # If value is not an instance of FileStorage, show error + if not isinstance(value, FileStorage): + self.fail("invalid") # raises a ValidationError + + return value + + +class ImageSchema(Schema): + image = FileStorageField(required=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 }} >.