From 07ccbd3db1cd370d91e3003a56dcb9ccb5fba0a0 Mon Sep 17 00:00:00 2001 From: Florian du Garage Num Date: Thu, 13 Apr 2023 15:34:46 +0200 Subject: [PATCH] initial commit --- .flaskenv | 1 + .gitignore | 6 + .vscode/launch.json | 20 ++ Procfile | 1 + README.md | 12 + model.env | 26 ++ project/admin.py | 55 ++++ project/airtableget.py | 224 +++++++++++++ project/app.py | 156 ++++++++++ project/config.py | 45 +++ project/database.py | 25 ++ project/models.py | 36 +++ project/static/css/thumbnail.css | 5 + project/static/js/attachment.js | 147 +++++++++ .../svg/full-cross-circle-alt-svgrepo-com.svg | 2 + project/templates/admin/actions.html | 43 +++ project/templates/admin/file/form.html | 9 + project/templates/admin/file/list.html | 191 ++++++++++++ project/templates/admin/file/modals/form.html | 19 ++ project/templates/admin/index.html | 5 + project/templates/admin/lib.html | 294 ++++++++++++++++++ project/templates/admin/master.html | 72 +++++ project/templates/admin/model/create.html | 32 ++ project/templates/admin/model/details.html | 54 ++++ project/templates/admin/model/edit.html | 42 +++ project/templates/admin/model/list.html | 212 +++++++++++++ .../templates/admin/model/modals/create.html | 36 +++ .../templates/admin/model/modals/details.html | 40 +++ .../templates/admin/model/modals/edit.html | 31 ++ project/templates/admin/rediscli/console.html | 27 ++ project/templates/airtable/airtable_base.html | 59 ++++ project/templates/airtable/dashboard.html | 8 + project/templates/airtable/edit_record.html | 43 +++ project/templates/airtable/form.html | 58 ++++ project/templates/airtable/my_modules.html | 37 +++ .../templates/airtable/partials/footer.html | 18 ++ .../participants/participants_detail.html | 21 ++ .../participants/participants_edit.html | 121 +++++++ .../participants/participants_list.html | 43 +++ project/templates/airtable/submitted.html | 6 + project/templates/base.html | 54 ++++ project/templates/index.html | 12 + project/templates/partials/navbar.html | 40 +++ project/templates/profile.html | 8 + project/templates/security/_macros.html | 55 ++++ .../templates/security/forgot_password.html | 12 + project/templates/security/login_user.html | 64 ++++ project/templates/security/register_user.html | 21 ++ project/templates/users.html | 70 +++++ project/views.py | 37 +++ requirements.txt | 14 + 51 files changed, 2669 insertions(+) create mode 100644 .flaskenv create mode 100644 .gitignore create mode 100644 .vscode/launch.json create mode 100644 Procfile create mode 100644 README.md create mode 100644 model.env create mode 100644 project/admin.py create mode 100644 project/airtableget.py create mode 100644 project/app.py create mode 100644 project/config.py create mode 100644 project/database.py create mode 100644 project/models.py create mode 100644 project/static/css/thumbnail.css create mode 100644 project/static/js/attachment.js create mode 100644 project/static/svg/full-cross-circle-alt-svgrepo-com.svg create mode 100644 project/templates/admin/actions.html create mode 100644 project/templates/admin/file/form.html create mode 100644 project/templates/admin/file/list.html create mode 100644 project/templates/admin/file/modals/form.html create mode 100644 project/templates/admin/index.html create mode 100644 project/templates/admin/lib.html create mode 100644 project/templates/admin/master.html create mode 100644 project/templates/admin/model/create.html create mode 100644 project/templates/admin/model/details.html create mode 100644 project/templates/admin/model/edit.html create mode 100755 project/templates/admin/model/list.html create mode 100644 project/templates/admin/model/modals/create.html create mode 100755 project/templates/admin/model/modals/details.html create mode 100644 project/templates/admin/model/modals/edit.html create mode 100644 project/templates/admin/rediscli/console.html create mode 100644 project/templates/airtable/airtable_base.html create mode 100644 project/templates/airtable/dashboard.html create mode 100644 project/templates/airtable/edit_record.html create mode 100644 project/templates/airtable/form.html create mode 100644 project/templates/airtable/my_modules.html create mode 100644 project/templates/airtable/partials/footer.html create mode 100644 project/templates/airtable/participants/participants_detail.html create mode 100644 project/templates/airtable/participants/participants_edit.html create mode 100644 project/templates/airtable/participants/participants_list.html create mode 100644 project/templates/airtable/submitted.html create mode 100644 project/templates/base.html create mode 100644 project/templates/index.html create mode 100644 project/templates/partials/navbar.html create mode 100644 project/templates/profile.html create mode 100644 project/templates/security/_macros.html create mode 100644 project/templates/security/forgot_password.html create mode 100644 project/templates/security/login_user.html create mode 100644 project/templates/security/register_user.html create mode 100644 project/templates/users.html create mode 100644 project/views.py create mode 100644 requirements.txt diff --git a/.flaskenv b/.flaskenv new file mode 100644 index 0000000..660fea3 --- /dev/null +++ b/.flaskenv @@ -0,0 +1 @@ +FLASK_APP=project.app:create_app \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3e71c76 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +actual.env +.env +.venv +venv +data.db +*__pycache__* diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..5762f67 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Python: Flask", + "type": "python", + "request": "launch", + "module": "flask", + "args": [ + "run", + "--debug", + ], + "jinja": true, + "justMyCode": true + } + ] +} \ No newline at end of file diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..18182ee --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: gunicorn project.app:app \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..865130b --- /dev/null +++ b/README.md @@ -0,0 +1,12 @@ +# Flask Secure application + +This is a template for a Flask application, using Flask-Security-Too and SQLAlchemy. + +## Usage + +1. Clone the project +2. Rename prod.env or dev.env to .env +3. In .env, modify what you need to +4. Create and activate python virtual env +5. Install requirements with `pip install -r requirements.txt` +6. Now you can run your app with `flask run` \ No newline at end of file diff --git a/model.env b/model.env new file mode 100644 index 0000000..02652c9 --- /dev/null +++ b/model.env @@ -0,0 +1,26 @@ +AIRTABLE_API_KEY="keyXXXXXXXXXXX" +AIRTABLE_BASE_ID="appXXXXXXXXXXX" +AIRTABLE_TABLE_NAME="my_table_name" +APP_SETTINGS="project.config.DevelopmentConfig" +DATABASE_URL="sqlite:///data.db" +DEV_SECRET_KEY="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +DEV_SECURITY_PASSWORD_SALT="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +DOKKU_APP_RESTORE="1" +DOKKU_APP_TYPE="herokuish" +DOKKU_PROXY_PORT="80" +DOKKU_PROXY_PORT_MAP="http:80:5000 https:443:5000" +DOKKU_PROXY_SSL_PORT="443" +FLASK_APP="project.app" +GIT_REV="a7ab18abec8faf898ade0d4c4afbd15c16306287" +GOOGLE_OAUTH_CLIENT_ID="xxxxxxxxxxxxxxxxxxxxxx.apps.googleusercontent.com" +GOOGLE_OAUTH_CLIENT_SECRET="GOCSPX-XXXXXX-XXXXXXXXXXXXXXXXX" +MAIL_PASSWORD="xxxxxxxxxxxxxxxxxx" +MAIL_PORT="587" +MAIL_SERVER="mail.xx.xx" +MAIL_USERNAME="mail@mail.xx" +MAIL_USE_TLS="True" +OAUTHLIB_INSECURE_TRANSPORT="true" +PRODUCTION_SECRET_KEY="XXXXXXXXXXXXXXXXXXXXXXXXXXXXXxx" +PRODUCTION_SECURITY_PASSWORD_SALT="XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXxx" +SQLALCHEMY_ECHO="True" +ALLOWED_USERS=["user@test.xx, "admin@test.xx"] diff --git a/project/admin.py b/project/admin.py new file mode 100644 index 0000000..b9e4af7 --- /dev/null +++ b/project/admin.py @@ -0,0 +1,55 @@ +from flask_admin import Admin +from flask_admin.contrib.sqla import ModelView +from flask_security import Security, current_user +from wtforms.fields import PasswordField + +from flask import current_app +from database import db_session +from models import User, Role + +# + + +bulma = ["https://cdnjs.cloudflare.com/ajax/libs/bulma/0.9.4/css/bulma.css"] +current_app.config['FLASK_ADMIN_EXTRA_CSS'] = bulma +admin = Admin(current_app, name='Admin', template_mode='bootstrap4') + +class UserModelView(ModelView): + column_list = ['email','role'] + create_modal = False + can_view_details = True + extra_css = ["https://cdnjs.cloudflare.com/ajax/libs/bulma/0.9.4/css/bulma.css"] + + def is_accessible(self): + return current_user.is_authenticated + + def inaccessible_callback(self, name, **kwargs): + return redirect(url_for('security.login', next=request.url)) + + def scaffold_form(self): + form_class = super(UserModelView, self).scaffold_form() + form_class.password2 = PasswordField('New Password') + return form_class + def on_model_change(self, form, model, is_created): + if len(model.password2): + model.password = utils.encrypt_password(model.password2) + + def get_save_return_url(self, model, is_created): + return self.get_url('.details_view', id=model.id) + + # define a context processor for merging flask-admin's template context into the + # flask-security views. + security = current_app.security + @security.context_processor + def security_context_processor(): + def get_url(admin, **kwargs): + return url_for(admin, **kwargs) + return dict( + admin_base_template=admin.base_template, + admin_view=admin.index_view, + h=admin_helpers, + get_url=url_for + ) + + +admin.add_view(UserModelView(User, db_session)) diff --git a/project/airtableget.py b/project/airtableget.py new file mode 100644 index 0000000..5e6fa86 --- /dev/null +++ b/project/airtableget.py @@ -0,0 +1,224 @@ +from flask import Blueprint, render_template, request, redirect, url_for, current_app +from flask_security import current_user, auth_required +from werkzeug.utils import secure_filename +from airtable import Airtable +from dotenv import load_dotenv +import requests +import os +from base64 import b64encode +import json + +basedir = os.path.abspath(os.path.dirname(__file__)) +load_dotenv(os.path.join(os.path.dirname(basedir), '.env')) + +api_key = os.environ.get("AIRTABLE_API_KEY" or '') +base_id = os.environ.get("AIRTABLE_BASE_ID" or '') + +airtable_bp = Blueprint("airtable", __name__, template_folder="templates/airtable") + +table_call = { + 'participants': "Participants", + 'profs': "Professeurs", + 'formations': "Formations", + 'parcours': "Spécialités", + 'sessions': "Sessions", + 'modules': "Matières", + 'ateliers': "Séances", + 'groupes': "Groupes", + 'log_ateliers': "Suivi_séances", + 'skills': "Compétences", + 'villes': "Villes", + 'log_modules': "Suivi_matières", + 'log_skills': "Suivi_compétences", + 'log_participants': "Bulletins" +} + +def fetch_api(table): + data = Airtable(base_id, table_call[table], api_key) + return data + +def upload_files(files): + urls = [] + for filename, storage in files: + filename = secure_filename(filename) + filepath = os.path.join(current_app.static_folder, filename) + storage.save(filepath) + file_url = request.url_root.rstrip('/') + url_for('static', filename=filename) + attachment = {'url': file_url, 'filename': filename} + urls.append(attachment) + print("urls uploaded: " + str(urls)) + return urls + +def update_airtable_attachment_field(request, record, field_name, airtable_column_name=None): + airtable_column_name = airtable_column_name or field_name + uploaded_files = list(request.files.items()) or [] + + # set of files sent via input fields + set_input = set(file_id[len(field_name) + 1:] for file_id, file_storage in uploaded_files if file_id.startswith(field_name)) + if record.get('fields').get(airtable_column_name): + # set of airtable attachments ids + set_airtable = set(file['id'] for file in record['fields'][airtable_column_name]) + else: + set_airtable = set() + print("set_airtable: " + str(set_airtable)) + print("set_input: " + str(set_input)) + + # Éléments présents dans set_airtable mais pas dans set_input + to_remove = list(set_airtable - set_input) + print("Éléments dans airtable mais pas dans le formulaire:", to_remove) + + # Éléments présents dans set_input mais pas dans set_airtable + to_add = list(set_input - set_airtable) + print("Éléments dans le formulaire mais pas dans airtable:", to_add, "(len: ", len(to_add), " )") + + base_data = [ attachment for attachment in record['fields'].get(airtable_column_name, [])] + print("base_data: " + str(base_data)) + data = [ attachment for attachment in record['fields'].get(airtable_column_name, []) if attachment['id'] not in to_remove] + print(" ") + print("======================================") + print("base_data after filter: " + str(base_data)) + if len(to_add) > 0: + files = [file for file in uploaded_files if file[0][len(field_name) + 1:] in to_add] + new_attachments = upload_files(files) + data.extend(new_attachments) + + print("======================================") + print("data + " + airtable_column_name + " : " + str(data)) + + return data + + + +@airtable_bp.route('/dashboard', methods=['GET']) +@auth_required() +def airtable_dashboard(): + return render_template('dashboard.html') + + +@airtable_bp.route('/participants', methods=['GET']) +@auth_required() +def display_participants(): + participants = fetch_api('participants') + print("Participants: " + str(participants)) + records = [] + for participant in participants.get_all(): + records.append(participant) + return render_template('participants/participants_list.html', records=records) + +@airtable_bp.route("/participants/", methods=['GET']) +@auth_required() +def display_participant_form(participant_number): + participants = fetch_api('participants') + sessions = fetch_api('sessions') + participant = participants.search('Number', participant_number)[0] + return render_template('participants/participants_detail.html', record=participant, sessions=sessions) + +@airtable_bp.route('/participants//edit', methods=['GET', 'POST']) +@auth_required() +def edit_participant(participant_number): + participants = fetch_api('participants') + record = participants.search('Number', participant_number)[0] + record_id = record['id'] + #print("record: " + str(record_id) + " - record_CNI: " + str(record['fields']['CNI'])) + if request.method == 'POST': + print("======================================") + print("request.form elements: ") + + for field, value in request.form.items(): + print(f'{field}: {value}') + + print("======================================") + print("request files: ") + for field, value in request.files.items(): + print(f'{field}: {value}') + print("======================================") + data = {} + if request.form['first_name'] != record['fields']['Prénom']: + data['Prénom'] = request.form['first_name'] + if request.form['family_name'] != record['fields']['Nom']: + data['Nom de famille'] = request.form['family_name'] + data['CNI'] = update_airtable_attachment_field(request, record, "CNI") + data["Attestation Pôle Emploi"] = update_airtable_attachment_field(request, record, "Attestation", "Attestation Pôle Emploi") + + if data: + participants.update(record_id, data) + return redirect(url_for('airtable.display_participant_form', participant_number=participant_number)) + else: + return render_template('airtable/participants/participants_edit.html', record=record) + + + +@airtable_bp.route('/my_modules', methods=['GET']) +@auth_required() +def display_records(): + profs = fetch_api('profs') + all_modules = fetch_api('log_modules') + + # Get the mentor's email from the current user + user_email = current_user.email + for prof in profs.get_all(): + if prof['fields'].get('email') and prof['fields']['email'] == user_email: + print("prof found: " + prof['fields']['email'] + " - id : "+ prof['id']) + records = [record for record in all_modules.get_all() if record['fields'].get('Professeur') and record['fields']['Professeur'][0] == prof['id']] + break + else: + records = [] + + return render_template('my_modules.html', records=records, profs=profs) + +@airtable_bp.route('/edit_record/', methods=['GET', 'POST']) +def edit_record(record_id): + field_options = {} + for field, table_name in ext_fields.items(): + field_options[field.capitalize()] = get_field_options(base_id, table_name) + + record = airtable.get(record_id) + if request.method == 'POST': + data = {} + + if request.form['name'] != record['fields']['Name']: + data['Name'] = request.form['name'] + if request.form['notes'] != record['fields']['Notes']: + data['Notes'] = request.form['notes'] + if request.form['status'] != record['fields']['Status']: + data['Status'] = request.form['status'] + if request.form['atelier'] != record['fields']['atelier'][0]: + data['atelier'] = [request.form['atelier']] + if set(request.form.getlist('mentors')) != set(record['fields'].get('mentors', [])): + data['mentors'] = request.form.getlist('mentors') + + if data: + airtable.update(record_id, data) + return redirect(url_for('airtable.display_records')) + else: + record = airtable.get(record_id) + + return render_template('edit_record.html', record=record, field_options=field_options) + + +@airtable_bp.route('/create_record', methods=['GET', 'POST']) +def create_record(): + field_options = {} + for field, table_name in ext_fields.items(): + field_options[field.capitalize()] = get_field_options(base_id, table_name) + + if request.method == 'POST': + data = { + 'Name': request.form['name'], + 'Notes': request.form['notes'], + 'Status': request.form['status'], + 'atelier': [request.form['atelier']], + 'mentors': [request.form['mentors']] + #'Date et heure de la fin du Passe Numérique': request.form['date_time'], + #'As-tu terminé le Passe Numérique jusqu\'au bout?': request.form['completed'], + #'Résultat à la sortie de l\'opération': request.form['result'], + #'Choisi le niveau que tu penses avoir atteint à la fin du Passe Numérique': request.form['level'], + #'Peux-tu me parler de ton parcours?': request.form['story'] + } + airtable.insert(data) + return redirect(url_for('airtable.submitted')) + return render_template('form.html', field_options=field_options) + +@airtable_bp.route('/submitted') +def submitted(): + return render_template('submitted.html') diff --git a/project/app.py b/project/app.py new file mode 100644 index 0000000..f923708 --- /dev/null +++ b/project/app.py @@ -0,0 +1,156 @@ +import os +import sys +import string +import random +import uuid + +from flask import Flask, session, render_template_string, render_template, redirect, url_for, flash, request, current_app +from flask_security import Security, current_user, auth_required, hash_password, SQLAlchemySessionUserDatastore +from flask_login import login_user, logout_user +from flask_font_awesome import FontAwesome + +from flask_dance.contrib.google import make_google_blueprint, google +from urllib.parse import urlencode + +from flask_mailman import Mail +from werkzeug.middleware.proxy_fix import ProxyFix +from werkzeug.security import generate_password_hash + +from .config import Config, ProductionConfig, DevelopmentConfig +from dotenv import load_dotenv + +from .database import db_session, init_db +from .models import User, Role + +from .views import views_bp +from .airtableget import airtable_bp + +fa = FontAwesome() + +# Create app +def create_app(): + app = Flask(__name__, instance_relative_config=True) + app.config['MAX_CONTENT_LENGTH'] = 20 * 1024 * 1024 # Set the maximum allowed request size to 100MB + debug_mode = False + print("Creating app") + for i, arg in enumerate(sys.argv): + print("arg: " + str(arg)) + if arg == "--debug": + print("debug mode activé") + app.config.from_object(DevelopmentConfig) + debug_mode = True + break + print("end of variable retrieval") + + if not debug_mode: + app.config.from_object(ProductionConfig) + # Create Google OAuth blueprint + google_blueprint = make_google_blueprint( + client_id=app.config['GOOGLE_OAUTH_CLIENT_ID'], + client_secret=app.config['GOOGLE_OAUTH_CLIENT_SECRET'], + scope=["https://www.googleapis.com/auth/userinfo.email", "openid"], + redirect_url="/authorized", + ) + # Register the Google OAuth blueprint + app.register_blueprint(google_blueprint, url_prefix="/login") + + def google_login_url(): + google_auth_url = url_for("google.login", _external=True) + params = { + "access_type": "offline", + "prompt": "consent", + } + return f"{google_auth_url}?{urlencode(params)}" + + @app.context_processor + def inject_google_login_url(): + return dict(google_login_url=google_login_url) + + user_datastore = SQLAlchemySessionUserDatastore(db_session, User, Role) + print("user_datastore retrieved in prod") + app.security = Security(app, user_datastore) + print("app.security initialized in prod") + + mail = Mail(app) + + app.wsgi_app = ProxyFix( + app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1 + ) + + @app.route('/authorized') + def authorized(): + if debug_mode: + return "Not available in development mode", 404 + if not google.authorized: + return redirect(url_for("google.login")) + resp = google.get("/oauth2/v3/tokeninfo") + if not resp.ok: + flash("Failed to authenticate with Google.", "danger") + return redirect(url_for("views.home")) + + idinfo = resp.json() + email = idinfo['email'] + if 'name' in idinfo: + name = idinfo['name'] + else: + name = email + + # Check if the user's email is in the allowed list + if email not in app.config['ALLOWED_USERS']: + flash("Your email is not authorized.", "danger") + return redirect(url_for("views.home")) + + # Check if the user exists in the database + user = app.security.datastore.find_user(email=email) + # If the user does not exist, create a new user + if not user: + random_password = generate_random_password() + hashed_password = generate_password_hash(random_password, method='sha256') + #fs_uniquifier = str(uuid.uuid4()) + user = app.security.datastore.create_user(email=email, username=name, password=hashed_password) + db_session.commit() + + login_user(user) + + # Set the session variable + session['logged_in_with_google'] = True + + # Redirect to the desired page after login, e.g., the index page + return redirect(url_for("views.home")) + + # Register the Google authorized route only in production + app.add_url_rule('/authorized', view_func=authorized) + + + def generate_random_password(length=12): + characters = string.ascii_letters + string.digits + string.punctuation + return ''.join(random.choice(characters) for i in range(length)) + + @app.route("/logout") + @auth_required() + def logout(): + logout_user() + return redirect(url_for("views.home")) + + app.register_blueprint(views_bp) + app.register_blueprint(airtable_bp) + + with app.app_context(): + # Create a user to test with + init_db() + for user in app.config['ALLOWED_USERS']: + if not (user.endswith("gmail.com") or user.endswith("thargo.io")) and not app.security.datastore.find_user(email=user): + app.security.datastore.create_user(email=user, password=hash_password(generate_random_password())) + db_session.commit() + + #from admin import admin + #admin.init_app(app) + + fa.init_app(app) + + return app + +app = create_app() + +if __name__ == "__main__": + app.run() \ No newline at end of file diff --git a/project/config.py b/project/config.py new file mode 100644 index 0000000..c346f6b --- /dev/null +++ b/project/config.py @@ -0,0 +1,45 @@ +import os +from dotenv import load_dotenv +import json + +basedir = os.path.abspath(os.path.dirname(__file__)) +load_dotenv(os.path.join(os.path.dirname(basedir), '.env')) + +class Config(): + DEBUG = False + TESTING = False + CSRF_ENABLED = True + SQLALCHEMY_TRACK_MODIFICATIONS = False + SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL") + SESSION_COOKIE_SECURE = True + SESSION_COOKIE_HTTPONLY = True + SESSION_COOKIE_SAMESITE = 'None' + MAIL_SERVER = os.environ.get("MAIL_SERVER") + MAIL_PORT = os.environ.get("MAIL_PORT") + MAIL_USE_TLS = os.environ.get("MAIL_USE_TLS") + MAIL_USE_SSL = os.environ.get("MAIL_USE_SSL") + MAIL_USERNAME = os.environ.get("MAIL_USERNAME") + MAIL_PASSWORD = os.environ.get("MAIL_PASSWORD") + MAIL_DEFAULT_SENDER = os.environ.get("MAIL_DEFAULT_SENDER") + SECURITY_RECOVERABLE = True + SECURITY_EMAIL_SENDER = os.environ.get("MAIL_SENDER") + +class ProductionConfig(Config): + DEBUG = False + FLASK_DEBUG = False + SECRET_KEY = os.environ.get("PRODUCTION_SECRET_KEY") + SECURITY_PASSWORD_SALT = os.environ.get("PRODUCTION_SECURITY_PASSWORD_SALT") + SQLALCHEMY_TRACK_MODIFICATIONS = False + GOOGLE_OAUTH_CLIENT_ID = os.environ.get("GOOGLE_OAUTH_CLIENT_ID") + GOOGLE_OAUTH_CLIENT_SECRET = os.environ.get("GOOGLE_OAUTH_CLIENT_SECRET") + ALLOWED_USERS = json.loads(os.environ.get("ALLOWED_USERS")) + +class DevelopmentConfig(Config): + DEVELOPMENT = True + FLASK_DEBUG = True + DEBUG = True + OAUTHLIB_INSECURE_TRANSPORT = True + SECRET_KEY = os.environ.get("DEV_SECRET_KEY") + SECURITY_PASSWORD_SALT = os.environ.get("DEV_SECURITY_PASSWORD_SALT") + SQLALCHEMY_TRACK_MODIFICATIONS = True + ALLOWED_USERS = json.loads(os.environ.get("ALLOWED_USERS")) diff --git a/project/database.py b/project/database.py new file mode 100644 index 0000000..f6e11cf --- /dev/null +++ b/project/database.py @@ -0,0 +1,25 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import scoped_session, sessionmaker +from sqlalchemy.ext.declarative import declarative_base + +from flask import current_app + +import os + +engine = None +db_session = scoped_session(sessionmaker(autocommit=False, + autoflush=False, + bind=engine)) +Base = declarative_base() +Base.query = db_session.query_property() + +def init_db(): + DATABASE_URL = current_app.config['SQLALCHEMY_DATABASE_URI'] + print("inside init_db, database_url : " + str(DATABASE_URL)) + engine = create_engine(DATABASE_URL, pool_size=10, max_overflow=20) + db_session.configure(bind=engine) + # import all modules here that might define models so that + # they will be registered properly on the metadata. Otherwise + # you will have to import them first before calling init_db() + from . import models + Base.metadata.create_all(bind=engine) \ No newline at end of file diff --git a/project/models.py b/project/models.py new file mode 100644 index 0000000..a30807b --- /dev/null +++ b/project/models.py @@ -0,0 +1,36 @@ +from .database import Base +from flask_security import UserMixin, RoleMixin +from sqlalchemy import create_engine +from sqlalchemy.orm import relationship, backref +from sqlalchemy import Boolean, DateTime, Column, Integer, \ + String, ForeignKey, UnicodeText + +class RolesUsers(Base): + __tablename__ = 'roles_users' + id = Column(Integer(), primary_key=True) + user_id = Column('user_id', Integer(), ForeignKey('user.id')) + role_id = Column('role_id', Integer(), ForeignKey('role.id')) + +class Role(Base, RoleMixin): + __tablename__ = 'role' + id = Column(Integer(), primary_key=True) + name = Column(String(80), unique=True) + description = Column(String(255)) + permissions = Column(UnicodeText) + +class User(Base, UserMixin): + __tablename__ = 'user' + id = Column(Integer, primary_key=True) + email = Column(String(255), unique=True) + username = Column(String(255), unique=True, nullable=True) + password = Column(String(255), nullable=False) + last_login_at = Column(DateTime()) + current_login_at = Column(DateTime()) + last_login_ip = Column(String(100)) + current_login_ip = Column(String(100)) + login_count = Column(Integer) + active = Column(Boolean()) + fs_uniquifier = Column(String(255), unique=True, nullable=False) + confirmed_at = Column(DateTime()) + roles = relationship('Role', secondary='roles_users', + backref=backref('users', lazy='dynamic')) \ No newline at end of file diff --git a/project/static/css/thumbnail.css b/project/static/css/thumbnail.css new file mode 100644 index 0000000..44dda67 --- /dev/null +++ b/project/static/css/thumbnail.css @@ -0,0 +1,5 @@ +.thumbnail { + width: 100%; + height: auto; + object-fit: cover; +} \ No newline at end of file diff --git a/project/static/js/attachment.js b/project/static/js/attachment.js new file mode 100644 index 0000000..f356722 --- /dev/null +++ b/project/static/js/attachment.js @@ -0,0 +1,147 @@ +let oldImages = {}; +let newImages = {}; + +function toggleThumbnail(attachmentId, fieldName) { + const redCross = document.getElementById(`red_cross_${fieldName}_${attachmentId}`); + const removeLabel = document.getElementById(`remove_label_${fieldName}_${attachmentId}`); + const inputElement = document.getElementById(`${fieldName}_${attachmentId}`); + if (redCross.style.display === "none") { + redCross.style.display = "block"; + removeLabel.innerText = "Keep"; + inputElement.disabled = true; + } else { + redCross.style.display = "none"; + removeLabel.innerText = "Remove"; + inputElement.disabled = false; + }; +}; + + +function removeNewThumbnail(inputId, fieldName) { + const columnElement = document.getElementById(`column_${inputId}`); + columnElement.remove(); +}; + +function addImage(fieldName, columnsId) { + const columns = document.getElementById(columnsId); + const inputId = `${fieldName}_${columns.childElementCount + 1}`; + const thumbnailId = `new_${fieldName}_thumbnail_${columns.childElementCount + 1}`; + const removeLabelId = `remove_label_${fieldName}_${columns.childElementCount + 1}`; + + // Create a new column element + const newColumn = document.createElement('div'); + newColumn.setAttribute('class', 'column is-one-quarter'); + newColumn.setAttribute('id', `column_${inputId}`); + + // Create a new card element + const newCard = document.createElement('div'); + newCard.setAttribute('class', 'card'); + + // Create a new card image element + const newCardImage = document.createElement('div'); + newCardImage.setAttribute('class', 'card-image'); + + // Create a new figure element + const newFigure = document.createElement('figure'); + newFigure.setAttribute('class', 'image is-4by3'); + + // Create a new thumbnail element + const newThumbnail = document.createElement('img'); + newThumbnail.setAttribute('class', 'thumbnail'); + newThumbnail.setAttribute('id', thumbnailId); + + // Create new thumbnail link + const newAThumbnail = document.createElement('a'); + newAThumbnail.setAttribute('href', '#'); + newAThumbnail.setAttribute('target', '_blank'); + newAThumbnail.appendChild(newThumbnail); + + // Add elements to the figure + newFigure.appendChild(newAThumbnail); + + // Add elements to the card image + newCardImage.appendChild(newFigure); + + // Create a new card footer element + const newCardFooter = document.createElement('div'); + newCardFooter.setAttribute('class', 'card-footer'); + + const newRemoveLabelDiv = document.createElement('div'); + newRemoveLabelDiv.setAttribute('class', 'button card-footer-item'); + newRemoveLabelDiv.setAttribute('id', removeLabelId); + newRemoveLabelDiv.setAttribute('data-attachment-id', inputId); + newRemoveLabelDiv.innerText = 'Remove'; + + // Add elements to the card footer + newCardFooter.appendChild(newRemoveLabelDiv); + + // Add elements to the card + newCard.appendChild(newCardImage); + newCard.appendChild(newCardFooter); + + // Add elements to the new column + newColumn.appendChild(newCard); + + // Append the new column element to the columns container + columns.appendChild(newColumn); + + // Trigger the "click" event on the file input + const newInput = document.createElement('input'); + newInput.setAttribute('id', `${inputId}`); + newInput.setAttribute('type', 'file'); + newInput.setAttribute('data-field', `${fieldName}`) + newInput.style.display = 'none'; + newInput.setAttribute('name', `${inputId}`); + newCard.appendChild(newInput); + newInput.click(); + + window.addEventListener('focus', function handleFocus() { + window.removeEventListener('focus', handleFocus); // Remove the event listener to prevent it from triggering multiple times + + setTimeout(() => { + const file = newInput.files[0]; + if (file) { + const reader = new FileReader(); + reader.onload = function (e) { + // Set the thumbnail source + newThumbnail.src = e.target.result; + newAThumbnail.href = e.target.result; + + // Add a click event listener to the remove label + newRemoveLabelDiv.addEventListener('click', function (event) { + event.preventDefault(); + removeNewThumbnail(inputId, fieldName); + }); + }; + reader.readAsDataURL(file); + } else { + // Remove the div and input if the file dialog is canceled + removeNewThumbnail(inputId); + } + + // Remove the input element after processing the file + //newInput.remove(); + }, 100); // Add a 100ms delay + }); +}; + +// Add a click event listener for each add-image-label element +document.querySelectorAll('[id^="add-image-label"]').forEach(function (element) { + element.addEventListener('click', function (event) { + event.preventDefault(); + const fieldName = element.getAttribute('data-field'); + const columnsContainer = element.getAttribute('data-columns-container'); + addImage(fieldName, columnsContainer); + }); +}); + +// Add event listeners for existing images fetched from Airtable +document.querySelectorAll('.existing-remove-label').forEach(function (removeLabel) { + const fieldName = removeLabel.getAttribute('data-field'); + const attachmentId = removeLabel.getAttribute('data-attachment-id'); + + removeLabel.addEventListener('click', function (event) { + event.preventDefault(); + toggleThumbnail(attachmentId, fieldName); + }); +}); \ No newline at end of file diff --git a/project/static/svg/full-cross-circle-alt-svgrepo-com.svg b/project/static/svg/full-cross-circle-alt-svgrepo-com.svg new file mode 100644 index 0000000..71af829 --- /dev/null +++ b/project/static/svg/full-cross-circle-alt-svgrepo-com.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/project/templates/admin/actions.html b/project/templates/admin/actions.html new file mode 100644 index 0000000..d8e29d2 --- /dev/null +++ b/project/templates/admin/actions.html @@ -0,0 +1,43 @@ +{% import 'admin/static.html' as admin_static with context %} + +{% macro dropdown(actions) -%} + +{% endmacro %} + +{% macro form(actions, url) %} + {% if actions %} + + {% endif %} +{% endmacro %} + +{% macro script(message, actions, actions_confirmation) %} + {% if actions %} + + + + {% endif %} +{% endmacro %} diff --git a/project/templates/admin/file/form.html b/project/templates/admin/file/form.html new file mode 100644 index 0000000..d78246e --- /dev/null +++ b/project/templates/admin/file/form.html @@ -0,0 +1,9 @@ +{% extends 'admin/master.html' %} +{% import 'admin/lib.html' as lib with context %} + +{% block content %} + {% block header %}

