0.3 ready for docker testing

This commit is contained in:
Ross Mountjoy 2020-02-08 17:05:48 -05:00
parent 91c5350330
commit 838831b857
23 changed files with 325 additions and 144 deletions

View File

@ -16,7 +16,9 @@
* hideable sidebar with dragable reveal button * hideable sidebar with dragable reveal button
* user login system * user login system
* 'app templates' which are sample config entries for popular self hosted apps * 'app templates' which are sample config entries for popular self hosted apps
* ability to display rest api data on application cards * powerful plugin system for adding data from various sources to display on cards
* multiple users, access groups, access settings
* tagging system
## Installation ## Installation
### Docker ### Docker
@ -59,14 +61,3 @@ If you change the config.ini file, you either have to restart the container (or
* Materialize css * Materialize css
* JavaScript/jQuery/jQueryUI * JavaScript/jQuery/jQueryUI
* Requests (python) * Requests (python)
## Version 1.0 TODO list
- [ ] finish rest api data functions (post requests, auth)
- [ ] finish rest api data display (make it look prettier)
- [ ] include nginx & gunicorn in docker container
- [ ] tag/folder system & support for services without web redirection
- [ ] add more template apps from popular self hosted apps
- [ ] pull request template for adding template apps
- [ ] rest api data examples for template apps
- [ ] find a way to mirror this repo on GitHub for exposure
- [ ] support multiple users

View File

@ -8,9 +8,12 @@ If you change the config.ini file, you either have to restart the container
config to be applied. config to be applied.
```ini ```ini
[Settings] [Settings]
theme = dark theme = light
accent = orange accent = orange
background = static/images/backgrounds/background.png background = None
roles = admin,user,public_user
home_access_groups = admin_only
settings_access_groups = admin_only
``` ```
| Variable | Required | Description | Options | | Variable | Required | Description | Options |
@ -35,6 +38,8 @@ sidebar_icon = static/images/apps/default.png
description = Example description description = Example description
open_in = iframe open_in = iframe
data_sources = None data_sources = None
tags = Example Tag
groups = admin_only
``` ```
| Variable | Required | Description | Options | | Variable | Required | Description | Options |
@ -47,6 +52,8 @@ data_sources = None
| sidebar_icon | No | Icon for the sidenav. | /static/images/icons/yourpicture.png, external link to image | | sidebar_icon | No | Icon for the sidenav. | /static/images/icons/yourpicture.png, external link to image |
| description | No | A short description for the app. | string | | description | No | A short description for the app. | string |
| data_sources | No | Data sources to be included on the app's card.*Note: you must have a data source set up in the config above this application entry. | comma separated string | | data_sources | No | Data sources to be included on the app's card.*Note: you must have a data source set up in the config above this application entry. | comma separated string |
| tags | No | Optionally specify tags for organization on /home | comma separated string |
| groups | No | Optionally the access groups that can see this app. | comma separated string |
##### Access Groups ##### Access Groups
You can create access groups to control what user roles can access parts of the ui. Each You can create access groups to control what user roles can access parts of the ui. Each

View File

@ -19,3 +19,8 @@ def error_403(error):
@error_pages.app_errorhandler(500) @error_pages.app_errorhandler(500)
def error_500(error): def error_500(error):
return render_template("/error_pages/500.html"), 500 return render_template("/error_pages/500.html"), 500
@error_pages.route("/unauthorized")
def unauthorized():
return render_template("/error_pages/unauthorized.html")

View File

@ -0,0 +1,6 @@
from flask_wtf import FlaskForm
from wtforms import SelectField
class TagsForm(FlaskForm):
tags = SelectField(choices=[("All tags", "All tags")])

View File

@ -27,6 +27,7 @@ class Apps(db.Model):
open_in = db.Column(db.String()) open_in = db.Column(db.String())
data_template = db.Column(db.String()) data_template = db.Column(db.String())
groups = db.Column(db.String()) groups = db.Column(db.String())
tags = db.Column(db.String())
class TemplateApps(db.Model): class TemplateApps(db.Model):
@ -63,3 +64,8 @@ class Groups(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String()) name = db.Column(db.String())
roles = db.Column(db.String()) roles = db.Column(db.String())
class Tags(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String())

View File

@ -1,6 +1,6 @@
import os import os
from configparser import ConfigParser from configparser import ConfigParser
from dashmachine.main.models import Apps, Groups, DataSources, DataSourcesArgs from dashmachine.main.models import Apps, Groups, DataSources, DataSourcesArgs, Tags
from dashmachine.settings_system.models import Settings from dashmachine.settings_system.models import Settings
from dashmachine.paths import user_data_folder from dashmachine.paths import user_data_folder
from dashmachine import db from dashmachine import db
@ -29,6 +29,7 @@ def read_config():
Apps.query.delete() Apps.query.delete()
Settings.query.delete() Settings.query.delete()
Groups.query.delete() Groups.query.delete()
Tags.query.delete()
for section in config.sections(): for section in config.sections():
@ -139,6 +140,17 @@ def read_config():
else: else:
app.groups = None app.groups = None
if "tags" in config[section]:
app.tags = config[section]["tags"].title()
for tag in app.tags.split(","):
tag = tag.strip().title()
if not Tags.query.filter_by(name=tag).first():
tag_db = Tags(name=tag)
db.session.add(tag_db)
db.session.commit()
else:
app.tags = None
db.session.add(app) db.session.add(app)
db.session.commit() db.session.commit()

View File

@ -4,7 +4,8 @@ from secrets import token_hex
from htmlmin.main import minify from htmlmin.main import minify
from flask import render_template, url_for, redirect, request, Blueprint, jsonify from flask import render_template, url_for, redirect, request, Blueprint, jsonify
from flask_login import current_user from flask_login import current_user
from dashmachine.main.models import Files, Apps, DataSources from dashmachine.main.models import Files, Apps, DataSources, Tags
from dashmachine.main.forms import TagsForm
from dashmachine.main.utils import ( from dashmachine.main.utils import (
public_route, public_route,
check_groups, check_groups,
@ -58,10 +59,14 @@ def check_valid_login():
@main.route("/") @main.route("/")
@main.route("/home", methods=["GET", "POST"]) @main.route("/home", methods=["GET", "POST"])
def home(): def home():
tags_form = TagsForm()
tags_form.tags.choices += [
(tag.name, tag.name) for tag in Tags.query.order_by(Tags.name).all()
]
settings = Settings.query.first() settings = Settings.query.first()
if not check_groups(settings.home_access_groups, current_user): if not check_groups(settings.home_access_groups, current_user):
return redirect(url_for("user_system.login")) return redirect(url_for("error_pages.unauthorized"))
return render_template("main/home.html") return render_template("main/home.html", tags_form=tags_form)
@public_route @public_route

View File

@ -17,6 +17,7 @@ from dashmachine.paths import (
user_data_folder, user_data_folder,
) )
from dashmachine.version import version from dashmachine.version import version
from dashmachine import db
settings_system = Blueprint("settings_system", __name__) settings_system = Blueprint("settings_system", __name__)
@ -99,12 +100,14 @@ def edit_user():
if form.validate_on_submit(): if form.validate_on_submit():
if form.password.data != form.confirm_password.data: if form.password.data != form.confirm_password.data:
return jsonify(data={"err": "Passwords don't match"}) return jsonify(data={"err": "Passwords don't match"})
add_edit_user( err = add_edit_user(
form.username.data, form.username.data,
form.password.data, form.password.data,
user_id=form.id.data, user_id=form.id.data,
role=form.role.data, role=form.role.data,
) )
if err:
return jsonify(data={"err": err})
else: else:
err_str = "" err_str = ""
for fieldName, errorMessages in form.errors.items(): for fieldName, errorMessages in form.errors.items():
@ -115,3 +118,17 @@ def edit_user():
users = User.query.all() users = User.query.all()
html = render_template("settings_system/user.html", users=users) html = render_template("settings_system/user.html", users=users)
return jsonify(data={"err": "success", "html": html}) return jsonify(data={"err": "success", "html": html})
@settings_system.route("/settings/delete_user", methods=["GET"])
def delete_user():
admin_users = User.query.filter_by(role="admin").all()
user = User.query.filter_by(id=request.args.get("id")).first()
if len(admin_users) < 2 and user.role == "admin":
return jsonify(data={"err": "You must have at least one admin user"})
else:
User.query.filter_by(id=request.args.get("id")).delete()
db.session.commit()
users = User.query.all()
html = render_template("settings_system/user.html", users=users)
return jsonify(data={"err": "success", "html": html})

View File

@ -28,6 +28,9 @@
.no-vis { .no-vis {
visibility: hidden; visibility: hidden;
} }
.filtered {
display: none !important;
}
.scrollbar { .scrollbar {
overflow-y: scroll !important; overflow-y: scroll !important;

View File

@ -0,0 +1,23 @@
.tags-select-col {
position: relative;
top: 15px;
margin: 0;
border-radius: .4rem;
height: 45px;
-webkit-box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.12), 0 1px 3px 0 rgba(0, 0, 0, 0.12), 0 1px 3px 0 rgba(0, 0, 0, 0.12);
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.12), 0 1px 3px 0 rgba(0, 0, 0, 0.12), 0 1px 3px 0 rgba(0, 0, 0, 0.12);
}
.tags-select-col .select-wrapper {
top: 5px;
}
.tags-select-col .select-wrapper input {
color: var(--theme-secondary);
}
@media screen and (max-width: 992px) {
.tags-select-col {
top: 0;
width: calc(100vw - 45px) !important;
margin-left: 15px !important;
}
}

View File

@ -27,4 +27,15 @@ $( document ).ready(function() {
} }
}); });
}); });
$("#tags-select").on('change', function(e) {
var value = $(this).val();
$(".app-a").each(function(i, e) {
if ($(this).attr("data-tags").indexOf(value) > -1 || value === "All tags") {
$(this).removeClass('filtered');
} else {
$(this).addClass('filtered');
}
});
});
}); });

