Compare commits
21 commits
searching-
...
master
Author | SHA1 | Date | |
---|---|---|---|
![]() |
7255ccebfd | ||
![]() |
02eb9b4220 | ||
![]() |
59266eb865 | ||
![]() |
fb2dd9032b | ||
![]() |
97f072ddb8 | ||
![]() |
cf725a2206 | ||
![]() |
0f4ef0b690 | ||
![]() |
b0a1e142cd | ||
![]() |
c73184060c | ||
![]() |
29cdcf1899 | ||
![]() |
3c40553b45 | ||
![]() |
36bb39a8a0 | ||
![]() |
fa55e10e5b | ||
![]() |
35c0197a40 | ||
![]() |
1857e15791 | ||
![]() |
f6b03460d9 | ||
![]() |
8a94de44c0 | ||
![]() |
3dff046e84 | ||
![]() |
0b3480dd52 | ||
![]() |
b7ca654bf1 | ||
![]() |
c47a46c204 |
14 changed files with 118 additions and 142 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,7 +1,6 @@
|
||||||
resumes*/
|
resumes*/
|
||||||
resumes*.zip
|
resumes*.zip
|
||||||
config_hackWPI.py
|
config_hackWPI.py
|
||||||
config.py
|
|
||||||
admin/*.json
|
admin/*.json
|
||||||
admin/*.csv
|
admin/*.csv
|
||||||
|
|
||||||
|
|
30
config.py
Normal file
30
config.py
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import os
|
||||||
|
|
||||||
|
from dotenv import load_dotenv, dotenv_values
|
||||||
|
|
||||||
|
basedir = os.path.abspath(os.path.dirname(__file__))
|
||||||
|
load_dotenv(os.path.join(basedir, '.env'))
|
||||||
|
|
||||||
|
class Config():
|
||||||
|
TESTING = dotenv_values().get("TESTING") or False
|
||||||
|
DEBUG = dotenv_values().get("DEBUG") or False
|
||||||
|
|
||||||
|
SQLALCHEMY_DATABASE_URI = dotenv_values().get("SQLALCHEMY_DATABASE_URI") or "postgresql://localhost/goathacks"
|
||||||
|
|
||||||
|
MAX_BEFORE_WAITLIST = int(dotenv_values().get("MAX_BEFORE_WAITLIST") or 1)
|
||||||
|
MCE_API_KEY = dotenv_values().get("MCE_API_KEY")
|
||||||
|
SECRET_KEY = dotenv_values().get("SECRET_KEY") or "bad-key-change-me"
|
||||||
|
|
||||||
|
UPLOAD_FOLDER = dotenv_values().get("UPLOAD_FOLDER") or "./uploads/"
|
||||||
|
|
||||||
|
DISCORD_LINK = dotenv_values().get("DISCORD_LINK") or None
|
||||||
|
|
||||||
|
# Mail server settings
|
||||||
|
MAIL_SERVER = dotenv_values().get("MAIL_SERVER") or "localhost"
|
||||||
|
MAIL_PORT = dotenv_values().get("MAIL_PORT") or 25
|
||||||
|
MAIL_USE_TLS = dotenv_values().get("MAIL_USE_TLS") or False
|
||||||
|
MAIL_USE_SSL = dotenv_values().get("MAIL_USE_SSL") or False
|
||||||
|
MAIL_USERNAME = dotenv_values().get("MAIL_USERNAME") or "dummy"
|
||||||
|
MAIL_PASSWORD = dotenv_values().get("MAIL_PASSWORD") or "dummy"
|
||||||
|
MAIL_DEFAULT_SENDER = dotenv_values().get("MAIL_DEFAULT_SENDER") or "GoatHacks Team <hack@wpi.edu>"
|
||||||
|
MAIL_SUPPRESS_SEND = dotenv_values().get("MAIL_SUPPRESS_SEND") or TESTING
|
|
@ -9,6 +9,8 @@ from flask_bootstrap import Bootstrap5
|
||||||
from flask_font_awesome import FontAwesome
|
from flask_font_awesome import FontAwesome
|
||||||
from flask_qrcode import QRcode
|
from flask_qrcode import QRcode
|
||||||
|
|
||||||
|
from config import Config
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
db = SQLAlchemy()
|
db = SQLAlchemy()
|
||||||
|
@ -21,10 +23,10 @@ bootstrap = Bootstrap5()
|
||||||
font_awesome = FontAwesome()
|
font_awesome = FontAwesome()
|
||||||
qrcode = QRcode()
|
qrcode = QRcode()
|
||||||
|
|
||||||
def create_app():
|
def create_app(config_class=Config):
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
app.config.from_pyfile("config.py")
|
app.config.from_object(config_class)
|
||||||
|
|
||||||
db.init_app(app)
|
db.init_app(app)
|
||||||
migrate.init_app(app, db)
|
migrate.init_app(app, db)
|
||||||
|
|
|
@ -4,8 +4,6 @@ from flask_mail import Message
|
||||||
|
|
||||||
from goathacks.models import User
|
from goathacks.models import User
|
||||||
|
|
||||||
from sqlalchemy.exc import IntegrityError
|
|
||||||
|
|
||||||
bp = Blueprint("admin", __name__, url_prefix="/admin")
|
bp = Blueprint("admin", __name__, url_prefix="/admin")
|
||||||
|
|
||||||
from goathacks import db, mail as app_mail
|
from goathacks import db, mail as app_mail
|
||||||
|
@ -223,44 +221,6 @@ def hackers():
|
||||||
users = User.query.all()
|
users = User.query.all()
|
||||||
return User.create_json_output(users)
|
return User.create_json_output(users)
|
||||||
|
|
||||||
@bp.route("/updateHacker", methods=["POST"])
|
|
||||||
@login_required
|
|
||||||
def updateHacker():
|
|
||||||
if not current_user.is_admin:
|
|
||||||
return redirect(url_for("dashboard.home"))
|
|
||||||
|
|
||||||
# get params from json
|
|
||||||
hacker_id = request.json['hacker_id']
|
|
||||||
change_field = request.json['change_field']
|
|
||||||
new_val = request.json['new_val']
|
|
||||||
|
|
||||||
# find the user in db
|
|
||||||
user = User.query.filter_by(id=hacker_id).one()
|
|
||||||
if user is None:
|
|
||||||
return {"status": "error", "msg": "user not found"}
|
|
||||||
|
|
||||||
|
|
||||||
# update the hacker depending on change_field
|
|
||||||
match change_field:
|
|
||||||
case "first_name":
|
|
||||||
user.first_name = new_val
|
|
||||||
case "last_name":
|
|
||||||
user.last_name = new_val
|
|
||||||
case "school":
|
|
||||||
user.school = new_val
|
|
||||||
case "phone":
|
|
||||||
user.phone = new_val
|
|
||||||
|
|
||||||
try:
|
|
||||||
db.session.commit()
|
|
||||||
except IntegrityError as err:
|
|
||||||
db.session.rollback()
|
|
||||||
flash("Could not update user information for user " + hacker_id)
|
|
||||||
return {"status": "error"}
|
|
||||||
|
|
||||||
|
|
||||||
return {"status": "success"}
|
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import csv
|
import csv
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
|
|
|
@ -23,8 +23,10 @@ gr = AppGroup("user")
|
||||||
@click.option("--school", prompt=True)
|
@click.option("--school", prompt=True)
|
||||||
@click.option("--phone", prompt=True)
|
@click.option("--phone", prompt=True)
|
||||||
@click.option("--gender", prompt=True)
|
@click.option("--gender", prompt=True)
|
||||||
|
@click.option("--country", prompt=True)
|
||||||
|
@click.option("--age", prompt=True)
|
||||||
def create_user(email, first_name, last_name, password, school, phone, gender,
|
def create_user(email, first_name, last_name, password, school, phone, gender,
|
||||||
admin):
|
admin,age, country):
|
||||||
"""
|
"""
|
||||||
Creates a user
|
Creates a user
|
||||||
"""
|
"""
|
||||||
|
@ -48,7 +50,9 @@ def create_user(email, first_name, last_name, password, school, phone, gender,
|
||||||
school=school,
|
school=school,
|
||||||
phone=phone,
|
phone=phone,
|
||||||
gender=gender,
|
gender=gender,
|
||||||
is_admin=admin
|
is_admin=admin,
|
||||||
|
country=country,
|
||||||
|
age=age
|
||||||
)
|
)
|
||||||
db.session.add(user)
|
db.session.add(user)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
|
@ -1,18 +0,0 @@
|
||||||
SQLALCHEMY_DATABASE_URI="postgresql://localhost/goathacks"
|
|
||||||
MAX_BEFORE_WAITLIST=1
|
|
||||||
SECRET_KEY="bad-key-change-me"
|
|
||||||
|
|
||||||
UPLOAD_FOLDER="./uploads/"
|
|
||||||
|
|
||||||
DISCORD_LINK=None
|
|
||||||
|
|
||||||
|
|
||||||
# Mail settings
|
|
||||||
MAIL_SERVER="localhost"
|
|
||||||
MAIL_PORT=25
|
|
||||||
MAIL_USE_TLS=False
|
|
||||||
MAIL_USE_SSL=False
|
|
||||||
MAIL_USERNAME="dummy"
|
|
||||||
MAIL_PASSWORD="dummy"
|
|
||||||
MAIL_DEFAULT_SENDER="GoatHacks Team <hack@wpi.edu>"
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
from flask import Blueprint, current_app, flash, jsonify, render_template, request
|
from flask import Blueprint, current_app, flash, jsonify, redirect, render_template, request, url_for
|
||||||
from flask_login import current_user, login_required
|
from flask_login import current_user, login_required
|
||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
|
@ -47,9 +47,17 @@ def resume():
|
||||||
filename = current_user.first_name.lower() + '_' + current_user.last_name.lower() + '_' + str(
|
filename = current_user.first_name.lower() + '_' + current_user.last_name.lower() + '_' + str(
|
||||||
current_user.id) + '.' + resume.filename.split('.')[-1].lower()
|
current_user.id) + '.' + resume.filename.split('.')[-1].lower()
|
||||||
filename = secure_filename(filename)
|
filename = secure_filename(filename)
|
||||||
|
if not os.path.exists(current_app.config['UPLOAD_FOLDER']):
|
||||||
|
try:
|
||||||
|
os.makedirs(current_app.config['UPLOAD_FOLDER'])
|
||||||
|
except Exception:
|
||||||
|
flash("Error saving resume. Contact acm-sysadmin@wpi.edu")
|
||||||
|
return redirect(url_for("dashboard.home"))
|
||||||
resume.save(os.path.join(current_app.config['UPLOAD_FOLDER'], filename))
|
resume.save(os.path.join(current_app.config['UPLOAD_FOLDER'], filename))
|
||||||
return 'Resume uploaded! <a href="/dashboard">Return to dashboard</a>'
|
flash("Resume uploaded!")
|
||||||
return "Something went wrong. If this keeps happening, contact hack@wpi.edu for assistance"
|
return redirect(url_for("dashboard.home"))
|
||||||
|
flash("Something went wrong. If this keeps happening, contact hack@wpi.edu for assistance")
|
||||||
|
return redirect(url_for("dashboard.home"))
|
||||||
|
|
||||||
|
|
||||||
def allowed_file(filename):
|
def allowed_file(filename):
|
||||||
|
|
|
@ -25,6 +25,7 @@ class RegisterForm(FlaskForm):
|
||||||
country = SelectField("Country", choices=[(country.split(",")[0], country.split(",")[0]) for country in countries_list], widget=widgets.Select())
|
country = SelectField("Country", choices=[(country.split(",")[0], country.split(",")[0]) for country in countries_list], widget=widgets.Select())
|
||||||
newsletter = BooleanField("Subscribe to the MLH newsletter?")
|
newsletter = BooleanField("Subscribe to the MLH newsletter?")
|
||||||
agree_coc = BooleanField("I confirm that I have read and agree to the Code of Conduct", validators=[DataRequired()])
|
agree_coc = BooleanField("I confirm that I have read and agree to the Code of Conduct", validators=[DataRequired()])
|
||||||
|
logistics = BooleanField("I authorize you to share my application/registration with Major League Hacking for event administration, ranking, and MLH administration in-line with the MLH privacy policy.I further agree to the terms of both the MLH Contest Terms and Conditions and the MLH Privacy Policy.", validators=[DataRequired()])
|
||||||
|
|
||||||
submit = SubmitField("Register")
|
submit = SubmitField("Register")
|
||||||
|
|
||||||
|
|
|
@ -14,22 +14,8 @@
|
||||||
{% block app_content %}
|
{% block app_content %}
|
||||||
<div class="card text-center">
|
<div class="card text-center">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
<h1 class="h3 mb-3 fw-normal">Registered Users</h1>
|
||||||
<div style="display:flex;flex-wrap:nowrap;align-items:center;">
|
<table id="hackers" class="table table-striped">
|
||||||
<div class="dropdown">
|
|
||||||
<a href="#" class="btn btn-primary dropdown-toggle"
|
|
||||||
data-bs-toggle="dropdown">Search<span
|
|
||||||
class="caret"></span></a>
|
|
||||||
<ul class="dropdown-menu">
|
|
||||||
<input style="padding:5px;margin-left:10px;margin-right:10px;" type="text" id="searchbox" name="searchbox-text" placeholder="Search By Email" onkeyup="filterHackers()"/>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- TODO: get "Registered Users" properly centered -->
|
|
||||||
<h1 style="flex-grow:1;justify-content:center;" class="h3 mb-3 fw-normal">Registered Users</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<table id="hackers" class="table table-striped sortable">
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Options</th>
|
<th>Options</th>
|
||||||
|
@ -93,71 +79,16 @@
|
||||||
<td>{{ hacker.id }}</td>
|
<td>{{ hacker.id }}</td>
|
||||||
<td>{{ hacker.last_login }}</td>
|
<td>{{ hacker.last_login }}</td>
|
||||||
<td>{{ hacker.email }}</td>
|
<td>{{ hacker.email }}</td>
|
||||||
<td>
|
<td>{{ hacker.first_name + ' ' + hacker.last_name }}</td>
|
||||||
<div style="display: flex; justify-content: flex-start;">
|
<td>{{ hacker.phone }}</td>
|
||||||
<input style="padding:5px; margin-left:10px; margin-right:10px;width:fit-content;max-width:100px;" type="text" id="{{hacker.id}}-namebox-first" placeholder="first_name" value="{{hacker.first_name}}" onchange="updateHacker(this.id, 'first_name', this.value)"/>
|
|
||||||
<input style="padding:5px; margin-left:10px; margin-right:10px;width:fit-content;max-width:100px;" type="text" id="{{hacker.id}}-namebox-last" placeholder="last_name" value="{{hacker.last_name}}" onchange="updateHacker(this.id, 'last_name', this.value)"/>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<input style="padding:5px; margin-left:10px; margin-right:10px;width:fit-content;max-width:100px;" type="text" id="{{hacker.id}}-phonebox" placeholder="phone" value="{{hacker.phone}}" onchange="updateHacker(this.id, 'phone', this.value)"/>
|
|
||||||
</td>
|
|
||||||
<td>{{ hacker.shirt_size }}</td>
|
<td>{{ hacker.shirt_size }}</td>
|
||||||
<td>{{ hacker.accomodations }}</td>
|
<td>{{ hacker.accomodations }}</td>
|
||||||
<td>
|
<td>{{ hacker.school }}</td>
|
||||||
<input style="padding:5px; margin-left:10px; margin-right:10px;width:fit-content;max-width:75px;" type="text" id="{{hacker.id}}-schoolbox" placeholder="school" value="{{hacker.school}}" onchange="updateHacker(this.id, 'school', this.value)"/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="text/javascript">
|
|
||||||
function filterHackers() {
|
|
||||||
//get hacker table and searchbox info
|
|
||||||
const input = document.getElementById("searchbox").value.toLowerCase();
|
|
||||||
const hackertable = document.getElementById("hackers");
|
|
||||||
let rows = hackertable.getElementsByTagName("tr");
|
|
||||||
|
|
||||||
//iterate over all rows
|
|
||||||
for(let i = 1; i < rows.length; i++) {
|
|
||||||
//get the email
|
|
||||||
const cells = rows[i].getElementsByTagName("td");
|
|
||||||
const emailCell = cells[6];
|
|
||||||
|
|
||||||
//if there is an email, display or dont display based on searchbox
|
|
||||||
if(emailCell) {
|
|
||||||
const emailText = emailCell.textContent.toLowerCase();
|
|
||||||
if(!emailText.includes(input)) {
|
|
||||||
rows[i].style.display = "none";
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
rows[i].style.display = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateHacker(id, change_field, new_val) {
|
|
||||||
//tell backend to update a specific field for a hacker
|
|
||||||
const headers = [
|
|
||||||
["Content-Type", "application/json"],
|
|
||||||
];
|
|
||||||
|
|
||||||
let body = {
|
|
||||||
"hacker_id": id.substr(0, id.indexOf('-')),
|
|
||||||
"change_field": change_field,
|
|
||||||
"new_val": new_val,
|
|
||||||
}
|
|
||||||
|
|
||||||
//send the post request, and report the error if there is one
|
|
||||||
fetch('/admin/updateHacker', {method: 'POST', body: JSON.stringify(body), headers: headers}).catch((err) => {
|
|
||||||
window.alert("Error updating user - see console for details");
|
|
||||||
console.log(err);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
|
@ -15,6 +15,12 @@
|
||||||
<p class="card-text">Let us know if you have any questions by sending
|
<p class="card-text">Let us know if you have any questions by sending
|
||||||
them to <a href="mailto:hack@wpi.edu">hack@wpi.edu</a></p>
|
them to <a href="mailto:hack@wpi.edu">hack@wpi.edu</a></p>
|
||||||
|
|
||||||
|
{% if not current_user.waitlisted and config['DISCORD_LINK'] %}
|
||||||
|
<p>Make sure to join our Discord to get the latest updates!</p>
|
||||||
|
<button type="button" class="btn btn-primary mb-3"><a href="{{ config['DISCORD_LINK']
|
||||||
|
}}" class="link-light">Discord</a></button>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="row center justify-content-center">
|
<div class="row center justify-content-center">
|
||||||
<form method="post">
|
<form method="post">
|
||||||
{{ form.csrf_token() }}
|
{{ form.csrf_token() }}
|
||||||
|
@ -33,7 +39,7 @@
|
||||||
</div>
|
</div>
|
||||||
<hr/>
|
<hr/>
|
||||||
<div class="row center justify-content-center">
|
<div class="row center justify-content-center">
|
||||||
<form method="post" action={{url_for('dashboard.resume')}}"
|
<form method="post" action="{{url_for('dashboard.resume')}}"
|
||||||
enctype="multipart/form-data">
|
enctype="multipart/form-data">
|
||||||
{{ resform.csrf_token() }}
|
{{ resform.csrf_token() }}
|
||||||
<p><b>If you'd like, add your resume to send to
|
<p><b>If you'd like, add your resume to send to
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
Subproject commit a107d4daf149bac2b8bd1182b399e57e8171c1f8
|
Subproject commit db2a7a865f9b3865fa2180b1b53b1c2d2640be81
|
|
@ -86,6 +86,20 @@
|
||||||
{{ form.agree_coc }}
|
{{ form.agree_coc }}
|
||||||
I confirm that I have read and agree to the <a href="https://static.mlh.io/docs/mlh-code-of-conduct.pdf" target="_blank">MLH Code of Conduct</a>
|
I confirm that I have read and agree to the <a href="https://static.mlh.io/docs/mlh-code-of-conduct.pdf" target="_blank">MLH Code of Conduct</a>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-check mb-3 required">
|
||||||
|
{{ form.logistics }}
|
||||||
|
I authorize you to share my application/registration with Major League Hacking
|
||||||
|
for event administration, ranking, and MLH administration in-line with the MLH
|
||||||
|
privacy policy. I further agree to the terms of both the <a
|
||||||
|
href="https://github.com/MLH/mlh-policies/blob/main/contest-terms.md">MLH
|
||||||
|
Contest Terms
|
||||||
|
and
|
||||||
|
Conditions</a>
|
||||||
|
and the <a
|
||||||
|
href="https://github.com/MLH/mlh-policies/blob/main/privacy-policy.md">MLH
|
||||||
|
Privacy
|
||||||
|
Policy</a>.
|
||||||
|
</div>
|
||||||
<div class="form-check mb-3">
|
<div class="form-check mb-3">
|
||||||
{{ form.newsletter }}
|
{{ form.newsletter }}
|
||||||
Subscribe to the MLH newsletter?
|
Subscribe to the MLH newsletter?
|
||||||
|
|
38
migrations/versions/f5b70c6e73eb_.py
Normal file
38
migrations/versions/f5b70c6e73eb_.py
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: f5b70c6e73eb
|
||||||
|
Revises: 858e0d45876f
|
||||||
|
Create Date: 2024-10-31 13:04:48.500263
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'f5b70c6e73eb'
|
||||||
|
down_revision = '858e0d45876f'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('user', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('newsletter', sa.Boolean(), nullable=False))
|
||||||
|
batch_op.add_column(sa.Column('country', sa.String(), nullable=False))
|
||||||
|
batch_op.add_column(sa.Column('age', sa.Integer(), nullable=False))
|
||||||
|
batch_op.add_column(sa.Column('dietary_restrictions', sa.String(), nullable=True))
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('user', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('dietary_restrictions')
|
||||||
|
batch_op.drop_column('age')
|
||||||
|
batch_op.drop_column('country')
|
||||||
|
batch_op.drop_column('newsletter')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
|
@ -26,3 +26,4 @@ ulid
|
||||||
bootstrap-flask
|
bootstrap-flask
|
||||||
Font-Awesome-Flask
|
Font-Awesome-Flask
|
||||||
tabulate
|
tabulate
|
||||||
|
markupsafe
|
||||||
|
|
Loading…
Add table
Reference in a new issue