initial commit
This commit is contained in:
commit
07ccbd3db1
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
actual.env
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
venv
|
||||||
|
data.db
|
||||||
|
*__pycache__*
|
||||||
20
.vscode/launch.json
vendored
Normal file
20
.vscode/launch.json
vendored
Normal file
@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
12
README.md
Normal file
12
README.md
Normal file
@ -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`
|
||||||
26
model.env
Normal file
26
model.env
Normal file
@ -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"]
|
||||||
55
project/admin.py
Normal file
55
project/admin.py
Normal file
@ -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
|
||||||
|
|
||||||
|
#<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bulma/0.9.4/css/bulma.css" integrity="sha512-SI0aF82pT58nyOjCNfyeE2Y5/KHId8cLIX/1VYzdjTRs0HPNswsJR+aLQYSWpb88GDJieAgR4g1XWZvUROQv1A==" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||||
|
|
||||||
|
|
||||||
|
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))
|
||||||
224
project/airtableget.py
Normal file
224
project/airtableget.py
Normal file
@ -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/<participant_number>", 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/<participant_number>/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/<record_id>', 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')
|
||||||
156
project/app.py
Normal file
156
project/app.py
Normal file
@ -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()
|
||||||
45
project/config.py
Normal file
45
project/config.py
Normal file
@ -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"))
|
||||||
25
project/database.py
Normal file
25
project/database.py
Normal file
@ -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)
|
||||||
36
project/models.py
Normal file
36
project/models.py
Normal file
@ -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'))
|
||||||
5
project/static/css/thumbnail.css
Normal file
5
project/static/css/thumbnail.css
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
.thumbnail {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
147
project/static/js/attachment.js
Normal file
147
project/static/js/attachment.js
Normal file
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
2
project/static/svg/full-cross-circle-alt-svgrepo-com.svg
Normal file
2
project/static/svg/full-cross-circle-alt-svgrepo-com.svg
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 24 24" id="full-cross-circle-alt" data-name="Line Color" xmlns="http://www.w3.org/2000/svg" class="icon line-color"><line id="secondary" x1="18.15" y1="5.85" x2="5.85" y2="18.15" style="fill: none; stroke: rgb(44, 169, 188); stroke-linecap: round; stroke-linejoin: round; stroke-width: 2;"></line><circle id="primary" cx="12" cy="12" r="9" style="fill: none; stroke: rgb(0, 0, 0); stroke-linecap: round; stroke-linejoin: round; stroke-width: 2;"></circle></svg>
|
||||||
|
After Width: | Height: | Size: 642 B |
43
project/templates/admin/actions.html
Normal file
43
project/templates/admin/actions.html
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
{% import 'admin/static.html' as admin_static with context %}
|
||||||
|
|
||||||
|
{% macro dropdown(actions) -%}
|
||||||
|
<div class="dropdown is-active is-overlay">
|
||||||
|
<a class="dropdown-trigger" aria-controls="dropdown-menu" role="button" aria-haspopup="true"
|
||||||
|
aria-expanded="false">
|
||||||
|
<span>{{ _gettext('With selected') }}</span>
|
||||||
|
<span class="icon is-small">
|
||||||
|
<i class="fa fa-angle-down" aria-hidden="true"></i>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<div class="dropdown-menu" id="dropdown-menu" role="menu">
|
||||||
|
<div class="dropdown-content">
|
||||||
|
{% for p in actions %}
|
||||||
|
<a class="dropdown-item" href="javascript:void(0)"
|
||||||
|
onclick="return modelActions.execute('{{ p[0] }}');">{{ _gettext(p[1]) }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro form(actions, url) %}
|
||||||
|
{% if actions %}
|
||||||
|
<form id="action_form" action="{{ url }}" method="POST" style="display: none">
|
||||||
|
{% if action_form.csrf_token %}
|
||||||
|
{{ action_form.csrf_token }}
|
||||||
|
{% elif csrf_token %}
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
|
{% endif %}
|
||||||
|
{{ action_form.url(value=return_url) }}
|
||||||
|
{{ action_form.action() }}
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro script(message, actions, actions_confirmation) %}
|
||||||
|
{% if actions %}
|
||||||
|
<div id="actions-confirmation-data" style="display:none;">{{ actions_confirmation|tojson|safe }}</div>
|
||||||
|
<div id="message-data" style="display:none;">{{ message|tojson|safe }}</div>
|
||||||
|
<script src="{{ admin_static.url(filename='admin/js/actions.js', v='1.0.0') }}"></script>
|
||||||
|
{% endif %}
|
||||||
|
{% endmacro %}
|
||||||
9
project/templates/admin/file/form.html
Normal file
9
project/templates/admin/file/form.html
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{% extends 'admin/master.html' %}
|
||||||
|
{% import 'admin/lib.html' as lib with context %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% block header %}<h3>{{ header_text }}</h3>{% endblock %}
|
||||||
|
{% block fa_form %}
|
||||||
|
{{ lib.render_form(form, dir_url) }}
|
||||||
|
{% endblock %}
|
||||||
|
{% endblock %}
|
||||||
191
project/templates/admin/file/list.html
Normal file
191
project/templates/admin/file/list.html
Normal file
@ -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 %}
|
||||||
|
<nav area-label="breadcrumb">
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li class="breadcrumb-item">
|
||||||
|
<a href="{{ get_dir_url('.index_view', path=None) }}">{{ _gettext('Root') }}</a>
|
||||||
|
</li>
|
||||||
|
{% for name, path in breadcrumbs[:-1] %}
|
||||||
|
<li class="breadcrumb-item">
|
||||||
|
<a href="{{ get_dir_url('.index_view', path=path) }}">{{ name }}</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
{% if breadcrumbs %}
|
||||||
|
<li class="breadcrumb-item">
|
||||||
|
<a href="{{ get_dir_url('.index_view', path=breadcrumbs[-1][1]) }}">{{ breadcrumbs[-1][0] }}</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block file_list_table %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped table-bordered model-list">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{% block list_header scoped %}
|
||||||
|
{% if actions %}
|
||||||
|
<th class="list-checkbox-column">
|
||||||
|
<input type="checkbox" name="rowtoggle" class="action-rowtoggle" />
|
||||||
|
</th>
|
||||||
|
{% endif %}
|
||||||
|
<th class=""> </th>
|
||||||
|
{% for column in admin_view.column_list %}
|
||||||
|
<th>
|
||||||
|
{% if admin_view.is_column_sortable(column) %}
|
||||||
|
{% if sort_column == column %}
|
||||||
|
<a href="{{ sort_url(column, dir_path, True) }}" title="{{ _gettext('Sort by %(name)s', name=column) }}">
|
||||||
|
{{ admin_view.column_label(column) }}
|
||||||
|
{% if sort_desc %}
|
||||||
|
<span class="fa fa-chevron-up glyphicon glyphicon-chevron-up"></span>
|
||||||
|
{% else %}
|
||||||
|
<span class="fa fa-chevron-down glyphicon glyphicon-chevron-down"></span>
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ sort_url(column, dir_path) }}" title="{{ _gettext('Sort by %(name)s', name=column) }}">{{ admin_view.column_label(column) }}</a>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
{{ _gettext(admin_view.column_label(column)) }}
|
||||||
|
{% endif %}
|
||||||
|
</th>
|
||||||
|
{% endfor %}
|
||||||
|
{% endblock %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
{% for name, path, is_dir, size, date in items %}
|
||||||
|
<tr>
|
||||||
|
{% block list_row scoped %}
|
||||||
|
{% if actions %}
|
||||||
|
<td>
|
||||||
|
{% if not is_dir %}
|
||||||
|
<input type="checkbox" name="rowid" class="action-checkbox" value="{{ path }}" />
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
|
<td>
|
||||||
|
{% 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='<i class="fa fa-pencil glyphicon glyphicon-pencil"></i>') }}
|
||||||
|
{% else %}
|
||||||
|
<a class="icon" href="{{ get_url('.rename', path=path) }}" title="{{ _gettext('Rename File') }}">
|
||||||
|
<i class="fa fa-pencil glyphicon glyphicon-pencil"></i>
|
||||||
|
</a>
|
||||||
|
{%- endif -%}
|
||||||
|
{% endif %}
|
||||||
|
{%- if admin_view.can_delete and path -%}
|
||||||
|
{% if is_dir %}
|
||||||
|
{% if name != '..' and admin_view.can_delete_dirs %}
|
||||||
|
<form class="icon" method="POST" action="{{ get_url('.delete') }}">
|
||||||
|
{{ delete_form.path(value=path) }}
|
||||||
|
{{ delete_form.csrf_token }}
|
||||||
|
<button onclick="return confirm('{{ _gettext('Are you sure you want to delete \\\'%(name)s\\\' recursively?', name=name) }}')">
|
||||||
|
<i class="fa fa-times glyphicon glyphicon-remove"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<form class="icon" method="POST" action="{{ get_url('.delete') }}">
|
||||||
|
{{ delete_form.path(value=path) }}
|
||||||
|
{{ delete_form.csrf_token }}
|
||||||
|
<button onclick="return confirm('{{ _gettext('Are you sure you want to delete \\\'%(name)s\\\'?', name=name) }}')">
|
||||||
|
<i class="fa fa-trash glyphicon glyphicon-trash"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{%- endif -%}
|
||||||
|
{% endblock %}
|
||||||
|
</td>
|
||||||
|
{% if is_dir %}
|
||||||
|
<td colspan="2">
|
||||||
|
<a href="{{ get_dir_url('.index_view', path)|safe }}">
|
||||||
|
<i class="fa fa-folder-o glyphicon glyphicon-folder-close"></i> <span>{{ name }}</span>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
{% else %}
|
||||||
|
<td>
|
||||||
|
{% 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 %}
|
||||||
|
<a href="{{ get_file_url(path)|safe }}">{{ name }}</a>
|
||||||
|
{%- endif -%}
|
||||||
|
{% else %}
|
||||||
|
{{ name }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% if admin_view.is_column_visible('size') %}
|
||||||
|
<td>
|
||||||
|
{{ size|filesizeformat }}
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% if admin_view.is_column_visible('date') %}
|
||||||
|
<td>
|
||||||
|
{{ timestamp_format(date) }}
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
{% block toolbar %}
|
||||||
|
<div class="btn-toolbar">
|
||||||
|
{% if admin_view.can_upload %}
|
||||||
|
<div class="btn-group">
|
||||||
|
{%- 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 %}
|
||||||
|
<a class="btn btn-secondary" href="{{ get_dir_url('.upload', path=dir_path) }}">{{ _gettext('Upload File') }}</a>
|
||||||
|
{%- endif -%}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if admin_view.can_mkdir %}
|
||||||
|
<div class="mx-1">
|
||||||
|
{%- 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 %}
|
||||||
|
<a class="btn btn-secondary" href="{{ get_dir_url('.mkdir', path=dir_path) }}">{{ _gettext('Create Directory') }}</a>
|
||||||
|
{%- endif -%}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if actions %}
|
||||||
|
<div class="mx-1">
|
||||||
|
{{ actionslib.dropdown(actions, 'dropdown-toggle btn btn-secondary') }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% 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) }}
|
||||||
|
<script src="{{ admin_static.url(filename='admin/js/bs4_modal.js', v='1.0.0') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
19
project/templates/admin/file/modals/form.html
Normal file
19
project/templates/admin/file/modals/form.html
Normal file
@ -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 #}
|
||||||
|
<div class="modal-header">
|
||||||
|
{% block header %}<h3>{{ header_text }}</h3>{% endblock %}
|
||||||
|
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">×</span></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
{% block fa_form %}
|
||||||
|
{{ lib.render_form(form, dir_url, action=request.url, is_modal=True) }}
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block tail %}
|
||||||
|
<script src="{{ admin_static.url(filename='admin/js/bs4_modal.js', v='1.0.0') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
5
project/templates/admin/index.html
Normal file
5
project/templates/admin/index.html
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{% extends 'admin/master.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
Bonjour
|
||||||
|
{% endblock %}
|
||||||
294
project/templates/admin/lib.html
Normal file
294
project/templates/admin/lib.html
Normal file
@ -0,0 +1,294 @@
|
|||||||
|
{% import 'admin/static.html' as admin_static with context %}
|
||||||
|
|
||||||
|
{# ---------------------- Pager -------------------------- #}
|
||||||
|
{% macro pager(page, pages, generator) -%}
|
||||||
|
{% if pages > 1 %}
|
||||||
|
<ul class="pagination">
|
||||||
|
{% 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 %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="{{ generator(0) }}">«</a>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="page-item disabled">
|
||||||
|
<a class="page-link" href="javascript:void(0)">«</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% if page > 0 %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="{{ generator(page-1) }}"><</a>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="page-item disabled">
|
||||||
|
<a class="page-link" href="javascript:void(0)"><</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for p in range(min, max) %}
|
||||||
|
{% if page == p %}
|
||||||
|
<li class="page-item active">
|
||||||
|
<a class="page-link" href="javascript:void(0)">{{ p + 1 }}</a>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="{{ generator(p) }}">{{ p + 1 }}</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if page + 1 < pages %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="{{ generator(page + 1) }}">></a>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="page-item disabled">
|
||||||
|
<a class="page-link" href="javascript:void(0)">></a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% if max < pages %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="{{ generator(pages - 1) }}">»</a>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="page-item disabled">
|
||||||
|
<a class="page-link" href="javascript:void(0)">»</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{% macro simple_pager(page, have_next, generator) -%}
|
||||||
|
<ul class="pagination">
|
||||||
|
{% if page > 0 %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a href="{{ generator(page - 1) }}"><</a>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="page-item disabled">
|
||||||
|
<a href="{{ generator(0) }}"><</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% if have_next %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a href="{{ generator(page + 1) }}">></a>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="page-item disabled">
|
||||||
|
<a href="{{ generator(page) }}">></a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{# ---------------------- Modal Window ------------------- #}
|
||||||
|
{% macro add_modal_window(modal_window_id='fa_modal_window', modal_label_id='fa_modal_label') %}
|
||||||
|
<div class="modal fade" id="{{ modal_window_id }}" tabindex="-1" role="dialog" aria-labelledby="{{ modal_label_id }}">
|
||||||
|
<div class="modal-dialog modal-xl" role="document">
|
||||||
|
{# bootstrap version > 3.1.0 required for this to work #}
|
||||||
|
<div class="modal-content">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro add_modal_button(url='', title='', content='', modal_window_id='fa_modal_window', btn_class='icon') %}
|
||||||
|
<a class="{{ btn_class }}" data-target="#{{ modal_window_id }}" title="{{ title }}" href="{{ url }}" data-toggle="modal">
|
||||||
|
{{ content|safe }}
|
||||||
|
</a>
|
||||||
|
{% 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) %}
|
||||||
|
<div class="form-group {{ kwargs.get('column_class', '') }}">
|
||||||
|
<label for="{{ field.id }}" class="control-label" {% if field.widget.input_type == 'checkbox' %}style="display: block; margin-bottom: 0"{% endif %}>{{ field.label.text }}
|
||||||
|
{% if h.is_required_form_field(field) %}
|
||||||
|
<strong style="color: red">*</strong>
|
||||||
|
{%- else -%}
|
||||||
|
|
||||||
|
{%- endif %}
|
||||||
|
</label>
|
||||||
|
{% if prepend or append %}
|
||||||
|
<div class="input-group">
|
||||||
|
{%- if prepend -%}
|
||||||
|
<div class="input-group-prepend">
|
||||||
|
{{ prepend }}
|
||||||
|
</div>
|
||||||
|
{%- 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 -%}
|
||||||
|
<div class="input-group-append">
|
||||||
|
{{ append }}
|
||||||
|
</div>
|
||||||
|
{%- endif -%}
|
||||||
|
{% if direct_error %}
|
||||||
|
<div class="invalid-feedback">
|
||||||
|
<ul class="form-text text-muted" {% if field.widget.input_type == 'checkbox' %}style="margin-top: 0"{% endif %}>
|
||||||
|
{% for e in field.errors if e is string %}
|
||||||
|
<li>{{ e }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% elif field.description %}
|
||||||
|
<small class="form-text text-muted" {% if field.widget.input_type == 'checkbox' %}style="margin-top: 0"{% endif %}>
|
||||||
|
{{ field.description|safe }}
|
||||||
|
</small>
|
||||||
|
{% endif %}
|
||||||
|
{% if prepend or append %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if caller %}
|
||||||
|
{{ caller(form, field, direct_error, kwargs) }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro render_header(form, text) %}
|
||||||
|
<h3>{{ text }}</h3>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro render_form_fields(form, form_opts=None) %}
|
||||||
|
{% if form.hidden_tag is defined %}
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
{% else %}
|
||||||
|
{% if csrf_token %}
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ 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) %}
|
||||||
|
<form action="{{ action or '' }}" method="POST" role="form" class="admin-form" enctype="multipart/form-data">
|
||||||
|
<fieldset>
|
||||||
|
{{ caller() }}
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro render_form_buttons(cancel_url, extra=None, is_modal=False) %}
|
||||||
|
{% if is_modal %}
|
||||||
|
<input type="submit" class="btn btn-primary" value="{{ _gettext('Save') }}" />
|
||||||
|
{% if extra %}
|
||||||
|
{{ extra }}
|
||||||
|
{% endif %}
|
||||||
|
{% if cancel_url %}
|
||||||
|
<a href="{{ cancel_url }}" class="btn btn-danger" role="button" {% if is_modal %}data-dismiss="modal"{% endif %}>{{ _gettext('Cancel') }}</a>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<hr>
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="col-md-offset-2 col-md-10 submit-row">
|
||||||
|
<input type="submit" class="btn btn-primary" value="{{ _gettext('Save') }}" />
|
||||||
|
{% if extra %}
|
||||||
|
{{ extra }}
|
||||||
|
{% endif %}
|
||||||
|
{% if cancel_url %}
|
||||||
|
<a href="{{ cancel_url }}" class="btn btn-danger" role="button" {% if is_modal %}data-dismiss="modal"{% endif %}>{{ _gettext('Cancel') }}</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% 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() %}
|
||||||
|
<link href="{{ admin_static.url(filename='vendor/select2/select2.css', v='4.2.1') }}" rel="stylesheet">
|
||||||
|
<link href="{{ admin_static.url(filename='vendor/select2/select2-bootstrap4.css', v='1.4.6') }}" rel="stylesheet">
|
||||||
|
<link href="{{ admin_static.url(filename='vendor/bootstrap-daterangepicker/daterangepicker-bs4.css', v='1.3.22') }}" rel="stylesheet">
|
||||||
|
{% if config.MAPBOX_MAP_ID %}
|
||||||
|
<link href="{{ admin_static.url(filename='vendor/leaflet/leaflet.css', v='1.0.2') }}" rel="stylesheet">
|
||||||
|
<link href="{{ admin_static.url(filename='vendor/leaflet/leaflet.draw.css', v='0.4.6') }}" rel="stylesheet">
|
||||||
|
{% endif %}
|
||||||
|
{% if editable_columns %}
|
||||||
|
<link href="{{ admin_static.url(filename='vendor/x-editable/css/bootstrap4-editable.css', v='1.5.1.1') }}" rel="stylesheet">
|
||||||
|
{% endif %}
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro form_js() %}
|
||||||
|
{% if config.MAPBOX_MAP_ID %}
|
||||||
|
<script>
|
||||||
|
window.MAPBOX_MAP_ID = "{{ config.MAPBOX_MAP_ID }}";
|
||||||
|
{% if config.MAPBOX_ACCESS_TOKEN %}
|
||||||
|
window.MAPBOX_ACCESS_TOKEN = "{{ config.MAPBOX_ACCESS_TOKEN }}";
|
||||||
|
{% endif %}
|
||||||
|
{% if config.DEFAULT_CENTER_LAT and config.DEFAULT_CENTER_LONG %}
|
||||||
|
window.DEFAULT_CENTER_LAT = "{{ config.DEFAULT_CENTER_LAT }}";
|
||||||
|
window.DEFAULT_CENTER_LONG = "{{ config.DEFAULT_CENTER_LONG }}";
|
||||||
|
{% endif %}
|
||||||
|
</script>
|
||||||
|
<script src="{{ admin_static.url(filename='vendor/leaflet/leaflet.js', v='1.0.2') }}"></script>
|
||||||
|
<script src="{{ admin_static.url(filename='vendor/leaflet/leaflet.draw.js', v='0.4.6') }}"></script>
|
||||||
|
{% if config.MAPBOX_SEARCH %}
|
||||||
|
<script>
|
||||||
|
window.MAPBOX_SEARCH = "{{ config.MAPBOX_SEARCH }}";
|
||||||
|
</script>
|
||||||
|
<script src="https://maps.googleapis.com/maps/api/js?v=3&libraries=places&key={{ config.get('GOOGLE_MAPS_API_KEY') }}"></script>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
<script src="{{ admin_static.url(filename='vendor/bootstrap-daterangepicker/daterangepicker.js', v='1.3.22') }}"></script>
|
||||||
|
{% if editable_columns %}
|
||||||
|
<script src="{{ admin_static.url(filename='vendor/x-editable/js/bootstrap4-editable.min.js', v='1.5.1.1') }}"></script>
|
||||||
|
{% endif %}
|
||||||
|
<script src="{{ admin_static.url(filename='admin/js/form.js', v='1.0.1') }}"></script>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro extra() %}
|
||||||
|
{% if admin_view.can_create %}
|
||||||
|
<input name="_add_another" type="submit" class="btn btn-secondary" value="{{ _gettext('Save and Add Another') }}" />
|
||||||
|
{% endif %}
|
||||||
|
{% if admin_view.can_edit %}
|
||||||
|
<input name="_continue_editing" type="submit" class="btn btn-secondary" value="{{ _gettext('Save and Continue Editing') }}" />
|
||||||
|
{% endif %}
|
||||||
|
{% endmacro %}
|
||||||
72
project/templates/admin/master.html
Normal file
72
project/templates/admin/master.html
Normal file
@ -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 %}
|
||||||
|
<link href="{{ admin_static.url(filename='bootstrap/bootstrap4/swatch/{swatch}/bootstrap.min.css'.format(swatch=config.get('FLASK_ADMIN_SWATCH', 'default')), v='4.2.1') }}"
|
||||||
|
rel="stylesheet">
|
||||||
|
{% if config.get('FLASK_ADMIN_SWATCH', 'default') == 'default' %}
|
||||||
|
<link href="{{ admin_static.url(filename='bootstrap/bootstrap4/css/bootstrap.min.css', v='4.2.1') }}" rel="stylesheet">
|
||||||
|
{% endif %}
|
||||||
|
<link href="{{ admin_static.url(filename='admin/css/bootstrap4/admin.css', v='1.1.1') }}" rel="stylesheet">
|
||||||
|
<link href="{{ admin_static.url(filename='bootstrap/bootstrap4/css/font-awesome.min.css', v='4.7.0') }}" rel="stylesheet">
|
||||||
|
{% if admin_view.extra_css %}
|
||||||
|
{% for css_url in admin_view.extra_css %}
|
||||||
|
<link href="{{ css_url }}" rel="stylesheet">
|
||||||
|
{% 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 %}
|
||||||
|
<script src="{{ admin_static.url(filename='vendor/jquery.min.js', v='3.5.1') }}" type="text/javascript"></script>
|
||||||
|
<script src="{{ admin_static.url(filename='bootstrap/bootstrap4/js/popper.min.js') }}" type="text/javascript"></script>
|
||||||
|
<script src="{{ admin_static.url(filename='bootstrap/bootstrap4/js/bootstrap.min.js', v='4.2.1') }}"
|
||||||
|
type="text/javascript"></script>
|
||||||
|
<script src="{{ admin_static.url(filename='vendor/moment.min.js', v='2.9.0') }}" type="text/javascript"></script>
|
||||||
|
<script src="{{ admin_static.url(filename='vendor/bootstrap4/util.js', v='4.3.1') }}" type="text/javascript"></script>
|
||||||
|
<script src="{{ admin_static.url(filename='vendor/bootstrap4/dropdown.js', v='4.3.1') }}" type="text/javascript"></script>
|
||||||
|
<script src="{{ admin_static.url(filename='vendor/select2/select2.min.js', v='4.2.1') }}"
|
||||||
|
type="text/javascript"></script>
|
||||||
|
<script src="{{ admin_static.url(filename='vendor/multi-level-dropdowns-bootstrap/bootstrap4-dropdown-ml-hack.js') }}" type="text/javascript"></script>
|
||||||
|
<script src="{{ admin_static.url(filename='admin/js/helpers.js', v='1.0.0') }}" type="text/javascript"></script>
|
||||||
|
{% if admin_view.extra_js %}
|
||||||
|
{% for js_url in admin_view.extra_js %}
|
||||||
|
<script src="{{ js_url }}" type="text/javascript"></script>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock tail_js %}
|
||||||
|
{% block tail %}
|
||||||
|
{% endblock %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
32
project/templates/admin/model/create.html
Normal file
32
project/templates/admin/model/create.html
Normal file
@ -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 %}
|
||||||
|
<div class="tabs is-boxed">
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a href="{{ return_url }}">{{ _gettext('List') }}</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="javascript:void(0)" class="is-active">{{ _gettext('Create') }}</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block create_form %}
|
||||||
|
{{ lib.render_form(form, return_url, extra(), form_opts) }}
|
||||||
|
{% endblock %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block tail %}
|
||||||
|
{{ super() }}
|
||||||
|
{{ lib.form_js() }}
|
||||||
|
{% endblock %}
|
||||||
54
project/templates/admin/model/details.html
Normal file
54
project/templates/admin/model/details.html
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
{% extends 'admin/master.html' %}
|
||||||
|
{% import 'admin/lib.html' as lib with context %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% block navlinks %}
|
||||||
|
<div class="tabs">
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a href="{{ return_url }}">{{ _gettext('List') }}</a>
|
||||||
|
</li>
|
||||||
|
{%- if admin_view.can_create -%}
|
||||||
|
<li>
|
||||||
|
<a href="{{ get_url('.create_view', url=return_url) }}">{{ _gettext('Create') }}</a>
|
||||||
|
</li>
|
||||||
|
{%- endif -%}
|
||||||
|
{%- if admin_view.can_edit -%}
|
||||||
|
<li>
|
||||||
|
<a href="{{ get_url('.edit_view', id=request.args.get('id'), url=return_url) }}">{{ _gettext('Edit') }}</a>
|
||||||
|
</li>
|
||||||
|
{%- endif -%}
|
||||||
|
<li>
|
||||||
|
<a class="is-active disabled" href="javascript:void(0)">{{ _gettext('Details') }}</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block details_search %}
|
||||||
|
<div class="form-inline fa_filter_container col-lg-6">
|
||||||
|
<label for="fa_filter">{{ _gettext('Filter') }}</label>
|
||||||
|
<input id="fa_filter" type="text" class="ml-3 form-control">
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block details_table %}
|
||||||
|
<table class="table table-hover table-bordered searchable">
|
||||||
|
{% for c, name in details_columns %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<b>{{ name }}</b>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ get_value(model, c) }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
{% endblock %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block tail %}
|
||||||
|
{{ super() }}
|
||||||
|
<script src="{{ admin_static.url(filename='admin/js/details_filter.js', v='1.0.0') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
42
project/templates/admin/model/edit.html
Normal file
42
project/templates/admin/model/edit.html
Normal file
@ -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 %}
|
||||||
|
<div class="tabs is-boxed">
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a href="{{ return_url }}">{{ _gettext('List') }}</a>
|
||||||
|
</li>
|
||||||
|
{%- if admin_view.can_create -%}
|
||||||
|
<li>
|
||||||
|
<a href="{{ get_url('.create_view', url=return_url) }}">{{ _gettext('Create') }}</a>
|
||||||
|
</li>
|
||||||
|
{%- endif -%}
|
||||||
|
<li>
|
||||||
|
<a href="javascript:void(0)" class="is-active">{{ _gettext('Edit') }}</a>
|
||||||
|
</li>
|
||||||
|
{%- if admin_view.can_view_details -%}
|
||||||
|
<li>
|
||||||
|
<a href="{{ get_url('.details_view', id=request.args.get('id'), url=return_url) }}">{{ _gettext('Details') }}</a>
|
||||||
|
</li>
|
||||||
|
{%- endif -%}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block edit_form %}
|
||||||
|
{{ lib.render_form(form, return_url, extra(), form_opts) }}
|
||||||
|
{% endblock %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block tail %}
|
||||||
|
{{ super() }}
|
||||||
|
{{ lib.form_js() }}
|
||||||
|
{% endblock %}
|
||||||
212
project/templates/admin/model/list.html
Executable file
212
project/templates/admin/model/list.html
Executable file
@ -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 %}
|
||||||
|
<div class="tabs is-boxed">
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a href="javascript:void(0)" class="is-active">{{ _gettext('List') }}{% if count %} ({{ count }}){% endif %}</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
{% if admin_view.can_create %}
|
||||||
|
<li>
|
||||||
|
{%- 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 %}
|
||||||
|
<a href="{{ get_url('.create_view', url=return_url) }}" title="{{ _gettext('Create New Record') }}" class="nav-link">{{ _gettext('Create') }}</a>
|
||||||
|
{%- endif -%}
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if admin_view.can_export %}
|
||||||
|
{{ model_layout.export_options() }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% block model_menu_bar_before_filters %}{% endblock %}
|
||||||
|
|
||||||
|
{% if filters %}
|
||||||
|
<li class="dropdown">
|
||||||
|
{{ model_layout.filter_options() }}
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if can_set_page_size %}
|
||||||
|
<li class="dropdown">
|
||||||
|
{{ model_layout.page_size_form(page_size_url) }}
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if actions %}
|
||||||
|
<li>
|
||||||
|
{{ actionlib.dropdown(actions) }}
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if search_supported %}
|
||||||
|
<li class="box">
|
||||||
|
{{ model_layout.search_form() }}
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% block model_menu_bar_after_filters %}{% endblock %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% if filters %}
|
||||||
|
{{ model_layout.filter_form() }}
|
||||||
|
<div class=is-clearfix"></div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% block model_list_table %}
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="table is-bordered is-striped is-hoverable is-fullwidth model-list">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{% block list_header scoped %}
|
||||||
|
{% if actions %}
|
||||||
|
<th class="has-text-centered">
|
||||||
|
<input type="checkbox" name="rowtoggle" title="{{ _gettext('Select all records') }}" />
|
||||||
|
</th>
|
||||||
|
{% endif %}
|
||||||
|
{% block list_row_actions_header %}
|
||||||
|
{% if admin_view.column_display_actions %}
|
||||||
|
<th class="has-text-centered"> </th>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
{% for c, name in list_columns %}
|
||||||
|
{% set column = loop.index0 %}
|
||||||
|
<th class="has-text-centered">
|
||||||
|
{% if admin_view.is_sortable(c) %}
|
||||||
|
{% if sort_column == column %}
|
||||||
|
<a href="{{ sort_url(column, True) }}" title="{{ _gettext('Sort by %(name)s', name=name) }}">
|
||||||
|
{{ name }}
|
||||||
|
{% if sort_desc %}
|
||||||
|
<span class="icon fa fa-chevron-up glyphicon glyphicon-chevron-up"></span>
|
||||||
|
{% else %}
|
||||||
|
<span class="icon fa fa-chevron-down glyphicon glyphicon-chevron-down"></span>
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ sort_url(column) }}" title="{{ _gettext('Sort by %(name)s', name=name) }}">{{ name }}</a>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
{{ name }}
|
||||||
|
{% endif %}
|
||||||
|
{% if admin_view.column_descriptions.get(c) %}
|
||||||
|
<a class="icon fa fa-question-circle glyphicon glyphicon-question-sign"
|
||||||
|
title="{{ admin_view.column_descriptions[c] }}"
|
||||||
|
href="javascript:void(0)" data-role="tooltip"
|
||||||
|
></a>
|
||||||
|
{% endif %}
|
||||||
|
</th>
|
||||||
|
{% endfor %}
|
||||||
|
{% endblock %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
{% for row in data %}
|
||||||
|
<tr>
|
||||||
|
{% block list_row scoped %}
|
||||||
|
{% if actions %}
|
||||||
|
<td>
|
||||||
|
<input type="checkbox" name="rowid" value="{{ get_pk_value(row) }}" title="{{ _gettext('Select record') }}" />
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
|
{% block list_row_actions_column scoped %}
|
||||||
|
{% if admin_view.column_display_actions %}
|
||||||
|
<td>
|
||||||
|
{% block list_row_actions scoped %}
|
||||||
|
{% for action in list_row_actions %}
|
||||||
|
{{ action.render_ctx(get_pk_value(row), row) }}
|
||||||
|
{% endfor %}
|
||||||
|
{% endblock %}
|
||||||
|
</td>
|
||||||
|
{%- endif -%}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% for c, name in list_columns %}
|
||||||
|
<td>
|
||||||
|
{% 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 %}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
{% endblock %}
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{% block empty_list_message %}
|
||||||
|
<div>
|
||||||
|
{{ admin_view.get_empty_list_message() }}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% 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 %}
|
||||||
|
<div id="filter-groups-data" style="display:none;">{{ filter_groups|tojson|safe }}</div>
|
||||||
|
<div id="active-filters-data" style="display:none;">{{ active_filters|tojson|safe }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{{ lib.form_js() }}
|
||||||
|
<script src="{{ admin_static.url(filename='admin/js/bs4_modal.js', v='1.0.0') }}"></script>
|
||||||
|
<script src="{{ admin_static.url(filename='admin/js/bs4_filters.js', v='1.0.0') }}"></script>
|
||||||
|
|
||||||
|
|
||||||
|
{{ actionlib.script(_gettext('Please select at least one record.'),
|
||||||
|
actions,
|
||||||
|
actions_confirmation) }}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
|
||||||
|
var dropdown = document.querySelector('.dropdown');
|
||||||
|
|
||||||
|
dropdown.addEventListener('click', function(event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
dropdown.classList.toggle('is-active');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
36
project/templates/admin/model/modals/create.html
Normal file
36
project/templates/admin/model/modals/create.html
Normal file
@ -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 %}
|
||||||
|
|
||||||
|
<div class="modal-header">
|
||||||
|
{% block header_text %}<h5 class="modal-title">{{ _gettext('Create New Record') }}</h5>{% endblock %}
|
||||||
|
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||||
|
<span aria-hidden="true">×</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% call lib.form_tag(action=url_for('.create_view', url=return_url)) %}
|
||||||
|
<div class="modal-body">
|
||||||
|
{{ lib.render_form_fields(form, form_opts=form_opts) }}
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
{{ lib.render_form_buttons(return_url, extra=None, is_modal=True) }}
|
||||||
|
</div>
|
||||||
|
{% 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 %}
|
||||||
|
<script src="{{ admin_static.url(filename='admin/js/bs4_modal.js', v='1.0.0') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
40
project/templates/admin/model/modals/details.html
Executable file
40
project/templates/admin/model/modals/details.html
Executable file
@ -0,0 +1,40 @@
|
|||||||
|
{% import 'admin/static.html' as admin_static with context%}
|
||||||
|
{% import 'admin/lib.html' as lib with context %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="modal-header">
|
||||||
|
{% block header_text %}
|
||||||
|
<h3>{{ _gettext('View Record') + ' #' + request.args.get('id') }}</h3>
|
||||||
|
{% endblock %}
|
||||||
|
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">×</span></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
{% block details_search %}
|
||||||
|
<div class="form-inline fa_filter_container col-lg-6">
|
||||||
|
<label for="fa_filter">{{ _gettext('Filter') }}</label>
|
||||||
|
<input id="fa_filter" type="text" class="ml-3 form-control">
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block details_table %}
|
||||||
|
<table class="table table-hover table-bordered searchable">
|
||||||
|
{% for c, name in details_columns %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<b>{{ name }}</b>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ get_value(model, c) }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block tail %}
|
||||||
|
<script src="{{ admin_static.url(filename='admin/js/details_filter.js', v='1.0.0') }}"></script>
|
||||||
|
<script src="{{ admin_static.url(filename='admin/js/bs4_modal.js', v='1.0.0') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
31
project/templates/admin/model/modals/edit.html
Normal file
31
project/templates/admin/model/modals/edit.html
Normal file
@ -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 %}
|
||||||
|
<div class="modal-header">
|
||||||
|
{% block header_text %}
|
||||||
|
<h5 class="modal-title">{{ _gettext('Edit Record') + ' #' + request.args.get('id') }}</h5>
|
||||||
|
{% endblock %}
|
||||||
|
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||||
|
<span aria-hidden="true">×</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% call lib.form_tag(action=url_for('.edit_view', id=request.args.get('id'), url=return_url)) %}
|
||||||
|
<div class="modal-body">
|
||||||
|
{{ lib.render_form_fields(form, form_opts=form_opts) }}
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
{{ lib.render_form_buttons(return_url, extra=None, is_modal=True) }}
|
||||||
|
</div>
|
||||||
|
{% endcall %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block tail %}
|
||||||
|
<script src="{{ admin_static.url(filename='admin/js/bs4_modal.js', v='1.0.0') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
27
project/templates/admin/rediscli/console.html
Normal file
27
project/templates/admin/rediscli/console.html
Normal file
@ -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() }}
|
||||||
|
<link href="{{ admin_static.url(filename='admin/css/bootstrap4/rediscli.css', v='1.0.0') }}" rel="stylesheet">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="console">
|
||||||
|
<div class="console-container">
|
||||||
|
</div>
|
||||||
|
<div class="console-line mb-4">
|
||||||
|
<form action="#">
|
||||||
|
<input type="text"></input>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block tail %}
|
||||||
|
{{ super() }}
|
||||||
|
|
||||||
|
<div id="execute-view-data" style="display:none;">{{ admin_view.get_url('.execute_view')|tojson|safe }}</div>
|
||||||
|
<script src="{{ admin_static.url(filename='admin/js/rediscli.js', v='1.0.0') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
59
project/templates/airtable/airtable_base.html
Normal file
59
project/templates/airtable/airtable_base.html
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{%- block head_scripts %}
|
||||||
|
{{ super() }}
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/thumbnail.css') }}"/>
|
||||||
|
{%- endblock head_scripts %}
|
||||||
|
|
||||||
|
{% block styles %}
|
||||||
|
{{ super() }}
|
||||||
|
<style>
|
||||||
|
.loader-container {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(255, 255, 255, 0.8);
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader {
|
||||||
|
border: 16px solid #f3f3f3;
|
||||||
|
border-radius: 50%;
|
||||||
|
border-top: 16px solid #3498db;
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
-webkit-animation: spin 2s linear infinite;
|
||||||
|
animation: spin 2s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@-webkit-keyframes spin {
|
||||||
|
0% { -webkit-transform: rotate(0deg); }
|
||||||
|
100% { -webkit-transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock styles %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block footer -%}
|
||||||
|
{% include "airtable/partials/footer.html" %}
|
||||||
|
{%- endblock footer %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
{{ super() }}
|
||||||
|
<script src="{{ url_for('static', filename='js/attachment.js') }}"></script>
|
||||||
|
{% endblock scripts %}
|
||||||
8
project/templates/airtable/dashboard.html
Normal file
8
project/templates/airtable/dashboard.html
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{% extends 'airtable_base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{{ super() }}
|
||||||
|
<div class="column">
|
||||||
|
<h2 class="title">Tableau de bord</h1>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
43
project/templates/airtable/edit_record.html
Normal file
43
project/templates/airtable/edit_record.html
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
{% extends 'airtable_base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{{ super() }}
|
||||||
|
<h2>Edit Inscription Record</h2>
|
||||||
|
<form method="POST">
|
||||||
|
<label for="name">Name</label>
|
||||||
|
<input id="name" name="name" value="{{ record.fields.Name }}" required><br><br>
|
||||||
|
|
||||||
|
<label for="notes">Notes</label>
|
||||||
|
<textarea id="notes" name="notes">{{ record.fields.Notes }}</textarea><br><br>
|
||||||
|
|
||||||
|
<label for="status">Status</label>
|
||||||
|
<select id="status" name="status">
|
||||||
|
{% set status_options = ['Todo', 'In progress', 'Done'] %}
|
||||||
|
{% for option in status_options %}
|
||||||
|
<option value="{{ option }}" {% if option == record['fields']['Status'] %}selected{% endif %}>{{ option }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select><br><br>
|
||||||
|
|
||||||
|
|
||||||
|
<label for="atelier">Atelier</label>
|
||||||
|
<select id="atelier" name="atelier">
|
||||||
|
{% for option in field_options['Atelier'] %}
|
||||||
|
<option value="{{ option }}" {% if option == record['fields']['atelier'][0] %}selected{% endif %}>{{ field_options['Atelier'][option].get('Name') }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select><br><br>
|
||||||
|
|
||||||
|
<label for="mentors">Mentors</label>
|
||||||
|
<div class="control">
|
||||||
|
<div class="select is-multiple">
|
||||||
|
<select id="mentors" name="mentors" multiple>
|
||||||
|
{% for option in field_options['Mentors'] %}
|
||||||
|
<option value="{{ option }}" {% if option in record['fields']['mentors'] %}selected{% endif %}>{{ field_options['Mentors'][option].get('Name') }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div><br><br>
|
||||||
|
|
||||||
|
|
||||||
|
<input type="submit" value="Submit">
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
58
project/templates/airtable/form.html
Normal file
58
project/templates/airtable/form.html
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
{% extends "airtable_base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
{{ super() }}
|
||||||
|
<h1>Questionnaire final de sortie du Passe Numérique</h1>
|
||||||
|
<form action="{{ url_for('airtable.create_record') }}" method="POST">
|
||||||
|
<label for="name">Prénom et Nom *</label>
|
||||||
|
<input type="text" id="name" name="name" required><br><br>
|
||||||
|
|
||||||
|
<label for="name">Notes</label>
|
||||||
|
<input type="text" id="notes" name="notes" required><br><br>
|
||||||
|
|
||||||
|
<label for="status">Status</label>
|
||||||
|
<select id="status" name="status">
|
||||||
|
<option>Todo</option>
|
||||||
|
<option>In progress</option>
|
||||||
|
<option>Done</option>
|
||||||
|
</select><br><br>
|
||||||
|
|
||||||
|
<label for="atelier">Atelier</label>
|
||||||
|
<select id="atelier" name="atelier">
|
||||||
|
{% for option in field_options['Atelier'] %}
|
||||||
|
<option value="{{ option }}">{{ field_options['Atelier'][option].get('Name') }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select><br><br>
|
||||||
|
|
||||||
|
<label for="mentors">Mentors</label>
|
||||||
|
<div class="control">
|
||||||
|
<div class="select is-multiple">
|
||||||
|
<select id="mentors" name="mentors" multiple>
|
||||||
|
{% for option in field_options['Mentors'] %}
|
||||||
|
<option value="{{ option.id }}" {% if option.id in record['fields']['mentors'] %}selected{% endif %}>{{ option.fields['Name'] }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div><br><br>
|
||||||
|
|
||||||
|
|
||||||
|
<label for="completed">As-tu terminé le Passe Numérique jusqu'au bout ? *</label>
|
||||||
|
<input type="text" id="completed" name="completed" required><br><br>
|
||||||
|
|
||||||
|
<label for="result">Résultat à la sortie de l'opération *</label>
|
||||||
|
<select id="result" name="result">
|
||||||
|
<option>result 1</option>
|
||||||
|
<option>result 2</option>
|
||||||
|
</select><br><br>
|
||||||
|
|
||||||
|
<label for="level">Choisi le niveau que tu penses avoir atteint à la fin du Passe Numérique : *</label>
|
||||||
|
<select id="level" name="level">
|
||||||
|
<option>level 1</option>
|
||||||
|
<option>level 2</option>
|
||||||
|
</select><br><br>
|
||||||
|
|
||||||
|
<label for="story">Peux-tu me parler de ton parcours ?</label>
|
||||||
|
<textarea id="story" name="story"></textarea><br><br>
|
||||||
|
|
||||||
|
<input type="submit" value="Submit">
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
37
project/templates/airtable/my_modules.html
Normal file
37
project/templates/airtable/my_modules.html
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
{% extends 'airtable_base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{{ super() }}
|
||||||
|
|
||||||
|
<div class="column is-9">
|
||||||
|
<section class="section">
|
||||||
|
<div class="container">
|
||||||
|
<h2 class="title">Suivi des modules</h2>
|
||||||
|
<table class="table is-bordered is-striped is-hoverable is-fullwidth">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Nom</th>
|
||||||
|
<th>Matière</th>
|
||||||
|
<th>Spécialité</th>
|
||||||
|
<th>Formation</th>
|
||||||
|
<th>Professeur</th>
|
||||||
|
<th>Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for record in records %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ record['fields']['Name'] }}</td>
|
||||||
|
<td>{{ record['fields']['Matière'] }}</td>
|
||||||
|
<td>{{ record['fields']['Spécialité'] }}</td>
|
||||||
|
<td>{{ record['fields']['Formation'] }}</td>
|
||||||
|
<td>{{ (profs.get(record['fields']['Professeur'][0]) if 'Professeur' in record['fields'] else {}).get('fields', {}).get('Name')|default('Not Available') }}</td>
|
||||||
|
<td><a class="button is-small is-primary" href="{{ url_for('airtable.edit_record', record_id=record['id']) }}">Edit</a></td>
|
||||||
|
</tr>
|
||||||
|
</{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
18
project/templates/airtable/partials/footer.html
Normal file
18
project/templates/airtable/partials/footer.html
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{% block navbar %}
|
||||||
|
<nav class="tabs is-boxed is-medium is-centered">
|
||||||
|
<ul>
|
||||||
|
<li class="{{ 'is-active' if request.path == '/dashboard' else '' }}">
|
||||||
|
<a href="/dashboard">Tableau de bord</a>
|
||||||
|
</li>
|
||||||
|
<li class="{{ 'is-active' if request.path == '/participants' else '' }}">
|
||||||
|
<a href="/participants">Participants</a>
|
||||||
|
</li>
|
||||||
|
<li class="{{ 'is-active' if request.path == '/my_modules' else '' }}">
|
||||||
|
<a href="/my_modules">Matières</a>
|
||||||
|
</li>
|
||||||
|
<li class="{{ 'is-active' if request.path == '/my_mentorat' else '' }}">
|
||||||
|
<a href="/my_mentorat">Mentorat</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
{% endblock %}
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
{% extends 'airtable_base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{{ super() }}
|
||||||
|
<div class="column is-9">
|
||||||
|
<section class="section">
|
||||||
|
<div class="container">
|
||||||
|
<h2 class="title">Participant: {{ record['fields']['Nom complet'] }}</h2>
|
||||||
|
<ul>
|
||||||
|
<li>{{ record['fields']['Nom'] }}</li>
|
||||||
|
<li>{{ record['fields']['Prénom'] }}</li>
|
||||||
|
<li>{{ record['fields']['Sexe']}}</li>
|
||||||
|
<li>{{ record['fields']['Date de naissance'] }}</li>
|
||||||
|
<li>{{ record['fields']['Adresse complète'] }}</li>
|
||||||
|
<li>{{ sessions.get(record['fields']['Session Passe Numérique'][0])['fields']['Name']}}</li>
|
||||||
|
<li>{{ sessions.get(record['fields']['Session du Bac +1'][0])['fields']['Name']}}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
121
project/templates/airtable/participants/participants_edit.html
Normal file
121
project/templates/airtable/participants/participants_edit.html
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
{% extends 'airtable_base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="column is-three-quarters-desktop box is-centered has-text-centered">
|
||||||
|
|
||||||
|
<h2 class="title">Edit Participant</h2>
|
||||||
|
<form method="POST" enctype="multipart/form-data" id="editRecordForm">
|
||||||
|
<div class="field is-horizontal">
|
||||||
|
<div class="field-label is-normal">
|
||||||
|
<label class="label" for="family_name">Nom de famille</label>
|
||||||
|
</div>
|
||||||
|
<div class="field-body has-text-left">
|
||||||
|
<div class="field">
|
||||||
|
<div class="control">
|
||||||
|
<input id="family_name" name="family_name" value="{{ record.fields.Nom }}" required><br><br>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field is-horizontal">
|
||||||
|
<div class="field-label is-normal">
|
||||||
|
<label class="label" for="first_name">Prénom</label>
|
||||||
|
</div>
|
||||||
|
<div class="field-body has-text-left">
|
||||||
|
<div class="field">
|
||||||
|
<div class="control">
|
||||||
|
<input id="first_name" name="first_name" value="{{ record.fields.Prénom }}" required><br><br>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field is-horizontal">
|
||||||
|
<div class="field-label is-normal">
|
||||||
|
<label class="label">CNI</label>
|
||||||
|
</div>
|
||||||
|
<div class="field-body">
|
||||||
|
<div class="field">
|
||||||
|
<div class="control">
|
||||||
|
<div class="columns is-multiline" id="CNI-columns">
|
||||||
|
<div class="column is-one-quarter">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-image">
|
||||||
|
</div>
|
||||||
|
<div class="card-footer">
|
||||||
|
<div class="button card-footer-item" id="add-image-label-CNI" data-field="CNI" data-columns-container="CNI-columns">Add</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% for attachment in record.fields.CNI %}
|
||||||
|
<div class="column is-one-quarter">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-image">
|
||||||
|
<figure class="image is-4by3">
|
||||||
|
<a href="{{ attachment.url }}" target="_blank">
|
||||||
|
<img class="thumbnail" id="thumbnail_CNI_{{ attachment.id }}" src="{{ attachment.thumbnails.large.url }}"></img>
|
||||||
|
<img id="red_cross_CNI_{{ attachment.id }}" src="{{ url_for('static', filename='svg/full-cross-circle-alt-svgrepo-com.svg')}}" alt="Red Cross" style="display: none;">
|
||||||
|
</a>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer">
|
||||||
|
<div class="button card-footer-item existing-remove-label" id="remove_label_CNI_{{ attachment.id }}" data-field="CNI" data-attachment-id="{{ attachment.id }}">Remove</div>
|
||||||
|
</div>
|
||||||
|
<input type="file" name="CNI_{{ attachment.id }}" id="CNI_{{ attachment.id }}" field-data="CNI" value="{{ attachment.url }}" style="display: none;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field is-horizontal">
|
||||||
|
<div class="field-label is-normal">
|
||||||
|
<label class="label">Attestation Pôle Emploi</label>
|
||||||
|
</div>
|
||||||
|
<div class="field-body">
|
||||||
|
<div class="field">
|
||||||
|
<div class="control">
|
||||||
|
<div class="columns is-multiline" id="Attestation-columns">
|
||||||
|
<div class="column is-one-quarter">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-image">
|
||||||
|
</div>
|
||||||
|
<div class="card-footer">
|
||||||
|
<div class="button card-footer-item" id="add-image-label-Attestation" data-field="Attestation" data-columns-container="Attestation-columns">Add</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% for attachment in record.fields['Attestation Pôle Emploi'] %}
|
||||||
|
<div class="column is-one-quarter">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-image">
|
||||||
|
<figure class="image is-4by3">
|
||||||
|
<a href="{{ attachment.url }}" target="_blank">
|
||||||
|
<img class="thumbnail" id="thumbnail_Attestation_{{ attachment.id }}" src="{{ attachment.thumbnails.large.url }}"></img>
|
||||||
|
<img id="red_cross_Attestation_{{ attachment.id }}" src="{{ url_for('static', filename='svg/full-cross-circle-alt-svgrepo-com.svg')}}" alt="Red Cross" style="display: none;">
|
||||||
|
</a>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer">
|
||||||
|
<div class="button card-footer-item existing-remove-label" id="remove_label_Attestation_{{ attachment.id }}" data-field="Attestation" class="card-footer-item" data-attachment-id="{{ attachment.id }}">Remove</div>
|
||||||
|
</div>
|
||||||
|
<input type="file" name="Attestation_{{ attachment.id }}" id="Attestation_{{ attachment.id }}" field-data="Attestation" value="{{ attachment.url }}" style="display: none;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="control center">
|
||||||
|
<input class="button is-primary" type="submit" value="Submit">
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
{% extends 'airtable_base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="column is-9">
|
||||||
|
<section class="section">
|
||||||
|
<div class="container">
|
||||||
|
<h2 class="title">Participants</h2>
|
||||||
|
<table class="table is-bordered is-striped is-hoverable is-fullwidth">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Nom</th>
|
||||||
|
<th>Date de naissance</th>
|
||||||
|
<th>Sexe</th>
|
||||||
|
<th>Adresse</th>
|
||||||
|
<th>CNI</th>
|
||||||
|
<th>Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for record in records %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ record['fields']['Nom complet'] }}</td>
|
||||||
|
<td>{{ record['fields']['Date de naissance'] }}</td>
|
||||||
|
<td>{{ record['fields']['Sexe'] }}</td>
|
||||||
|
<td>{{ record['fields']['Adresse complète'] }}</td>
|
||||||
|
<td>
|
||||||
|
{% for attachment in record['fields']['CNI'] %}
|
||||||
|
<a href="{{ attachment['url']}}" ><img src="{{ attachment['thumbnails']['small']['url']}}"></img></a>
|
||||||
|
{% endfor %}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<a class="button is-small is-primary" href="{{ url_for('airtable.display_participant_form', participant_number=record['fields']['Number']) }}">Voir</a>
|
||||||
|
<a class="button is-small is-primary" href="{{ url_for('airtable.edit_participant', participant_number=record['fields']['Number']) }}">Edit</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
6
project/templates/airtable/submitted.html
Normal file
6
project/templates/airtable/submitted.html
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{% extends "airtable_base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
{{ super() }}
|
||||||
|
<h1>Your form has been submitted successfully!</h1>
|
||||||
|
<a href="{{ url_for('airtable.create_record') }}">Submit another response</a>
|
||||||
|
{% endblock %}
|
||||||
54
project/templates/base.html
Normal file
54
project/templates/base.html
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
{% extends "security/base.html" %}
|
||||||
|
{% block metas %}
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="description" content="">
|
||||||
|
<meta name="author" content="">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{%- block head_scripts %}
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bulma/0.9.4/css/bulma.css" integrity="sha512-SI0aF82pT58nyOjCNfyeE2Y5/KHId8cLIX/1VYzdjTRs0HPNswsJR+aLQYSWpb88GDJieAgR4g1XWZvUROQv1A==" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||||
|
{%- endblock head_scripts %}
|
||||||
|
|
||||||
|
|
||||||
|
{%- block styles %}
|
||||||
|
<style>
|
||||||
|
.hide { display: none;}
|
||||||
|
.fs-center { text-align: center }
|
||||||
|
.fs-important { font-size: larger; font-weight: bold }
|
||||||
|
.fs-gap { margin-top: 20px; }
|
||||||
|
.fs-div { margin: 4px; }
|
||||||
|
.fs-error-msg { color: darkred; }
|
||||||
|
</style>
|
||||||
|
{%- endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
|
||||||
|
<section class="hero is-primary is-fullheight">
|
||||||
|
|
||||||
|
<div class="hero-head">
|
||||||
|
{% block navbar %}
|
||||||
|
{% include "partials/navbar.html" %}
|
||||||
|
{% endblock navbar %}
|
||||||
|
</div>
|
||||||
|
<div class="hero-body">
|
||||||
|
<div class="container">
|
||||||
|
<div class="columns is-centered">
|
||||||
|
{% block content -%}
|
||||||
|
{%- endblock content %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="hero-foot">
|
||||||
|
{% block footer -%}
|
||||||
|
{%- endblock footer %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
{%- endblock scripts %}
|
||||||
|
|
||||||
|
{% endblock body %}
|
||||||
|
|
||||||
|
|
||||||
12
project/templates/index.html
Normal file
12
project/templates/index.html
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="column is-half">
|
||||||
|
<h1 class="title">
|
||||||
|
Flask Login Example
|
||||||
|
</h1>
|
||||||
|
<h2 class="subtitle">
|
||||||
|
Easy authentication and authorization in Flask.
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
40
project/templates/partials/navbar.html
Normal file
40
project/templates/partials/navbar.html
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
{% block navbar %}
|
||||||
|
<nav class="navbar" role="navigation">
|
||||||
|
<div class="container">
|
||||||
|
<div class="navbar-brand">
|
||||||
|
<a class="navbar-item">
|
||||||
|
<img src="https://bulma.io/images/bulma-type-white.png" alt="Logo">
|
||||||
|
</a>
|
||||||
|
<span class="navbar-burger" data-target="navbarMenuHeroA">
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div id="navbarMenuHeroA" class="navbar-menu">
|
||||||
|
<div class="navbar-end">
|
||||||
|
<a href="{{ url_for('views.home') }}" class="navbar-item">
|
||||||
|
Home
|
||||||
|
</a>
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
<a href="{{ url_for('views.profile') }}" class="navbar-item">
|
||||||
|
Profile
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('airtable.airtable_dashboard') }}" class="navbar-item">
|
||||||
|
Airtable
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="{{ url_for('security.logout') }}" class="navbar-item">
|
||||||
|
Logout
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if not current_user.is_authenticated %}
|
||||||
|
<a href="{{ url_for('security.login') }}" class="navbar-item">
|
||||||
|
Login
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
{% endblock %}
|
||||||
8
project/templates/profile.html
Normal file
8
project/templates/profile.html
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="hero-body">
|
||||||
|
<div class="container has-text-centered">
|
||||||
|
<h1>Hello {{name}}</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
55
project/templates/security/_macros.html
Normal file
55
project/templates/security/_macros.html
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
{% macro render_field_with_errors(field) %}
|
||||||
|
<div class="field" id="{{ field.id|default('fs-field') }}">
|
||||||
|
<div class="control">
|
||||||
|
{% set placeholder = field.label.text %}
|
||||||
|
{{ field(placeholder=placeholder,**kwargs)|safe }}
|
||||||
|
</div>
|
||||||
|
{% if field.errors %}
|
||||||
|
<ul>
|
||||||
|
{% for error in field.errors %}
|
||||||
|
<li class="fs-error-msg">{{ error }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro render_field(field) %}
|
||||||
|
<div class="fs-div field" id="{{ field.id|default('fs-field') }}">
|
||||||
|
{% if field.label.text == "Login" %}
|
||||||
|
{{ field(**kwargs)|safe }}
|
||||||
|
{% else %}
|
||||||
|
{{ field.label }} {{ field(**kwargs)|safe }}
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro render_field_errors(field) %}
|
||||||
|
<div class="fs-div" id="{{ field.id if field else 'fs-field' }}">
|
||||||
|
{% if field and field.errors %}
|
||||||
|
<ul>
|
||||||
|
{% for error in field.errors %}
|
||||||
|
<li class="fs-error-msg">{{ error }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{# render WTForms (>3.0) form level errors #}
|
||||||
|
{% macro render_form_errors(form) %}
|
||||||
|
{% if form.form_errors %}
|
||||||
|
<div class="fs-div field" id="fs-form-errors">
|
||||||
|
<ul>
|
||||||
|
{% for error in form.form_errors %}
|
||||||
|
<li class="fs-error-msg">{{ error }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro prop_next() -%}
|
||||||
|
{% if 'next' in request.args %}?next={{ request.args.next|urlencode }}{% endif %}
|
||||||
|
{%- endmacro %}
|
||||||
12
project/templates/security/forgot_password.html
Normal file
12
project/templates/security/forgot_password.html
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% from "security/_macros.html" import render_field_with_errors, render_field %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% include "security/_messages.html" %}
|
||||||
|
<h1>{{ _fsdomain('Send password reset instructions') }}</h1>
|
||||||
|
<form action="{{ url_for_security('forgot_password') }}" method="POST" name="forgot_password_form">
|
||||||
|
{{ forgot_password_form.hidden_tag() }}
|
||||||
|
{{ render_field_with_errors(forgot_password_form.email) }}
|
||||||
|
{{ render_field(forgot_password_form.submit, class="button") }}
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
64
project/templates/security/login_user.html
Normal file
64
project/templates/security/login_user.html
Normal file
@ -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 %}
|
||||||
|
<div class="column is-one-third">
|
||||||
|
{% include "security/_messages.html" %}
|
||||||
|
<h1 class="title">{{ _fsdomain('Login') }}</h1>
|
||||||
|
<div class="block">
|
||||||
|
<form action="{{ url_for_security('login') }}{{ prop_next() }}" method="POST" name="login_user_form">
|
||||||
|
<div class="box">
|
||||||
|
{{ 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 %}
|
||||||
|
<h3>{{ _fsdomain("or") }}</h3>
|
||||||
|
{% endif %}
|
||||||
|
{{ render_field_with_errors(login_user_form.username) }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{{ render_field_with_errors(login_user_form.password) }}
|
||||||
|
</div>
|
||||||
|
{{ 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") }}
|
||||||
|
</form>
|
||||||
|
{% if security.webauthn %}
|
||||||
|
<hr class="fs-gap">
|
||||||
|
<h2>{{ _fsdomain("Use WebAuthn to Sign In") }}</h2>
|
||||||
|
<div>
|
||||||
|
<form method="GET" id="wan-signin-form" name="wan_signin_form">
|
||||||
|
<input id="wan_signin" name="wan_signin" type="submit" value="{{ _fsdomain('Sign in with WebAuthn') }}"
|
||||||
|
formaction="{{ url_for_security('wan_signin') }}{{ prop_next() }}">
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if security.oauthglue %}
|
||||||
|
<hr class="fs-gap">
|
||||||
|
<h2>{{ _fsdomain("Use Social Oauth to Sign In") }}</h2>
|
||||||
|
{% for provider in security.oauthglue.provider_names %}
|
||||||
|
<div class="fs-gap">
|
||||||
|
<form method="POST" id={{ provider }}-form name={{ provider }}_form>
|
||||||
|
<input id={{ provider }} name={{ provider }} type="submit" value="{{ _fsdomain('Sign in with ')~provider }}"
|
||||||
|
formaction="{{ url_for_security('oauthstart', name=provider) }}{{ prop_next() }}">
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% if google_login_url is defined %}
|
||||||
|
<a class="button is-rounded is-primary" href="{{ google_login_url() }}" class="button is-primary">
|
||||||
|
<span class="icon">
|
||||||
|
<i class="fab fa-google"></i>
|
||||||
|
</span>
|
||||||
|
<span>Sign in with Google</span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if security.recoverable %}
|
||||||
|
<a class="button is-rounded is-primary" href="{{ url_for_security('forgot_password') }}">{{ _fsdomain('Forgot password') }}</a><br/>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
21
project/templates/security/register_user.html
Normal file
21
project/templates/security/register_user.html
Normal file
@ -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" %}
|
||||||
|
<h1>{{ _fsdomain('Register') }}</h1>
|
||||||
|
<form action="{{ url_for_security('register') }}" method="POST" name="register_user_form">
|
||||||
|
{{ 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) }}
|
||||||
|
</form>
|
||||||
|
{% include "security/_menu.html" %}
|
||||||
|
{% endblock %}
|
||||||
70
project/templates/users.html
Normal file
70
project/templates/users.html
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<h2>User Management</h2>
|
||||||
|
<table class="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for user in users %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ user.username }}</td>
|
||||||
|
<td>{{ user.email }}</td>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('edit_user', user_id=user.id) }}" class="btn btn-primary btn-sm">Edit</a>
|
||||||
|
{% if not user.is_admin %}
|
||||||
|
{% if user.is_active %}
|
||||||
|
<a href="{{ url_for('block_user', user_id=user.id) }}" class="btn btn-warning btn-sm">Block</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ url_for('unblock_user', user_id=user.id) }}" class="btn btn-success btn-sm">Unblock</a>
|
||||||
|
{% endif %}
|
||||||
|
<a href="{{ url_for('delete_user', user_id=user.id) }}" class="btn btn-danger btn-sm">Delete</a>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h3>Create User</h3>
|
||||||
|
<form method="POST" action="{{ url_for('create_user') }}">
|
||||||
|
{{ form.csrf_token }}
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
{{ form.username(class="form-control", placeholder="Enter username") }}
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email">Email</label>
|
||||||
|
{{ form.email(class="form-control", placeholder="Enter email address") }}
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
{{ form.password(class="form-control", placeholder="Enter password") }}
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Create</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h3>Change Password</h3>
|
||||||
|
<form method="POST" action="{{ url_for('change_password') }}">
|
||||||
|
{{ form.csrf_token }}
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email">Email</label>
|
||||||
|
{{ form.email(class="form-control", placeholder="Enter email address") }}
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">New Password</label>
|
||||||
|
{{ form.password(class="form-control", placeholder="Enter new password") }}
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Change</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
37
project/views.py
Normal file
37
project/views.py
Normal file
@ -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) '''
|
||||||
14
requirements.txt
Normal file
14
requirements.txt
Normal file
@ -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
|
||||||
|
|
||||||
Loading…
x
Reference in New Issue
Block a user