View File

@ -63,29 +63,21 @@ $( document ).ready(function() {
}); });
$("#save-user-btn").on('click', function(e) { $("#save-user-btn").on('click', function(e) {
$.ajax({ $.ajax({
url: $(this).attr('data-url'), url: $(this).attr('data-url'),
type: 'POST', type: 'POST',
data: $("#edit-user-form").serialize(), data: $("#edit-user-form").serialize(),
success: function(data){ success: function(data){
if (data.data.err !== 'success'){ if (data.data.err !== 'success'){
M.toast({html: data.data.err, classes: 'theme-failure'}); M.toast({html: data.data.err, classes: 'theme-failure'});
} else { } else {
$("#users-div").empty(); $("#users-div").empty();
$("#users-div").append(data.data.html); $("#users-div").append(data.data.html);
$("#edit-user-modal").modal('close'); $("#user-modal").modal('close');
M.toast({html: 'User saved'}); M.toast({html: 'User saved'});
} }
} }
}); });
});
$(".edit-user-btn").on('click', function(e) {
$("#user-modal").modal('open');
$("#user-form-username").val($(this).attr("data-username"));
$("#user-form-role").val($(this).attr("data-role"));
$("#user-form-id").val($(this).attr("data-id"));
M.updateTextFields();
}); });
}); });

