commit 618ad0ff74e132326f259e871f2d420310a9dec9 Author: DimitarAdmin Date: Wed Mar 20 15:09:25 2024 +0100 Adding code and structure diff --git a/Config/CacheProvider.py b/Config/CacheProvider.py new file mode 100644 index 0000000..c00b9e5 --- /dev/null +++ b/Config/CacheProvider.py @@ -0,0 +1,39 @@ +from functools import wraps +from flask import request, jsonify +import time + +class Cache: + def __init__(self): + """ + Simple caching mechanism that will only cache data if the user is not sending any GET / POST arguments in the request. + Use in combination with the RouteProvider class to be able to include in a request without extra instantiation and reduce performance cost. + """ + self.cache = {} + + def __call__(self, timeout): + """ + Can be expanded to include request payload based caching - make a caching hash which includes the request.args and request.form data. + Keep in mind that it is memory taxing given a dynamic request type. + Caching large payload routes with thousands of possible arguments could cause a memory overflow and crash the server. + """ + def decorator(func): + @wraps(func) + def wrapper(*args, **kwargs): + cache_key = f"{request.method}:{request.path}" + if (cache_key in self.cache and time.time() <= self.cache[cache_key]["date_expires"]) and not ( + request.form.keys() or request.args.keys() + ): + return jsonify(self.cache[cache_key]["data"]) + else: + data = func(*args, **kwargs) + expiration_time = time.time() + timeout + self.cache[cache_key] = { + "date_created": time.time(), + "date_expires": expiration_time, + "data": data + } + return jsonify(data) + return wrapper + return decorator + +_cache = Cache() \ No newline at end of file diff --git a/Config/Common.py b/Config/Common.py new file mode 100644 index 0000000..95f7dc4 --- /dev/null +++ b/Config/Common.py @@ -0,0 +1,51 @@ +from Config import db, uri +from sqlalchemy.orm import sessionmaker, scoped_session +import sqlalchemy, datetime + +class SQL: + def __init__(self): + self.engine = sqlalchemy.create_engine(uri) + self.Session = scoped_session(sessionmaker(bind=self.engine)) + pass + + def query(self, query, params): + session = self.Session() + result = session.execute(query, params).fetchall() + ret = [] + for row in result: + ret.append(dict(row)) + + return ret + + +def str_to_date(str, format="%Y-%m-%d %H:%M:%S"): + example = datetime.datetime(2021, 6, 2, 9, 39) + if str == None: + return False + + if type(str) == type(example): + return str + + return datetime.datetime.strptime(str, format) + +def dict_is_xor(_dict, keys): + exists = False + for key in keys: + if key in _dict and exists == False: + exists = key + continue + if key in _dict and exists != False: + return False + + return exists + +def crud_routes(request, instance): + method = request.method + if method == "POST": + return instance.create(request) + elif method == "GET": + return instance.read(request) + elif method == "PUT": + return instance.update(request) + elif method == "DELETE": + return instance.delete(request) \ No newline at end of file diff --git a/Config/Constants.py b/Config/Constants.py new file mode 100644 index 0000000..f8e1e6f --- /dev/null +++ b/Config/Constants.py @@ -0,0 +1,26 @@ +constants = { + "local" : { + "mysql": { + "db_name": "DATABASE NAME", + "host": "DATABASE HOST", + "user": "DATABASE USER", + "password": "DATABASE PASSWORD", + }, + "static_root": "/path/to/static/folder/root" + }, + "remote" : { + "mysql" : { + "db_name": "DATABASE NAME", + "host": "DATABASE HOST", + "user": "DATABASE USER", + "password": "DATABASE PASSWORD", + }, + "static_root": "/path/to/static/folder/root" + }, + "selector" : "remote" +} + +# KEEP IN MIND. ONLY DECLARE YOUR PASSWORD IN CONSTANTS DURING DEVELOPMENT. +# ONCE IN PRODUCTION, USE ENVIRONMENTAL VARIABLES + +constants = constants[constants["selector"]] diff --git a/Config/DB/Models.py b/Config/DB/Models.py new file mode 100644 index 0000000..2c728a3 --- /dev/null +++ b/Config/DB/Models.py @@ -0,0 +1,35 @@ +from Config import db, app +import datetime + +def connect_table_to_key(db, collection_name, foreign_keys): + return db.relationship(collection_name, foreign_keys=foreign_keys) + +class Users(db.Model): + __tablename__ = 'Users' + + id = db.Column(db.Integer, primary_key=True) + admin_id = db.Column(db.Integer, db.ForeignKey('Users.id'), nullable=True) + email = db.Column(db.String(256), unique=True, nullable=False) + password = db.Column(db.String(256), nullable=False) + sign_up_date = db.Column(db.DateTime, nullable=False) + archived = db.Column(db.Integer, nullable=False, default=0) + name = db.Column(db.String(64), nullable=False) + address = db.Column(db.String(256), nullable=False) + profile_picture_path = db.Column(db.String(512), nullable=False) + notification_token = db.Column(db.String(512), nullable=False, default="NA") + + Admin = connect_table_to_key(db, 'Users', foreign_keys=[admin_id]) + + __struct__ = { + "id" : "Number", + "admin_id" : "Number", + "email": "String", + "password": "String", + "sign_up_date": "String", + "archived": "Number", + "name": "String", + "address": "String", + "profile_picture_path": "String", + "notification_token": "String" + } + __unique__ = ["id", "email"] diff --git a/Config/DB/Schemas.py b/Config/DB/Schemas.py new file mode 100644 index 0000000..4430159 --- /dev/null +++ b/Config/DB/Schemas.py @@ -0,0 +1,13 @@ +from flask_sqlalchemy import SQLAlchemy +from flask_marshmallow import Marshmallow +from marshmallow import fields +from Config.DB.Models import Users + +ma = Marshmallow() + +class UsersSchema(ma.SQLAlchemyAutoSchema): + admin = fields.Nested('self', attribute='Admin', many=False, default=None) + + class Meta: + model = Users + exclude = ['password', 'notification_token'] diff --git a/Config/DB/__init__.py b/Config/DB/__init__.py new file mode 100644 index 0000000..e53da4b --- /dev/null +++ b/Config/DB/__init__.py @@ -0,0 +1,11 @@ +from Config.DB.Models import Users +from Config.DB.Schemas import UsersSchema + +class Tables: + Users = Users + # ADD REST OF THE TABLES HERE + +class Schemas: + User = UsersSchema() + Users = UsersSchema(many=True) + # ADD REST OF THE SCHEMAS HERE \ No newline at end of file diff --git a/Config/RouteProvider.py b/Config/RouteProvider.py new file mode 100644 index 0000000..5a5fc20 --- /dev/null +++ b/Config/RouteProvider.py @@ -0,0 +1,181 @@ +from flask_jwt_extended import get_jwt_identity +from Config.DB import Schemas, Tables +from Config import constants, db +from flask import jsonify +from functools import wraps +import random, json + +class RouteProvider: + def __init__(self): + self.auth_user = None + self.schemas = Schemas() + self.tables = Tables() + self.db = db + self.constants = constants + + @classmethod + def access_controller(cls, access_level=["*"]): + """ + Based on the access_level array, checks if the authenticated user request has access to the requested route. + Might require recoding if you use a different users authentication implementation + """ + def decorator(fn): + @wraps(fn) + def wrapper(self, *args, **kwargs): + current_user = get_jwt_identity() + try: + current_user = json.loads(current_user) + except Exception as e: + print(current_user) + return cls._abort(418, "I'm a teapot") + if not current_user: + return cls._abort(401, "User is not authenticated to access this route") + tables = Tables() + user = tables.Users.query.filter_by(id=current_user["id"]).first() + if user is None: + return cls._abort(403, "Something went wrong when trying to authenticate your access. Please try to log in again.") + if "*" not in access_level: + schemas = Schemas() + user_schemafied = schemas.User.dump(user) + if user_schemafied["role"]["value"] not in access_level: + return cls._abort(403, "Please check your access level..") + self.auth_user = user + return fn(self, *args, **kwargs) + return wrapper + return decorator + + @staticmethod + def _abort(code, message): + """ + The function is used to provide an error back to the user with an appropriate code and message (msg). + """ + return jsonify({"msg": message, "code": code}), code + + def validate(self, keys, data): + """ + The function validates the request - checks if all the required keys are in the data dictionary + """ + for key in keys: + if key not in data: + return False + return True + + def check_constraint(self, data, table): + """ + Checking constraints - making sure that the new entry does not break uniqueness constraints + """ + for key in table.__unique__: + if key not in data: + continue + res = table.query.filter_by(**{key: data[key]}).first() + if res is not None and key != "id": + if "id" in data and int(data["id"]) == res.id: + continue + return f"Conflict: {key} '{data[key]}' already exists. Please use a different value." + return True + + def save_file(self, files, file_key, static_suffix="/", name=None): + """ + The request object and file key must be passed. + If static_suffix is not passed, the function assumes static_root as save path. + If name is not passed, the system will generate one randomly. + Make sure the root location has been granted 755 privilege and is owned by the same + user who executes and starts the server + """ + if name is None: + name = self.get_random_alphanumerical() + + if file_key not in files: + return False + + file = files[file_key] + extension = self.get_extension(file) + name = name + "." + extension + print(self.constants) + file.save(self.constants["static_root"] + static_suffix + name) + + return static_suffix + name + + def get_random_alphanumerical(self, _len = 16): + """ + Provides a truely random alphanumerical string with _len number of characters + """ + asciiCodes = [] + alphanumerical = "" + asciiCodes += random.sample(range(97, 122), int(round(0.375 * _len))) + asciiCodes += random.sample(range(65, 90), int(round(0.375 * _len))) + asciiCodes += random.sample(range(48, 57), int(round(0.25 * _len))) + random.shuffle(asciiCodes) + for char in asciiCodes: + alphanumerical += chr(char) + return alphanumerical + + def get_random_numerical(self, _len = 16): + """ + Provides a truely random numerical string with _len number of characters + """ + asciiCodes = [] + alphanumerical = "" + asciiCodes += random.sample(range(48, 57), _len) + random.shuffle(asciiCodes) + for char in asciiCodes: + alphanumerical += chr(char) + return alphanumerical + + def generate_secret_key(self, length): + """ + Generates a secret key with length number of characters. The secret key consists of all lower case letters. + """ + key = "" + for x in range(length): + rand = random.randint(97, 122) + key += chr(rand) + return key + + def get_extension(self, _f): + """ + Provides the extension of the _f file + """ + ext = str(_f.filename.split(".")[len(_f.filename.split(".")) - 1]) + return ext + + def get_hash_info(self, args): + """ + Extracts the hash information out of the requests and provides info about enabled / key / type + """ + return { + "enable_hash" : False if "enable_hash" not in args or args["enable_hash"] != "true" else True, + "hash_key" : "id" if "hash_key" not in args else args["hash_key"], + "hash_type" : True if "hash_type" not in args or args["hash_type"] == "cbht" else False + } + + def build_params(self, keys, args): + """ + Using the table structure (keys), checks if each key is in the request arguments (args). + If the key is in args, then it is properly parsed and formatted and returned in the params dictionary + """ + params = dict() + for key in keys: + if key in args: + params[key] = args[key] if keys[key] != "Integer" else int(args[key]) + return params + + def hash_query_results(self, array, col_key, cbht=True): + """ + Creates a hash table of closed or open bucket type from a specific array with N dictionaries where the key exists inside each dictrionary. + """ + if type(array) != list: + array = [array] + if len(array) == 0: + return [] + ret = [None for _ in range(max(array,key=lambda x: x[col_key])[col_key]+1)] + + for item in array: + if cbht: + ret[item[col_key]] = item + else: + if ret[item[col_key]] is None: + ret[item[col_key]] = [item] + else: + ret[item[col_key]].append(item) + return ret \ No newline at end of file diff --git a/Config/__init__.py b/Config/__init__.py new file mode 100644 index 0000000..2f78c57 --- /dev/null +++ b/Config/__init__.py @@ -0,0 +1,25 @@ +# THIRD PARTY +from flask import Flask +from flask_cors import CORS +from flask_sqlalchemy import SQLAlchemy +from flask_jwt_extended import JWTManager, create_access_token +from flask_marshmallow import Marshmallow + +# CONSTANTS IMPORT +from .Constants import constants + +app = Flask(__name__, template_folder='../templates') # App initialization + +# DB URI +uri = "mysql+pymysql://"+constants["mysql"]["user"]+":"+constants["mysql"]["password"]+"@"+constants["mysql"]["host"]+"/"+constants["mysql"]["db_name"]+"?&autocommit=false" + +# APP CONFIGURATIONS +app.config["SQLALCHEMY_DATABASE_URI"] = uri +app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False +app.config["JWT_SECRET_KEY"] = "use-a-secret-key" + +# INSTANCES +cors = CORS(app) +db = SQLAlchemy(app) +jwt = JWTManager(app) +ma = Marshmallow(app) \ No newline at end of file diff --git a/Routes/Users/Crud.py b/Routes/Users/Crud.py new file mode 100644 index 0000000..09338ba --- /dev/null +++ b/Routes/Users/Crud.py @@ -0,0 +1,105 @@ +from Config.RouteProvider import RouteProvider +from flask import jsonify +from flask_jwt_extended import jwt_required +from datetime import datetime + +class UsersCRUD(RouteProvider): + def __init__(self): + super().__init__() + + @jwt_required() + @RouteProvider.access_controller(access_level=["Administrator", "System Administrator"]) + def create(self, request): + data = request.form + files = request.files + required_keys = ["email", "password", "name", "address"] + required_files = ["profile_picture"] + + if not self.validate(required_keys, data): + return self._abort(400, "Incorrectly formatted request. Please make sure that all the required fields are entered.") + + if not self.validate(required_files, files): + return self._abort(400, "Incorrectly formatted request. Please make sure that all the required files are attached.") + + constraint = self.check_constraint(data, self.tables.Users) + if self.check_constraint(data, self.tables.Users) is not True: + return self._abort(409, constraint) + + user = self.tables.Users() + [setattr(user, key, data[key]) for key in required_keys] + profile_picture_path = self.save_file(files, "profile_picture", "/users/") + user.profile_picture_path = profile_picture_path + user.sign_up_date = datetime.utcnow() + self.db.session.add(user) + self.db.session.commit() + + user = self.tables.Users.query.filter_by(email = data["email"]).first() + return jsonify({ "user" : self.schemas.User.dump(user) }) + + @jwt_required() + @RouteProvider.access_controller(access_level=["*"]) + def read(self, request): + params = self.build_params(self.tables.Users.__struct__, request.args) + query_result = self.tables.Users.query.filter_by(**params).all() + return jsonify({ "users" : self.schemas.Users.dump(query_result), "args" : params }) + + @jwt_required() + @RouteProvider.access_controller(access_level=["*"]) + def update(self, request): + data = request.form + files = request.files + + required_keys = ["id"] + updatable_keys = [] + [updatable_keys.append(key) for key in self.tables.Users.__struct__ if key not in ["sign_up_date", "id"]] + required_files = ["profile_picture"] + + if not self.validate(required_keys, data): + return self._abort(400, "Incorrectly formatted request. Please make sure that all the required fields are entered.") + + user = self.tables.Users.query.filter_by(id = data["id"]).first() + if user is None: + return self._abort(404, "User not found") + + constraint = self.check_constraint(data, self.tables.Users) + if constraint is not True: + return self._abort(409, constraint) + + + [setattr(user, key, data[key]) for key in updatable_keys if key in data] + if "profile_picture" in files: + profile_picture_path = self.save_file(files, "profile_picture", "/users/") + user.profile_picture_path = profile_picture_path + + if data["admin_id"] == 'null': + user.admin_id = None + + self.db.session.commit() + user = self.tables.Users.query.filter_by(id = data["id"]).first() + return jsonify({ "user" : self.schemas.User.dump(user) }) + + @jwt_required() + @RouteProvider.access_controller(access_level=["Administrator", "System Administrator"]) + def delete(self, request): + params = self.build_params(self.tables.Users.__struct__, request.args) + if "id" not in params: + return self._abort(400, "Missing params info: 'id'") + + user = self.tables.Users.query.filter_by(id = params["id"]).first() + + if user is None: + return self._abort(404, f'User with ID \'{params["id"]}\' is not found') + + user.archived = 1 if user.archived == 0 else 0 + + if user.archived == 1: + user.archived = 0 + else: + user.archived = 1 + + user = self.tables.Users.query.filter_by(**params).first() + + return jsonify({ "user" : self.schemas.User.dump(user) }) + + +users_crud = UsersCRUD() \ No newline at end of file diff --git a/Routes/Users/__init__.py b/Routes/Users/__init__.py new file mode 100644 index 0000000..7ed4730 --- /dev/null +++ b/Routes/Users/__init__.py @@ -0,0 +1,18 @@ +# THIRD PARTY +from flask import request, Blueprint + +# CONFIGURATION +from Config.Common import crud_routes + +# ROUTES +from Routes.Users.Crud import users_crud + +# Initialize a user blueprint +users_api = Blueprint("users_api", __name__) + +# DEFINE YOUR ROUTES HERE +# +# EXAMPLE USAGE +@users_api.route("/crud", methods=["GET", "POST", "PUT", "DELETE"]) +def users_crud_routes(): + return crud_routes(request, users_crud) diff --git a/app.py b/app.py new file mode 100644 index 0000000..a331329 --- /dev/null +++ b/app.py @@ -0,0 +1,19 @@ +# FROM THIRD PARTY +from flask_requests import request +from flask import jsonify + +# CONFIGURATIONS +from Config import app + +# BLUEPRINTS / ROUTES +from Routes.Users import users_api + +app.register_blueprint(users_api, url_prefix="/users/") + +# PUBLIC META ROUTE - USED TO GIVE THE FRONTEND APPLICATION INFORMATION ABOUT NEW VERSION RELEASE ETC. +@app.route("/meta", methods=["GET"]) +def meta(): + return jsonify({ "version" : "0.0.0" }) + +if __name__ == "__main__": + app.run() diff --git a/passenger_wsgi.py b/passenger_wsgi.py new file mode 100644 index 0000000..529dfff --- /dev/null +++ b/passenger_wsgi.py @@ -0,0 +1,5 @@ +# FOR PRODUCTION USE +from app import app as application + +if __name__ == "__main__": + application.run() \ No newline at end of file