diff --git a/app.py b/app.py index 5d20a01..5e32ae5 100644 --- a/app.py +++ b/app.py @@ -1,12 +1,6 @@ -from flask import Flask - -app = Flask(__name__) - - -@app.route('/') -def hello_world(): # put application's code here - return 'Hello World!' +from eveai_app import create_app +app = create_app() if __name__ == '__main__': app.run() diff --git a/config.py b/config.py new file mode 100644 index 0000000..6be4581 --- /dev/null +++ b/config.py @@ -0,0 +1,31 @@ +from os import environ, path + +basedir = path.abspath(path.dirname(__file__)) + + +class Config(object): + DEBUG = False + DEVELOPMENT = False + SECRET_KEY = '97867c1491bea5ee6a8e8436eb11bf2ba6a69ff53ab1b17ecba450d0f2e572e1' + + +class DevConfig(Config): + DEVELOPMENT = True + DEBUG = True + SQLALCHEMY_DATABASE_URI = 'postgresql+pg8000://josako@localhost:5432/eveAI' + SQLALCHEMY_BINDS = {'public': 'postgresql+pg8000://josako@localhost:5432/eveAI'} + EXPLAIN_TEMPLATE_LOADING = True + + +class ProdConfig(Config): + DEVELOPMENT = False + DEBUG = False + # SQLALCHEMY_DATABASE_URI = environ.get('SQLALCHEMY_DATABASE_URI') or \ + # 'sqlite:///' + os.path.join(basedir, 'db.sqlite') + + +config = { + 'dev': DevConfig(), + 'prod': ProdConfig(), + 'default': DevConfig(), +} diff --git a/eveai_app/__init__.py b/eveai_app/__init__.py new file mode 100644 index 0000000..6528192 --- /dev/null +++ b/eveai_app/__init__.py @@ -0,0 +1,43 @@ +import os +from flask import Flask +from .extensions import db, migrate, bcrypt, bootstrap, jwt +from .models.user import User, Tenant + + +def create_app(config_file=None): + app = Flask(__name__) + + if config_file is None: + app.config.from_object('config.DevConfig') + else: + app.config.from_object(config_file) + + try: + os.makedirs(app.instance_path) + except OSError: + pass + + register_extensions(app) + register_blueprints(app) + + print(app.config.get('SQLALCHEMY_DATABASE_URI')) + return app + + +def register_extensions(app): + db.init_app(app) + migrate.init_app(app, db) + bcrypt.init_app(app) + bootstrap.init_app(app) + jwt.init_app(app) + + +def register_blueprints(app): + from .views.user_views import user_bp + app.register_blueprint(user_bp) + + +def register_api(app): + pass + # from . import api + # app.register_blueprint(api.bp, url_prefix='/api') diff --git a/eveai_app/api/__init__.py b/eveai_app/api/__init__.py new file mode 100644 index 0000000..90695a6 --- /dev/null +++ b/eveai_app/api/__init__.py @@ -0,0 +1,4 @@ +# from flask import Blueprint, request +# +# public_api_bp = Blueprint("public", __name__, url_prefix="/api/v1") +# tenant_api_bp = Blueprint("tenant", __name__, url_prefix="/api/v1/tenant") diff --git a/eveai_app/api/auth.py b/eveai_app/api/auth.py new file mode 100644 index 0000000..7fb1d25 --- /dev/null +++ b/eveai_app/api/auth.py @@ -0,0 +1,7 @@ +from flask import request +from flask.views import MethodView + +class RegisterAPI(MethodView): + def post(self): + username = request.json['username'] + diff --git a/eveai_app/extensions.py b/eveai_app/extensions.py new file mode 100644 index 0000000..810f591 --- /dev/null +++ b/eveai_app/extensions.py @@ -0,0 +1,12 @@ +from flask_sqlalchemy import SQLAlchemy +from sqlalchemy.orm import DeclarativeBase +from flask_migrate import Migrate +from flask_bcrypt import Bcrypt +from flask_bootstrap import Bootstrap +from flask_jwt_extended import JWTManager + +db = SQLAlchemy() +migrate = Migrate() +bcrypt = Bcrypt() +bootstrap = Bootstrap() +jwt = JWTManager() diff --git a/eveai_app/models/__init__.py b/eveai_app/models/__init__.py new file mode 100644 index 0000000..b28b04f --- /dev/null +++ b/eveai_app/models/__init__.py @@ -0,0 +1,3 @@ + + + diff --git a/eveai_app/models/document.py b/eveai_app/models/document.py new file mode 100644 index 0000000..e69de29 diff --git a/eveai_app/models/interaction.py b/eveai_app/models/interaction.py new file mode 100644 index 0000000..e69de29 diff --git a/eveai_app/models/user.py b/eveai_app/models/user.py new file mode 100644 index 0000000..bc99def --- /dev/null +++ b/eveai_app/models/user.py @@ -0,0 +1,61 @@ +from ..extensions import db + + +class Tenant(db.Model): + """Tenant model""" + + __bind_key__ = 'public' + __table_args__ = {'schema': 'public'} + + # Versioning Information + created_at = db.Column(db.DateTime, nullable=False, server_default=db.func.now()) + updated_at = db.Column(db.DateTime, nullable=False, server_default=db.func.now(), onupdate=db.func.now()) + + # company Information + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + website = db.Column(db.String(255), nullable=True) + + # Licensing Information + license_start_date = db.Column(db.Date, nullable=True) + license_end_date = db.Column(db.Date, nullable=True) + allowed_monthly_interactions = db.Column(db.Integer, nullable=True) + + # Relations + users = db.relationship('User', backref='tenant') + + def __repr__(self): + return '' % self.name + + +class User(db.Model): + """User model""" + + __bind_key__ = 'public' + __table_args__ = {'schema': 'public'} + + # Versioning Information + created_at = db.Column(db.DateTime, nullable=False, server_default=db.func.now()) + updated_at = db.Column(db.DateTime, nullable=False, server_default=db.func.now(), onupdate=db.func.now()) + + # User Information + id = db.Column(db.Integer, primary_key=True) + user_name = db.Column(db.String(80), unique=True, nullable=False) + email = db.Column(db.String(255), unique=True, nullable=False) + password = db.Column(db.String(255), nullable=False) + first_name = db.Column(db.String(80), nullable=False) + last_name = db.Column(db.String(80), nullable=False) + is_active = db.Column(db.Boolean, default=True) + is_tester = db.Column(db.Boolean, default=False) + is_admin = db.Column(db.Boolean, default=False) + valid_to = db.Column(db.Date, nullable=True) + + # Login Information + last_login = db.Column(db.DateTime, nullable=True) + authenticated = db.Column(db.Boolean, default=False) + + # Relations + tenant_id = db.Column(db.Integer, db.ForeignKey('public.tenant.id'), nullable=False) + + def __repr__(self): + return '' % self.name() diff --git a/eveai_app/templates/base.html b/eveai_app/templates/base.html new file mode 100644 index 0000000..6781a93 --- /dev/null +++ b/eveai_app/templates/base.html @@ -0,0 +1,26 @@ + + + + + + + + + + + {% block title %}{% endblock %} + + +
Register
+
Login
+
+ {% block content %}{% endblock %} + + + + + + + + + \ No newline at end of file diff --git a/eveai_app/templates/user/tenant.html b/eveai_app/templates/user/tenant.html new file mode 100644 index 0000000..ef64aaa --- /dev/null +++ b/eveai_app/templates/user/tenant.html @@ -0,0 +1,29 @@ +{% extends 'base.html' %} + +{% block title %}Tenant Details{% endblock %} + +{% block content %} +
+