View File

@ -0,0 +1,11 @@
{% extends "main/base.html" %}
{% block content %}
<div class="row center-align mt-10">
<div class="col s12">
<i class="material-icons-outlined theme-failure-text" style="font-size: 8rem">warning</i>
<h4>Unauthorized</h4>
</div>
</div>
{% endblock content %}

View File

@ -169,10 +169,13 @@ col_style=None
id='', id='',
form_obj=None, form_obj=None,
size="s12", size="s12",
label='' label=None,
class=''
) %} ) %}
<div class="input-field col {{size}}"> <div class="input-field col {{size}}">
{{ form_obj(id=id) }} {{ form_obj(id=id, class=class, placeholder="Tags") }}
<label>{{ label }}</label> {% if label %}
<label>{{ label }}</label>
{% endif %}
</div> </div>
{% endmacro %} {% endmacro %}

View File

@ -1,10 +1,11 @@
{% extends "main/layout.html" %} {% extends "main/layout.html" %}
{% from 'global_macros.html' import data, preload_circle %} {% from 'global_macros.html' import data, preload_circle, select %}
{% block page_vendor_css %} {% block page_vendor_css %}
{% endblock page_vendor_css %} {% endblock page_vendor_css %}
{% block page_lvl_css %} {% block page_lvl_css %}
{{ process_css_sources(src="main/home.css")|safe }}
{% if settings.background and settings.background != 'None' %} {% if settings.background and settings.background != 'None' %}
<style> <style>
#main { #main {
@ -27,17 +28,22 @@
<input type="text" id="apps-filter" class="card-filter theme-surface-transparent" placeholder="Search apps"> <input type="text" id="apps-filter" class="card-filter theme-surface-transparent" placeholder="Search apps">
</span> </span>
</div> </div>
{% if tags_form.tags.choices|count > 1 %}
<div class="input-field col s12 l2 tags-select-col theme-surface-transparent">
{{ tags_form.tags(id='tags-select') }}
</div>
{% endif %}
</div> </div>
<div class="row"> <div class="row">
{% if apps %} {% if apps %}
{% for app in apps %} {% for app in apps %}
{% if app.open_in == 'iframe' %} {% if app.open_in == 'iframe' %}
<a href="{{ url_for('main.app_view', app_id=app.id) }}" class="app-a" data-name="{{ app.name }}" data-description="{{ app.description }}"> <a href="{{ url_for('main.app_view', app_id=app.id) }}" class="app-a" data-name="{{ app.name }}" data-description="{{ app.description }}" data-tags="{{ app.tags }}">
{% elif app.open_in == 'this_tab' %} {% elif app.open_in == 'this_tab' %}
<a href="{{ app.prefix }}{{ app.url }}" class="app-a" data-name="{{ app.name }}" data-description="{{ app.description }}"> <a href="{{ app.prefix }}{{ app.url }}" class="app-a" data-name="{{ app.name }}" data-description="{{ app.description }}" data-tags="{{ app.tags }}">
{% elif app.open_in == "new_tab" %} {% elif app.open_in == "new_tab" %}
<a href="{{ app.prefix }}{{ app.url }}" target="_blank" class="app-a" data-name="{{ app.name }}" data-description="{{ app.description }}"> <a href="{{ app.prefix }}{{ app.url }}" target="_blank" class="app-a" data-name="{{ app.name }}" data-description="{{ app.description }}" data-tags="{{ app.tags }}">
{% endif %} {% endif %}
<div class="col s12 m6 l3"> <div class="col s12 m6 l3">
<div class="card theme-surface-transparent app-card"> <div class="card theme-surface-transparent app-card">
@ -63,7 +69,8 @@
{% endif %} {% endif %}
</div> </div>
<div class="card-action center-align scrollbar" style="max-height: 127px; min-height: 127px; scrollbar-width: none;"> <div class="card-action center-align scrollbar" style="max-height: 127px; min-height: 127px; scrollbar-width: none;">
<h5>{{ app.name }}</h5> <h5>{{ app.name }}
</h5>
<span class="theme-secondary-text">{{ app.description }}</span> <span class="theme-secondary-text">{{ app.description }}</span>
</div> </div>
</div> </div>

