@@ -0,0 +1,37 @@ | |||
*.pyc | |||
/venv | |||
/config.py | |||
/.idea | |||
*.db | |||
/uploads/*/* | |||
/tmp/cty.xml | |||
/.pytest_cache/ | |||
/api/*.egg-info | |||
/api/.eggs | |||
/.eggs | |||
/configtest.py | |||
/api/*.log | |||
.coverage | |||
.ropeproject | |||
/.mypy_cache | |||
/api/test-reports | |||
test/unit/coverage/ | |||
test/e2e/reports | |||
.DS_Store | |||
/docs-build | |||
/docs/_build/ | |||
api/config/*_secret.py | |||
# Translations | |||
*.mo | |||
*~ | |||
front/src/translations.json | |||
front/src/translations/*.json | |||
front/locales/*/LC_MESSAGES/app.po | |||
.env | |||
/data | |||
/api/celerybeat-schedule |
@@ -0,0 +1,22 @@ | |||
# .tmuxinator.yml | |||
name: dev | |||
root: . | |||
windows: | |||
- backend: | |||
- export FLASK_ENV=development | |||
- export APP_SETTINGS='config.development_secret.Config' | |||
- source venv/bin/activate | |||
- cd api | |||
- flask run | |||
# - workers: | |||
# - export FLASK_ENV=development | |||
# - export APP_SETTINGS='config.development_secret.Config' | |||
# - source venv/bin/activate | |||
# - cd api | |||
# - celery worker -A tasks.celery --loglevel=error | |||
- frontend: | |||
- cd front | |||
- yarn dev | |||
@@ -0,0 +1,42 @@ | |||
{ | |||
"python.linting.pylintEnabled": false, | |||
"python.linting.flake8Enabled": true, | |||
"python.linting.enabled": true, | |||
"files.exclude": { | |||
"**/.git": true, | |||
"**/.svn": true, | |||
"**/.hg": true, | |||
"**/CVS": true, | |||
"**/.DS_Store": true, | |||
"**/node_modueles": true, | |||
"**/__pycache__": true, | |||
"**/*.egg-info": true | |||
}, | |||
"editor.codeActionsOnSave": { | |||
"source.fixAll": true | |||
}, | |||
"eslint.autoFixOnSave": true, | |||
"eslint.validate": [ | |||
{ | |||
"language": "vue", | |||
"autoFix": true | |||
}, | |||
{ | |||
"language": "html", | |||
"autoFix": true | |||
}, | |||
{ | |||
"language": "javascript", | |||
"autoFix": true | |||
}, | |||
{ | |||
"language": "typescript", | |||
"autoFix": true | |||
} | |||
], | |||
"vetur.format.defaultFormatter.js": "vscode-typescript", | |||
"vetur.format.defaultFormatter.html": "js-beautify-html", | |||
"javascript.format.insertSpaceBeforeFunctionParenthesis": true, | |||
"python.pythonPath": "/home/dashie/dev/omnomnomnom/venv/bin/python", | |||
"restructuredtext.confPath": "${workspaceFolder}/docs" | |||
} |
@@ -0,0 +1,69 @@ | |||
### OSX ### | |||
.DS_Store | |||
.AppleDouble | |||
.LSOverride | |||
### SublimeText ### | |||
# cache files for sublime text | |||
*.tmlanguage.cache | |||
*.tmPreferences.cache | |||
*.stTheme.cache | |||
# workspace files are user-specific | |||
*.sublime-workspace | |||
# project files should be checked into the repository, unless a significant | |||
# proportion of contributors will probably not be using SublimeText | |||
# *.sublime-project | |||
# sftp configuration file | |||
sftp-config.json | |||
# Basics | |||
*.py[cod] | |||
__pycache__ | |||
# Logs | |||
*.log | |||
pip-log.txt | |||
# Unit test / coverage reports | |||
.coverage | |||
.tox | |||
nosetests.xml | |||
htmlcov | |||
# Translations | |||
*.mo | |||
*.pot | |||
# Pycharm | |||
.idea | |||
# Vim | |||
*~ | |||
*.swp | |||
*.swo | |||
# npm | |||
front/node_modules/ | |||
# Compass | |||
.sass-cache | |||
# virtual environments | |||
.env | |||
# User-uploaded media | |||
uploads/ | |||
# Hitch directory | |||
tests/.hitch | |||
# MailHog binary | |||
mailhog | |||
*.sqlite3 | |||
music | |||
media |
@@ -0,0 +1,260 @@ | |||
# encoding: utf-8 | |||
import logging | |||
import os | |||
import subprocess | |||
from logging.handlers import RotatingFileHandler | |||
from flask_babelex import gettext, Babel | |||
from flask import Flask, g, send_from_directory, jsonify, safe_join, request, flash, Response | |||
from flask_mail import Mail | |||
from flask_migrate import Migrate | |||
from flask_security import Security | |||
from flask_security import signals as FlaskSecuritySignals | |||
from flask_cors import CORS, cross_origin | |||
from flasgger import Swagger | |||
from werkzeug.middleware.proxy_fix import ProxyFix | |||
from werkzeug.utils import import_string | |||
import commands | |||
from utils.flake_id import FlakeId | |||
from sqlalchemy import event | |||
from sqlalchemy.engine import Engine | |||
import time | |||
from models import db, user_datastore | |||
from utils.various import InvalidUsage, is_admin | |||
from celery import Celery | |||
from version import VERSION | |||
__VERSION__ = VERSION | |||
AVAILABLE_LOCALES = ["fr", "fr_FR", "en", "en_US", "pl"] | |||
try: | |||
import sentry_sdk | |||
from sentry_sdk.integrations.flask import FlaskIntegration as SentryFlaskIntegration | |||
from sentry_sdk.integrations.celery import CeleryIntegration as SentryCeleryIntegration | |||
print(" * Sentry Flask/Celery support have been loaded") | |||
HAS_SENTRY = True | |||
except ImportError: | |||
print(" * No Sentry Flask/Celery support available") | |||
HAS_SENTRY = False | |||
GIT_VERSION = "" | |||
gitpath = os.path.join(os.getcwd(), "../.git") | |||
if os.path.isdir(gitpath): | |||
GIT_VERSION = subprocess.check_output(["git", "rev-parse", "--short", "HEAD"]) | |||
if GIT_VERSION: | |||
GIT_VERSION = GIT_VERSION.strip().decode("UTF-8") | |||
def make_celery(remoulade): | |||
celery = Celery(remoulade.import_name, broker=remoulade.config["CELERY_BROKER_URL"]) | |||
celery.conf.update(remoulade.config) | |||
TaskBase = celery.Task | |||
class ContextTask(TaskBase): | |||
abstract = True | |||
def __call__(self, *args, **kwargs): | |||
with remoulade.app_context(): | |||
return TaskBase.__call__(self, *args, **kwargs) | |||
celery.Task = ContextTask | |||
return celery # omnomnom | |||
def create_app(config_filename="config.development.Config", app_name=None, register_blueprints=True): | |||
# App configuration | |||
app = Flask(app_name or __name__) | |||
app_settings = os.getenv("APP_SETTINGS", config_filename) | |||
print(f" * Loading config: '{app_settings}'") | |||
try: | |||
cfg = import_string(app_settings)() | |||
except ImportError: | |||
print(" *** Cannot import config ***") | |||
cfg = import_string("config.config.BaseConfig") | |||
print(" *** Default config loaded, expect problems ***") | |||
app.config.from_object(cfg) | |||
app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1) | |||
app.jinja_env.add_extension("jinja2.ext.with_") | |||
app.jinja_env.add_extension("jinja2.ext.do") | |||
app.jinja_env.globals.update(is_admin=is_admin) | |||
if HAS_SENTRY: | |||
sentry_sdk.init( | |||
app.config["SENTRY_DSN"], | |||
integrations=[SentryFlaskIntegration(), SentryCeleryIntegration()], | |||
release=f"{VERSION} ({GIT_VERSION})", | |||
) | |||
print(" * Sentry Flask/Celery support activated") | |||
print(" * Sentry DSN: %s" % app.config["SENTRY_DSN"]) | |||
if app.debug: | |||
app.jinja_env.auto_reload = True | |||
logging.basicConfig(level=logging.DEBUG) | |||
# Logging | |||
if not app.debug: | |||
formatter = logging.Formatter("%(asctime)s %(levelname)s: %(message)s " "[in %(pathname)s:%(lineno)d]") | |||
file_handler = RotatingFileHandler("%s/errors_app.log" % os.getcwd(), "a", 1000000, 1) | |||
file_handler.setLevel(logging.INFO) | |||
file_handler.setFormatter(formatter) | |||
app.logger.addHandler(file_handler) | |||
dbLogger = logging.getLogger("omnomnomnom.sqltime") | |||
dbLogger.setLevel(logging.DEBUG) | |||
CORS(app, origins=["*"]) | |||
if app.debug: | |||
logging.getLogger("flask_cors.extension").level = logging.DEBUG | |||
mail = Mail(app) # noqa: F841 | |||
migrate = Migrate(app, db) # noqa: F841 lgtm [py/unused-local-variable] | |||
babel = Babel(app) # noqa: F841 | |||
app.babel = babel | |||
template = { | |||
"swagger": "2.0", | |||
"info": {"title": "omnomnomnom API", "description": "API", "version": VERSION}, | |||
"host": app.config["APP_HOST"], | |||
"basePath": "/", | |||
"schemes": ["https"], | |||
"securityDefinitions": { | |||
}, | |||
"consumes": ["application/json"], | |||
"produces": ["application/json"], | |||
} | |||
db.init_app(app) | |||
# Setup Flask-Security | |||
security = Security(app, user_datastore) # noqa: F841 lgtm [py/unused-local-variable] | |||
@FlaskSecuritySignals.password_reset.connect_via(app) | |||
@FlaskSecuritySignals.password_changed.connect_via(app) | |||
def log_password_reset(sender, user): | |||
if not user: | |||
return | |||
add_user_log(user.id, user.id, "user", "info", "Your password has been changed !") | |||
@FlaskSecuritySignals.reset_password_instructions_sent.connect_via(app) | |||
def log_reset_password_instr(sender, user, token): | |||
if not user: | |||
return | |||
add_user_log(user.id, user.id, "user", "info", "Password reset instructions sent.") | |||
@security.mail_context_processor | |||
def mail_ctx_proc(): | |||
return dict() | |||
@babel.localeselector | |||
def get_locale(): | |||
# if a user is logged in, use the locale from the user settings | |||
identity = getattr(g, "identity", None) | |||
if identity is not None and identity.id: | |||
return identity.user.locale | |||
# otherwise try to guess the language from the user accept | |||
# header the browser transmits. We support fr/en in this | |||
# example. The best match wins. | |||
return request.accept_languages.best_match(AVAILABLE_LOCALES) | |||
@babel.timezoneselector | |||
def get_timezone(): | |||
identity = getattr(g, "identity", None) | |||
if identity is not None and identity.id: | |||
return identity.user.timezone | |||
@app.before_request | |||
def before_request(): | |||
cfg = { | |||
"OMNOMNOMNOM_VERSION_VER": VERSION, | |||
"OMNOMNOMNOM_VERSION_GIT": GIT_VERSION, | |||
} | |||
if GIT_VERSION: | |||
cfg["OMNOMNOMNOM_VERSION"] = "{0}-{1}".format(VERSION, GIT_VERSION) | |||
else: | |||
cfg["OMNOMNOMNOM_VERSION"] = VERSION | |||
g.cfg = cfg | |||
@app.errorhandler(InvalidUsage) | |||
def handle_invalid_usage(error): | |||
response = jsonify(error.to_dict()) | |||
response.status_code = error.status_code | |||
return response | |||
@event.listens_for(Engine, "before_cursor_execute") | |||
def before_cursor_execute(conn, cursor, statement, parameters, context, executemany): | |||
if not False: | |||
return | |||
conn.info.setdefault("query_start_time", []).append(time.time()) | |||
dbLogger.debug("Start Query: %s", statement) | |||
@event.listens_for(Engine, "after_cursor_execute") | |||
def after_cursor_execute(conn, cursor, statement, parameters, context, executemany): | |||
if not False: | |||
return | |||
total = time.time() - conn.info["query_start_time"].pop(-1) | |||
dbLogger.debug("Query Complete!") | |||
dbLogger.debug("Total Time: %f", total) | |||
app.flake_id = FlakeId() | |||
if register_blueprints: | |||
# from controllers.api.v1.Xm import bp_X | |||
# app.register_blueprint(bp_X) | |||
swagger = Swagger(app, template=template) # noqa: F841 lgtm [py/unused-local-variable] | |||
@app.route("/uploads/<string:thing>/<path:stuff>", methods=["GET"]) | |||
@cross_origin(origins="*", methods=["GET", "HEAD", "OPTIONS"], expose_headers="content-length", send_wildcard=True) | |||
def get_uploads_stuff(thing, stuff): | |||
if app.testing or app.debug: | |||
directory = safe_join(app.config["UPLOADS_DEFAULT_DEST"], thing) | |||
app.logger.debug(f"serving {stuff} from {directory}") | |||
return send_from_directory(directory, stuff, as_attachment=True) | |||
else: | |||
app.logger.debug(f"X-Accel-Redirect serving {stuff}") | |||
resp = Response("") | |||
resp.headers["Content-Disposition"] = f"attachment; filename={stuff}" | |||
resp.headers["X-Accel-Redirect"] = f"/_protected/media/{thing}/{stuff}" | |||
resp.headers["Content-Type"] = "" # empty it so Nginx will guess it correctly | |||
return resp | |||
@app.errorhandler(404) | |||
def page_not_found(msg): | |||
return jsonify({"error": "page not found"}), 404 | |||
@app.errorhandler(403) | |||
def err_forbidden(msg): | |||
return jsonify({"error": "access forbidden"}), 403 | |||
@app.errorhandler(410) | |||
def err_gone(msg): | |||
return jsonify({"error": "gone"}), 410 | |||
if not app.debug: | |||
@app.errorhandler(500) | |||
def err_failed(msg): | |||
return jsonify({"error": "server error"}), 500 | |||
@app.after_request | |||
def set_x_powered_by(response): | |||
response.headers["X-Powered-By"] = "omnomnomnom" | |||
return response | |||
# Register CLI commands | |||
app.cli.add_command(commands.db_datas) | |||
app.cli.add_command(commands.users) | |||
app.cli.add_command(commands.roles) | |||
app.cli.add_command(commands.system) | |||
return app |
@@ -0,0 +1,5 @@ | |||
# flake8: noqa: F401 | |||
from .db_datas import db_datas | |||
from .users import users | |||
from .roles import roles | |||
from .system import system |
@@ -0,0 +1,39 @@ | |||
import click | |||
from models import db, Role | |||
from flask.cli import with_appcontext | |||
@click.group() | |||
def db_datas(): | |||
""" | |||
Datas migrations sometimes needed. | |||
Run them only one time unless specified BREAKING. | |||
""" | |||
pass | |||
def make_db_seed(db): | |||
# roles | |||
roles = db.session.query(Role.name).all() | |||
roles = [r[0] for r in roles] | |||
if "user" not in roles: | |||
role_usr = Role(name="user", description="Simple user") | |||
db.session.add(role_usr) | |||
if "admin" not in roles: | |||
role_adm = Role(name="admin", description="Admin user") | |||
db.session.add(role_adm) | |||
# Final commit | |||
db.session.commit() | |||
@db_datas.command(name="000-seeds") | |||
@with_appcontext | |||
def seeds(): | |||
""" | |||
Seed database with default config and roles values | |||
non breaking. | |||
""" | |||
make_db_seed(db) |
@@ -0,0 +1,32 @@ | |||
import click | |||
import texttable | |||
from models import Role | |||
from flask.cli import with_appcontext | |||
@click.group() | |||
def roles(): | |||
""" | |||
Role commands. | |||
""" | |||
pass | |||
@roles.command(name="list") | |||
@with_appcontext | |||
def list(): | |||
""" | |||
List roles. | |||
""" | |||
roles = Role.query.order_by(Role.id.asc()) | |||
table = texttable.Texttable() | |||
table.set_deco(texttable.Texttable().HEADER) | |||
table.set_cols_dtype(["t", "t"]) | |||
table.set_cols_align(["l", "l"]) | |||
table.add_rows([["Name", "Description"]]) | |||
for role in roles.all(): | |||
table.add_row([role.name, role.description]) | |||
print(table.draw()) |
@@ -0,0 +1,56 @@ | |||
import click | |||
import sys | |||
from flask.cli import with_appcontext | |||
from flask_mail import Message | |||
from flask import render_template, current_app | |||
import texttable | |||
@click.group() | |||
def system(): | |||
""" | |||
System commands. | |||
""" | |||
pass | |||
@system.command(name="test-email") | |||
@click.option("--email", prompt=True, help="Email to send the test to") | |||
@with_appcontext | |||
def test_email(email): | |||
""" | |||
Test email sending. | |||
""" | |||
mail = current_app.extensions.get("mail") | |||
if not mail: | |||
print("ERROR: mail extensions is None !!!") | |||
exit(-1) | |||
msg = Message(subject="omnomnomnom test email", recipients=[email], sender=current_app.config["MAIL_DEFAULT_SENDER"]) | |||
msg.body = render_template("email/test_email.txt") | |||
msg.html = render_template("email/test_email.html") | |||
try: | |||
mail.send(msg) | |||
except: # noqa: E722 | |||
print(f"Error sending mail: {sys.exc_info()[0]}") | |||
@system.command(name="routes") | |||
@with_appcontext | |||
def routes(): | |||
""" | |||
Dump all routes of defined app | |||
""" | |||
table = texttable.Texttable() | |||
table.set_deco(texttable.Texttable().HEADER) | |||
table.set_cols_dtype(["t", "t", "t"]) | |||
table.set_cols_align(["l", "l", "l"]) | |||
table.set_cols_width([50, 30, 80]) | |||
table.add_rows([["Prefix", "Verb", "URI Pattern"]]) | |||
for rule in sorted(current_app.url_map.iter_rules(), key=lambda x: str(x)): | |||
methods = ",".join(rule.methods) | |||
table.add_row([rule.endpoint, methods, rule]) | |||
print(table.draw()) |
@@ -0,0 +1,87 @@ | |||
import click | |||
from models import db, user_datastore, Role, User | |||
from flask.cli import with_appcontext | |||
from flask import current_app | |||
from flask_security.utils import hash_password | |||
from flask_security import confirmable as FSConfirmable | |||
import texttable | |||
import datetime | |||
@click.group() | |||
def users(): | |||
""" | |||
User commands. | |||
""" | |||
pass | |||
@users.command(name="list") | |||
@with_appcontext | |||
def list(): | |||
""" | |||
List local users. | |||
""" | |||
users = User.query.filter() | |||
table = texttable.Texttable(max_width=120) | |||
table.set_deco(texttable.Texttable().HEADER) | |||
table.set_cols_dtype(["i", "t"]) | |||
table.set_cols_align(["l", "l"]) | |||
table.add_rows([["ID", "username"]]) | |||
for user in users.all(): | |||
table.add_row( | |||
[ | |||
user.id, | |||
user.name, | |||
] | |||
) | |||
print(table.draw()) | |||
@users.command(name="create") | |||
@with_appcontext | |||
def create(): | |||
""" | |||
Create a user. | |||
""" | |||
username = click.prompt("Username", type=str) | |||
email = click.prompt("Email", type=str) | |||
password = click.prompt("Password", type=str, hide_input=True, confirmation_prompt=True) | |||
while True: | |||
role = click.prompt("Role [admin/user]", type=str) | |||
if role == "admin" or role == "user": | |||
break | |||
if click.confirm("Do you want to continue ?"): | |||
role = Role.query.filter(Role.name == role).first() | |||
if not role: | |||
raise click.UsageError("Roles not present in database") | |||
u = user_datastore.create_user(name=username, email=email, password=hash_password(password), roles=[role]) | |||
db.session.commit() | |||
if FSConfirmable.requires_confirmation(u): | |||
with current_app.app_context(): | |||
FSConfirmable.send_confirmation_instructions(u) | |||
print("Look at your emails for validation instructions.") | |||
@users.command(name="confirm") | |||
@click.option("--username", prompt=True, help="Username") | |||
@with_appcontext | |||
def confirm(username): | |||
""" | |||
Force activation of an user. | |||
""" | |||
u = User.query.filter(User.name == username).first() | |||
if not u: | |||
print(f"Cannot find user with username '{username}'") | |||
exit(1) | |||
u.confirmed_at = datetime.datetime.now() | |||
db.session.commit() | |||
print("User confirmed at: ", u.confirmed_at) |
@@ -0,0 +1,141 @@ | |||
import os | |||
# .i;;;;i. | |||
# iYcviii;vXY: | |||
# .YXi .i1c. | |||
# .YC. . in7. | |||
# .vc. ...... ;1c. | |||
# i7, .. .;1; | |||
# i7, .. ... .Y1i | |||
# ,7v .6MMM@; .YX, | |||
# .7;. ..IMMMMMM1 :t7. | |||
# .;Y. ;$MMMMMM9. :tc. | |||
# vY. .. .nMMM@MMU. ;1v. | |||
# i7i ... .#MM@M@C. .....:71i | |||
# it: .... $MMM@9;.,i;;;i,;tti | |||
# :t7. ..... 0MMMWv.,iii:::,,;St. | |||
# .nC. ..... IMMMQ..,::::::,.,czX. | |||
# .ct: ....... .ZMMMI..,:::::::,,:76Y. | |||
# c2: ......,i..Y$M@t..:::::::,,..inZY | |||
# vov ......:ii..c$MBc..,,,,,,,,,,..iI9i | |||
# i9Y ......iii:..7@MA,..,,,,,,,,,....;AA: | |||
# iIS. ......:ii::..;@MI....,............;Ez. | |||
# .I9. ......:i::::...8M1..................C0z. | |||
# .z9; ......:i::::,.. .i:...................zWX. | |||
# vbv ......,i::::,,. ................. :AQY | |||
# c6Y. .,...,::::,,..:t0@@QY. ................ :8bi | |||
# :6S. ..,,...,:::,,,..EMMMMMMI. ............... .;bZ, | |||
# :6o, .,,,,..:::,,,..i#MMMMMM#v................. YW2. | |||
# .n8i ..,,,,,,,::,,,,.. tMMMMM@C:.................. .1Wn | |||
# 7Uc. .:::,,,,,::,,,,.. i1t;,..................... .UEi | |||
# 7C...::::::::::::,,,,.. .................... vSi. | |||
# ;1;...,,::::::,......... .................. Yz: | |||
# v97,......... .voC. | |||
# izAotX7777777777777777777777777777777777777777Y7n92: | |||
# .;CoIIIIIUAA666666699999ZZZZZZZZZZZZZZZZZZZZ6ov. | |||
# | |||
# !!! ATTENTION !!! | |||
# DO NOT EDIT THIS FILE! THIS FILE CONTAINS THE DEFAULT VALUES FOR THE CON- | |||
# FIGURATION! EDIT YOUR OWN CONFIG FILE (based on production.py or development.py or testing.py). | |||
# AND DICTATE THE APP TO USE YOUR FILE WITH ENV VARIABLE: | |||
# APP_SETTINGS='config.yourEnv.Config' | |||
# | |||
# Editing this file is done at your own risks, don't cry if doing that transforms your cat in an opossum. | |||
def bool_env(var_name, default=False): | |||
test_val = os.getenv(var_name, default) | |||
if test_val in ("False", "false", "0", "no"): | |||
return False | |||
return bool(test_val) | |||
class BaseConfig(object): | |||
""" Base configuration, pls dont edit me """ | |||
# Debug and testing specific | |||
TESTING = bool_env("TESTING", False) | |||
DEBUG = bool_env("DEBUG", False) | |||
@property | |||
def TEMPLATES_AUTO_RELOAD(self): | |||
return self.DEBUG | |||
# Can users register | |||
REGISTRATION_ENABLED = bool_env("REGISTRATION_ENABLED", False) | |||
# Registration, same as upper | |||
@property | |||
def SECURITY_REGISTERABLE(self): | |||
return self.REGISTRATION_ENABLED | |||
# Secret key, you are supposed to generate one | |||
# Ex: `openssl rand -hex 42` | |||
SECRET_KEY = os.getenv("SECRET_KEY", None) | |||
# Ex: `openssl rand -hex 5` | |||
SECURITY_PASSWORD_SALT = os.getenv("SECURITY_PASSWORD_SALT", None) | |||
# Database stuff | |||
SQLALCHEMY_DATABASE_URI = os.getenv("SQLALCHEMY_DATABASE_URI", "postgresql+psycopg2://postgres@localhost/omnomnomnom") | |||
SQLALCHEMY_ECHO = bool_env("SQLALCHEMY_ECHO", False) | |||
# Thoses two shouldn't be touched | |||
SQLALCHEMY_TRACK_MODIFICATIONS = False | |||
SQLALCHEMY_RECORD_QUERIES = True | |||
# Flask-Security stuff | |||
# Should users confirm theire email address ? | |||
SECURITY_CONFIRMABLE = bool_env("SECURITY_CONFIRMABLE", True) | |||
# We have an alternative way | |||
SECURITY_RECOVERABLE = False | |||
# Don't change or you will break things | |||
SECURITY_CHANGEABLE = True | |||
# Same or I bite you | |||
SECURITY_PASSWORD_HASH = "bcrypt" | |||
SECURITY_SEND_REGISTER_EMAIL = True | |||
SECURITY_SEND_PASSWORD_CHANGE_EMAIL = True | |||
SECURITY_SEND_PASSWORD_RESET_NOTICE_EMAIL = True | |||
# Backend default language | |||
BABEL_DEFAULT_LOCALE = os.getenv("BABEL_DEFAULT_LOCALE", "en") | |||
# Not sure this one has any effect... | |||
BABEL_DEFAULT_TIMEZONE = os.getenv("BABEL_DEFAULT_TIMEZONE", "UTC") | |||
# If using sentry | |||
SENTRY_DSN = os.getenv("SENTRY_DSN", None) | |||
# Broker setup for Celery, same redis base for both | |||
CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL", "redis://127.0.0.1:6379/0") | |||
CELERY_RESULT_BACKEND = os.getenv("CELERY_RESULT_BACKEND", "redis://127.0.0.1:6379/0") | |||
# ActivityPub stuff | |||
AP_ENABLED = bool_env("AP_ENABLED", False) | |||
# Sources of that instance, should be your repos if forked | |||
SOURCES_REPOSITORY_URL = os.getenv("SOURCES_REPOSITORY_URL", "https://github.com/dashie/omnopmnomnom") | |||
# Mail setup | |||
MAIL_SERVER = os.getenv("MAIL_SERVER", "localhost") | |||
MAIL_PORT = os.getenv("MAIL_PORT", 25) | |||
MAIL_USE_TLS = bool_env("MAIL_USE_TLS", False) | |||
MAIL_USE_SSL = bool_env("MAIL_USE_SSL", False) | |||
MAIL_USERNAME = os.getenv("MAIL_USERNAME", None) | |||
MAIL_PASSWORD = os.getenv("MAIL_PASSWORD", None) | |||
APP_HOST = "localhost" | |||
@property | |||
def MAIL_DEFAULT_SENDER(self): | |||
return os.getenv("MAIL_DEFAULT_SENDER", f"postmaster@{self.APP_HOST}") | |||
@property | |||
def SECURITY_EMAIL_SENDER(self): | |||
return os.getenv("MAIL_DEFAULT_SENDER", f"postmaster@{self.APP_HOST}") | |||
SECURITY_EMAIL_SUBJECT_REGISTER = os.getenv("SECURITY_EMAIL_SUBJECT_REGISTER", "Welcome to omnomnomnom") | |||
# Don't touch | |||
SWAGGER_UI_DOC_EXPANSION = "list" | |||
SECURITY_CLI_USERS_NAME = False | |||
SECURITY_CLI_ROLES_NAME = False |
@@ -0,0 +1,5 @@ | |||
from .config import BaseConfig | |||
class Config(BaseConfig): | |||
DEBUG = True |
@@ -0,0 +1,33 @@ | |||
from .development import Config as BaseConfig | |||
class Config(BaseConfig): | |||
# See the Configuration documentation at: | |||
# FIXME AHAH | |||
# For all the config keys you can use | |||
# Please generate me with: openssl rand -hex 42 | |||
SECRET_KEY = "fdsfsdfsdfsdfdsfsdfsdfsdfdsfsdfsd" | |||
# Please generate me with: openssl rand -hex 5 | |||
SECURITY_PASSWORD_SALT = "omgponies" | |||
# Set your DB URI | |||
SQLALCHEMY_DATABASE_URI = "postgresql+psycopg2://dashie@localhost/omnomnomnom" | |||
APP_HOST = "miam.host.tld" | |||
# If you are using Sentry, otherwise, set to None | |||
SENTRY_DSN = None | |||
# If you are using a modified instance, please set your own repository URL | |||
SOURCES_REPOSITORY_URL = "https://github.com/dashie/omnomnomnom" | |||
# Email settings | |||
MAIL_SERVER = "localhost" | |||
# MAIL_PORT = 25 | |||
# MAIL_USE_TLS = False | |||
# MAIL_USE_SSL = False | |||
# MAIL_USERNAME = None | |||
# MAIL_PASSWORD = None | |||
# CELERY_BROKER_URL = 'redis://127.0.0.1:6379/0' | |||
# CELERY_RESULT_BACKEND = 'redis://127.0.0.1:6379/0' |
@@ -0,0 +1,25 @@ | |||
from .config import BaseConfig | |||
class Config(BaseConfig): | |||
DEBUG = True | |||
TESTING = True | |||
MAIL_DEBUG = True | |||
REGISTRATION_ENABLED = True | |||
SECRET_KEY = "udf298euf02uf2f02f2uf0" | |||
SECURITY_CONFIRMABLE = False | |||
SECURITY_REGISTERABLE = True | |||
SECURITY_RECOVERABLE = True | |||
SECURITY_TRACKABLE = False | |||
SECURITY_CHANGEABLE = True | |||
SECURITY_PASSWORD_HASH = "bcrypt" | |||
SECURITY_PASSWORD_SALT = "dsadsaasd" | |||
SQLALCHEMY_DATABASE_URI = "postgresql+psycopg2://postgres@localhost:5432/omnomnomnom_test" | |||
SECURITY_SEND_REGISTER_EMAIL = False | |||
SECURITY_SEND_PASSWORD_CHANGE_EMAIL = False | |||
SECURITY_SEND_PASSWORD_RESET_NOTICE_EMAIL = False | |||
SECURITY_SEND_PASSWORD_RESET_EMAIL = False | |||
# Bcrypt algorithm hashing rounds (reduced for testing purposes only!) | |||
BCRYPT_LOG_ROUNDS = 4 | |||
CELERY_BROKER_URL = "redis://redis:6379/0" | |||
CELERY_RESULT_BACKEND = "redis://redis:6379/0" |
@@ -0,0 +1 @@ | |||
Generic single-database configuration. |
@@ -0,0 +1,45 @@ | |||
# A generic, single database configuration. | |||
[alembic] | |||
# template used to generate migration files | |||
# file_template = %%(rev)s_%%(slug)s | |||
# set to 'true' to run the environment during | |||
# the 'revision' command, regardless of autogenerate | |||
# revision_environment = false | |||
# Logging configuration | |||
[loggers] | |||
keys = root,sqlalchemy,alembic | |||
[handlers] | |||
keys = console | |||
[formatters] | |||
keys = generic | |||
[logger_root] | |||
level = WARN | |||
handlers = console | |||
qualname = | |||
[logger_sqlalchemy] | |||
level = WARN | |||
handlers = | |||
qualname = sqlalchemy.engine | |||
[logger_alembic] | |||
level = INFO | |||
handlers = | |||
qualname = alembic | |||
[handler_console] | |||
class = StreamHandler | |||
args = (sys.stderr,) | |||
level = NOTSET | |||
formatter = generic | |||
[formatter_generic] | |||
format = %(levelname)-5.5s [%(name)s] %(message)s | |||
datefmt = %H:%M:%S |
@@ -0,0 +1,96 @@ | |||
from __future__ import with_statement | |||
import logging | |||
from logging.config import fileConfig | |||
from sqlalchemy import engine_from_config | |||
from sqlalchemy import pool | |||
from alembic import context | |||
# this is the Alembic Config object, which provides | |||
# access to the values within the .ini file in use. | |||
config = context.config | |||
# Interpret the config file for Python logging. | |||
# This line sets up loggers basically. | |||
fileConfig(config.config_file_name) | |||
logger = logging.getLogger('alembic.env') | |||
# add your model's MetaData object here | |||
# for 'autogenerate' support | |||
# from myapp import mymodel | |||
# target_metadata = mymodel.Base.metadata | |||
from flask import current_app | |||
config.set_main_option( | |||
'sqlalchemy.url', current_app.config.get( | |||
'SQLALCHEMY_DATABASE_URI').replace('%', '%%')) | |||
target_metadata = current_app.extensions['migrate'].db.metadata | |||
# other values from the config, defined by the needs of env.py, | |||
# can be acquired: | |||
# my_important_option = config.get_main_option("my_important_option") | |||
# ... etc. | |||
def run_migrations_offline(): | |||
"""Run migrations in 'offline' mode. | |||
This configures the context with just a URL | |||
and not an Engine, though an Engine is acceptable | |||
here as well. By skipping the Engine creation | |||
we don't even need a DBAPI to be available. | |||
Calls to context.execute() here emit the given string to the | |||
script output. | |||
""" | |||
url = config.get_main_option("sqlalchemy.url") | |||
context.configure( | |||
url=url, target_metadata=target_metadata, literal_binds=True | |||
) | |||
with context.begin_transaction(): | |||
context.run_migrations() | |||
def run_migrations_online(): | |||
"""Run migrations in 'online' mode. | |||
In this scenario we need to create an Engine | |||
and associate a connection with the context. | |||
""" | |||
# this callback is used to prevent an auto-migration from being generated | |||
# when there are no changes to the schema | |||
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html | |||
def process_revision_directives(context, revision, directives): | |||
if getattr(config.cmd_opts, 'autogenerate', False): | |||
script = directives[0] | |||
if script.upgrade_ops.is_empty(): | |||
directives[:] = [] | |||
logger.info('No changes in schema detected.') | |||
connectable = engine_from_config( | |||
config.get_section(config.config_ini_section), | |||
prefix='sqlalchemy.', | |||
poolclass=pool.NullPool, | |||
) | |||
with connectable.connect() as connection: | |||
context.configure( | |||
connection=connection, | |||
target_metadata=target_metadata, | |||
process_revision_directives=process_revision_directives, | |||
**current_app.extensions['migrate'].configure_args | |||
) | |||
with context.begin_transaction(): | |||
context.run_migrations() | |||
if context.is_offline_mode(): | |||
run_migrations_offline() | |||
else: | |||
run_migrations_online() |
@@ -0,0 +1,24 @@ | |||
"""${message} | |||
Revision ID: ${up_revision} | |||
Revises: ${down_revision | comma,n} | |||
Create Date: ${create_date} | |||
""" | |||
from alembic import op # noqa: E402 | |||
import sqlalchemy as sa # noqa: E402 | |||
${imports if imports else ""} | |||
# revision identifiers, used by Alembic. | |||
revision = ${repr(up_revision)} | |||
down_revision = ${repr(down_revision)} | |||
branch_labels = ${repr(branch_labels)} | |||
depends_on = ${repr(depends_on)} | |||
def upgrade(): | |||
${upgrades if upgrades else "pass"} | |||
def downgrade(): | |||
${downgrades if downgrades else "pass"} |
@@ -0,0 +1,69 @@ | |||
"""Initial migration | |||
Revision ID: 77dc08b8493f | |||
Revises: | |||
Create Date: 2020-08-09 11:20:39.008541 | |||
""" | |||
from alembic import op # noqa: E402 | |||
import sqlalchemy as sa # noqa: E402 | |||
from sqlalchemy.dialects import postgresql | |||
# revision identifiers, used by Alembic. | |||
revision = '77dc08b8493f' | |||
down_revision = None | |||
branch_labels = None | |||
depends_on = None | |||
def upgrade(): | |||
# ### commands auto generated by Alembic - please adjust! ### | |||
op.create_table('role', | |||
sa.Column('id', sa.Integer(), nullable=False), | |||
sa.Column('name', sa.String(length=80), nullable=False), | |||
sa.Column('description', sa.String(length=255), nullable=True), | |||
sa.PrimaryKeyConstraint('id'), | |||
sa.UniqueConstraint('name') | |||
) | |||
op.create_table('user', | |||
sa.Column('id', sa.Integer(), nullable=False), | |||
sa.Column('email', sa.String(length=255), nullable=True), | |||
sa.Column('name', sa.String(length=255), nullable=False), | |||
sa.Column('password', sa.String(length=255), nullable=True), | |||
sa.Column('active', sa.Boolean(), nullable=True), | |||
sa.Column('confirmed_at', sa.DateTime(), nullable=True), | |||
sa.Column('fs_uniquifier', sa.String(length=255), nullable=True), | |||
sa.Column('created_at', sa.DateTime(), nullable=True), | |||
sa.Column('updated_at', sa.DateTime(), nullable=True), | |||
sa.Column('locale', sa.String(length=5), nullable=True), | |||
sa.Column('flake_id', postgresql.UUID(as_uuid=True), nullable=True), | |||
sa.Column('slug', sa.String(length=255), nullable=True), | |||
sa.PrimaryKeyConstraint('id'), | |||
sa.UniqueConstraint('email') | |||
) | |||
op.create_table('logging', | |||
sa.Column('id', sa.Integer(), nullable=False), | |||
sa.Column('category', sa.String(length=255), nullable=False), | |||
sa.Column('level', sa.String(length=255), nullable=False), | |||
sa.Column('message', sa.Text(), nullable=False), | |||
sa.Column('timestamp', sa.DateTime(), nullable=True), | |||
sa.Column('user_id', sa.Integer(), nullable=True), | |||
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), | |||
sa.PrimaryKeyConstraint('id') | |||
) | |||
op.create_table('roles_users', | |||
sa.Column('user_id', sa.Integer(), nullable=True), | |||
sa.Column('role_id', sa.Integer(), nullable=True), | |||
sa.ForeignKeyConstraint(['role_id'], ['role.id'], ), | |||
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ) | |||
) | |||
# ### end Alembic commands ### | |||
def downgrade(): | |||
# ### commands auto generated by Alembic - please adjust! ### | |||
op.drop_table('roles_users') | |||
op.drop_table('logging') | |||
op.drop_table('user') | |||
op.drop_table('role') | |||
# ### end Alembic commands ### |
@@ -0,0 +1,117 @@ | |||
import datetime | |||
from flask import current_app | |||
from flask_security import SQLAlchemyUserDatastore, UserMixin, RoleMixin | |||
from flask_security.utils import verify_password | |||
from flask_sqlalchemy import SQLAlchemy | |||
from sqlalchemy.ext.hybrid import Comparator | |||
from sqlalchemy.sql import func | |||
from sqlalchemy_searchable import make_searchable | |||
from sqlalchemy.dialects.postgresql import UUID | |||
import uuid | |||
from sqlalchemy import event | |||
db = SQLAlchemy() | |||
make_searchable(db.metadata) | |||
# #### Base #### | |||
class CaseInsensitiveComparator(Comparator): | |||
def __eq__(self, other): | |||
return func.lower(self.__clause_element__()) == func.lower(other) | |||
# #### User #### | |||
roles_users = db.Table( | |||
"roles_users", | |||
db.Column("user_id", db.Integer(), db.ForeignKey("user.id")), | |||
db.Column("role_id", db.Integer(), db.ForeignKey("role.id")), | |||
) | |||
class Role(db.Model, RoleMixin): | |||
id = db.Column(db.Integer(), primary_key=True) | |||
name = db.Column(db.String(80), unique=True, nullable=False, info={"label": "Name"}) | |||
description = db.Column(db.String(255), info={"label": "Description"}) | |||
__mapper_args__ = {"order_by": name} | |||
class User(db.Model, UserMixin): | |||
id = db.Column(db.Integer, primary_key=True) | |||
email = db.Column(db.String(255), unique=True, nullable=True, info={"label": "Email"}) | |||
name = db.Column(db.String(255), nullable=False, info={"label": "Username"}) | |||
password = db.Column(db.String(255), nullable=True, info={"label": "Password"}) | |||
active = db.Column(db.Boolean()) | |||
confirmed_at = db.Column(db.DateTime()) | |||
fs_uniquifier = db.Column(db.String(255)) | |||
created_at = db.Column(db.DateTime(timezone=False), default=datetime.datetime.utcnow) | |||
updated_at = db.Column( | |||
db.DateTime(timezone=False), default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow | |||
) | |||
locale = db.Column(db.String(5), default="en") | |||
flake_id = db.Column(UUID(as_uuid=True), unique=False, nullable=True) | |||
slug = db.Column(db.String(255), nullable=True) | |||
# Relations | |||
roles = db.relationship( | |||
"Role", secondary=roles_users, backref=db.backref("users", lazy="dynamic"), cascade_backrefs=False | |||
) | |||
loggings = db.relationship("Logging", backref="user", lazy="dynamic", cascade="delete") | |||
__mapper_args__ = {"order_by": name} | |||
def is_admin(self): | |||
admin_role = db.session.query(Role).filter(Role.name == "admin").one() | |||
return admin_role in self.roles | |||
def join_roles(self, string): | |||
return string.join([i.description for i in self.roles]) | |||
@staticmethod | |||
def generate_slug(target, value, oldvalue, initiator): | |||
if value and (not target.slug or value != oldvalue): | |||
target.slug = value | |||
def __repr__(self): | |||
return f"<User(id='{self.id}', name='{self.name}')>" | |||
def check_password(self, password): | |||
return verify_password(password, self.password) | |||
event.listen(User.name, "set", User.generate_slug, retval=False) | |||
@event.listens_for(User, "after_insert") | |||
def generate_user_flakeid(mapper, connection, target): | |||
if not target.flake_id: | |||
flake_id = uuid.UUID(int=current_app.flake_id.get()) | |||
connection.execute(User.__table__.update().where(User.__table__.c.id == target.id).values(flake_id=flake_id)) | |||
user_datastore = SQLAlchemyUserDatastore(db, User, Role) | |||
class Logging(db.Model): | |||
__tablename__ = "logging" | |||
id = db.Column(db.Integer, primary_key=True) | |||
category = db.Column(db.String(255), nullable=False, default="General") | |||
level = db.Column(db.String(255), nullable=False, default="INFO") | |||
message = db.Column(db.Text, nullable=False) | |||
timestamp = db.Column( | |||
db.DateTime(timezone=False), default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow | |||
) | |||
user_id = db.Column(db.Integer(), db.ForeignKey("user.id"), nullable=True) | |||
__mapper_args__ = {"order_by": timestamp.desc()} |
@@ -0,0 +1,13 @@ | |||
[tool.black] | |||
line-length = 120 | |||
exclude = ''' | |||
/( | |||
\.git | |||
| \.eggs | |||
| \.idea | |||
| \__pycache__ | |||
| venv | |||
| front | |||
)/ | |||
''' | |||
skip-numeric-underscore-normalization = true |
@@ -0,0 +1,21 @@ | |||
psycopg2-binary==2.8.4 | |||
texttable==1.6.2 | |||
Unidecode==1.1.1 | |||
flask==1.1.1 | |||
python-slugify==4.0.0 | |||
sqlalchemy==1.3.12 | |||
bcrypt==3.1.7 | |||
celery==4.4.0 | |||
flask_mail==0.9.1 | |||
flask_migrate==2.5.2 | |||
git+https://github.com/maxcountryman/flask-uploads#egg=flask_uploads | |||
sqlalchemy_searchable==1.1.0 | |||
redis==3.3.11 | |||
flask-accept==0.0.6 | |||
PyQRCode==1.2.1 | |||
flask-cors>=3.0.8 | |||
cachetools==4.0.0 | |||
flasgger==0.9.5 | |||
waitress==1.4.3 | |||
Flask-SQLAlchemy==2.4.1 | |||
git+https://github.com/jwag956/flask-security#egg=Flask-Security-too |
@@ -0,0 +1,26 @@ | |||
[aliases] | |||
test=pytest | |||
[tool:pytest] | |||
addopts = --verbose --cov=. --cov-report=xml:test-reports/coverage.xml --junitxml=test-reports/junit.xml | |||
python_files = tests/*.py | |||
junit_family=legacy | |||
[flake8] | |||
max-line-length = 120 | |||
ignore = E501,W504,W503 | |||
exclude = .svn,CVS,.bzr,.hg,.git,__pycache__,.tox,venv,.eggs | |||
[coverage:run] | |||
omit = | |||
tests/* | |||
.eggs/* | |||
config.py | |||
configtest.py | |||
**/*__init__.py | |||
venv/* | |||
front/* | |||
[isort] | |||
line_length=120 | |||
force_single_line=false |
@@ -0,0 +1,31 @@ | |||
from setuptools import setup | |||
setup( | |||
name="omnomnomnom", | |||
version="0.1", | |||
license="AGPL", | |||
python_requires=">=3.6", | |||
long_description=open("../README.md").read(), | |||
url="http://dev.sigpipe.me/dashie/omnomnomnom", | |||
author="Dashie", | |||
author_email="dashie@sigpipe.me", | |||
install_requires=[ | |||
"Flask==1.1.1", | |||
"SQLAlchemy==1.3.12", | |||
"SQLAlchemy-Searchable==1.1.0", | |||
"SQLAlchemy-Utils==0.36.1", | |||
"Flask-Mail==0.9.1", | |||
"Flask-Migrate==2.4.0", | |||
"bcrypt==3.1.7", | |||
"psycopg2-binary==2.8.4", | |||
"unidecode==1.1.1", | |||
"Flask_Babelex==0.9.4", | |||
"texttable==1.6.1", | |||
"python-slugify==4.0.0", | |||
"redis==3.3.11", | |||
"celery==4.4.0", | |||
"flask-accept==0.0.6", | |||
], | |||
setup_requires=["pytest-runner"], | |||
tests_require=["pytest==5.3.2", "pytest-cov==2.8.1"], | |||
) |
@@ -0,0 +1,82 @@ | |||
import time | |||
import threading | |||
import os | |||
import random | |||
import binascii | |||
import warnings | |||
""" | |||
k-ordered ID generation thingy | |||
adapted from https://git.pleroma.social/pleroma/flake_id | |||
""" | |||
# Hack for python <=3.6; time_ns is >=3.7 | |||
if not hasattr(time, "time_ns"): | |||
time.time_ns = lambda: int(time.time() * 1e9) | |||
# The FlakeId.get() should return a 128bit integer | |||
# Which should be able to be fed to uuid.UUID(int=FlakeId.get()) | |||
class FlakeId(object): | |||
def __init__(self): | |||
self.state_node = self.worker_id() | |||
self.state_time = 0 | |||
self.state_sq = 0 | |||
def worker_id(self): | |||
"""Worker ID generated from PID, ThreadID and 16bit random thing""" | |||
random_bits = random.getrandbits(16) | |||
# more cursed random things | |||
pid = os.getpid() | |||
tid = threading.get_ident() | |||
ptid = f"{pid}{random_bits}{tid}" | |||
return binascii.crc32(ptid.encode("utf-8")) | |||
def time(self): | |||
"""some length cursed timestamp""" | |||
# docs don't seem to guarantee this to be 64bits so here's a bitmask | |||
return time.time_ns() & 0xFFFF_FFFF_FFFF_FFFF | |||
def gen_flakeid(self): | |||
fid_time = format(self.state_time, "064b") # 64bit | |||
fid_node = format(self.state_node, "048b") # 48bit | |||
fid_seq = format(self.state_sq, "016b") # 16bit | |||
# return a big 128bit binary string | |||
return f"{fid_time}{fid_node}{fid_seq}" | |||
def get(self): | |||
"""Return a Flake ID""" | |||
# Increment sequence on call | |||
self.state_sq = (self.state_sq + 1) & 0xFFFF | |||
# Get time | |||
self.state_time = self.time() | |||
# Get a Flake ID | |||
flake_id = self.gen_flakeid() | |||
return int(flake_id, 2) | |||
def gen_flakeid(): | |||
warnings.warn("Use the class FlakeId.get() instead", DeprecationWarning, stacklevel=2) | |||
# (64 bits) timestamp in ns | |||
t = time.time_ns() | |||
# (47 bits + 1 extra) "worker id", rand | |||
# https://stackoverflow.com/questions/17125237/getrandbits-does-not-produce-constant-length-numbers | |||
# computers are bad | |||
w = random.getrandbits(47) + (1 << 47) | |||
pid = os.getpid() | |||
tid = threading.get_ident() | |||
ptid = f"{pid}{tid}" | |||
# (16b) sequence from PID+threadID | |||
# might be possible to throw in the | |||
# object id of the flask request | |||
# 32bit crc | |||
s = binascii.crc32(ptid.encode("utf-8")) | |||
# butcher it | |||
s = s & 0xFFFF | |||
return int(f"{t}{w}{s}") |
@@ -0,0 +1,112 @@ | |||
import datetime | |||
import hashlib | |||
from os.path import splitext | |||
import random | |||
import string | |||
from flask_security import current_user | |||
from models import db, Role, Logging | |||
class InvalidUsage(Exception): | |||
status_code = 400 | |||
def __init__(self, message, status_code=None, payload=None): | |||
Exception.__init__(self) | |||
self.message = message | |||
if status_code is not None: | |||
self.status_code = status_code | |||
self.payload = payload | |||
def to_dict(self): | |||
rv = dict(self.payload or ()) | |||
rv["message"] = self.message | |||
rv["status"] = "error" | |||
rv["code"] = self.status_code | |||
return rv | |||
def is_admin(): | |||
adm = Role.query.filter(Role.name == "admin").first() | |||
if not current_user or not current_user.is_authenticated or not adm: | |||
return False | |||
if adm in current_user.roles: | |||
return True | |||
return False | |||
def add_log(category, level, message): | |||
if not category or not level or not message: | |||
print("!! Fatal error in add_log() one of three variables not set") | |||
print("[LOG][{0}][{1}] {2}".format(level.upper(), category, message)) | |||
a = Logging(category=category, level=level.upper(), message=message) | |||
db.session.add(a) | |||
db.session.commit() | |||
def duration_human(seconds): | |||
if seconds is None: | |||
return "error" | |||
seconds = float(seconds) | |||
minutes, seconds = divmod(seconds, 60) | |||
hours, minutes = divmod(minutes, 60) | |||
days, hours = divmod(hours, 24) | |||
years, days = divmod(days, 365.242199) | |||
minutes = int(minutes) | |||
hours = int(hours) | |||
days = int(days) | |||
years = int(years) | |||
if years > 0: | |||
return "%d year" % years + "s" * (years != 1) | |||
elif days > 0: | |||
return "%d day" % days + "s" * (days != 1) | |||
elif hours > 0: | |||
return "%d hour" % hours + "s" * (hours != 1) | |||
elif minutes > 0: | |||
return "%d mn" % minutes + "s" * (minutes != 1) | |||
else: | |||
return "%.2f sec" % seconds + "s" * (seconds != 1) | |||
def get_hashed_filename(filename): | |||
f_n, f_e = splitext(filename) | |||
fs_fname = hashlib.sha256() | |||
hashed_format = "%s-%s" % (f_n, datetime.datetime.now()) | |||
fs_fname.update(hashed_format.encode("utf-8")) | |||
fs_fname = fs_fname.hexdigest() | |||
return fs_fname + f_e | |||
def generate_random_token(): | |||
t = hashlib.sha256() | |||
magic_sauce = "".join([random.choice(string.ascii_letters + string.digits) for n in range(250)]) | |||
magic_sauce += str(datetime.datetime.now()) | |||
t.update(magic_sauce.encode("utf-8")) | |||
return t.hexdigest()[:250] | |||
def join_url(start, end): | |||
if end.startswith("http://") or end.startswith("https://"): | |||
# alread a full URL, joining makes no sense | |||
return end | |||
if start.endswith("/") and end.startswith("/"): | |||
return start + end[1:] | |||
if not start.endswith("/") and not end.startswith("/"): | |||
return start + "/" + end | |||
return start + end | |||
# Also present in models.py, thx circle import | |||
def strip_end(text, suffix): | |||
if not text.endswith(suffix): | |||
return text | |||
return text[: len(text) - len(suffix)] |
@@ -0,0 +1 @@ | |||
VERSION = "0.0.1" |
@@ -0,0 +1,4 @@ | |||
[python: api/**.py] | |||
[jinja2: api/**/templates/**.jinja2] | |||
[jinja2: api/**/templates/**.html] | |||
extensions=jinja2.ext.autoescape,jinja2.ext.with_ |