+ {{ form.name.label }}
+ {{ form.name(size=80) }} +

+

+ {{ form.website.label }}
+ {{ form.website(size=80) }} +

+

+ {{ form.license_start_date.label }}
+ {{ form.license_start_date(size=20) }} +

+

+ {{ form.license_end_date.label }}
+ {{ form.license_end_date(size=20) }} +

+

+ {{ form.allowed_monthly_interactions.label }}
+ {{ form.allowed_monthly_interactions(size=20) }} +

+

{{ form.submit() }}

+
+{% endblock %} diff --git a/eveai_app/views/__init__.py b/eveai_app/views/__init__.py new file mode 100644 index 0000000..f66c135 --- /dev/null +++ b/eveai_app/views/__init__.py @@ -0,0 +1,6 @@ +import eveai_app.views.user_views + + +# document_bp = Blueprint('document_bp', __name__, url_prefix='document') +# interaction_bp = Blueprint('interaction_bp', __name__, url_prefix='interaction') + diff --git a/eveai_app/views/user_forms.py b/eveai_app/views/user_forms.py new file mode 100644 index 0000000..7abae40 --- /dev/null +++ b/eveai_app/views/user_forms.py @@ -0,0 +1,26 @@ +from flask_wtf import FlaskForm +from wtforms import StringField, PasswordField, BooleanField, SubmitField, EmailField, IntegerField, DateField +from wtforms.validators import DataRequired, Length, Email, NumberRange + + +class TenantForm(FlaskForm): + name = StringField('Name', validators=[DataRequired(), Length(max=80)]) + website = StringField('Website', validators=[DataRequired(), Length(max=255)]) + license_start_date = DateField('License Start Date', id='datepicker') + license_end_date = DateField('License End Date', id='datepicker') + allowed_monthly_interactions = IntegerField('Allowed Monthly Interactions', validators=[NumberRange(min=0)]) + submit = SubmitField('Submit') + + +class UserForm(FlaskForm): + user_name = StringField('Name', validators=[DataRequired(), Length(max=80)]) + email = EmailField('Email', validators=[DataRequired(), Email()]) + password = PasswordField('Password', validators=[DataRequired(), Length(min=8)]) + first_name = StringField('First Name', validators=[DataRequired(), Length(max=80)]) + last_name = StringField('Last Name', validators=[DataRequired(), Length(max=80)]) + is_active = BooleanField('Is Active') + is_tester = BooleanField('Is Tester') + is_admin = BooleanField('Is Administrator') + valid_to: DateField('Valid To', id='datepicker') + tenant = IntegerField('Tenant ID', validators=[NumberRange(min=0)]) + submit = SubmitField('Submit') diff --git a/eveai_app/views/user_views.py b/eveai_app/views/user_views.py new file mode 100644 index 0000000..6265a5a --- /dev/null +++ b/eveai_app/views/user_views.py @@ -0,0 +1,106 @@ +# from . import user_bp +from datetime import datetime as dt, timezone as tz +from flask import request, redirect, url_for, flash, render_template, Blueprint +from ..models.user import User, Tenant +from ..extensions import db +from .user_forms import TenantForm, UserForm + +user_bp = Blueprint('user_bp', __name__, url_prefix='/user') + + +@user_bp.route('/tenant', methods=['GET', 'POST']) +def tenant(): + if request.method == 'POST': + # Handle the required attributes + name = request.form.get('name') + website = request.form.get('website') + error = None + + if not name: + error = 'Tenant name is required.' + elif not website: + error = 'Tenant website is required.' + + # Create new tenant if there is no error + if error is None: + new_tenant = Tenant(name=name, website=website) + + # Handle optional attributes + lic_start = request.form.get('license_start_date') + lic_end = request.form.get('license_end_date') + monthly = request.form.get('allowed_monthly_interactions') + + if lic_start != '': + new_tenant.license_start_date = dt.strptime(lic_start, '%d-%m-%Y') + if lic_end != '': + new_tenant.license_end_date = dt.strptime(lic_end, '%d-%m-%Y') + if monthly != '': + new_tenant.allowed_monthly_interactions = int(monthly) + + # Handle Timestamps + timestamp = dt.now(tz.utc) + new_tenant.created_at = timestamp + new_tenant.updated_at = timestamp + + # Add the new tenant to the database and commit the changes + + try: + db.session.add(new_tenant) + db.session.commit() + except Exception as e: + error = e.args + + flash(error) if error else flash('Tenant added successfully.') + + form = TenantForm() + return render_template('user/tenant.html', form=form) + + +@user_bp.route('/user', methods=['GET', 'POST']) +def user(): + if request.method == 'POST': + # Handle the required attributes + username = request.form.get('username') + email = request.form.get('email') + password = request.form.get('password') + first_name = request.form.get('first_name') + last_name = request.form.get('last_name') + error = None + + if not username: + error = 'Username is required.' + elif not email: + error = 'Email is required.' + elif not password: + error = 'Password is required.' + elif not first_name: + error = 'First name is required.' + elif not last_name: + error = 'Last name is required.' + if error is None: + new_user = User(username=username, email=email, password=password, first_name=first_name, last_name=last_name) + + # Handle optional attributes + new_user.is_active = request.form.get('is_active') + new_user.is_tester = request.form.get('is_tester') + new_user.is_admin = request.form.get('is_admin') + new_user.valid_to = request.form.get('valid_to') + + # Handle Timestamps + timestamp = dt.now(tz.utc) + new_user.created_at = timestamp + new_user.updated_at = timestamp + + # Handle the relations + new_user.tenant_id = request.form.get('tenant_id') + + try: + db.session.add(new_user) + db.session.commit() + except Exception as e: + error = e.args + + flash(error) if error else flash('User added successfully.') + + form = UserForm() + return render_template('user/user.html', form=form) diff --git a/migrations/public/README b/migrations/public/README new file mode 100644 index 0000000..0e04844 --- /dev/null +++ b/migrations/public/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/migrations/public/alembic.ini b/migrations/public/alembic.ini new file mode 100644 index 0000000..ec9d45c --- /dev/null +++ b/migrations/public/alembic.ini @@ -0,0 +1,50 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/migrations/public/env.py b/migrations/public/env.py new file mode 100644 index 0000000..6705cfe --- /dev/null +++ b/migrations/public/env.py @@ -0,0 +1,114 @@ +import logging +from logging.config import fileConfig + +from flask import current_app + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + + +def get_engine(): + try: + # this works with Flask-SQLAlchemy<3 and Alchemical + return current_app.extensions['migrate'].db.get_engine() + except (TypeError, AttributeError): + # this works with Flask-SQLAlchemy>=3 + return current_app.extensions['migrate'].db.engine + + +def get_engine_url(): + try: + return get_engine().url.render_as_string(hide_password=False).replace( + '%', '%%') + except AttributeError: + return str(get_engine().url).replace('%', '%%') + + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option('sqlalchemy.url', get_engine_url()) +target_db = current_app.extensions['migrate'].db + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def get_metadata(): + # JOS: Changed to use public schema + if hasattr(target_db, 'metadatas'): + return target_db.metadatas['public'] + return target_db.metadata + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, target_metadata=get_metadata(), literal_binds=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + conf_args = current_app.extensions['migrate'].configure_args + if conf_args.get("process_revision_directives") is None: + conf_args["process_revision_directives"] = process_revision_directives + + connectable = get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=get_metadata(), + **conf_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/public/script.py.mako b/migrations/public/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/migrations/public/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/migrations/tenant/README b/migrations/tenant/README new file mode 100644 index 0000000..0e04844 --- /dev/null +++ b/migrations/tenant/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/migrations/tenant/alembic.ini b/migrations/tenant/alembic.ini new file mode 100644 index 0000000..ec9d45c --- /dev/null +++ b/migrations/tenant/alembic.ini @@ -0,0 +1,50 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/migrations/tenant/env.py b/migrations/tenant/env.py new file mode 100644 index 0000000..66d89ce --- /dev/null +++ b/migrations/tenant/env.py @@ -0,0 +1,124 @@ +import logging +from logging.config import fileConfig + +from flask import current_app + +from alembic import context + +from eveai_app.models.user import Tenant + + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + + +def get_engine(): + try: + # this works with Flask-SQLAlchemy<3 and Alchemical + return current_app.extensions['migrate'].db.get_engine() + except (TypeError, AttributeError): + # this works with Flask-SQLAlchemy>=3 + return current_app.extensions['migrate'].db.engine + + +def get_engine_url(): + try: + return get_engine().url.render_as_string(hide_password=False).replace( + '%', '%%') + except AttributeError: + return str(get_engine().url).replace('%', '%%') + + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option('sqlalchemy.url', get_engine_url()) +target_db = current_app.extensions['migrate'].db + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + +# List of Tenants +tenants = [tenant.id for tenant in Tenant.query.all()] + + +def get_metadata(): + if hasattr(target_db, 'metadatas'): + return target_db.metadatas[None] + return target_db.metadata + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, target_metadata=get_metadata(), literal_binds=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Updated migration script for handling schema based multi-tenancy + + ref: + - https://alembic.sqlalchemy.org/en/latest/cookbook.html#rudimental-schema-level-multi-tenancy-for-postgresql-databases # noqa + """ + + connectable = engine_from_config( + config.get_section(config.config_ini_section), + prefix="sqlalchemy.", + poolclass=NullPool, + ) + + with connectable.connect() as connection: + for tenant in tenants: + logger.info(f"Migrating tenant: {tenant}") + # set search path on the connection, which ensures that + # PostgreSQL will emit all CREATE / ALTER / DROP statements + # in terms of this schema by default + connection.execute(text(f'SET search_path TO "{tenant}"')) + # in SQLAlchemy v2+ the search path change needs to be committed + # connection.commit() + + # make use of non-supported SQLAlchemy attribute to ensure + # the dialect reflects tables in terms of the current tenant name + connection.dialect.default_schema_name = tenant + + context.configure( + connection=connection, + target_metadata=get_metadata(), + ) + + with context.begin_transaction(): + context.run_migrations() + + # for checking migrate or upgrade is running + if getattr(config.cmd_opts, "autogenerate", False): + break + + +if context.is_offline_mode(): + raise Exception("Offline migrations are not supported") +else: + run_migrations_online() diff --git a/migrations/tenant/script.py.mako b/migrations/tenant/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/migrations/tenant/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e691aec --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +Flask~=3.0.3 +WTForms~=3.1.2 +SQLAlchemy~=2.0.29 +alembic~=1.13.1 \ No newline at end of file