View File

@ -24,10 +24,17 @@
<span class="menu-title" data-i18n="">Settings</span> <span class="menu-title" data-i18n="">Settings</span>
</a></li> </a></li>
<li class="bold"><a id="logout-sidenav" class="waves-effect waves-cyan" href="{{ url_for('user_system.logout') }}"> {% if current_user.is_authenticated %}
<i class="material-icons-outlined">exit_to_app</i> <li class="bold"><a id="logout-sidenav" class="waves-effect waves-cyan" href="{{ url_for('user_system.logout') }}">
<span class="menu-title" data-i18n="">Logout</span> <i class="material-icons-outlined">exit_to_app</i>
</a></li> <span class="menu-title" data-i18n="">Logout</span>
</a></li>
{% else %}
<li class="bold"><a id="logout-sidenav" class="waves-effect waves-cyan" href="{{ url_for('user_system.login') }}">
<i class="material-icons-outlined">account_circle</i>
<span class="menu-title" data-i18n="">Login</span>
</a></li>
{% endif %}
<li class="bold"><a id="hide-sidenav" class="waves-effect waves-cyan" href="#"> <li class="bold"><a id="hide-sidenav" class="waves-effect waves-cyan" href="#">
<i class="material-icons-outlined">menu_open</i> <i class="material-icons-outlined">menu_open</i>