{{ header_text }}

{% endblock %} + {% block fa_form %} + {{ lib.render_form(form, dir_url) }} + {% endblock %} +{% endblock %} diff --git a/project/templates/admin/file/list.html b/project/templates/admin/file/list.html new file mode 100644 index 0000000..8616b65 --- /dev/null +++ b/project/templates/admin/file/list.html @@ -0,0 +1,191 @@ +{% extends 'admin/master.html' %} +{% import 'admin/lib.html' as lib with context %} +{% import 'admin/actions.html' as actionslib with context %} + +{% block content %} + {% block breadcrums %} + + {% endblock %} + + {% block file_list_table %} +
+ + + + {% block list_header scoped %} + {% if actions %} + + {% endif %} + + {% for column in admin_view.column_list %} + + {% endfor %} + {% endblock %} + + + {% for name, path, is_dir, size, date in items %} + + {% block list_row scoped %} + {% if actions %} + + {% endif %} + + {% if is_dir %} + + {% else %} + + {% if admin_view.is_column_visible('size') %} + + {% endif %} + {% endif %} + {% if admin_view.is_column_visible('date') %} + + {% endif %} + {% endblock %} + + {% endfor %} +
+ +   + {% if admin_view.is_column_sortable(column) %} + {% if sort_column == column %} + + {{ admin_view.column_label(column) }} + {% if sort_desc %} + + {% else %} + + {% endif %} + + {% else %} + {{ admin_view.column_label(column) }} + {% endif %} + {% else %} + {{ _gettext(admin_view.column_label(column)) }} + {% endif %} +
+ {% if not is_dir %} + + {% endif %} + + {% block list_row_actions scoped %} + {% if admin_view.can_rename and path and name != '..' %} + {%- if admin_view.rename_modal -%} + {{ lib.add_modal_button(url=get_url('.rename', path=path, modal=True), + title=_gettext('Rename File'), + content='') }} + {% else %} + + + + {%- endif -%} + {% endif %} + {%- if admin_view.can_delete and path -%} + {% if is_dir %} + {% if name != '..' and admin_view.can_delete_dirs %} +
+ {{ delete_form.path(value=path) }} + {{ delete_form.csrf_token }} + +
+ {% endif %} + {% else %} +
+ {{ delete_form.path(value=path) }} + {{ delete_form.csrf_token }} + +
+ {% endif %} + {%- endif -%} + {% endblock %} +
+ + {{ name }} + + + {% if admin_view.can_download %} + {%- if admin_view.edit_modal and admin_view.is_file_editable(path) -%} + {{ lib.add_modal_button(url=get_file_url(path, modal=True)|safe, + btn_class='', content=name) }} + {% else %} + {{ name }} + {%- endif -%} + {% else %} + {{ name }} + {% endif %} + + {{ size|filesizeformat }} + + {{ timestamp_format(date) }} +
+
+ {% endblock %} + {% block toolbar %} +
+ {% if admin_view.can_upload %} +
+ {%- if admin_view.upload_modal -%} + {{ lib.add_modal_button(url=get_dir_url('.upload', path=dir_path, modal=True), + btn_class="btn btn-secondary", + content=_gettext('Upload File')) }} + {% else %} + {{ _gettext('Upload File') }} + {%- endif -%} +
+ {% endif %} + {% if admin_view.can_mkdir %} +
+ {%- if admin_view.mkdir_modal -%} + {{ lib.add_modal_button(url=get_dir_url('.mkdir', path=dir_path, modal=True), + btn_class="btn btn-secondary", + content=_gettext('Create Directory')) }} + {% else %} + {{ _gettext('Create Directory') }} + {%- endif -%} +
+ {% endif %} + {% if actions %} +
+ {{ actionslib.dropdown(actions, 'dropdown-toggle btn btn-secondary') }} +
+ {% endif %} +
+ {% endblock %} + + {% block actions %} + {{ actionslib.form(actions, get_url('.action_view')) }} + {% endblock %} + + {%- if admin_view.rename_modal or admin_view.mkdir_modal + or admin_view.upload_modal or admin_view.edit_modal -%} + {{ lib.add_modal_window() }} + {%- endif -%} +{% endblock %} + +{% block tail %} + {{ super() }} + {{ actionslib.script(_gettext('Please select at least one file.'), + actions, + actions_confirmation) }} + +{% endblock %} diff --git a/project/templates/admin/file/modals/form.html b/project/templates/admin/file/modals/form.html new file mode 100644 index 0000000..eebc699 --- /dev/null +++ b/project/templates/admin/file/modals/form.html @@ -0,0 +1,19 @@ +{% import 'admin/static.html' as admin_static with context %} +{% import 'admin/lib.html' as lib with context %} + +{% block body %} + {# content added to modal-content #} + + +{% endblock %} + +{% block tail %} + +{% endblock %} diff --git a/project/templates/admin/index.html b/project/templates/admin/index.html new file mode 100644 index 0000000..89cfd27 --- /dev/null +++ b/project/templates/admin/index.html @@ -0,0 +1,5 @@ +{% extends 'admin/master.html' %} + +{% block content %} +Bonjour +{% endblock %} diff --git a/project/templates/admin/lib.html b/project/templates/admin/lib.html new file mode 100644 index 0000000..a4d0c37 --- /dev/null +++ b/project/templates/admin/lib.html @@ -0,0 +1,294 @@ +{% import 'admin/static.html' as admin_static with context %} + +{# ---------------------- Pager -------------------------- #} +{% macro pager(page, pages, generator) -%} +{% if pages > 1 %} +
    + {% set min = page - 3 %} + {% set max = page + 3 + 1 %} + + {% if min < 0 %} + {% set max = max - min %} + {% endif %} + {% if max >= pages %} + {% set min = min - max + pages %} + {% endif %} + + {% if min < 0 %} + {% set min = 0 %} + {% endif %} + {% if max >= pages %} + {% set max = pages %} + {% endif %} + + {% if min > 0 %} +
  • + « +
  • + {% else %} +
  • + « +
  • + {% endif %} + {% if page > 0 %} +
  • + < +
  • + {% else %} +
  • + < +
  • + {% endif %} + + {% for p in range(min, max) %} + {% if page == p %} +
  • + {{ p + 1 }} +
  • + {% else %} +
  • + {{ p + 1 }} +
  • + {% endif %} + {% endfor %} + + {% if page + 1 < pages %} +
  • + > +
  • + {% else %} +
  • + > +
  • + {% endif %} + {% if max < pages %} +
  • + » +
  • + {% else %} +
  • + » +
  • + {% endif %} +
+{% endif %} +{%- endmacro %} + +{% macro simple_pager(page, have_next, generator) -%} +
    + {% if page > 0 %} +
  • + < +
  • + {% else %} +
  • + < +
  • + {% endif %} + {% if have_next %} +
  • + > +
  • + {% else %} +
  • + > +
  • + {% endif %} +
+{%- endmacro %} + +{# ---------------------- Modal Window ------------------- #} +{% macro add_modal_window(modal_window_id='fa_modal_window', modal_label_id='fa_modal_label') %} + +{% endmacro %} + +{% macro add_modal_button(url='', title='', content='', modal_window_id='fa_modal_window', btn_class='icon') %} + + {{ content|safe }} + +{% endmacro %} + +{# ---------------------- Forms -------------------------- #} +{% macro render_field(form, field, kwargs={}, caller=None) %} + {% set direct_error = h.is_field_error(field.errors) %} + {% set prepend = kwargs.pop('prepend', None) %} + {% set append = kwargs.pop('append', None) %} +
+ + {% if prepend or append %} +
+ {%- if prepend -%} +
+ {{ prepend }} +
+ {%- endif -%} + {% endif %} + {% if field.widget.input_type == 'checkbox' %} + {% set _class = kwargs.setdefault('class', '') %} + {% elif field.widget.input_type == 'file' %} + {% set _class = kwargs.setdefault('class', 'form-control-file') %} + {% else %} + {% set _class = kwargs.setdefault('class', 'form-control') %} + {% endif %} + {%- if direct_error %} {% set _ = kwargs.update({'class': kwargs['class'] ~ ' is-invalid'}) %} {% endif -%} + {{ field(**kwargs) | safe }} + {%- if append -%} +
+ {{ append }} +
+ {%- endif -%} + {% if direct_error %} +
+
    + {% for e in field.errors if e is string %} +
  • {{ e }}
  • + {% endfor %} +
+
+ {% elif field.description %} + + {{ field.description|safe }} + + {% endif %} + {% if prepend or append %} +
+ {% endif %} + {% if caller %} + {{ caller(form, field, direct_error, kwargs) }} + {% endif %} +
+{% endmacro %} + +{% macro render_header(form, text) %} +

{{ text }}

+{% endmacro %} + +{% macro render_form_fields(form, form_opts=None) %} + {% if form.hidden_tag is defined %} + {{ form.hidden_tag() }} + {% else %} + {% if csrf_token %} + + {% endif %} + {% for f in form if f.widget.input_type == 'hidden' %} + {{ f }} + {% endfor %} + {% endif %} + + {% if form_opts and form_opts.form_rules %} + {% for r in form_opts.form_rules %} + {{ r(form, form_opts=form_opts) }} + {% endfor %} + {% else %} + {% for f in form if f.widget.input_type != 'hidden' %} + {% if form_opts %} + {% set kwargs = form_opts.widget_args.get(f.short_name, {}) %} + {% else %} + {% set kwargs = {} %} + {% endif %} + {{ render_field(form, f, kwargs) }} + {% endfor %} + {% endif %} +{% endmacro %} + +{% macro form_tag(form=None, action=None) %} +
+
+ {{ caller() }} +
+
+{% endmacro %} + +{% macro render_form_buttons(cancel_url, extra=None, is_modal=False) %} + {% if is_modal %} + + {% if extra %} + {{ extra }} + {% endif %} + {% if cancel_url %} + {{ _gettext('Cancel') }} + {% endif %} + {% else %} +
+
+
+ + {% if extra %} + {{ extra }} + {% endif %} + {% if cancel_url %} + {{ _gettext('Cancel') }} + {% endif %} +
+
+ {% endif %} +{% endmacro %} + +{% macro render_form(form, cancel_url, extra=None, form_opts=None, action=None, is_modal=False) -%} + {% call form_tag(action=action) %} + {{ render_form_fields(form, form_opts=form_opts) }} + {{ render_form_buttons(cancel_url, extra, is_modal) }} + {% endcall %} +{% endmacro %} + +{% macro form_css() %} + + + + {% if config.MAPBOX_MAP_ID %} + + + {% endif %} + {% if editable_columns %} + + {% endif %} +{% endmacro %} + +{% macro form_js() %} + {% if config.MAPBOX_MAP_ID %} + + + + {% if config.MAPBOX_SEARCH %} + + + {% endif %} + {% endif %} + + {% if editable_columns %} + + {% endif %} + +{% endmacro %} + +{% macro extra() %} + {% if admin_view.can_create %} + + {% endif %} + {% if admin_view.can_edit %} + + {% endif %} +{% endmacro %} diff --git a/project/templates/admin/master.html b/project/templates/admin/master.html new file mode 100644 index 0000000..0403237 --- /dev/null +++ b/project/templates/admin/master.html @@ -0,0 +1,72 @@ +{% import 'admin/layout.html' as layout with context -%} +{% import 'admin/static.html' as admin_static with context %} + +{% extends "/base.html" %} + +{% block head_scripts %} + {% block head_admin_scripts %} + {% endblock %} + + {% if config.get('FLASK_ADMIN_SWATCH', 'default') == 'default' %} + + {% endif %} + + + {% if admin_view.extra_css %} + {% for css_url in admin_view.extra_css %} + + {% endfor %} + {% endif %} + + {{ super() }} + +{% endblock %} + +{%- block styles %} + {{ super() }} +{% endblock %} + + + +{% block page_body %} + {% block brand %} + {% endblock %} + {% block main_menu %} + {% endblock %} + {% block menu_links %} + {% endblock %} + {% block access_control %} + {% endblock %} + {% block messages %} + {% endblock %} + {# store the jinja2 context for form_rules rendering logic #} + {% set render_ctx = h.resolve_ctx() %} + +{% endblock %} + +{% block scripts %} + {{ super() }} + {% block tail_js %} + + + + + + + + + + {% if admin_view.extra_js %} + {% for js_url in admin_view.extra_js %} + + {% endfor %} + {% endif %} + {% endblock tail_js %} + {% block tail %} + {% endblock %} +{% endblock %} + + diff --git a/project/templates/admin/model/create.html b/project/templates/admin/model/create.html new file mode 100644 index 0000000..c1f8792 --- /dev/null +++ b/project/templates/admin/model/create.html @@ -0,0 +1,32 @@ +{% extends 'admin/master.html' %} +{% import 'admin/lib.html' as lib with context %} +{% from 'admin/lib.html' import extra with context %} {# backward compatible #} + +{% block head_admin_scripts %} + {{ super() }} + {{ lib.form_css() }} +{% endblock %} + +{% block content %} + {% block navlinks %} + + {% endblock %} + + {% block create_form %} + {{ lib.render_form(form, return_url, extra(), form_opts) }} + {% endblock %} +{% endblock %} + +{% block tail %} + {{ super() }} + {{ lib.form_js() }} +{% endblock %} diff --git a/project/templates/admin/model/details.html b/project/templates/admin/model/details.html new file mode 100644 index 0000000..a222f87 --- /dev/null +++ b/project/templates/admin/model/details.html @@ -0,0 +1,54 @@ +{% extends 'admin/master.html' %} +{% import 'admin/lib.html' as lib with context %} + +{% block content %} + {% block navlinks %} +
+ +
+ {% endblock %} + + {% block details_search %} +
+ + +
+ {% endblock %} + + {% block details_table %} + + {% for c, name in details_columns %} + + + + + {% endfor %} +
+ {{ name }} + + {{ get_value(model, c) }} +
+ {% endblock %} +{% endblock %} + +{% block tail %} + {{ super() }} + +{% endblock %} diff --git a/project/templates/admin/model/edit.html b/project/templates/admin/model/edit.html new file mode 100644 index 0000000..f7cac97 --- /dev/null +++ b/project/templates/admin/model/edit.html @@ -0,0 +1,42 @@ +{% extends 'admin/master.html' %} +{% import 'admin/lib.html' as lib with context %} +{% from 'admin/lib.html' import extra with context %} {# backward compatible #} + +{% block head_admin_scripts %} + {{ super() }} + {{ lib.form_css() }} +{% endblock %} + +{% block content %} + {% block navlinks %} +
+ +
+ {% endblock %} + + {% block edit_form %} + {{ lib.render_form(form, return_url, extra(), form_opts) }} + {% endblock %} +{% endblock %} + +{% block tail %} + {{ super() }} + {{ lib.form_js() }} +{% endblock %} diff --git a/project/templates/admin/model/list.html b/project/templates/admin/model/list.html new file mode 100755 index 0000000..6ff2e70 --- /dev/null +++ b/project/templates/admin/model/list.html @@ -0,0 +1,212 @@ +{% extends 'admin/master.html' %} +{% import 'admin/lib.html' as lib with context %} +{% import 'admin/static.html' as admin_static with context%} +{% import 'admin/model/layout.html' as model_layout with context %} +{% import 'admin/actions.html' as actionlib with context %} +{% import 'admin/model/row_actions.html' as row_actions with context %} + +{% block head_admin_scripts %} + {{ super() }} + {{ lib.form_css() }} +{% endblock %} + +{% block content %} + {% block model_menu_bar %} +
+
    +
  • + {{ _gettext('List') }}{% if count %} ({{ count }}){% endif %} +
  • + + {% if admin_view.can_create %} +
  • + {%- if admin_view.create_modal -%} + {{ lib.add_modal_button(url=get_url('.create_view', url=return_url, modal=True), btn_class='nav-link', title=_gettext('Create New Record'), content=_gettext('Create')) }} + {% else %} + {{ _gettext('Create') }} + {%- endif -%} +
  • + {% endif %} + + {% if admin_view.can_export %} + {{ model_layout.export_options() }} + {% endif %} + + {% block model_menu_bar_before_filters %}{% endblock %} + + {% if filters %} + + {% endif %} + + {% if can_set_page_size %} + + {% endif %} + + {% if actions %} +
  • + {{ actionlib.dropdown(actions) }} +
  • + {% endif %} + + {% if search_supported %} +
  • + {{ model_layout.search_form() }} +
  • + {% endif %} + {% block model_menu_bar_after_filters %}{% endblock %} +
+
+ {% endblock %} + + {% if filters %} + {{ model_layout.filter_form() }} +
+ {% endif %} + + {% block model_list_table %} +
+ + + + {% block list_header scoped %} + {% if actions %} + + {% endif %} + {% block list_row_actions_header %} + {% if admin_view.column_display_actions %} + + {% endif %} + {% endblock %} + {% for c, name in list_columns %} + {% set column = loop.index0 %} + + {% endfor %} + {% endblock %} + + + {% for row in data %} + + {% block list_row scoped %} + {% if actions %} + + {% endif %} + {% block list_row_actions_column scoped %} + {% if admin_view.column_display_actions %} + + {%- endif -%} + {% endblock %} + + {% for c, name in list_columns %} + + {% endfor %} + {% endblock %} + + {% else %} + + + + {% endfor %} +
+ +   + {% if admin_view.is_sortable(c) %} + {% if sort_column == column %} + + {{ name }} + {% if sort_desc %} + + {% else %} + + {% endif %} + + {% else %} + {{ name }} + {% endif %} + {% else %} + {{ name }} + {% endif %} + {% if admin_view.column_descriptions.get(c) %} + + {% endif %} +
+ + + {% block list_row_actions scoped %} + {% for action in list_row_actions %} + {{ action.render_ctx(get_pk_value(row), row) }} + {% endfor %} + {% endblock %} + + {% if admin_view.is_editable(c) %} + {% set form = list_forms[get_pk_value(row)] %} + {% if form.csrf_token %} + {{ form[c](pk=get_pk_value(row), display_value=get_value(row, c), csrf=form.csrf_token._value()) }} + {% elif csrf_token %} + {{ form[c](pk=get_pk_value(row), display_value=get_value(row, c), csrf=csrf_token()) }} + {% else %} + {{ form[c](pk=get_pk_value(row), display_value=get_value(row, c)) }} + {% endif %} + {% else %} + {{ get_value(row, c) }} + {% endif %} +
+ {% block empty_list_message %} +
+ {{ admin_view.get_empty_list_message() }} +
+ {% endblock %} +
+
+ {% block list_pager %} + {% if num_pages is not none %} + {{ lib.pager(page, num_pages, pager_url) }} + {% else %} + {{ lib.simple_pager(page, data|length == page_size, pager_url) }} + {% endif %} + {% endblock %} + {% endblock %} + + {% block actions %} + {{ actionlib.form(actions, get_url('.action_view')) }} + {% endblock %} + + {%- if admin_view.edit_modal or admin_view.create_modal or admin_view.details_modal -%} + {{ lib.add_modal_window() }} + {%- endif -%} +{% endblock %} + +{% block tail %} + {{ super() }} + + {% if filter_groups %} + + + {% endif %} + {{ lib.form_js() }} + + + + + {{ actionlib.script(_gettext('Please select at least one record.'), + actions, + actions_confirmation) }} + + +{% endblock %} diff --git a/project/templates/admin/model/modals/create.html b/project/templates/admin/model/modals/create.html new file mode 100644 index 0000000..3f5097f --- /dev/null +++ b/project/templates/admin/model/modals/create.html @@ -0,0 +1,36 @@ +{% import 'admin/static.html' as admin_static with context%} +{% import 'admin/lib.html' as lib with context %} + +{# store the jinja2 context for form_rules rendering logic #} +{% set render_ctx = h.resolve_ctx() %} + +{% block body %} + + + {% call lib.form_tag(action=url_for('.create_view', url=return_url)) %} + + + {% endcall %} + + {# "save and add" button is removed from modal (it won't function properly) #} + {# % block create_form %} + {{ lib.render_form(form, return_url, extra=None, form_opts=form_opts, + action=url_for('.create_view', url=return_url), + is_modal=True) }} + {% endblock % #} + + +{% endblock %} + +{% block tail %} + +{% endblock %} diff --git a/project/templates/admin/model/modals/details.html b/project/templates/admin/model/modals/details.html new file mode 100755 index 0000000..793b4d0 --- /dev/null +++ b/project/templates/admin/model/modals/details.html @@ -0,0 +1,40 @@ +{% import 'admin/static.html' as admin_static with context%} +{% import 'admin/lib.html' as lib with context %} + +{% block body %} + + + +{% endblock %} + +{% block tail %} + + +{% endblock %} diff --git a/project/templates/admin/model/modals/edit.html b/project/templates/admin/model/modals/edit.html new file mode 100644 index 0000000..dd9e777 --- /dev/null +++ b/project/templates/admin/model/modals/edit.html @@ -0,0 +1,31 @@ +{% import 'admin/static.html' as admin_static with context%} +{% import 'admin/lib.html' as lib with context %} + +{# store the jinja2 context for form_rules rendering logic #} +{% set render_ctx = h.resolve_ctx() %} + +{% block body %} + + + {% call lib.form_tag(action=url_for('.edit_view', id=request.args.get('id'), url=return_url)) %} + + + {% endcall %} + +{% endblock %} + +{% block tail %} + +{% endblock %} diff --git a/project/templates/admin/rediscli/console.html b/project/templates/admin/rediscli/console.html new file mode 100644 index 0000000..d101ffe --- /dev/null +++ b/project/templates/admin/rediscli/console.html @@ -0,0 +1,27 @@ +{% extends 'admin/master.html' %} +{% import 'admin/lib.html' as lib with context %} +{% import 'admin/static.html' as admin_static with context%} + +{%- block head_scripts %} + {{ super() }} + +{% endblock %} + +{% block content %} +
+
+
+
+
+ +
+
+
+{% endblock %} + +{% block tail %} + {{ super() }} + + + +{% endblock %} diff --git a/project/templates/airtable/airtable_base.html b/project/templates/airtable/airtable_base.html new file mode 100644 index 0000000..cd7a188 --- /dev/null +++ b/project/templates/airtable/airtable_base.html @@ -0,0 +1,59 @@ +{% extends 'base.html' %} + +{%- block head_scripts %} +{{ super() }} + +{%- endblock head_scripts %} + +{% block styles %} +{{ super() }} + +{% endblock styles %} + + +{% block content %} +{% endblock %} + +{% block footer -%} +{% include "airtable/partials/footer.html" %} +{%- endblock footer %} + +{% block scripts %} +{{ super() }} + +{% endblock scripts %} \ No newline at end of file diff --git a/project/templates/airtable/dashboard.html b/project/templates/airtable/dashboard.html new file mode 100644 index 0000000..1b80eba --- /dev/null +++ b/project/templates/airtable/dashboard.html @@ -0,0 +1,8 @@ +{% extends 'airtable_base.html' %} + +{% block content %} +{{ super() }} +
+

Tableau de bord

+
+{% endblock %} \ No newline at end of file diff --git a/project/templates/airtable/edit_record.html b/project/templates/airtable/edit_record.html new file mode 100644 index 0000000..d909822 --- /dev/null +++ b/project/templates/airtable/edit_record.html @@ -0,0 +1,43 @@ +{% extends 'airtable_base.html' %} + +{% block content %} +{{ super() }} +

Edit Inscription Record

+
+ +

+ + +

+ + +

+ + + +

+ + +
+
+ +
+


+ + + +
+{% endblock %} \ No newline at end of file diff --git a/project/templates/airtable/form.html b/project/templates/airtable/form.html new file mode 100644 index 0000000..acfce16 --- /dev/null +++ b/project/templates/airtable/form.html @@ -0,0 +1,58 @@ +{% extends "airtable_base.html" %} +{% block content %} +{{ super() }} +

Questionnaire final de sortie du Passe Numérique

+
+ +

+ + +

+ + +

+ + +

+ + +
+
+ +
+


+ + + +

+ + +

+ + +

+ + +

+ + +
+{% endblock %} diff --git a/project/templates/airtable/my_modules.html b/project/templates/airtable/my_modules.html new file mode 100644 index 0000000..ec61ac9 --- /dev/null +++ b/project/templates/airtable/my_modules.html @@ -0,0 +1,37 @@ +{% extends 'airtable_base.html' %} + +{% block content %} +{{ super() }} + +
+
+
+

Suivi des modules

+ + + + + + + + + + + + + {% for record in records %} + + + + + + + + + +
NomMatièreSpécialitéFormationProfesseurAction
{{ record['fields']['Name'] }}{{ record['fields']['Matière'] }}{{ record['fields']['Spécialité'] }}{{ record['fields']['Formation'] }}{{ (profs.get(record['fields']['Professeur'][0]) if 'Professeur' in record['fields'] else {}).get('fields', {}).get('Name')|default('Not Available') }}Edit
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/project/templates/airtable/partials/footer.html b/project/templates/airtable/partials/footer.html new file mode 100644 index 0000000..5ffa63f --- /dev/null +++ b/project/templates/airtable/partials/footer.html @@ -0,0 +1,18 @@ +{% block navbar %} + +{% endblock %} \ No newline at end of file diff --git a/project/templates/airtable/participants/participants_detail.html b/project/templates/airtable/participants/participants_detail.html new file mode 100644 index 0000000..21046e0 --- /dev/null +++ b/project/templates/airtable/participants/participants_detail.html @@ -0,0 +1,21 @@ +{% extends 'airtable_base.html' %} + +{% block content %} +{{ super() }} +
+
+
+

Participant: {{ record['fields']['Nom complet'] }}

+
    +
  • {{ record['fields']['Nom'] }}
  • +
  • {{ record['fields']['Prénom'] }}
  • +
  • {{ record['fields']['Sexe']}}
  • +
  • {{ record['fields']['Date de naissance'] }}
  • +
  • {{ record['fields']['Adresse complète'] }}
  • +
  • {{ sessions.get(record['fields']['Session Passe Numérique'][0])['fields']['Name']}}
  • +
  • {{ sessions.get(record['fields']['Session du Bac +1'][0])['fields']['Name']}}
  • +
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/project/templates/airtable/participants/participants_edit.html b/project/templates/airtable/participants/participants_edit.html new file mode 100644 index 0000000..9e4713f --- /dev/null +++ b/project/templates/airtable/participants/participants_edit.html @@ -0,0 +1,121 @@ +{% extends 'airtable_base.html' %} + +{% block content %} +
+ +

Edit Participant

+
+
+
+ +
+
+
+
+

+
+
+
+
+
+
+ +
+
+
+
+

+
+
+
+
+ +
+
+ +
+
+
+
+
+
+
+
+
+ +
+
+ {% for attachment in record.fields.CNI %} +
+
+
+
+ + + + +
+
+ + +
+
+ {% endfor %} +
+
+
+
+
+ +
+
+ +
+
+
+
+
+
+
+
+
+ +
+
+ {% for attachment in record.fields['Attestation Pôle Emploi'] %} +
+
+
+
+ + + + +
+
+ + +
+
+ {% endfor %} +
+
+
+
+
+ + +
+ +
+
+
+{% endblock %} diff --git a/project/templates/airtable/participants/participants_list.html b/project/templates/airtable/participants/participants_list.html new file mode 100644 index 0000000..92b6649 --- /dev/null +++ b/project/templates/airtable/participants/participants_list.html @@ -0,0 +1,43 @@ +{% extends 'airtable_base.html' %} + +{% block content %} +
+
+
+

Participants

+ + + + + + + + + + + + + {% for record in records %} + + + + + + + + + + +
NomDate de naissanceSexeAdresseCNIAction
{{ record['fields']['Nom complet'] }}{{ record['fields']['Date de naissance'] }}{{ record['fields']['Sexe'] }}{{ record['fields']['Adresse complète'] }} + {% for attachment in record['fields']['CNI'] %} + + {% endfor %} + + Voir + Edit +
+
+
+
+{% endblock %} diff --git a/project/templates/airtable/submitted.html b/project/templates/airtable/submitted.html new file mode 100644 index 0000000..1cca1d4 --- /dev/null +++ b/project/templates/airtable/submitted.html @@ -0,0 +1,6 @@ +{% extends "airtable_base.html" %} +{% block content %} +{{ super() }} +

Your form has been submitted successfully!

+Submit another response +{% endblock %} diff --git a/project/templates/base.html b/project/templates/base.html new file mode 100644 index 0000000..3c052f7 --- /dev/null +++ b/project/templates/base.html @@ -0,0 +1,54 @@ +{% extends "security/base.html" %} +{% block metas %} + + + + + +{% endblock %} + +{%- block head_scripts %} + +{%- endblock head_scripts %} + + +{%- block styles %} + +{%- endblock %} + + {% block body %} + +
+ +
+ {% block navbar %} + {% include "partials/navbar.html" %} + {% endblock navbar %} +
+
+
+
+ {% block content -%} + {%- endblock content %} +
+
+
+
+ {% block footer -%} + {%- endblock footer %} +
+
+ + {% block scripts %} + {%- endblock scripts %} + +{% endblock body %} + + diff --git a/project/templates/index.html b/project/templates/index.html new file mode 100644 index 0000000..c73d4a4 --- /dev/null +++ b/project/templates/index.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} + +{% block content %} +
+

+ Flask Login Example +

+

+ Easy authentication and authorization in Flask. +

+
+{% endblock %} \ No newline at end of file diff --git a/project/templates/partials/navbar.html b/project/templates/partials/navbar.html new file mode 100644 index 0000000..95a02a5 --- /dev/null +++ b/project/templates/partials/navbar.html @@ -0,0 +1,40 @@ +{% block navbar %} + +{% endblock %} \ No newline at end of file diff --git a/project/templates/profile.html b/project/templates/profile.html new file mode 100644 index 0000000..8731029 --- /dev/null +++ b/project/templates/profile.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} +{% block content %} +
+
+

Hello {{name}}

+
+
+{% endblock %} \ No newline at end of file diff --git a/project/templates/security/_macros.html b/project/templates/security/_macros.html new file mode 100644 index 0000000..fdd85c5 --- /dev/null +++ b/project/templates/security/_macros.html @@ -0,0 +1,55 @@ +{% macro render_field_with_errors(field) %} +
+
+ {% set placeholder = field.label.text %} + {{ field(placeholder=placeholder,**kwargs)|safe }} +
+ {% if field.errors %} +
    + {% for error in field.errors %} +
  • {{ error }}
  • + {% endfor %} +
+ {% endif %} +
+{% endmacro %} + +{% macro render_field(field) %} +
+ {% if field.label.text == "Login" %} + {{ field(**kwargs)|safe }} + {% else %} + {{ field.label }} {{ field(**kwargs)|safe }} + + {% endif %} +
+{% endmacro %} + +{% macro render_field_errors(field) %} +
+ {% if field and field.errors %} +
    + {% for error in field.errors %} +
  • {{ error }}
  • + {% endfor %} +
+ {% endif %} +
+{% endmacro %} + +{# render WTForms (>3.0) form level errors #} +{% macro render_form_errors(form) %} + {% if form.form_errors %} +
+
    + {% for error in form.form_errors %} +
  • {{ error }}
  • + {% endfor %} +
+
+ {% endif %} +{% endmacro %} + +{% macro prop_next() -%} + {% if 'next' in request.args %}?next={{ request.args.next|urlencode }}{% endif %} +{%- endmacro %} diff --git a/project/templates/security/forgot_password.html b/project/templates/security/forgot_password.html new file mode 100644 index 0000000..48bda09 --- /dev/null +++ b/project/templates/security/forgot_password.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} +{% from "security/_macros.html" import render_field_with_errors, render_field %} + +{% block content %} +{% include "security/_messages.html" %} +

{{ _fsdomain('Send password reset instructions') }}

+
+ {{ forgot_password_form.hidden_tag() }} + {{ render_field_with_errors(forgot_password_form.email) }} + {{ render_field(forgot_password_form.submit, class="button") }} +
+{% endblock %} diff --git a/project/templates/security/login_user.html b/project/templates/security/login_user.html new file mode 100644 index 0000000..885d792 --- /dev/null +++ b/project/templates/security/login_user.html @@ -0,0 +1,64 @@ +{% extends "base.html" %} +{% from "security/_macros.html" import render_field_with_errors, render_field, render_field_errors, render_form_errors, prop_next %} + +{% block content %} +
+ {% include "security/_messages.html" %} +

{{ _fsdomain('Login') }}

+
+
+
+ {{ login_user_form.hidden_tag() }} + {{ render_form_errors(login_user_form) }} + {% if "email" in identity_attributes %} + {{ render_field_with_errors(login_user_form.email) }} + {% endif %} + {% if login_user_form.username and "username" in identity_attributes %} + {% if "email" in identity_attributes %} +

{{ _fsdomain("or") }}

+ {% endif %} + {{ render_field_with_errors(login_user_form.username) }} + {% endif %} + + {{ render_field_with_errors(login_user_form.password) }} +
+ {{ render_field(login_user_form.remember) }} + {{ render_field_errors(login_user_form.csrf_token) }} + {{ render_field(login_user_form.submit, class="button is-rounded is-primary", label="bonjour") }} +
+ {% if security.webauthn %} +
+

{{ _fsdomain("Use WebAuthn to Sign In") }}

+
+
+ +
+
+ {% endif %} + {% if security.oauthglue %} +
+

{{ _fsdomain("Use Social Oauth to Sign In") }}

+ {% for provider in security.oauthglue.provider_names %} +
+
+ +
+
+ {% endfor %} + {% endif %} + {% if google_login_url is defined %} + + + + + Sign in with Google + + {% endif %} + {% if security.recoverable %} + {{ _fsdomain('Forgot password') }}
+ {% endif %} +
+
+{% endblock %} diff --git a/project/templates/security/register_user.html b/project/templates/security/register_user.html new file mode 100644 index 0000000..1650b3a --- /dev/null +++ b/project/templates/security/register_user.html @@ -0,0 +1,21 @@ +{% extends "security/base.html" %} +{% from "security/_macros.html" import render_field_with_errors, render_field, render_form_errors %} + +{% block content %} +{% include "security/_messages.html" %} +

{{ _fsdomain('Register') }}

+
+ {{ register_user_form.hidden_tag() }} + {{ render_form_errors(register_user_form) }} + {{ render_field_with_errors(register_user_form.email) }} + {% if security.username_enable %} + {{ render_field_with_errors(register_user_form.username) }} + {% endif %} + {{ render_field_with_errors(register_user_form.password) }} + {% if register_user_form.password_confirm %} + {{ render_field_with_errors(register_user_form.password_confirm) }} + {% endif %} + {{ render_field(register_user_form.submit) }} +
+{% include "security/_menu.html" %} +{% endblock %} diff --git a/project/templates/users.html b/project/templates/users.html new file mode 100644 index 0000000..d3cff1f --- /dev/null +++ b/project/templates/users.html @@ -0,0 +1,70 @@ +{% extends "base.html" %} +{% block content %} +

User Management

+ + + + + + + + + + {% for user in users %} + + + + + + {% endfor %} + +
UsernameEmailActions
{{ user.username }}{{ user.email }} + Edit + {% if not user.is_admin %} + {% if user.is_active %} + Block + {% else %} + Unblock + {% endif %} + Delete + {% endif %} +
+ +
+
+

Create User

+
+ {{ form.csrf_token }} +
+ + {{ form.username(class="form-control", placeholder="Enter username") }} +
+
+ + {{ form.email(class="form-control", placeholder="Enter email address") }} +
+
+ + {{ form.password(class="form-control", placeholder="Enter password") }} +
+ +
+
+ +
+

Change Password

+
+ {{ form.csrf_token }} +
+ + {{ form.email(class="form-control", placeholder="Enter email address") }} +
+
+ + {{ form.password(class="form-control", placeholder="Enter new password") }} +
+ +
+
+
+{% endblock %} \ No newline at end of file diff --git a/project/views.py b/project/views.py new file mode 100644 index 0000000..bda5365 --- /dev/null +++ b/project/views.py @@ -0,0 +1,37 @@ +# Views + +from flask import Blueprint, render_template, redirect, url_for +from flask_security import current_user, auth_required + +from .models import User, Role +from .database import db_session + +views_bp = Blueprint('views', __name__) + +@views_bp.route('/') +def home(): + return render_template('index.html') + + +@views_bp.route('/profile') +@auth_required() +def profile(): + if not current_user.is_authenticated: + return redirect(url_for('security.login')) + else: + return render_template('profile.html', name=current_user.email) + +''' @app.route('/users', endpoint='users', methods=['GET', 'POST']) +@auth_required +def users(): + form = CreateUserForm() + if form.validate_on_submit(): + user = User(email=form.email.data, password=form.password.data) + db.session.add(user) + db.session.commit() + flash('User created successfully!', 'success') + return redirect(url_for('users')) + + users = User.query.all() + + return render_template('users.html', users=users, form=form) ''' \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..87ad415 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,14 @@ +Flask-Security-Too[common]==5.1.0 +SQLAlchemy==2.0.4 +python-dotenv +Flask-Admin==1.6.1 +Flask-WTF==1.1.1 +Font-Awesome-Flask==0.1.1 +airtable-python-wrapper==0.15.3 +Flask-Dance==6.2.0 +google-auth==2.17.0 +google-auth-httplib2==0.1.0 +google-auth-oauthlib==1.0.0 +gunicorn==20.1.0 +oauth2client==4.1.3 +