View File

@ -1,78 +1,106 @@
<style> {% macro FilesTab() %}
.file-title { <div class="row">
position: relative; <h5>Images</h5>
top: .6em; <form id="add-images-form">
} <div class="input-field col s12 mt-4">
</style> <select name="folder">
<option value="icons">Icons</option>
<option value="backgrounds">Backgrounds</option>
</select>
<label>Folder</label>
</div>
<input name="files" id="add-images-input" class="hide">
</form>
<div class="col s12">
{{ tcdrop(allowed_types='jpg,jpeg,png,gif', id="images-tcdrop", max_files="30") }}
{{ button(text="save", icon="save", id="save-images-btn", float="left", data={"url": url_for('settings_system.add_images')}) }}
</div>
</div>
<div class="divider"></div> <div class="row">
<div class="row"> <div id="files-div">{{ files_html|safe }}</div>
<div class="col s12"> </div>
<ul class="collection with-header"> {% endmacro %}
<li class="collection-header theme-primary theme-on-primary-text">Backgrounds</li>
{% if backgrounds %} {% macro Files(icons, backgrounds) %}
{% for background in backgrounds %} <style>
<li class="collection-item pt-2 pb-2 avatar"> .file-title {
<a href="static/images/backgrounds/{{ background }}" target="_blank"> position: relative;
<img src="static/images/backgrounds/{{ background }}" alt="" class="circle"> top: .6em;
</a> }
<span class="selectable-all copy-target file-title">static/images/backgrounds/{{ background }}</span> </style>
<span class="secondary-content">
<div class="divider"></div>
<div class="row">
<div class="col s12">
<ul class="collection with-header">
<li class="collection-header theme-primary theme-on-primary-text">Backgrounds</li>
{% if backgrounds %}
{% for background in backgrounds %}
<li class="collection-item pt-2 pb-2 avatar">
<a href="static/images/backgrounds/{{ background }}" target="_blank">
<img src="static/images/backgrounds/{{ background }}" alt="" class="circle">
</a>
<span class="selectable-all copy-target file-title">static/images/backgrounds/{{ background }}</span>
<span class="secondary-content">
<i class="material-icons-outlined icon-btn delete-file-btn" <i class="material-icons-outlined icon-btn delete-file-btn"
data-url="{{ url_for('settings_system.delete_file') }}" data-url="{{ url_for('settings_system.delete_file') }}"
data-folder="backgrounds" data-folder="backgrounds"
data-file="{{ background }}">close</i> data-file="{{ background }}">close</i>
</span> </span>
<span class="secondary-content mr-4"><i class="material-icons-outlined icon-btn copy-btn">filter_none</i></span> <span class="secondary-content mr-4"><i class="material-icons-outlined icon-btn copy-btn">filter_none</i></span>
</li> </li>
{% endfor %} {% endfor %}
{% else %} {% else %}
<li class="collection-item">No files yet</li> <li class="collection-item">No files yet</li>
{% endif %} {% endif %}
</ul> </ul>
</div>
</div> </div>
</div> <div class="divider"></div>
<div class="divider"></div> <div class="row">
<div class="row"> <div class="col s12">
<div class="col s12"> <ul class="collection with-header">
<ul class="collection with-header"> <li class="collection-header theme-primary theme-on-primary-text">Icons</li>
<li class="collection-header theme-primary theme-on-primary-text">Icons</li> {% if icons %}
{% if icons %} {% for icon in icons %}
{% for icon in icons %} <li class="collection-item pt-2 pb-2 avatar">
<li class="collection-item pt-2 pb-2 avatar"> <a href="static/images/icons/{{ icon }}" target="_blank">
<a href="static/images/icons/{{ icon }}" target="_blank"> <img src="static/images/icons/{{ icon }}" alt="" class="circle">
<img src="static/images/icons/{{ icon }}" alt="" class="circle"> </a>
</a> <span class="selectable-all copy-target file-title">static/images/icons/{{ icon }}</span>
<span class="selectable-all copy-target file-title">static/images/icons/{{ icon }}</span> <span class="secondary-content">
<span class="secondary-content">
<i class="material-icons-outlined icon-btn delete-file-btn" <i class="material-icons-outlined icon-btn delete-file-btn"
data-url="{{ url_for('settings_system.delete_file') }}" data-url="{{ url_for('settings_system.delete_file') }}"
data-folder="icons" data-folder="icons"
data-file="{{ icon }}">close</i> data-file="{{ icon }}">close</i>
</span> </span>
<span class="secondary-content mr-4"><i class="material-icons-outlined icon-btn copy-btn">filter_none</i></span> <span class="secondary-content mr-4"><i class="material-icons-outlined icon-btn copy-btn">filter_none</i></span>
</li> </li>
{% endfor %} {% endfor %}
{% else %} {% else %}
<li class="collection-item">No files yet</li> <li class="collection-item">No files yet</li>
{% endif %} {% endif %}
</ul> </ul>
</div>
</div> </div>
</div>
<script> <script>
$( document ).ready(function() { $( document ).ready(function() {
init_copy_btn(".collection-item"); init_copy_btn(".collection-item");
$(".delete-file-btn").on('click', function(e) { $(".delete-file-btn").on('click', function(e) {
$.ajax({ $.ajax({
url: $(this).attr('data-url'), url: $(this).attr('data-url'),
type: 'GET', type: 'GET',
data: {folder: $(this).attr("data-folder"), file: $(this).attr("data-file")}, data: {folder: $(this).attr("data-folder"), file: $(this).attr("data-file")},
success: function(data){ success: function(data){
$("#files-div").empty(); $("#files-div").empty();
$("#files-div").append(data); $("#files-div").append(data);
} }
}); });
});
}); });
}); </script>
</script> {% endmacro %}
{{ Files(icons, backgrounds) }}

View File

@ -2,6 +2,7 @@
{% from 'global_macros.html' import input, button, select %} {% from 'global_macros.html' import input, button, select %}
{% from 'main/tcdrop.html' import tcdrop %} {% from 'main/tcdrop.html' import tcdrop %}
{% from 'settings_system/user.html' import UserTab with context %} {% from 'settings_system/user.html' import UserTab with context %}
{% from 'settings_system/files.html' import FilesTab with context %}
{% block page_vendor_css %} {% block page_vendor_css %}
{% endblock page_vendor_css %} {% endblock page_vendor_css %}
@ -80,28 +81,7 @@
</div> </div>
<div id="images" class="col s12"> <div id="images" class="col s12">
<div class="row"> {{ FilesTab() }}
<h5>Images</h5>
<form id="add-images-form">
<div class="input-field col s12 mt-4">
<select name="folder">
<option value="icons">Icons</option>
<option value="backgrounds">Backgrounds</option>
</select>
<label>Folder</label>
</div>
<input name="files" id="add-images-input" class="hide">
</form>
<div class="col s12">
{{ tcdrop(allowed_types='jpg,jpeg,png,gif', id="images-tcdrop", max_files="30") }}
{{ button(text="save", icon="save", id="save-images-btn", float="left", data={"url": url_for('settings_system.add_images')}) }}
</div>
</div>
<div class="row">
<div id="files-div">{{ files_html|safe }}</div>
</div>
</div> </div>
<div id="apps" class="col s12"> <div id="apps" class="col s12">

View File

@ -61,7 +61,7 @@
</a> </a>
</h5> </h5>
<div class="users-div"> <div id="users-div">
{{ Users(users) }} {{ Users(users) }}
</div> </div>
@ -88,11 +88,43 @@
data-role="{{ user.role }}" data-role="{{ user.role }}"
data-id="{{ user.id }}" data-id="{{ user.id }}"
data-username="{{ user.username }}">edit</i> data-username="{{ user.username }}">edit</i>
<i class="material-icons-outlined icon-btn">close</i> <i class="material-icons-outlined icon-btn delete-user-btn"
data-id="{{ user.id }}"
data-url="{{ url_for('settings_system.delete_user') }}">close</i>
</span> </span>
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
<script>
$(".edit-user-btn").on('click', function(e) {
$("#user-modal").modal('open');
$("#user-form-username").val($(this).attr("data-username"));
$("#user-form-role").val($(this).attr("data-role"));
$("#user-form-id").val($(this).attr("data-id"));
M.updateTextFields();
});
$(".delete-user-btn").on('click', function(e) {
var r = confirm("Are you sure?");
if (r == true) {
$.ajax({
url: $(this).attr('data-url'),
type: 'GET',
data: {id: $(this).attr('data-id')},
success: function(data){
if (data.data.err !== 'success') {
M.toast({html: data.data.err, classes: 'theme-failure'});
} else {
$("#users-div").empty();
$("#users-div").append(data.data.html);
$("#edit-user-modal").modal('close');
M.toast({html: 'User deleted'});
}
}
});
}
});
</script>
{% endmacro %} {% endmacro %}
{{Users(users)}} {{Users(users)}}

View File

@ -10,6 +10,10 @@ def add_edit_user(username, password, user_id=None, role=None):
else: else:
user = User() user = User()
admin_users = User.query.filter_by(role="admin").all()
if user_id and role != "admin" and len(admin_users) < 2:
return "You must have at least one admin user"
hashed_password = bcrypt.generate_password_hash(password).decode("utf-8") hashed_password = bcrypt.generate_password_hash(password).decode("utf-8")
user.username = username user.username = username
user.password = hashed_password user.password = hashed_password

View File

@ -1 +1 @@
version = "v0.22" version = "v0.3"

View File

@ -2,3 +2,6 @@
theme = light theme = light
accent = orange accent = orange
background = None background = None
roles = admin,user,public_user
home_access_groups = admin_only
settings_access_groups = admin_only

View File

@ -0,0 +1,28 @@
"""empty message
Revision ID: 885c5f9b33d5
Revises: 8f5a046465e8
Create Date: 2020-02-08 13:30:01.632487
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "885c5f9b33d5"
down_revision = "8f5a046465e8"
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column("apps", sa.Column("tags", sa.String(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("apps", "tags")
# ### end Alembic commands ###