Add 'dd-sso/' from commit '53cd18d417d39500eef1d9bd4dcf05d226801e6a'

git-subtree-dir: dd-sso
git-subtree-mainline: ee0c27c6ab
git-subtree-split: 53cd18d417
Evilham 2022-06-03 19:19:59 +02:00
commit e65cbb5b32
1815 changed files with 949037 additions and 0 deletions

25
dd-sso/.gitignore vendored Normal file
View File

@ -0,0 +1,25 @@
.env
**/.env
main.conf
docker-compose.yml
#*.yml
**/custom.yaml
**/system.yaml
admin/src/node_modules
admin/src/admin/node_modules/
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# Python compiled and cached
*.pyc
**/*.pyc
__pycache__
**/__pycache__
# api generated templates
docker/api/src/api/static/templates/*.html
docker/api/src/api/static/templates/*.json

8
dd-sso/.isort.cfg Normal file
View File

@ -0,0 +1,8 @@
[settings]
multi_line_output = 3
include_trailing_comma = True
force_grid_wrap = 0
use_parentheses = True
ensure_newline_before_comments = True
line_length = 88

20
dd-sso/CHANGELOG.md Normal file
View File

@ -0,0 +1,20 @@
# IsardVDI OpenID infrastructure
All notable changes to this project will be documented in this file.
## [alpha1] - not released
### Added
- Auth containers: freeipa, mokey, hydra
- Configuration file: main.conf
- Build docker-compose yml: build.sh
### Fixed
### Changed
### Removed

45
dd-sso/Makefile Normal file
View File

@ -0,0 +1,45 @@
#!make
include main.conf
export $(shell sed 's/=.*//' main.conf)
VERSION := 0.0.1-rc0
export VERSION
BUILD_ROOT_PATH=$(shell pwd)
.PHONY: environment
environment:
cp main.conf .env
echo "BUILD_ROOT_PATH=$(BUILD_ROOT_PATH)" >> .env
.PHONY: all
all: environment
cp .env docker-compose-parts
docker-compose -f docker-compose-parts/haproxy.yml \
-f docker-compose-parts/api.yml \
-f docker-compose-parts/freeipa.yml \
-f docker-compose-parts/keycloak.yml \
-f docker-compose-parts/avatars.yml \
-f docker-compose-parts/postgresql.yml \
-f docker-compose-parts/network.yml \
config > docker-compose.yml
.PHONY: up
up: all
docker-compose up -d
.PHONY: api
api: environment
cp .env docker-compose-parts
docker-compose -f docker-compose-parts/haproxy.yml \
-f docker-compose-parts/api.yml \
-f docker-compose-parts/network.yml \
config > api.yml
api-devel: environment
cp .env docker-compose-parts
docker-compose -f docker-compose-parts/haproxy.yml \
-f docker-compose-parts/api.yml \
-f docker-compose-parts/api.devel.yml \
-f docker-compose-parts/network.yml \
config > api.devel.yml

168
dd-sso/README.md Normal file
View File

@ -0,0 +1,168 @@
# IsardVDI - OpenID infrastructure
**NOTE**: This repo now is included in https://gitlab.com/digitaldemocratic/digitaldemocratic that is the repo that includes this repo. Maybe this SSO repo won't work alone by now...
This will bring up a full OpenID auth infrastructure consisting in this hosts from $DOMAIN var set in main.conf:
- FreeIPA: https://ipa.$DOMAIN
- Mokey: https://login.$DOMAIN
- Hydra: https://hydra.$DOMAIN
NOTE: If you use the default example domain in main.conf.example you will need to add this domain mapping to IP in your hosts file at clients.
# Quick start
1. Edit main.conf (to suit your needs). If you set Letsencrypt vars it will ask and renew the certificate automatically.
2. Build docker.compose.yml: ```./build-compose.sh```
3. Bring containers up: ```docker-compose up -d```
Wait till freeipa container is ready (it can take SEVERAL minutes the first time): ```docker logs freeipa --follow```
And wait to be ready. It will log: *FreeIPA server configured.*
4. Add mokey client in freeipa: ```docker exec freeipa /bin/sh -c "scripts/mokey.sh"```
5. check that mokey is up: ```docker logs mokey --follow```. It will log: *⇨ http server started on [::]:8080*
Access your IP/DNS and login page should come up.
# Firewall
You should open 80 and 443
# Add client apps
## The easy way
[**Not working yet! Refer to 'Do it yourself'**]
For most client apps the default script will be enough:
```
docker exec \
-e APP_ID=<app name> \
-e APP_SECRET=<app secret> \
-e APP_CALLBACKS=<app callback url wiht https://...> \
hydra /bin/sh -c "scripts/add_app.sh"
```
## Do it yourself
For example we will be creating a moodle app client. You need to adapt the vars to your app
```
DOMAIN=(your domain root as set in main.conf)
APP_ID=moodle
APP_SECRET=Sup3rS3cr3t
```
```
docker-compose exec hydra \
hydra clients create \
--endpoint http://hydra:4445/ \
--id $APP_ID \
--secret $APP_SECRET \
--grant-types client_credentials,authorization_code,refresh_token \
--token-endpoint-auth-m01d4f1c0-8a53-4df5-8a46-de670f42a4dfethod client_secret_post \
--response-types code \
--scope openid,offline,profile,email \
--callbacks https://moodle.${DOMAIN}/auth/oidc/
```
And then validate the app:
```
docker-compose exec hydra \
hydra token client \
--endpoint http://hydra:4444/ \
--client-id $APP_ID \
--client-secret $APP_SECRET
```
## Example configuration for Moodle OpenID Connect
- authendpoint: https://hydra.${DOMAIN}/oauth2/auth
- tokenendpoint: https://hydra.${DOMAIN}/oauth2/token
- oidcresource: https://hydra.${DOMAIN}/userinfo
- oidcscope: openid profile email
- single_sign_off: [checked]
- logouturi: https://login.${DOMAIN}/auth/logout
## Example configuration for Nextcloud Custom OpenID Connect
- Prevent creating an account if the email address exists
- Update user profile every login
- Do not prune not available user groups on login
- automatically create groups if they do not exist
- Authorize url: https://hydra.${DOMAIN}/oauth2/auth
- Token url: https://hydra.${DOMAIN}/oauth2/token
- User info URL (optional): https://hydra.${DOMAIN}/userinfo
- Scope: openid profile offline email
- Logout URL (optional): https://login.${DOMAIN}/auth/logout
### Be aware on nextcloud behind proxy
Behind proxy we should force nextcloud-app to use https. From documentation it should be NEXTCLOUD_OVERWRITEPROTOCOL but it is not:
- https://docs.nextcloud.com/server/latest/admin_manual/configuration_server/reverse_proxy_configuration.html
Instead we used in docker/nextcloud/nextcloud.yml compose file the envvar NC_overwriteprotocol as stated in this thread:
- https://github.com/nextcl01d4f1c0-8a53-4df5-8a46-de670f42a4dfoud/docker/pull/819
Extra information about this:
Some users may get strange reply(Callback) url error from provider even if you pasted the right url, that's because your nextcloud
server may generate http urls when you are actually using https. Please set 'overwriteprotocol' => 'https', in your config.php file.
<?php
$CONFIG = array (
'overwriteprotocol' => 'https',
'memcache.local' => '\\OC\\Memcache\\APCu',
'apps_paths' =>
## Nexcloud autoredirect for unauthorized users
Set social_login_auto_redirect and social_login_http_client in config.php
- https://apps.nextcloud.com/apps/sociallogin
### APPENDIX: Nextcloud Social Login plugin
Config
You can use 'social_login_auto_redirect' => true setting in config.php for auto redirect unauthorized users to social login if only one
provider is configured. If you want to temporary disable this function (e.g. for login as local admin), you can add noredir=1 query
parameter in url for login page. Something like https://cloud.domain.com/login?noredir=1
To set timeout for http client, you can use
'social_login_http_client' => [
'timeout' => 45,
],
# IsardVDI office apps
Refer to https://gitlab.com/isard/isard-office repository for sample moodle, nextcloud, jitsi (and more) apps
# Troubleshooting
## FreeIPA
ldapsearch -x -b "dc=domain,dc=org" -H ldap://ipa.domain.org
ldapsearch -x -b "dc=domain,dc=org" -H ldap://ipa.domain.org -D "uid=admin,cn=users,cn=compat,dc=domain,dc=org" -W
# KEYCLOACK
Heres a list of OIDC endpoints that the Keycloak publishes. These URLs are useful if you are using a non-Keycloak client adapter to talk OIDC with the auth server. These are all relative URLs and the root of the URL being the HTTP(S) protocol, hostname, and usually path prefixed with /auth: i.e. https://localhost:8080/auth
/realms/{realm-name}/protocol/isard-sso-connect/token
This is the URL endpoint for obtaining a temporary code in the Authorization Code Flow or for obtaining tokens via the Implicit Flow, Direct Grants, or Client Grants.
/realms/{realm-name}/protocol/isard-sso-connect/auth
This is the URL endpoint for the Authorization Code Flow to turn a temporary code into a token.
/realms/{realm-name}/protocol/isard-sso-connect/logout
This is the URL endpoint for performing logouts.
/realms/{realm-name}/protocol/isard-sso-connect/userinfo
This is the URL endpoint for the User Info service described in the OIDC specification.
In all of these replace {realm-name} with the name of the realm.
http://login.mydomain.duckns.org/auth/realms/master/protocol/isard-sso-connect/logout

View File

@ -0,0 +1,44 @@
FROM alpine:3.12.0 as production
MAINTAINER isard <info@isardvdi.com>
RUN apk add python3 py3-pip py3-pyldap~=3.2.0
RUN pip3 install --upgrade pip
RUN apk add --no-cache --virtual .build_deps \
build-base \
python3-dev \
libffi-dev \
gcc python3-dev linux-headers musl-dev postgresql-dev
COPY admin/docker/requirements.pip3 /requirements.pip3
RUN pip3 install --no-cache-dir -r requirements.pip3
RUN apk del .build_deps
RUN apk add --no-cache curl py3-yaml yarn libpq openssl py3-pillow
RUN wget -O /usr/lib/python3.8/site-packages/diceware/wordlists/wordlist_cat_ascii.txt https://raw.githubusercontent.com/1ma/diceware-cat/master/cat-wordlist-ascii.txt
# SSH configuration
# ARG SSH_ROOT_PWD
# RUN apk add openssh
# RUN echo "root:$SSH_ROOT_PWD" |chpasswd
# RUN sed -i \
# -e 's|[#]*PermitRootLogin prohibit-password|PermitRootLogin yes|g' \
# -e 's|[#]*PasswordAuthentication yes|PasswordAuthentication yes|g' \
# -e 's|[#]*ChallengeResponseAuthentication yes|ChallengeResponseAuthentication yes|g' \
# -e 's|[#]*UsePAM yes|UsePAM yes|g' \
# -e 's|[#]#Port 22|Port 22|g' \
# /etc/ssh/sshd_config
# Let's test 0.26.1 python-keycloak version
# RUN apk add --no-cache git && \
# git clone -b delete_realm_roles https://github.com/isard-vdi/python-keycloak.git && \
# cd python-keycloak && \
# python3 setup.py install && \
# apk del git
COPY admin/src /admin
RUN cd /admin/admin && yarn install
COPY admin/docker/run.sh /run.sh
#EXPOSE 7039
CMD [ "/run.sh" ]

View File

@ -0,0 +1,16 @@
Flask==2.0.1
Flask-Login==0.5.0
eventlet==0.33.0
Flask-SocketIO==5.1.0
bcrypt==3.2.0
diceware==0.9.6
mysql-connector-python==8.0.25
psycopg2==2.8.6
python-keycloak==0.26.1
minio==7.0.3
urllib3==1.26.6
schema==0.7.5
Werkzeug~=2.0.0
python-jose==3.3.0
Cerberus==1.3.4
PyYAML==6.0

11
dd-sso/admin/docker/run.sh Executable file
View File

@ -0,0 +1,11 @@
#!/bin/sh
# ssh-keygen -A
## Only in development
cd /admin/admin
yarn install
## End Only in development
cd /admin
export PYTHONWARNINGS="ignore:Unverified HTTPS request"
python3 start.py
#&
# /usr/sbin/sshd -D -e -f /etc/ssh/sshd_config

View File

@ -0,0 +1,111 @@
#!flask/bin/python
# coding=utf-8
import logging as log
import os
from flask import Flask, render_template, send_from_directory
app = Flask(__name__, static_url_path="")
app = Flask(__name__, template_folder="static/templates")
app.url_map.strict_slashes = False
"""
App secret key for encrypting cookies
You can generate one with:
import os
os.urandom(24)
And paste it here.
"""
app.secret_key = "Change this key!/\xf7\x83\xbe\x17\xfa\xa3zT\n\\]m\xa6\x8bF\xdd\r\xf7\x9e\x1d\x1f\x14'"
print("Starting isard-sso api...")
from admin.lib.load_config import loadConfig
try:
loadConfig(app)
except:
print("Could not get environment variables...")
from admin.lib.postup import Postup
Postup()
from admin.lib.admin import Admin
app.admin = Admin()
app.ready = False
"""
Debug should be removed on production!
"""
if app.debug:
log.warning("Debug mode: {}".format(app.debug))
else:
log.info("Debug mode: {}".format(app.debug))
"""
Serve static files
"""
@app.route("/build/<path:path>")
def send_build(path):
return send_from_directory(
os.path.join(app.root_path, "node_modules/gentelella/build"), path
)
@app.route("/vendors/<path:path>")
def send_vendors(path):
return send_from_directory(
os.path.join(app.root_path, "node_modules/gentelella/vendors"), path
)
@app.route("/node_modules/<path:path>")
def send_nodes(path):
return send_from_directory(os.path.join(app.root_path, "node_modules"), path)
@app.route("/templates/<path:path>")
def send_templates(path):
return send_from_directory(os.path.join(app.root_path, "templates"), path)
# @app.route('/templates/<path:path>')
# def send_templates(path):
# return send_from_directory(os.path.join(app.root_path, 'static/templates'), path)
@app.route("/static/<path:path>")
def send_static_js(path):
return send_from_directory(os.path.join(app.root_path, "static"), path)
@app.route("/avatars/<path:path>")
def send_avatars_img(path):
return send_from_directory(
os.path.join(app.root_path, "../avatars/master-avatars"), path
)
@app.route("/custom/<path:path>")
def send_custom(path):
return send_from_directory(os.path.join(app.root_path, "../custom"), path)
# @app.errorhandler(404)
# def not_found_error(error):
# return render_template('page_404.html'), 404
# @app.errorhandler(500)
# def internal_error(error):
# return render_template('page_500.html'), 500
"""
Import all views
"""
from .views import ApiViews, AppViews, LoginViews, WebViews, WpViews

View File

View File

@ -0,0 +1,60 @@
import os
from flask_login import LoginManager, UserMixin
from admin import app
""" OIDC TESTS """
# from flask_oidc import OpenIDConnect
# app.config.update({
# 'SECRET_KEY': 'u\x91\xcf\xfa\x0c\xb9\x95\xe3t\xba2K\x7f\xfd\xca\xa3\x9f\x90\x88\xb8\xee\xa4\xd6\xe4',
# 'TESTING': True,
# 'DEBUG': True,
# 'OIDC_CLIENT_SECRETS': 'client_secrets.json',
# 'OIDC_ID_TOKEN_COOKIE_SECURE': False,
# 'OIDC_REQUIRE_VERIFIED_EMAIL': False,
# 'OIDC_VALID_ISSUERS': ['https://sso.mydomain.duckdns.org:8080/auth/realms/master'],
# 'OIDC_OPENID_REALM': 'https://sso.mydomain.duckdns.org//custom_callback',
# 'OVERWRITE_REDIRECT_URI': 'https://sso.mydomain.duckdns.org//custom_callback',
# })
# # 'OVERWRITE_REDIRECT_URI': 'https://sso.mydomain.duckdns.org//custom_callback',
# # 'OIDC_CALLBACK_ROUTE': '//custom_callback'
# oidc = OpenIDConnect(app)
""" OIDC TESTS """
login_manager = LoginManager()
login_manager.init_app(app)
login_manager.login_view = "login"
ram_users = {
os.environ["ADMINAPP_USER"]: {
"id": os.environ["ADMINAPP_USER"],
"password": os.environ["ADMINAPP_PASSWORD"],
"role": "manager",
},
os.environ["KEYCLOAK_USER"]: {
"id": os.environ["KEYCLOAK_USER"],
"password": os.environ["KEYCLOAK_PASSWORD"],
"role": "admin",
},
os.environ["WORDPRESS_MARIADB_USER"]: {
"id": os.environ["WORDPRESS_MARIADB_USER"],
"password": os.environ["WORDPRESS_MARIADB_PASSWORD"],
"role": "manager",
},
}
class User(UserMixin):
def __init__(self, dict):
self.id = dict["id"]
self.username = dict["id"]
self.password = dict["password"]
self.role = dict["role"]
@login_manager.user_loader
def user_loader(username):
return User(ram_users[username])

View File

@ -0,0 +1,99 @@
# Copyright 2017 the Isard-vdi project authors:
# Josep Maria Viñolas Auquer
# Alberto Larraz Dalmases
# License: AGPLv3
import json
import logging as log
import os
import traceback
from functools import wraps
from flask import request
from jose import jwt
from admin import app
from ..lib.api_exceptions import Error
def get_header_jwt_payload():
return get_token_payload(get_token_auth_header())
def get_token_header(header):
"""Obtains the Access Token from the a Header"""
auth = request.headers.get(header, None)
if not auth:
raise Error(
"unauthorized",
"Authorization header is expected",
traceback.format_stack(),
)
parts = auth.split()
if parts[0].lower() != "bearer":
raise Error(
"unauthorized",
"Authorization header must start with Bearer",
traceback.format_stack(),
)
elif len(parts) == 1:
raise Error("bad_request", "Token not found")
elif len(parts) > 2:
raise Error(
"unauthorized",
"Authorization header must be Bearer token",
traceback.format_stack(),
)
return parts[1] # Token
def get_token_auth_header():
return get_token_header("Authorization")
def get_token_payload(token):
# log.warning("The received token in get_token_payload is: " + str(token))
try:
claims = jwt.get_unverified_claims(token)
secret = app.config["API_SECRET"]
except:
log.warning(
"JWT token with invalid parameters. Can not parse it.: " + str(token)
)
raise Error(
"unauthorized",
"Unable to parse authentication parameters token.",
traceback.format_stack(),
)
try:
payload = jwt.decode(
token,
secret,
algorithms=["HS256"],
options=dict(verify_aud=False, verify_sub=False, verify_exp=True),
)
except jwt.ExpiredSignatureError:
log.warning("Token expired")
raise Error("unauthorized", "Token is expired", traceback.format_stack())
except jwt.JWTClaimsError:
raise Error(
"unauthorized",
"Incorrect claims, please check the audience and issuer",
traceback.format_stack(),
)
except Exception:
raise Error(
"unauthorized",
"Unable to parse authentication token.",
traceback.format_stack(),
)
if payload.get("data", False):
return payload["data"]
return payload

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,143 @@
import inspect
import json
import logging as log
import os
import traceback
from flask import jsonify, request
from admin import app
content_type = {"Content-Type": "application/json"}
ex = {
"bad_request": {
"error": {
"error": "bad_request",
"msg": "Bad request",
},
"status_code": 400,
},
"unauthorized": {
"error": {
"error": "unauthorized",
"msg": "Unauthorized",
},
"status_code": 401,
},
"forbidden": {
"error": {
"error": "forbidden",
"msg": "Forbidden",
},
"status_code": 403,
},
"not_found": {
"error": {
"error": "not_found",
"msg": "Not found",
},
"status_code": 404,
},
"conflict": {
"error": {
"error": "conflict",
"msg": "Conflict",
},
"status_code": 409,
},
"internal_server": {
"error": {
"error": "internal_server",
"msg": "Internal server error",
},
"status_code": 500,
},
"gateway_timeout": {
"error": {
"error": "gateway_timeout",
"msg": "Gateway timeout",
},
"status_code": 504,
},
"precondition_required": {
"error": {
"error": "precondition_required",
"msg": "Precondition required",
},
"status_code": 428,
},
"insufficient_storage": {
"error": {
"error": "insufficient_storage",
"msg": "Insufficient storage",
},
"status_code": 507,
},
}
class Error(Exception):
def __init__(self, error="bad_request", description="", debug="", data=None):
self.error = ex[error]["error"].copy()
self.error["function"] = (
inspect.stack()[1][1].split(os.sep)[-1]
+ ":"
+ str(inspect.stack()[1][2])
+ ":"
+ inspect.stack()[1][3]
)
self.error["function_call"] = (
inspect.stack()[2][1].split(os.sep)[-1]
+ ":"
+ str(inspect.stack()[2][2])
+ ":"
+ inspect.stack()[2][3]
)
self.error["description"] = str(description)
self.error["debug"] = "{}\n\r{}{}".format(
"----------- DEBUG START -------------",
debug,
"----------- DEBUG STOP -------------",
)
self.error["request"] = (
"{}\n{}\r\n{}\r\n\r\n{}{}".format(
"----------- REQUEST START -----------",
request.method + " " + request.url,
"\r\n".join("{}: {}".format(k, v) for k, v in request.headers.items()),
request.body if hasattr(request, "body") else "",
"----------- REQUEST STOP -----------",
)
if request
else ""
)
self.error["data"] = (
"{}\n{}\n{}".format(
"----------- DATA START -----------",
json.dumps(data, indent=2),
"----------- DATA STOP -----------",
)
if data
else ""
)
self.status_code = ex[error]["status_code"]
self.content_type = content_type
log.debug(
"%s - %s - [%s -> %s]\r\n%s\r\n%s\r\n%s"
% (
error,
str(description),
self.error["function_call"],
self.error["function"],
self.error["debug"],
self.error["request"],
self.error["data"],
)
)
@app.errorhandler(Error)
def handle_user_error(ex):
response = jsonify(ex.error)
response.status_code = ex.status_code
response.headers = {"content-type": content_type}
return response

View File

@ -0,0 +1,82 @@
import logging as log
import os
from pprint import pprint
from minio import Minio
from minio.commonconfig import REPLACE, CopySource
from minio.deleteobjects import DeleteObject
from requests import get, post
from admin import app
class Avatars:
def __init__(self):
self.mclient = Minio(
"isard-sso-avatars:9000",
access_key="AKIAIOSFODNN7EXAMPLE",
secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
secure=False,
)
self.bucket = "master-avatars"
self._minio_set_realm()
# self.update_missing_avatars()
def add_user_default_avatar(self, userid, role="unknown"):
self.mclient.fput_object(
self.bucket,
userid,
os.path.join(app.root_path, "../custom/avatars/" + role + ".jpg"),
content_type="image/jpeg ",
)
log.warning(
" AVATARS: Updated avatar for user " + userid + " with role " + role
)
def delete_user_avatar(self, userid):
self.minio_delete_object(userid)
def update_missing_avatars(self, users):
sys_roles = ["admin", "manager", "teacher", "student"]
for u in self.get_users_without_image(users):
try:
img = [r + ".jpg" for r in sys_roles if r in u["roles"]][0]
except:
img = "unknown.jpg"
self.mclient.fput_object(
self.bucket,
u["id"],
os.path.join(app.root_path, "../custom/avatars/" + img),
content_type="image/jpeg ",
)
log.warning(
" AVATARS: Updated avatar for user "
+ u["username"]
+ " with role "
+ img.split(".")[0]
)
def _minio_set_realm(self):
if not self.mclient.bucket_exists(self.bucket):
self.mclient.make_bucket(self.bucket)
def minio_get_objects(self):
return [o.object_name for o in self.mclient.list_objects(self.bucket)]
def minio_delete_all_objects(self):
delete_object_list = map(
lambda x: DeleteObject(x.object_name),
self.mclient.list_objects(self.bucket),
)
errors = self.mclient.remove_objects(self.bucket, delete_object_list)
for error in errors:
log.error(" AVATARS: Error occured when deleting avatar object: " + error)
def minio_delete_object(self, oid):
errors = self.mclient.remove_objects(self.bucket, [DeleteObject(oid)])
for error in errors:
log.error(" AVATARS: Error occured when deleting avatar object: " + error)
def get_users_without_image(self, users):
return [u for u in users if u["id"] and u["id"] not in self.minio_get_objects()]

View File

@ -0,0 +1,87 @@
import logging as log
import os
import shutil
import traceback
from io import BytesIO
from pprint import pprint
import requests
import yaml
from PIL import Image
from schema import And, Optional, Schema, SchemaError, Use
from admin import app
class Dashboard:
def __init__(
self,
):
self.custom_menu = os.path.join(app.root_path, "../custom/menu/custom.yaml")
def _update_custom_menu(self, custom_menu_part):
with open(self.custom_menu) as yml:
menu = yaml.load(yml, Loader=yaml.FullLoader)
menu = {**menu, **custom_menu_part}
with open(self.custom_menu, "w") as yml:
yml.write(yaml.dump(menu, default_flow_style=False))
return True
def update_colours(self, colours):
schema_template = Schema(
{
"background": And(Use(str)),
"primary": And(Use(str)),
"secondary": And(Use(str)),
}
)
try:
schema_template.validate(colours)
except SchemaError:
return False
self._update_custom_menu({"colours": colours})
return self.apply_updates()
def update_menu(self, menu):
items = []
for menu_item in menu.keys():
for mustexist_key in ["href", "icon", "name", "shortname"]:
if mustexist_key not in menu[menu_item].keys():
return False
items.append(menu[menu_item])
self._update_custom_menu({"apps_external": items})
return self.apply_updates()
def update_logo(self, logo):
img = Image.open(logo.stream)
img.save(os.path.join(app.root_path, "../custom/img/logo.png"))
img.save(
os.path.join(
app.root_path,
"../custom/system/keycloak/themes/liiibrelite/login/resources/img/logo.png",
)
)
return self.apply_updates()
def update_background(self, background):
img = Image.open(background.stream)
img.save(os.path.join(app.root_path, "../custom/img/background.png"))
img.save(
os.path.join(
app.root_path,
"../custom/system/keycloak/themes/liiibrelite/login/resources/img/loginBG.png",
)
)
img.save(
os.path.join(
app.root_path,
"../custom/system/keycloak/themes/liiibrelite/login/resources/img/loginBG2.png",
)
)
return self.apply_updates()
def apply_updates(self):
resp = requests.get("http://isard-sso-api:7039/restart")
return True

View File

@ -0,0 +1,176 @@
#!flask/bin/python
# coding=utf-8
import base64
import json
import logging as log
import os
import sys
import traceback
from time import sleep
from uuid import uuid4
from flask import Response, jsonify, redirect, render_template, request, url_for
from flask_socketio import (
SocketIO,
close_room,
disconnect,
emit,
join_room,
leave_room,
rooms,
send,
)
from admin import app
def sio_event_send(event, data):
app.socketio.emit(
event,
json.dumps(data),
namespace="/sio/events",
room="events",
)
sleep(0.001)
class Events:
def __init__(self, title, text="", total=0, table=False, type="info"):
# notice, info, success, and error
self.eid = str(base64.b64encode(os.urandom(32))[:8])
self.title = title
self.text = text
self.total = total
self.table = table
self.item = 0
self.type = type
self.create()
def create(self):
log.info("START " + self.eid + ": " + self.text)
app.socketio.emit(
"notify-create",
json.dumps(
{
"id": self.eid,
"title": self.title,
"text": self.text,
"type": self.type,
}
),
namespace="/sio",
room="admin",
)
sleep(0.001)
def __del__(self):
log.info("END " + self.eid + ": " + self.text)
app.socketio.emit(
"notify-destroy",
json.dumps({"id": self.eid}),
namespace="/sio",
room="admin",
)
sleep(0.001)
def update_text(self, text):
self.text = text
app.socketio.emit(
"notify-update",
json.dumps(
{
"id": self.eid,
"text": self.text,
}
),
namespace="/sio",
room="admin",
)
sleep(0.001)
def append_text(self, text):
self.text = self.text + "<br>" + text
app.socketio.emit(
"notify-update",
json.dumps(
{
"id": self.eid,
"text": self.text,
}
),
namespace="/sio",
room="admin",
)
sleep(0.001)
def increment(self, data={"name": "", "data": []}):
self.item += 1
log.info("INCREMENT " + self.eid + ": " + self.text)
app.socketio.emit(
"notify-increment",
json.dumps(
{
"id": self.eid,
"title": self.title,
"text": "["
+ str(self.item)
+ "/"
+ str(self.total)
+ "] "
+ self.text
+ " "
+ data["name"],
"item": self.item,
"total": self.total,
"table": self.table,
"type": self.type,
"data": data,
}
),
namespace="/sio",
room="admin",
)
sleep(0.0001)
def decrement(self, data={"name": "", "data": []}):
self.item -= 1
log.info("DECREMENT " + self.eid + ": " + self.text)
app.socketio.emit(
"notify-decrement",
json.dumps(
{
"id": self.eid,
"title": self.title,
"text": "["
+ str(self.item)
+ "/"
+ str(self.total)
+ "] "
+ self.text
+ " "
+ data["name"],
"item": self.item,
"total": self.total,
"table": self.table,
"type": self.type,
"data": data,
}
),
namespace="/sio",
room="admin",
)
sleep(0.001)
def reload(self):
app.socketio.emit("reload", json.dumps({}), namespace="/sio", room="admin")
sleep(0.0001)
def table(self, event, table, data={}):
# refresh, add, delete, update
app.socketio.emit(
"table_" + event,
json.dumps({"table": table, "data": data}),
namespace="/sio",
room="admin",
)
sleep(0.0001)

View File

@ -0,0 +1,8 @@
#!/usr/bin/env python
# coding=utf-8
class UserExists(Exception):
pass
class UserNotFound(Exception):
pass

View File

@ -0,0 +1,115 @@
import random
import string
from collections import Counter
from pprint import pprint
def get_recursive_groups(l_groups, l):
for d_group in l_groups:
data = {}
for key, value in d_group.items():
if key == "subGroups":
get_recursive_groups(value, l)
else:
data[key] = value
l.append(data)
return l
def get_group_with_childs(keycloak_group):
return [g["path"] for g in get_recursive_groups([keycloak_group], [])]
def system_username(username):
return (
True
if username in ["guest", "ddadmin", "admin"] or username.startswith("system_")
else False
)
def system_group(groupname):
return True if groupname in ["admin", "manager", "teacher", "student"] else False
def get_group_from_group_id(group_id, groups):
return next((d for d in groups if d.get("id") == group_id), None)
def get_kid_from_kpath(kpath, groups):
ids = [g["id"] for g in groups if g["path"] == kpath]
if not len(ids) or len(ids) > 1:
return False
return ids[0]
def get_gid_from_kgroup_id(kgroup_id, groups):
return [
g["path"].replace("/", ".")[1:] if len(g["path"].split("/")) else g["path"][1:]
for g in groups
if g["id"] == kgroup_id
][0]
def get_gids_from_kgroup_ids(kgroup_ids, groups):
return [get_gid_from_kgroup_id(kgroup_id, groups) for kgroup_id in kgroup_ids]
def kpath2gid(path):
# print(path.replace('/','.')[1:])
if path.startswith("/"):
return path.replace("/", ".")[1:]
return path.replace("/", ".")
def kpath2gids(path):
path = kpath2gid(path)
l = []
for i in range(len(path.split("."))):
l.append(".".join(path.split(".")[: i + 1]))
return l
def kpath2kpaths(path):
l = []
for i in range(len(path.split("/"))):
l.append("/".join(path.split("/")[: i + 1]))
return l[1:]
def gid2kpath(gid):
return "/" + gid.replace(".", "/")
def count_repeated(itemslist):
print(Counter(itemslist))
def groups_kname2gid(groups):
return [name.replace(".", "/") for name in groups]
def groups_path2id(groups):
return [g.replace("/", ".")[1:] for g in groups]
def groups_id2path(groups):
return ["/" + g.replace(".", "/") for g in groups]
def filter_roles_list(role_list):
client_roles = ["admin", "manager", "teacher", "student"]
return [r for r in role_list if r in client_roles]
def filter_roles_listofdicts(role_listofdicts):
client_roles = ["admin", "manager", "teacher", "student"]
return [r for r in role_listofdicts if r["name"] in client_roles]
def rand_password(lenght):
characters = string.ascii_letters + string.digits + string.punctuation
passwd = "".join(random.choice(characters) for i in range(lenght))
while not any(ele.isupper() for ele in passwd):
passwd = "".join(random.choice(characters) for i in range(lenght))
return passwd

View File

@ -0,0 +1,442 @@
#!/usr/bin/env python
# coding=utf-8
import json
import logging as log
import os
import time
import traceback
from datetime import datetime, timedelta
from pprint import pprint
import yaml
from jinja2 import Environment, FileSystemLoader
from keycloak import KeycloakAdmin
from .api_exceptions import Error
from .helpers import get_recursive_groups, kpath2kpaths
from .postgres import Postgres
# from admin import app
class KeycloakClient:
"""https://www.keycloak.org/docs-api/13.0/rest-api/index.html
https://github.com/marcospereirampj/python-keycloak
https://gist.github.com/kaqfa/99829941121188d7cef8271f93f52f1f
"""
def __init__(
self,
url="http://isard-sso-keycloak:8080/auth/",
username=os.environ["KEYCLOAK_USER"],
password=os.environ["KEYCLOAK_PASSWORD"],
realm="master",
verify=True,
):
self.url = url
self.username = username
self.password = password
self.realm = realm
self.verify = verify
self.keycloak_pg = Postgres(
"isard-apps-postgresql",
"keycloak",
os.environ["KEYCLOAK_DB_USER"],
os.environ["KEYCLOAK_DB_PASSWORD"],
)
def connect(self):
self.keycloak_admin = KeycloakAdmin(
server_url=self.url,
username=self.username,
password=self.password,
realm_name=self.realm,
verify=self.verify,
)
# from keycloak import KeycloakAdmin
# keycloak_admin = KeycloakAdmin(server_url="http://isard-sso-keycloak:8080/auth/",username="admin",password="keycloakkeycloak",realm_name="master",verify=False)
""" USERS """
def get_user_id(self, username):
self.connect()
return self.keycloak_admin.get_user_id(username)
def get_users(self):
self.connect()
return self.keycloak_admin.get_users({})
def get_users_with_groups_and_roles(self):
q = """select u.id, u.username, u.email, u.first_name, u.last_name, u.realm_id, u.enabled, ua.value as quota
,json_agg(g."id") as group, json_agg(g_parent."name") as group_parent1, json_agg(g_parent2."name") as group_parent2
,json_agg(r.name) as role
from user_entity as u
left join user_attribute as ua on ua.user_id=u.id and ua.name = 'quota'
left join user_group_membership as ugm on ugm.user_id = u.id
left join keycloak_group as g on g.id = ugm.group_id
left join keycloak_group as g_parent on g.parent_group = g_parent.id
left join keycloak_group as g_parent2 on g_parent.parent_group = g_parent2.id
left join user_role_mapping as rm on rm.user_id = u.id
left join keycloak_role as r on r.id = rm.role_id
group by u.id,u.username,u.email,u.first_name,u.last_name, u.realm_id, u.enabled, ua.value
order by u.username"""
(headers, users) = self.keycloak_pg.select_with_headers(q)
users_with_lists = [
list(l[:-4])
+ ([[]] if l[-4] == [None] else [list(set(l[-4]))])
+ ([[]] if l[-3] == [None] else [list(set(l[-3]))])
+ ([[]] if l[-3] == [None] else [list(set(l[-2]))])
+ ([[]] if l[-1] == [None] else [list(set(l[-1]))])
for l in users
]
users_with_lists = [
list(l[:-4])
+ ([[]] if l[-4] == [None] else [list(set(l[-4]))])
+ ([[]] if l[-3] == [None] else [list(set(l[-3]))])
+ ([[]] if l[-3] == [None] else [list(set(l[-2]))])
+ ([[]] if l[-1] == [None] else [list(set(l[-1]))])
for l in users_with_lists
]
list_dict_users = [dict(zip(headers, r)) for r in users_with_lists]
return list_dict_users
def getparent(self, group_id, data):
# Recursively get full path from any group_id in the tree
path = ""
for item in data:
if group_id == item[0]:
path = self.getparent(item[2], data)
path = f"{path}/{item[1]}"
return path
def get_group_path(self, group_id):
# Get full path using getparent recursive func
# RETURNS: String with full path
q = """SELECT * FROM keycloak_group"""
groups = self.keycloak_pg.select(q)
return self.getparent(group_id, groups)
def get_user_groups_paths(self, user_id):
# Get full paths for user grups
# RETURNS list of paths
q = """SELECT group_id FROM user_group_membership WHERE user_id = '%s'""" % (
user_id
)
user_group_ids = self.keycloak_pg.select(q)
paths = []
for g in user_group_ids:
paths.append(self.get_group_path(g[0]))
return paths
## Too slow. Used the direct postgres
# def get_users_with_groups_and_roles(self):
# self.connect()
# users=self.keycloak_admin.get_users({})
# for user in users:
# user['groups']=[g['path'] for g in self.keycloak_admin.get_user_groups(user_id=user['id'])]
# user['roles']= [r['name'] for r in self.keycloak_admin.get_realm_roles_of_user(user_id=user['id'])]
# return users
def add_user(
self,
username,
first,
last,
email,
password,
group=False,
password_temporary=True,
enabled=True,
):
# RETURNS string with keycloak user id (the main id in this app)
self.connect()
username = username.lower()
try:
uid = self.keycloak_admin.create_user(
{
"email": email,
"username": username,
"enabled": enabled,
"firstName": first,
"lastName": last,
"credentials": [
{
"type": "password",
"value": password,
"temporary": password_temporary,
}
],
}
)
except Exception as e:
log.error(traceback.format_exc())
raise Error(
"conflict",
"user/email already exists: " + str(username) + "/" + str(email),
)
if group:
path = "/" + group if group[1:] != "/" else group
try:
gid = self.keycloak_admin.get_group_by_path(
path=path, search_in_subgroups=False
)["id"]
except:
self.keycloak_admin.create_group({"name": group})
gid = self.keycloak_admin.get_group_by_path(path)["id"]
self.keycloak_admin.group_user_add(uid, gid)
return uid
def update_user_pwd(self, user_id, password, password_temporary=True):
# Updates
payload = {
"credentials": [
{"type": "password", "value": password, "temporary": password_temporary}
]
}
self.connect()
return self.keycloak_admin.update_user(user_id, payload)
def user_update(self, user_id, enabled, email, first, last, groups=[], roles=[]):
## NOTE: Roles didn't seem to be updated/added. Also not confident with groups
# Updates
payload = {
"enabled": enabled,
"email": email,
"firstName": first,
"lastName": last,
"groups": groups,
"realmRoles": roles,
}
self.connect()
return self.keycloak_admin.update_user(user_id, payload)
def user_enable(self, user_id):
payload = {"enabled": True}
self.connect()
return self.keycloak_admin.update_user(user_id, payload)
def user_disable(self, user_id):
payload = {"enabled": False}
self.connect()
return self.keycloak_admin.update_user(user_id, payload)
def group_user_remove(self, user_id, group_id):
self.connect()
return self.keycloak_admin.group_user_remove(user_id, group_id)
# def add_user_role(self,user_id,role_id):
# self.connect()
# return self.keycloak_admin.assign_role(client_id=client_id, user_id=user_id, role_id=role_id, role_name="test")
def remove_user_realm_roles(self, user_id, roles):
self.connect()
roles = [
r
for r in self.get_user_realm_roles(user_id)
if r["name"] in ["admin", "manager", "teacher", "student"]
]
return self.keycloak_admin.delete_user_realm_role(user_id, roles)
def delete_user(self, userid):
self.connect()
return self.keycloak_admin.delete_user(user_id=userid)
def get_user_groups(self, userid):
self.connect()
return self.keycloak_admin.get_user_groups(user_id=userid)
def get_user_realm_roles(self, userid):
self.connect()
return self.keycloak_admin.get_realm_roles_of_user(user_id=userid)
def add_user_client_role(self, client_id, user_id, role_id, role_name):
self.connect()
return self.keycloak_admin.assign_client_role(
client_id=client_id, user_id=user_id, role_id=role_id, role_name="test"
)
## GROUPS
def get_all_groups(self):
## RETURNS ONLY MAIN GROUPS WITH NESTED subGroups list
self.connect()
return self.keycloak_admin.get_groups()
def get_groups(self, with_subgroups=True):
## RETURNS ALL GROUPS in root list
self.connect()
groups = self.keycloak_admin.get_groups()
return get_recursive_groups(groups, [])
def get_group_by_id(self, group_id):
self.connect()
return self.keycloak_admin.get_group(group_id=group_id)
def get_group_by_path(self, path, recursive=True):
self.connect()
return self.keycloak_admin.get_group_by_path(
path=path, search_in_subgroups=recursive
)
def add_group(self, name, parent=None, skip_exists=False):
self.connect()
if parent != None:
parent = self.get_group_by_path(parent)["id"]
return self.keycloak_admin.create_group({"name": name}, parent=parent)
def delete_group(self, group_id):
self.connect()
return self.keycloak_admin.delete_group(group_id=group_id)
def group_user_add(self, user_id, group_id):
self.connect()
return self.keycloak_admin.group_user_add(user_id, group_id)
def add_group_tree(self, path):
paths = kpath2kpaths(path)
parent = "/"
for path in paths:
try:
parent_path = None if parent == "/" else parent
# print("parent: "+str(parent_path)+" path: "+path.split("/")[-1])
self.add_group(path.split("/")[-1], parent_path, skip_exists=True)
parent = path
except:
# print(traceback.format_exc())
log.warning("KEYCLOAK: Group :" + path + " already exists.")
parent = path
def add_user_with_groups_and_role(
self, username, first, last, email, password, role, groups
):
## Add user
uid = self.add_user(username, first, last, email, password)
## Add user to role
log.info("User uid: " + str(uid) + " role: " + str(role))
try:
therole = role[0]
except:
therole = ""
log.info(self.assign_realm_roles(uid, role))
## Create groups in user
for g in groups:
log.warning("Creating keycloak group: " + g)
parts = g.split("/")
parent_path = None
for i in range(1, len(parts)):
# parent_id=None if parent_path==None else self.get_group(parent_path)['id']
try:
self.add_group(parts[i], parent_path, skip_exists=True)
except:
log.warning(
"Group "
+ str(parent_path)
+ " already exists. Skipping creation"
)
pass
if parent_path is None:
thepath = "/" + parts[i]
else:
thepath = parent_path + "/" + parts[i]
if thepath == "/":
log.warning(
"Not adding the user "
+ username
+ " to any group as does not have any..."
)
continue
gid = self.get_group_by_path(path=thepath)["id"]
log.warning(
"Adding "
+ username
+ " with uuid: "
+ uid
+ " to group "
+ g
+ " with uuid: "
+ gid
)
self.keycloak_admin.group_user_add(uid, gid)
if parent_path == None:
parent_path = ""
parent_path = parent_path + "/" + parts[i]
# self.group_user_add(uid,gid)
## ROLES
def get_roles(self):
self.connect()
return self.keycloak_admin.get_realm_roles()
def get_role(self, name):
self.connect()
return self.keycloak_admin.get_realm_role(name)
def add_role(self, name, description=""):
self.connect()
return self.keycloak_admin.create_realm_role(
{"name": name, "description": description}
)
def delete_role(self, name):
self.connect()
return self.keycloak_admin.delete_realm_role(name)
## CLIENTS
def get_client_roles(self, client_id):
self.connect()
return self.keycloak_admin.get_client_roles(client_id=client_id)
def add_client_role(self, client_id, name, description=""):
self.connect()
return self.keycloak_admin.create_client_role(
client_id, {"name": name, "description": description, "clientRole": True}
)
## SYSTEM
def get_server_info(self):
self.connect()
return self.keycloak_admin.get_server_info()
def get_server_clients(self):
self.connect()
return self.keycloak_admin.get_clients()
def get_server_rsa_key(self):
self.connect()
rsa_key = [
k for k in self.keycloak_admin.get_keys()["keys"] if k["type"] == "RSA"
][0]
return {"name": rsa_key["kid"], "certificate": rsa_key["certificate"]}
## REALM
def assign_realm_roles(self, user_id, role):
self.connect()
try:
role = [
r for r in self.keycloak_admin.get_realm_roles() if r["name"] == role
]
except:
return False
return self.keycloak_admin.assign_realm_roles(user_id=user_id, roles=role)
# return self.keycloak_admin.assign_realm_roles(user_id=user_id, client_id=None, roles=role)
## CLIENTS
def delete_client(self, clientid):
self.connect()
return self.keycloak_admin.delete_client(clientid)
def add_client(self, client):
self.connect()
return self.keycloak_admin.create_client(client)

View File

@ -0,0 +1,27 @@
import logging as log
import os
import traceback
from admin import app
from pprint import pprint
from minio import Minio
from minio.commonconfig import REPLACE, CopySource
from minio.deleteobjects import DeleteObject
from requests import get, post
legal_path= os.path.join(app.root_path, "static/templates/pages/legal/")
def get_legal(lang):
with open(legal_path+lang, "r") as languagefile:
return languagefile.read()
def gen_legal_if_not_exists(lang):
if not os.path.isfile(legal_path+lang):
log.debug("Creating new language file")
with open(legal_path+lang, "w") as languagefile:
languagefile.write("<b>Legal</b><br>This is the default legal page for language " + lang)
def new_legal(lang,html):
with open(legal_path+lang, "w") as languagefile:
languagefile.write(html)

View File

@ -0,0 +1,76 @@
#!/usr/bin/env python
# coding=utf-8
import logging as log
import os
import sys
import traceback
import yaml
from cerberus import Validator, rules_set_registry, schema_registry
from admin import app
class AdminValidator(Validator):
None
# def _normalize_default_setter_genid(self, document):
# return _parse_string(document["name"])
# def _normalize_default_setter_genidlower(self, document):
# return _parse_string(document["name"]).lower()
# def _normalize_default_setter_gengroupid(self, document):
# return _parse_string(
# document["parent_category"] + "-" + document["uid"]
# ).lower()
def load_validators(purge_unknown=True):
validators = {}
schema_path = os.path.join(app.root_path, "schemas")
for schema_filename in os.listdir(schema_path):
try:
with open(os.path.join(schema_path, schema_filename)) as file:
schema_yml = file.read()
schema = yaml.load(schema_yml, Loader=yaml.FullLoader)
validators[schema_filename.split(".")[0]] = AdminValidator(
schema, purge_unknown=purge_unknown
)
except IsADirectoryError:
None
return validators
app.validators = load_validators()
class loadConfig:
def __init__(self, app=None):
try:
app.config.setdefault("DOMAIN", os.environ["DOMAIN"])
app.config.setdefault(
"KEYCLOAK_POSTGRES_USER", os.environ["KEYCLOAK_DB_USER"]
)
app.config.setdefault(
"KEYCLOAK_POSTGRES_PASSWORD", os.environ["KEYCLOAK_DB_PASSWORD"]
)
app.config.setdefault(
"MOODLE_POSTGRES_USER", os.environ["MOODLE_POSTGRES_USER"]
)
app.config.setdefault(
"MOODLE_POSTGRES_PASSWORD", os.environ["MOODLE_POSTGRES_PASSWORD"]
)
app.config.setdefault(
"NEXTCLOUD_POSTGRES_USER", os.environ["NEXTCLOUD_POSTGRES_USER"]
)
app.config.setdefault(
"NEXTCLOUD_POSTGRES_PASSWORD", os.environ["NEXTCLOUD_POSTGRES_PASSWORD"]
)
app.config.setdefault(
"VERIFY", True if os.environ["VERIFY"] == "true" else False
)
app.config.setdefault("API_SECRET", os.environ.get("API_SECRET"))
except Exception as e:
log.error(traceback.format_exc())
raise

View File

@ -0,0 +1,266 @@
import logging as log
import traceback
from pprint import pprint
from requests import get, post
from admin import app
from .exceptions import UserExists, UserNotFound
from .postgres import Postgres
# Module variables to connect to moodle api
class Moodle:
"""https://github.com/mrcinv/moodle_api.py
https://docs.moodle.org/dev/Web_service_API_functions
https://docs.moodle.org/311/en/Using_web_services
"""
def __init__(
self,
key=app.config["MOODLE_WS_TOKEN"],
url="https://moodle." + app.config["DOMAIN"],
endpoint="/webservice/rest/server.php",
verify=app.config["VERIFY"],
):
self.key = key
self.url = url
self.endpoint = endpoint
self.verify = verify
self.moodle_pg = Postgres(
"isard-apps-postgresql",
"moodle",
app.config["MOODLE_POSTGRES_USER"],
app.config["MOODLE_POSTGRES_PASSWORD"],
)
def rest_api_parameters(self, in_args, prefix="", out_dict=None):
"""Transform dictionary/array structure to a flat dictionary, with key names
defining the structure.
Example usage:
>>> rest_api_parameters({'courses':[{'id':1,'name': 'course1'}]})
{'courses[0][id]':1,
'courses[0][name]':'course1'}
"""
if out_dict == None:
out_dict = {}
if not type(in_args) in (list, dict):
out_dict[prefix] = in_args
return out_dict
if prefix == "":
prefix = prefix + "{0}"
else:
prefix = prefix + "[{0}]"
if type(in_args) == list:
for idx, item in enumerate(in_args):
self.rest_api_parameters(item, prefix.format(idx), out_dict)
elif type(in_args) == dict:
for key, item in in_args.items():
self.rest_api_parameters(item, prefix.format(key), out_dict)
return out_dict
def call(self, fname, **kwargs):
"""Calls moodle API function with function name fname and keyword arguments.
Example:
>>> call_mdl_function('core_course_update_courses',
courses = [{'id': 1, 'fullname': 'My favorite course'}])
"""
parameters = self.rest_api_parameters(kwargs)
parameters.update(
{"wstoken": self.key, "moodlewsrestformat": "json", "wsfunction": fname}
)
response = post(self.url + self.endpoint, parameters, verify=self.verify)
response = response.json()
if type(response) == dict and response.get("exception"):
raise SystemError(response)
return response
def create_user(self, email, username, password, first_name="-", last_name="-"):
if len(self.get_user_by("username", username)["users"]):
raise UserExists
try:
data = [
{
"username": username,
"email": email,
"password": password,
"firstname": first_name,
"lastname": last_name,
}
]
user = self.call("core_user_create_users", users=data)
return user # [{'id': 8, 'username': 'asdfw'}]
except SystemError as se:
raise SystemError(se.args[0]["message"])
def update_user(self, username, email, first_name, last_name, enabled=True):
user = self.get_user_by("username", username)["users"][0]
if not len(user):
raise UserNotFound
try:
data = [
{
"id": user["id"],
"username": username,
"email": email,
"firstname": first_name,
"lastname": last_name,
"suspended": 0 if enabled else 1,
}
]
user = self.call("core_user_update_users", users=data)
return user
except SystemError as se:
raise SystemError(se.args[0]["message"])
def delete_user(self, user_id):
user = self.call("core_user_delete_users", userids=[user_id])
return user
def delete_users(self, userids):
user = self.call("core_user_delete_users", userids=userids)
return user
def get_user_by(self, key, value):
criteria = [{"key": key, "value": value}]
try:
user = self.call("core_user_get_users", criteria=criteria)
except:
raise SystemError("Error calling Moodle API\n", traceback.format_exc())
return user
# {'users': [{'id': 8, 'username': 'asdfw', 'firstname': 'afowie', 'lastname': 'aokjdnfwe', 'fullname': 'afowie aokjdnfwe', 'email': 'awfewe@ads.com', 'department': '', 'firstaccess': 0, 'lastaccess': 0, 'auth': 'manual', 'suspended': False, 'confirmed': True, 'lang': 'ca', 'theme': '', 'timezone': '99', 'mailformat': 1, 'profileimageurlsmall': 'https://moodle.mydomain.duckdns.org/theme/image.php/cbe/core/1630941606/u/f2', 'profileimageurl': 'https://DOMAIN/theme/image.php/cbe/core/1630941606/u/f1'}], 'warnings': []}
def get_users_with_groups_and_roles(self):
q = """select u.id as id, username, firstname as first, lastname as last, email, json_agg(h.name) as groups, json_agg(r.shortname) as roles
from mdl_user as u
LEFT JOIN mdl_cohort_members AS hm on hm.userid = u.id
left join mdl_cohort AS h ON h.id = hm.cohortid
left join mdl_role_assignments AS ra ON ra.id = u.id
left join mdl_role as r on r.id = ra.roleid
where u.deleted = 0
group by u.id , username, first, last, email"""
(headers, users) = self.moodle_pg.select_with_headers(q)
users_with_lists = [
list(l[:-2])
+ ([[]] if l[-2] == [None] else [list(set(l[-2]))])
+ ([[]] if l[-1] == [None] else [list(set(l[-1]))])
for l in users
]
list_dict_users = [dict(zip(headers, r)) for r in users_with_lists]
return list_dict_users
## NOT USED. Too slow
# def get_users_with_groups_and_roles(self):
# users=self.get_user_by('email','%%')['users']
# for user in users:
# user['groups']=[c['name'] for c in self.get_user_cohorts(user['id'])]
# user['roles']=[]
# return users
def enroll_user_to_course(self, user_id, course_id, role_id=5):
# 5 is student
data = [{"roleid": role_id, "userid": user_id, "courseid": course_id}]
enrolment = self.call("enrol_manual_enrol_users", enrolments=data)
return enrolment
def get_quiz_attempt(self, quiz_id, user_id):
attempts = self.call(
"mod_quiz_get_user_attempts", quizid=quiz_id, userid=user_id
)
return attempts
def get_cohorts(self):
cohorts = self.call("core_cohort_get_cohorts")
return cohorts
def add_system_cohort(self, name, description="", visible=True):
visible = 1 if visible else 0
data = [
{
"categorytype": {"type": "system", "value": ""},
"name": name,
"idnumber": name,
"description": description,
"visible": visible,
}
]
cohort = self.call("core_cohort_create_cohorts", cohorts=data)
return cohort
# def add_users_to_cohort(self,users,cohort):
# criteria = [{'key': key, 'value': value}]
# user = self.call('core_cohort_add_cohort_members', criteria=criteria)
# return user
def add_user_to_cohort(self, userid, cohortid):
members = [
{
"cohorttype": {"type": "id", "value": cohortid},
"usertype": {"type": "id", "value": userid},
}
]
user = self.call("core_cohort_add_cohort_members", members=members)
return user
def delete_user_in_cohort(self, userid, cohortid):
members = [{"cohortid": cohortid, "userid": userid}]
user = self.call("core_cohort_delete_cohort_members", members=members)
return user
def get_cohort_members(self, cohort_ids):
members = self.call("core_cohort_get_cohort_members", cohortids=cohort_ids)
# [0]['userids']
return members
def delete_cohorts(self, cohortids):
deleted = self.call("core_cohort_delete_cohorts", cohortids=cohortids)
return deleted
def get_user_cohorts(self, user_id):
user_cohorts = []
cohorts = self.get_cohorts()
for cohort in cohorts:
if user_id in self.get_cohort_members(cohort["id"]):
user_cohorts.append(cohort)
return user_cohorts
def add_user_to_siteadmin(self, user_id):
q = """SELECT value FROM mdl_config WHERE name='siteadmins'"""
value = self.moodle_pg.select(q)[0][0]
if str(user_id) not in value:
value = value + "," + str(user_id)
q = """UPDATE mdl_config SET value = '%s' WHERE name='siteadmins'""" % (
value
)
self.moodle_pg.update(q)
log.warning(
"MOODLE:ADDING THE USER TO ADMINS: This needs a purge cache in moodle!"
)
# def add_role_to_user(self, user_id, role='admin', context='missing'):
# if role=='admin':
# role_id=1
# else:
# return False
# assignments = [{'roleid': role_id, 'userid': user_id, 'contextid': 0}]
# self.call('core_role_assign_roles', assignments=assignments)
# userid=user_id, role_id=role_id)
# 'contextlevel': 1,
# define('CONTEXT_SYSTEM', 10);
# define('CONTEXT_USER', 30);
# define('CONTEXT_COURSECAT', 40);
# define('CONTEXT_COURSE', 50);
# define('CONTEXT_MODULE', 70);
# define('CONTEXT_BLOCK', 80);
# 'contextlevel': , 'instanceid'
# $assignment = array( 'roleid' => $role_id, 'userid' => $user_id, 'contextid' => $context_id );
# $assignments = array( $assignment );
# $params = array( 'assignments' => $assignments );
# $response = call_moodle( 'core_role_assign_roles', $params, $token );

View File

@ -0,0 +1,32 @@
#!/usr/bin/env python
# coding=utf-8
import json
import logging as log
import time
import traceback
from datetime import datetime, timedelta
import mysql.connector
import yaml
# from admin import app
class Mysql:
def __init__(self, host, database, user, password):
self.conn = mysql.connector.connect(
host=host, database=database, user=user, password=password
)
def select(self, sql):
self.cur = self.conn.cursor()
self.cur.execute(sql)
data = self.cur.fetchall()
self.cur.close()
return data
def update(self, sql):
self.cur = self.conn.cursor()
self.cur.execute(sql)
self.conn.commit()
self.cur.close()

View File

@ -0,0 +1,565 @@
#!/usr/bin/env python
# coding=utf-8
import json
import logging as log
import os
import pprint
import time
import traceback
import urllib
import requests
from psycopg2 import sql
# from ..lib.log import *
from admin import app
from .nextcloud_exc import *
from .postgres import Postgres
class Nextcloud:
def __init__(
self,
url="https://nextcloud." + app.config["DOMAIN"],
username=os.environ["NEXTCLOUD_ADMIN_USER"],
password=os.environ["NEXTCLOUD_ADMIN_PASSWORD"],
verify=True,
):
self.verify_cert = verify
self.apiurl = url + "/ocs/v1.php/cloud/"
self.shareurl = url + "/ocs/v2.php/apps/files_sharing/api/v1/"
self.davurl = url + "/remote.php/dav/files/"
self.auth = (username, password)
self.user = username
self.nextcloud_pg = Postgres(
"isard-apps-postgresql",
"nextcloud",
app.config["NEXTCLOUD_POSTGRES_USER"],
app.config["NEXTCLOUD_POSTGRES_PASSWORD"],
)
def _request(
self, method, url, data={}, headers={"OCS-APIRequest": "true"}, auth=False
):
if auth == False:
auth = self.auth
try:
response = requests.request(
method,
url,
data=data,
auth=auth,
verify=self.verify_cert,
headers=headers,
)
if "meta" in response.text:
if "<statuscode>997</statuscode>" in response.text:
raise ProviderUnauthorized
# if '<statuscode>998</statuscode>' in response.text: raise ProviderInvalidQuery
return response.text
## At least the ProviderSslError is not being catched or not raised correctly
except requests.exceptions.HTTPError as errh:
raise ProviderConnError
except requests.exceptions.Timeout as errt:
raise ProviderConnTimeout
except requests.exceptions.SSLError as err:
raise ProviderSslError
except requests.exceptions.ConnectionError as errc:
raise ProviderConnError
# except requests.exceptions.RequestException as err:
# raise ProviderError
except Exception as e:
if str(e) == "an integer is required (got type bytes)":
raise ProviderConnError
raise ProviderError
def check_connection(self):
url = self.apiurl + "users/" + self.user + "?format=json"
try:
result = self._request("GET", url)
if json.loads(result)["ocs"]["meta"]["statuscode"] == 100:
return True
raise ProviderError
except requests.exceptions.HTTPError as errh:
raise ProviderConnError
except requests.exceptions.ConnectionError as errc:
raise ProviderConnError
except requests.exceptions.Timeout as errt:
raise ProviderConnTimeout
except requests.exceptions.SSLError as err:
raise ProviderSslError
except requests.exceptions.RequestException as err:
raise ProviderError
except Exception as e:
if str(e) == "an integer is required (got type bytes)":
raise ProviderConnError
raise ProviderError
def get_user(self, userid):
url = self.apiurl + "users/" + userid + "?format=json"
try:
result = json.loads(self._request("GET", url))
if result["ocs"]["meta"]["statuscode"] == 100:
return result["ocs"]["data"]
raise ProviderItemNotExists
except:
# log.error(traceback.format_exc())
raise
# 100 - successful
# q = """select u.uid as username, adn.value as displayname, ade.value as email, json_agg(gg.displayname) as admin_groups,json_agg(g.displayname) as groups
# from oc_users as u
# left join oc_group_user as gu on gu.uid = u.uid
# left join oc_groups as g on gu.gid = g.gid
# left join oc_group_admin as ga on ga.uid = u.uid
# left join oc_groups as gg on gg.gid = ga.gid
# left join oc_accounts_data as adn on adn.uid = u.uid and adn.name = 'displayname'
# left join oc_accounts_data as ade on ade.uid = u.uid and ade.name = 'email'
# group by u.uid, adn.value, ade.value"""
# cur.execute(q)
# users = cur.fetchall()
# fields = [a.name for a in cur.description]
# cur.close()
# conn.close()
# users_with_lists = [list(l[:-2])+([[]] if l[-2] == [None] else [list(set(l[-2]))]) + ([[]] if l[-1] == [None] else [list(set(l[-1]))]) for l in users]
# users_with_lists = [list(l[:-2])+([[]] if l[-2] == [None] else [list(set(l[-2]))]) + ([[]] if l[-1] == [None] else [list(set(l[-1]))]) for l in users_with_lists]
# list_dict_users = [dict(zip(fields, r)) for r in users_with_lists]
def get_users_list(self):
# q = """select u.uid as username, adn.value as displayname, ade.value as email, json_agg(gg.displayname) as admin_groups,json_agg(g.displayname) as groups
# from oc_users as u
# left join oc_group_user as gu on gu.uid = u.uid
# left join oc_groups as g on gu.gid = g.gid
# left join oc_group_admin as ga on ga.uid = u.uid
# left join oc_groups as gg on gg.gid = ga.gid
# left join oc_accounts_data as adn on adn.uid = u.uid and adn.name = 'displayname'
# left join oc_accounts_data as ade on ade.uid = u.uid and ade.name = 'email'
# group by u.uid, adn.value, ade.value"""
# With quotas
q = """select u.uid as username, configvalue as quota, sum(size) as total_bytes, adn.value as displayname, ade.value as email, json_agg(gg.displayname) as admin_groups,json_agg(g.displayname) as groups
from oc_accounts as u
left join oc_group_user as gu on gu.uid = u.uid
left join oc_groups as g on gu.gid = g.gid
left join oc_group_admin as ga on ga.uid = u.uid
left join oc_groups as gg on gg.gid = ga.gid
left join oc_accounts_data as adn on adn.uid = u.uid and adn.name = 'displayname'
left join oc_accounts_data as ade on ade.uid = u.uid and ade.name = 'email'
left join oc_preferences as pref on u.uid=pref.userid and appid='files' and configkey='quota'
left join oc_storages as s on s.id=CONCAT('home::',u.uid)
left join oc_filecache as fc on fc.storage = numeric_id
group by u.uid, adn.value, ade.value, pref.configvalue"""
(headers, users) = self.nextcloud_pg.select_with_headers(q)
users_with_lists = [
list(l[:-2])
+ ([[]] if l[-2] == [None] else [list(set(l[-2]))])
+ ([[]] if l[-1] == [None] else [list(set(l[-1]))])
for l in users
]
users_with_lists = [
list(l[:-2])
+ ([[]] if l[-2] == [None] else [list(set(l[-2]))])
+ ([[]] if l[-1] == [None] else [list(set(l[-1]))])
for l in users_with_lists
]
list_dict_users = [dict(zip(headers, r)) for r in users_with_lists]
return list_dict_users
### Too slow...
# def get_users_list(self):
# url = self.apiurl + "users?format=json"
# try:
# result = json.loads(self._request('GET',url))
# if result['ocs']['meta']['statuscode'] == 100: return result['ocs']['data']['users']
# log.error('Get Nextcloud provider users list error: '+str(result))
# raise ProviderOpError
# except:
# log.error(traceback.format_exc())
# raise
def add_user(
self, userid, userpassword, quota=False, group=False, email="", displayname=""
):
data = {
"userid": userid,
"password": userpassword,
"quota": quota,
"groups[]": group,
"email": email,
"displayname": displayname,
}
if not group:
del data["groups[]"]
if not quota:
del data["quota"]
# if group:
# data={'userid':userid,'password':userpassword,'quota':quota,'groups[]':group,'email':email,'displayname':displayname}
# else:
# data={'userid':userid,'password':userpassword,'quota':quota,'email':email,'displayname':displayname}
url = self.apiurl + "users?format=json"
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"OCS-APIRequest": "true",
}
try:
result = json.loads(self._request("POST", url, data=data, headers=headers))
if result["ocs"]["meta"]["statuscode"] == 100:
return True
if result["ocs"]["meta"]["statuscode"] == 102:
raise ProviderItemExists
if result["ocs"]["meta"]["statuscode"] == 104:
self.add_group(group)
# raise ProviderGroupNotExists
log.error("Get Nextcloud provider user add error: " + str(result))
raise ProviderOpError
except:
# log.error(traceback.format_exc())
raise
# 100 - successful
# 101 - invalid input data
# 102 - username already exists
# 103 - unknown error occurred whilst adding the user
# 104 - group does not exist
# 105 - insufficient privileges for group
# 106 - no group specified (required for subadmins)
# 107 - all errors that contain a hint - for example “Password is among the 1,000,000 most common ones. Please make it unique.” (this code was added in 12.0.6 & 13.0.1)
def update_user(self, userid, key_values):
# key_values={'quota':quota,'email':email,'displayname':displayname}
url = self.apiurl + "users/" + userid + "?format=json"
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"OCS-APIRequest": "true",
}
for k, v in key_values.items():
data = {"key": k, "value": v}
try:
result = json.loads(
self._request("PUT", url, data=data, headers=headers)
)
if result["ocs"]["meta"]["statuscode"] == 100:
return True
if result["ocs"]["meta"]["statuscode"] == 102:
raise ProviderItemExists
if result["ocs"]["meta"]["statuscode"] == 104:
raise ProviderGroupNotExists
log.error("Get Nextcloud provider user add error: " + str(result))
raise ProviderOpError
except:
# log.error(traceback.format_exc())
raise
def add_user_to_group(self, userid, group_id):
data = {"groupid": group_id}
url = self.apiurl + "users/" + userid + "/groups?format=json"
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"OCS-APIRequest": "true",
}
try:
result = json.loads(self._request("POST", url, data=data, headers=headers))
if result["ocs"]["meta"]["statuscode"] == 100:
return True
if result["ocs"]["meta"]["statuscode"] == 102:
raise ProviderItemExists
if result["ocs"]["meta"]["statuscode"] == 104:
raise ProviderGroupNotExists
log.error("Get Nextcloud provider user add error: " + str(result))
raise ProviderOpError
except:
# log.error(traceback.format_exc())
raise
def remove_user_from_group(self, userid, group_id):
data = {"groupid": group_id}
url = self.apiurl + "users/" + userid + "/groups?format=json"
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"OCS-APIRequest": "true",
}
try:
result = json.loads(
self._request("DELETE", url, data=data, headers=headers)
)
if result["ocs"]["meta"]["statuscode"] == 100:
return True
if result["ocs"]["meta"]["statuscode"] == 102:
raise ProviderItemExists
if result["ocs"]["meta"]["statuscode"] == 104:
self.add_group(group)
# raise ProviderGroupNotExists
log.error("Get Nextcloud provider user add error: " + str(result))
raise ProviderOpError
except:
# log.error(traceback.format_exc())
raise
def add_user_with_groups(
self, userid, userpassword, quota=False, groups=[], email="", displayname=""
):
data = {
"userid": userid,
"password": userpassword,
"quota": quota,
"groups[]": groups,
"email": email,
"displayname": displayname,
}
# if not group: del data['groups[]']
if not quota:
del data["quota"]
# if group:
# data={'userid':userid,'password':userpassword,'quota':quota,'groups[]':group,'email':email,'displayname':displayname}
# else:
# data={'userid':userid,'password':userpassword,'quota':quota,'email':email,'displayname':displayname}
url = self.apiurl + "users?format=json"
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"OCS-APIRequest": "true",
}
try:
result = json.loads(self._request("POST", url, data=data, headers=headers))
if result["ocs"]["meta"]["statuscode"] == 100:
return True
if result["ocs"]["meta"]["statuscode"] == 102:
raise ProviderItemExists
if result["ocs"]["meta"]["statuscode"] == 104:
# self.add_group(group)
None
# raise ProviderGroupNotExists
log.error("Get Nextcloud provider user add error: " + str(result))
raise ProviderOpError
except:
# log.error(traceback.format_exc())
raise
# 100 - successful
# 101 - invalid input data
# 102 - username already exists
# 103 - unknown error occurred whilst adding the user
# 104 - group does not exist
# 105 - insufficient privileges for group
# 106 - no group specified (required for subadmins)
# 107 - all errors that contain a hint - for example “Password is among the 1,000,000 most common ones. Please make it unique.” (this code was added in 12.0.6 & 13.0.1)
def delete_user(self, userid):
url = self.apiurl + "users/" + userid + "?format=json"
try:
result = json.loads(self._request("DELETE", url))
if result["ocs"]["meta"]["statuscode"] == 100:
return True
if result["ocs"]["meta"]["statuscode"] == 101:
raise ProviderUserNotExists
# log.error(traceback.format_exc())
raise ProviderOpError
except:
# log.error(traceback.format_exc())
raise
# 100 - successful
# 101 - failure
def enable_user(self, userid):
None
def disable_user(self, userid):
None
def exists_user_folder(self, userid, userpassword, folder="IsardVDI"):
auth = (userid, userpassword)
url = self.davurl + userid + "/" + folder + "?format=json"
headers = {
"Depth": "0",
"Content-Type": "application/x-www-form-urlencoded",
"OCS-APIRequest": "true",
}
try:
result = self._request("PROPFIND", url, auth=auth, headers=headers)
if "<d:status>HTTP/1.1 200 OK</d:status>" in result:
return True
return False
except:
# log.error(traceback.format_exc())
raise
def add_user_folder(self, userid, userpassword, folder="IsardVDI"):
auth = (userid, userpassword)
url = self.davurl + userid + "/" + folder + "?format=json"
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"OCS-APIRequest": "true",
}
try:
result = self._request("MKCOL", url, auth=auth, headers=headers)
if result == "":
return True
if (
"<s:message>The resource you tried to create already exists</s:message>"
in result
):
raise ProviderItemExists
log.error(result.split("message>")[1].split("<")[0])
raise ProviderOpError
except:
# log.error(traceback.format_exc())
raise
def exists_user_share_folder(self, userid, userpassword, folder="IsardVDI"):
auth = (userid, userpassword)
url = self.shareurl + "shares?format=json"
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"OCS-APIRequest": "true",
}
try:
result = json.loads(self._request("GET", url, auth=auth, headers=headers))
if result["ocs"]["meta"]["statuscode"] == 200:
share = [s for s in result["ocs"]["data"] if s["path"] == "/" + folder]
if len(share) >= 1:
# Should we delete all but the first (0) one?
return {"token": share[0]["token"], "url": share[0]["url"]}
raise ProviderItemNotExists
raise ProviderOpError
except:
# log.error(traceback.format_exc())
raise
def add_user_share_folder(self, userid, userpassword, folder="IsardVDI"):
auth = (userid, userpassword)
data = {"path": "/" + folder, "shareType": 3}
url = self.shareurl + "shares?format=json"
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"OCS-APIRequest": "true",
}
try:
result = json.loads(
self._request("POST", url, data=data, auth=auth, headers=headers)
)
if (
result["ocs"]["meta"]["statuscode"] == 100
or result["ocs"]["meta"]["statuscode"] == 200
):
return {
"token": result["ocs"]["data"]["token"],
"url": result["ocs"]["data"]["url"],
}
log.error(
"Add user share folder error: " + result["ocs"]["meta"]["message"]
)
raise ProviderFolderNotExists
except:
# log.error(traceback.format_exc())
raise
def get_group(self, userid):
None
def get_groups_list(self):
url = self.apiurl + "groups?format=json"
try:
result = json.loads(self._request("GET", url))
if result["ocs"]["meta"]["statuscode"] == 100:
return [g for g in result["ocs"]["data"]["groups"]]
raise ProviderOpError
except:
# log.error(traceback.format_exc())
raise
def add_group(self, groupid):
data = {"groupid": groupid}
url = self.apiurl + "groups?format=json"
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"OCS-APIRequest": "true",
}
try:
result = json.loads(
self._request("POST", url, data=data, auth=self.auth, headers=headers)
)
if result["ocs"]["meta"]["statuscode"] == 100:
return True
if result["ocs"]["meta"]["statuscode"] == 102:
raise ProviderItemExists
raise ProviderOpError
except:
# log.error(traceback.format_exc())
raise
# 100 - successful
# 101 - invalid input data
# 102 - group already exists
# 103 - failed to add the group
def delete_group(self, groupid):
group = urllib.parse.quote(groupid, safe="")
url = self.apiurl + "groups/" + group + "?format=json"
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"OCS-APIRequest": "true",
}
try:
result = json.loads(
self._request("DELETE", url, auth=self.auth, headers=headers)
)
if result["ocs"]["meta"]["statuscode"] == 100:
return True
# log.error(traceback.format_exc())
raise ProviderOpError
except:
# log.error(traceback.format_exc())
raise
# 100 - successful
# 101 - invalid input data
# 102 - group already exists
# 103 - failed to add the group
def set_user_mail(self, data):
query = """SELECT * FROM "oc_mail_accounts" WHERE "email" = '%s'"""
sql_query = sql.SQL(query.format(data["email"]))
if not len(self.nextcloud_pg.select(sql_query)):
query = """INSERT INTO "oc_mail_accounts" ("user_id","name","email","inbound_host","inbound_port","inbound_ssl_mode","inbound_user","inbound_password","outbound_host","outbound_port","outbound_ssl_mode","outbound_user","outbound_password") VALUES
('%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s');"""
account = [
data["user_id"],
data["name"],
data["email"],
data["inbound_host"],
data["inbound_port"],
data["inbound_ssl_mode"],
data["inbound_user"],
data["inbound_password"],
data["outbound_host"],
data["outbound_port"],
data["outbound_ssl_mode"],
data["outbound_user"],
data["outbound_password"],
]
else:
query = """UPDATE "oc_mail_accounts" SET ("user_id","name","email","inbound_host","inbound_port","inbound_ssl_mode","inbound_user","inbound_password","outbound_host","outbound_port","outbound_ssl_mode","outbound_user","outbound_password") =
('%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s') WHERE email = '%s';"""
account = [
data["user_id"],
data["name"],
data["email"],
data["inbound_host"],
data["inbound_port"],
data["inbound_ssl_mode"],
data["inbound_user"],
data["inbound_password"],
data["outbound_host"],
data["outbound_port"],
data["outbound_ssl_mode"],
data["outbound_user"],
data["outbound_password"],
data["email"],
]
sql_query = sql.SQL(query.format(",".join([str(acc) for acc in account])))
self.nextcloud_pg.update(sql_query)

View File

@ -0,0 +1,40 @@
#!/usr/bin/env python
# coding=utf-8
class ProviderUnauthorized(Exception):
pass
class ProviderConnError(Exception):
pass
class ProviderSslError(Exception):
pass
class ProviderConnTimeout(Exception):
pass
class ProviderError(Exception):
pass
class ProviderItemExists(Exception):
pass
class ProviderItemNotExists(Exception):
pass
class ProviderGroupNotExists(Exception):
pass
class ProviderFolderNotExists(Exception):
pass
class ProviderOpError(Exception):
pass

View File

@ -0,0 +1,53 @@
#!/usr/bin/env python
# coding=utf-8
import json
import logging as log
import time
import traceback
from datetime import datetime, timedelta
import psycopg2
import yaml
# from admin import app
class Postgres:
def __init__(self, host, database, user, password):
self.conn = psycopg2.connect(
host=host, database=database, user=user, password=password
)
# def __del__(self):
# self.cur.close()
# self.conn.close()
def select(self, sql):
self.cur = self.conn.cursor()
self.cur.execute(sql)
data = self.cur.fetchall()
self.cur.close()
return data
def update(self, sql):
self.cur = self.conn.cursor()
self.cur.execute(sql)
self.conn.commit()
self.cur.close()
# return self.cur.fetchall()
def select_with_headers(self, sql):
self.cur = self.conn.cursor()
self.cur.execute(sql)
data = self.cur.fetchall()
fields = [a.name for a in self.cur.description]
self.cur.close()
return (fields, data)
# def update_moodle_saml_plugin(self):
# plugin[('idpmetadata', '<md:EntitiesDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" Name="urn:keycloak"><md:EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" entityID="https://sso.'+app.config['DOMAIN']+'/auth/realms/master"><md:IDPSSODescriptor WantAuthnRequestsSigned="true" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol"><md:KeyDescriptor use="signing"><ds:KeyInfo><ds:KeyName>NrtA5ynG0htowP3SXw7dBJRIAMxn-1PwuuXwOwNhlRw</ds:KeyName><ds:X509Data><ds:X509Certificate>MIICmzCCAYMCBgF5jb0RCTANBgkqhkiG9w0BAQsFADARMQ8wDQYDVQQDDAZtYXN0ZXIwHhcNMjEwNTIxMDcwMjI4WhcNMzEwNTIxMDcwNDA4WjARMQ8wDQYDVQQDDAZtYXN0ZXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCI8xh/C0+frz3kgWiUbziTDls71R2YiXLSVE+bw7gbEgZUGCLhoEI679azMtIxmnzM/snIX+yTb12+XoYkgbiLTMPQfnH+Kiab6g3HL3KPfhqS+yWkFxOoCp6Ibmp7yPlVWuHH+MBfO8OBr/r8Ao7heFbuzjiLd1KG67rcoaxfDgMuBoEomg1bgEjFgHaQIrSC6OZzH0h987/arqufZXeXlfyiqScMPUi+u5IpDWSwz06UKP0k8mxzNSlpZ93CKOUSsV0SMLxqg7FQ3SGiOk577bGW9o9BDTkkmSo3Up6smc0LzwvvUwuNd0B1irGkWZFQN9OXJnJYf1InEebIMtmPAgMBAAEwDQYJKoZIhvcNAQELBQADggEBADM34+qEGeBQ22luphVTuVJtGxcbxLx7DfsT0QfJD/OuxTTbNAa1VRyarb5juIAkqdj4y2quZna9ZXLecVo4RkwpzPoKoAkYA8b+kHnWqEwJi9iPrDvKb+GR0bBkLPN49YxIZ8IdKX/PRa3yuLHe+loiNsCaS/2ZK2KO46COsqU4QX1iVhF9kWphNLybjNAX45B6cJLsa1g0vXLdm3kv3SB4I2fErFVaOoDtFIjttoYlXdpUiThkPXBfr7N67P3dZHaS4tjJh+IZ8I6TINpcsH8dBkUhzYEIPHCePwSiC1w6WDBLNDuKt1mj1CZrLq+1x+Yhrs+QNRheEKGi89HZ8N0=</ds:X509Certificate></ds:X509Data></ds:KeyInfo></md:KeyDescriptor><md:ArtifactResolutionService Binding="urn:oasis:names:tc:SAML:2.0:bindings:SOAP" Location="https://sso.mydomain.duckdns.org/auth/realms/master/protocol/saml/resolve" index="0"/><md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://sso.mydomain.duckdns.org/auth/realms/master/protocol/saml"/><md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://sso.mydomain.duckdns.org/auth/realms/master/protocol/saml"/><md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact" Location="https://sso.mydomain.duckdns.org/auth/realms/master/protocol/saml"/><md:NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:persistent</md:NameIDFormat><md:NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:transient</md:NameIDFormat><md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified</md:NameIDFormat><md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat><md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://sso.mydomain.duckdns.org/auth/realms/master/protocol/saml"/><md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://sso.mydomain.duckdns.org/auth/realms/master/protocol/saml"/><md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:SOAP" Location="https://sso.mydomain.duckdns.org/auth/realms/master/protocol/saml"/><md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact" Location="https://sso.mydomain.duckdns.org/auth/realms/master/protocol/saml"/></md:IDPSSODescriptor></md:EntityDescriptor></md:EntitiesDescriptor>')]
# pg_update = """UPDATE mdl_config_plugins set title = %s where plugin = auth_saml2 and name ="""
# cursor.execute(pg_update, (title, bookid))
# connection.commit()
# count = cursor.rowcount
# print(count, "Successfully Updated!")

View File

@ -0,0 +1,210 @@
#!/usr/bin/env python
# coding=utf-8
import json
import logging as log
import os
import random
# from .keycloak import Keycloak
# from .moodle import Moodle
import string
import time
import traceback
from datetime import datetime, timedelta
import psycopg2
import yaml
from admin import app
from .postgres import Postgres
class Postup:
def __init__(self):
ready = False
while not ready:
try:
self.pg = Postgres(
"isard-apps-postgresql",
"moodle",
app.config["MOODLE_POSTGRES_USER"],
app.config["MOODLE_POSTGRES_PASSWORD"],
)
ready = True
except:
log.warning("Could not connect to moodle database. Retrying...")
time.sleep(2)
log.info("Connected to moodle database.")
ready = False
while not ready:
try:
with open(
os.path.join(
app.root_path,
"../moodledata/saml2/moodle." + app.config["DOMAIN"] + ".crt",
),
"r",
) as crt:
app.config.setdefault("SP_CRT", crt.read())
ready = True
except IOError:
log.warning("Could not get moodle SAML2 crt certificate. Retrying...")
time.sleep(2)
except:
log.error(traceback.format_exc())
log.info("Got moodle srt certificate.")
ready = False
while not ready:
try:
with open(
os.path.join(
app.root_path,
"../moodledata/saml2/moodle." + app.config["DOMAIN"] + ".pem",
),
"r",
) as pem:
app.config.setdefault("SP_PEM", pem.read())
ready = True
except IOError:
log.warning("Could not get moodle SAML2 pem certificate. Retrying...")
time.sleep(2)
log.info("Got moodle pem certificate.")
self.select_and_configure_theme()
self.configure_tipnc()
self.add_moodle_ws_token()
def select_and_configure_theme(self, theme="cbe"):
try:
self.pg.update(
"""UPDATE "mdl_config" SET value = '%s' WHERE "name" = 'theme';"""
% (theme)
)
except:
log.error(traceback.format_exc())
exit(1)
None
try:
self.pg.update(
"""UPDATE "mdl_config_plugins" SET value = '%s' WHERE "plugin" = 'theme_cbe' AND "name" = 'host';"""
% (os.environ["DOMAIN"])
)
self.pg.update(
"""UPDATE "mdl_config_plugins" SET value = '%s' WHERE "plugin" = 'theme_cbe' AND "name" = 'logourl';"""
% ("https://api." + os.environ["DOMAIN"] + "/img/logo.png")
)
self.pg.update(
"""UPDATE "mdl_config_plugins" SET value = '1' WHERE "plugin" = 'theme_cbe' AND "name" = 'header_api';"""
)
self.pg.update(
"""UPDATE "mdl_config_plugins" SET value = '1' WHERE "plugin" = 'theme_cbe' AND "name" = 'vclasses_direct';"""
)
self.pg.update(
"""UPDATE "mdl_config_plugins" SET value = '1' WHERE "plugin" = 'theme_cbe' AND "name" = 'uniquenamecourse';"""
)
except:
log.error(traceback.format_exc())
exit(1)
None
def configure_tipnc(self):
try:
self.pg.update(
"""UPDATE "mdl_config_plugins" SET value = '%s' WHERE "plugin" = 'assignsubmission_tipnc' AND "name" = 'host';"""
% ("https://nextcloud." + os.environ["DOMAIN"] + "/")
)
self.pg.update(
"""UPDATE "mdl_config_plugins" SET value = '%s' WHERE "plugin" = 'assignsubmission_tipnc' AND "name" = 'password';"""
% (os.environ["NEXTCLOUD_ADMIN_PASSWORD"])
)
self.pg.update(
"""UPDATE "mdl_config_plugins" SET value = 'template.docx' WHERE "plugin" = 'assignsubmission_tipnc' AND "name" = 'template';"""
)
self.pg.update(
"""UPDATE "mdl_config_plugins" SET value = '/apps/onlyoffice/' WHERE "plugin" = 'assignsubmission_tipnc' AND "name" = 'location';"""
)
self.pg.update(
"""UPDATE "mdl_config_plugins" SET value = '%s' WHERE "plugin" = 'assignsubmission_tipnc' AND "name" = 'user';"""
% (os.environ["NEXTCLOUD_ADMIN_USER"])
)
self.pg.update(
"""UPDATE "mdl_config_plugins" SET value = 'tasks' WHERE "plugin" = 'assignsubmission_tipnc' AND "name" = 'folder';"""
)
except:
log.error(traceback.format_exc())
exit(1)
None
def add_moodle_ws_token(self):
try:
token = self.pg.select(
"""SELECT * FROM "mdl_external_tokens" WHERE "externalserviceid" = 3"""
)[0][1]
app.config.setdefault("MOODLE_WS_TOKEN", token)
return
except:
# log.error(traceback.format_exc())
None
try:
self.pg.update(
"""INSERT INTO "mdl_external_services" ("name", "enabled", "requiredcapability", "restrictedusers", "component", "timecreated", "timemodified", "shortname", "downloadfiles", "uploadfiles") VALUES
('dd admin', 1, '', 1, NULL, 1621719763, 1621719850, 'dd_admin', 0, 0);"""
)
self.pg.update(
"""INSERT INTO "mdl_external_services_functions" ("externalserviceid", "functionname") VALUES
(3, 'core_course_update_courses'),
(3, 'core_user_get_users'),
(3, 'core_user_get_users_by_field'),
(3, 'core_user_update_picture'),
(3, 'core_user_update_users'),
(3, 'core_user_delete_users'),
(3, 'core_user_create_users'),
(3, 'core_cohort_get_cohort_members'),
(3, 'core_cohort_add_cohort_members'),
(3, 'core_cohort_delete_cohort_members'),
(3, 'core_cohort_create_cohorts'),
(3, 'core_cohort_delete_cohorts'),
(3, 'core_cohort_search_cohorts'),
(3, 'core_cohort_update_cohorts'),
(3, 'core_role_assign_roles'),
(3, 'core_cohort_get_cohorts');"""
)
self.pg.update(
"""INSERT INTO "mdl_external_services_users" ("externalserviceid", "userid", "iprestriction", "validuntil", "timecreated") VALUES
(3, 2, NULL, NULL, 1621719871);"""
)
b32 = "".join(
random.choices(
string.ascii_uppercase
+ string.ascii_uppercase
+ string.ascii_lowercase,
k=32,
)
)
b64 = "".join(
random.choices(
string.ascii_uppercase
+ string.ascii_uppercase
+ string.ascii_lowercase,
k=64,
)
)
self.pg.update(
"""INSERT INTO "mdl_external_tokens" ("token", "privatetoken", "tokentype", "userid", "externalserviceid", "sid", "contextid", "creatorid", "iprestriction", "validuntil", "timecreated", "lastaccess") VALUES
('%s', '%s', 0, 2, 3, NULL, 1, 2, NULL, 0, 1621831206, NULL);"""
% (b32, b64)
)
app.config.setdefault("MOODLE_WS_TOKEN", b32)
except:
log.error(traceback.format_exc())
exit(1)
None

View File

@ -0,0 +1,7 @@
{
"dependencies": {
"font-linux": "^0.6.1",
"gentelella": "^1.4.0",
"socket.io": "^4.1.3"
}
}

View File

@ -0,0 +1,11 @@
name:
required: true
type: string
description:
required: false
type: string
default: "Api created"
parent:
required: false
type: string
default: ""

View File

@ -0,0 +1,34 @@
user_id:
type: string
required: true
name:
type: string
required: false
email:
type: string
required: true
regex: ^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$
inbound_host:
type: string
required: true
inbound_port:
type: integer
required: true
inbound_ssl_mode:
type: string
default: ssl
inbound_user:
type: string
required: true
outbound_host:
type: string
required: true
outbound_port:
type: integer
required: true
outbound_ssl_mode:
type: string
default: ssl
outbound_user:
type: string
required: true

View File

@ -0,0 +1,37 @@
mails:
type: list
schema:
user_id:
type: string
required: true
name:
type: string
required: false
email:
type: string
required: true
regex: ^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$
inbound_host:
type: string
required: true
inbound_port:
type: integer
required: true
inbound_ssl_mode:
type: string
default: ssl
inbound_user:
type: string
required: true
outbound_host:
type: string
required: true
outbound_port:
type: integer
required: true
outbound_ssl_mode:
type: string
default: ssl
outbound_user:
type: string
required: true

View File

@ -0,0 +1,34 @@
username:
required: true
type: string
first:
required: true
type: string
last:
required: true
type: string
email:
required: true
type: string
regex: ^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$
password:
required: true
type: string
password_temporary:
required: false
type: boolean
default: true
quota:
required: true
type: string
enabled:
required: true
type: boolean
role:
required: true
type: string
empty: false
groups:
required: true
type: list

View File

@ -0,0 +1,31 @@
first:
required: false
type: string
last:
required: false
type: string
email:
required: false
type: string
regex: ^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$
password:
required: false
type: string
password_temporary:
required: false
type: boolean
default: true
quota:
required: false
type: string
enabled:
required: false
type: boolean
role:
required: false
type: string
empty: false
groups:
required: false
type: list

View File

@ -0,0 +1,139 @@
body {
color: #3b3e47;
background: #3b3e47
}
.dataTables_filter {float: left; position: absolute;}
.dataTables_filter input { max-width:90px;}
.roundbox{border-radius:4px;border:1px solid #AAAAAA;}
.blink {
animation: blink 2s steps(5, start) infinite;
-webkit-animation: blink 1s steps(5, start) infinite;
}
@keyframes blink {
to {
visibility: hidden;
}
}
@-webkit-keyframes blink {
to {
visibility: hidden;
}
}
.pnotify-center {
right: calc(50% - 150px) !important;
}
.ui-select-match-text{
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
padding-right: 40px;
}
.ui-select-toggle > .btn.btn-link {
margin-right: 10px;
top: 6px;
position: absolute;
right: 10px;
}
.fancytree-plain` span.fancytree-selected span.fancytree-title {
background-color: yellow;
color: black;
}
.fancytree-plain span.fancytree-active span.fancytree-title {
background-color: blue;
color: white;
}
.quota-form-input {
width: 100% !important;
overflow: hidden;
text-overflow: ellipsis;
}
/*
* Workaround to fix select2 placeholder cut off
* https://github.com/select2/select2/issues/291
* https://github.com/kartik-v/yii2-widgets/issues/324
*/
.select2-search, .select2-search__field {
width: 100% !important;
}
table.dataTable td.details-show > button > i:before,
table.dataTable td.details-control > button > i:before {
content: '\f067';
font-family: FontAwesome;
cursor: pointer;
color: white;
}
table.dataTable tr.shown td.details-show > button > i:before,
table.dataTable tr.shown td.details-control > button > i:before {
content: '\f068';
color: white;
}
.howto-desktops {
background-color:rgb(238, 238, 238);
cursor: pointer;
padding: 5px 17px;
}
.x_title h4, h3 {
margin: 5px 0 6px;
float: left;
display: block;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.x_panel {
border: none
}
/* Sidebar */
.sidebar-footer {
background: #3b3e47;
}
#menu_toggle {
color: #3b3e47;
}
.logo_white {
filter: invert(1);
}
.left_col {
background: #3b3e47;
}
.nav.side-menu>li.active>a {
background: #3b3e47;
}
.nav_title {
background: #3b3e47;
}
.nav_menu {
padding: 10px;
background: white;
height: 75px;
max-height: 75px;
}
.nav_menu_logo {
margin-top: 5px;
width: 100;
height: 45px;
}

View File

@ -0,0 +1,354 @@
/*
* Copyright 2017 the Isard-vdi project authors:
* Josep Maria Viñolas Auquer
* Alberto Larraz Dalmases
* License: AGPLv3
*/
/**
* Resize function without multiple trigger
*
* Usage:
* $(window).smartresize(function(){
* // code here
* });
*/
(function($,sr){
// debouncing function from John Hann
// http://unscriptable.com/index.php/2009/03/20/debouncing-javascript-methods/
var debounce = function (func, threshold, execAsap) {
var timeout;
return function debounced () {
var obj = this, args = arguments;
function delayed () {
if (!execAsap)
func.apply(obj, args);
timeout = null;
}
if (timeout)
clearTimeout(timeout);
else if (execAsap)
func.apply(obj, args);
timeout = setTimeout(delayed, threshold || 100);
};
};
// smartresize
jQuery.fn[sr] = function(fn){ return fn ? this.bind('resize', debounce(fn)) : this.trigger(sr); };
})(jQuery,'smartresize');
/**
* To change this license header, choose License Headers in Project Properties.
* To change this template file, choose Tools | Templates
* and open the template in the editor.
*/
// Validator.js
// initialize the validator function
validator.message.date = 'not a real date';
// validate a field on "blur" event, a 'select' on 'change' event & a '.reuired' classed multifield on 'keyup':
$('form')
.on('blur', 'input[required], input.optional, select.required', validator.checkField)
.on('change', 'select.required', validator.checkField)
.on('keypress', 'input[required][pattern]', validator.keypress);
//~ .on('keypress', 'input[required][pattern]', function(){console.log('press')});
$('.multi.required').on('keyup blur', 'input', function() {
validator.checkField.apply($(this).siblings().last()[0]);
});
$('form').submit(function(e) {
e.preventDefault();
var submit = true;
// evaluate the form using generic validaing
if (!validator.checkAll($(this))) {
submit = false;
}
if (submit)
this.submit();
return false;
});
// /Validator.js
//PNotify
var stack_center = {"dir1": "down", "dir2": "right", "firstpos1": 25, "firstpos2": ($(window).width() / 2) - (Number(PNotify.prototype.options.width.replace(/\D/g, '')) / 2)};
$(window).resize(function(){
stack_center.firstpos2 = ($(window).width() / 2) - (Number(PNotify.prototype.options.width.replace(/\D/g, '')) / 2);
});
PNotify.prototype.options.styling = "bootstrap3";
// /PNotify
// Sidebar
var CURRENT_URL = window.location.href.split('#')[0].split('?')[0],
$BODY = $('body'),
$MENU_TOGGLE = $('#menu_toggle'),
$SIDEBAR_MENU = $('#sidebar-menu'),
$SIDEBAR_FOOTER = $('.sidebar-footer'),
$LEFT_COL = $('.left_col'),
$RIGHT_COL = $('.right_col'),
$NAV_MENU = $('.nav_menu'),
$FOOTER = $('footer');
function init_sidebar() {
// TODO: This is some kind of easy fix, maybe we can improve this
var setContentHeight = function () {
// reset height
$RIGHT_COL.css('min-height', $(window).height());
var bodyHeight = $BODY.outerHeight(),
footerHeight = $BODY.hasClass('footer_fixed') ? -10 : $FOOTER.height(),
leftColHeight = $LEFT_COL.eq(1).height() + $SIDEBAR_FOOTER.height(),
contentHeight = bodyHeight < leftColHeight ? leftColHeight : bodyHeight;
// normalize content
contentHeight -= $NAV_MENU.height() + footerHeight;
$RIGHT_COL.css('min-height', contentHeight);
};
$SIDEBAR_MENU.find('a').on('click', function(ev) {
var $li = $(this).parent();
if ($li.is('.active')) {
$li.removeClass('active active-sm');
$('ul:first', $li).slideUp(function() {
setContentHeight();
});
} else {
// prevent closing menu if we are on child menu
if (!$li.parent().is('.child_menu')) {
$SIDEBAR_MENU.find('li').removeClass('active active-sm');
$SIDEBAR_MENU.find('li ul').slideUp();
}else
{
if ( $BODY.is( ".nav-sm" ) )
{
$SIDEBAR_MENU.find( "li" ).removeClass( "active active-sm" );
$SIDEBAR_MENU.find( "li ul" ).slideUp();
}
}
$li.addClass('active');
$('ul:first', $li).slideDown(function() {
setContentHeight();
});
}
});
// toggle small or large menu
$MENU_TOGGLE.on('click', function() {
if ($BODY.hasClass('nav-md')) {
$SIDEBAR_MENU.find('li.active ul').hide();
$SIDEBAR_MENU.find('li.active').addClass('active-sm').removeClass('active');
} else {
$SIDEBAR_MENU.find('li.active-sm ul').show();
$SIDEBAR_MENU.find('li.active-sm').addClass('active').removeClass('active-sm');
}
$BODY.toggleClass('nav-md nav-sm');
setContentHeight();
});
// check active menu
$SIDEBAR_MENU.find('a[href="' + CURRENT_URL + '"]').parent('li').addClass('current-page');
$SIDEBAR_MENU.find('a').filter(function () {
return this.href == CURRENT_URL;
}).parent('li').addClass('current-page').parents('ul').slideDown(function() {
setContentHeight();
}).parent().addClass('active');
// recompute content when resizing
$(window).smartresize(function(){
setContentHeight();
});
setContentHeight();
// fixed sidebar
if ($.fn.mCustomScrollbar) {
$('.menu_fixed').mCustomScrollbar({
autoHideScrollbar: true,
theme: 'minimal',
mouseWheel:{ preventDefault: true }
});
}
};
// /Sidebar
$(document).ready(function() {
init_sidebar();
$('input').iCheck({
checkboxClass: 'icheckbox_flat-green',
radioClass: 'iradio_flat-green',
})
});
// Form serialization
(function($){
$.fn.serializeObject = function(){
var self = this,
json = {},
push_counters = {},
patterns = {
"validate": /^[a-z][a-z0-9_-]*(?:\[(?:\d*|[a-z0-9_-]+)\])*$/i,
"key": /[a-z0-9_-]+|(?=\[\])/gi,
"named": /^[a-z0-9_-]+$/i,
//~ "validate": /^[a-zA-Z][a-zA-Z0-9_]*(?:\[(?:\d*|[a-zA-Z0-9_]+)\])*$/,
//~ "key": /[a-zA-Z0-9_]+|(?=\[\])/g,
"push": /^$/,
"fixed": /^\d+$/,
//~ "named": /^[a-zA-Z0-9_]+$/
};
this.build = function(base, key, value){
base[key] = value;
return base;
};
this.push_counter = function(key){
if(push_counters[key] === undefined){
push_counters[key] = 0;
}
return push_counters[key]++;
};
$.each($(this).serializeArray(), function(){
// skip invalid keys
if(!patterns.validate.test(this.name)){
return;
}
var k,
keys = this.name.match(patterns.key),
merge = this.value,
reverse_key = this.name;
while((k = keys.pop()) !== undefined){
// adjust reverse_key
reverse_key = reverse_key.replace(new RegExp("\\[" + k + "\\]$"), '');
// push
if(k.match(patterns.push)){
merge = self.build([], self.push_counter(reverse_key), merge);
}
// fixed
else if(k.match(patterns.fixed)){
merge = self.build([], k, merge);
}
// named
else if(k.match(patterns.named)){
merge = self.build({}, k, merge);
}
}
json = $.extend(true, json, merge);
});
return json;
};
})(jQuery);
function dtUpdateInsertoLD(table, data, append){
//Quickly appends new data rows. Does not update rows
if(append == true){
table.rows.add(data);
//Locate and update rows by rowId or add if new
}else{
found=false;
table.rows().every( function ( rowIdx, tableLoop, rowLoop ) {
if(this.data().id==data.id){
table.row(rowIdx).data(data).invalidate();
found=true;
return false; //Break
}
});
if(!found){
table.row.add(data);
}
}
//Redraw table maintaining paging
table.draw(false);
}
function dtUpdateInsert(table, data, append){
table=$("#"+table).DataTable()
//Quickly appends new data rows. Does not update rows
new_id=false
if(append == true){
table.rows.add(data);
new_id=true
//Locate and update rows by rowId or add if new
}else{
if(typeof(table.row('#'+data.id).id())=='undefined'){
// Does not exists yes
table.row.add(data);
new_id=true
}else{
// Exists, do update
table.row('#'+data.id).data(data).invalidate();
}
}
//Redraw table maintaining paging
table.draw(false);
return new_id
}
function dtUpdateOnly(table, data){
if(typeof(table.row('#'+data.id).id())=='undefined'){
// Does not exists yes
}else{
// Exists, do update
table.row('#'+data.id).data(data).invalidate();
}
//Redraw table maintaining paging
table.draw(false);
}
// Panel toolbox
$(document).ready(function() {
$('.collapse-link').on('click', function() {
var $BOX_PANEL = $(this).closest('.x_panel'),
$ICON = $(this).find('i'),
$BOX_CONTENT = $BOX_PANEL.find('.x_content');
// fix for some div with hardcoded fix class
if ($BOX_PANEL.attr('style')) {
$BOX_CONTENT.slideToggle(200, function(){
$BOX_PANEL.removeAttr('style');
});
} else {
$BOX_CONTENT.slideToggle(200);
$BOX_PANEL.css('height', 'auto');
}
$ICON.toggleClass('fa-chevron-up fa-chevron-down');
});
$('.close-link').click(function () {
var $BOX_PANEL = $(this).closest('.x_panel');
$BOX_PANEL.remove();
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@ -0,0 +1,459 @@
$(document).ready(function() {
$('.icon-dropdown').select2({
width: "100%",
templateSelection: formatText,
templateResult: formatText
});
function formatText (icon) {
return $('<span><i class="fa ' + $(icon.element).data('icon') + '"></i> ' + icon.text + '</span>');
};
// Update background input when colorpicker is used
$('#colorpicker-background').colorpicker().on('changeColor', function (e) {
$('#colorpicker-background-input').val(e.color.toHex());
});
// Update background colorpicker when input is used
$('#colorpicker-background-input').on('keyup', function (e) {
if ($('#colorpicker-background-input').val()[0] == '#' && $('#colorpicker-background-input').val().length == 7) {
$('#colorpicker-background').colorpicker('setValue', $('#colorpicker-background-input').val());
}
})
// Update primary input when colorpicker is used
$('#colorpicker-primary').colorpicker().on('changeColor', function (e) {
$('#colorpicker-primary-input').val(e.color.toHex());
});
// Update primary colorpicker when input is used
$('#colorpicker-primary-input').on('keyup', function (e) {
if ($('#colorpicker-primary-input').val()[0] == '#' && $('#colorpicker-primary-input').val().length == 7) {
$('#colorpicker-primary').colorpicker('setValue', $('#colorpicker-primary-input').val());
}
})
// Update secondary input when colorpicker is used
$('#colorpicker-secondary').colorpicker().on('changeColor', function (e) {
$('#colorpicker-secondary-input').val(e.color.toHex());
});
// Update primary colorpicker when input is used
$('#colorpicker-secondary-input').on('keyup', function (e) {
if ($('#colorpicker-secondary-input').val()[0] == '#' && $('#colorpicker-secondary-input').val().length == 7) {
$('#colorpicker-secondary').colorpicker('setValue', $('#colorpicker-secondary-input').val());
}
})
init_logo_cropper()
init_background_cropper()
$('#save-colors').click(function () {
// console.log({
// 'background': $('#colorpicker-background-input').val(),
// 'primary': $('#colorpicker-primary-input').val(),
// 'secondary': $('#colorpicker-secondary-input').val()
// })
$.ajax({
type: "PUT",
url:"/api/dashboard/colours",
data: JSON.stringify({
'background': $('#colorpicker-background-input').val(),
'primary': $('#colorpicker-primary-input').val(),
'secondary': $('#colorpicker-secondary-input').val()
}),
success: function(data)
{
$('#colorpicker-background').attr('data-container', data.colours.background);
$('#colorpicker-background-input').val(data.colours.background);
$('#colorpicker-primary').attr('data-container', data.colours.primary);
$('#colorpicker-primary-input').val(data.colours.primary);
$('#colorpicker-secondary').attr('data-container', data.colours.secondary);
$('#colorpicker-secondary-input').val(data.colours.secondary);
},
error: function(data)
{
console.log('ERROR!')
console.log(data)
}
});
})
$('#save-menu').click(function () {
ids = $('[id^="apps_external-"]')
var menu_options = {};
ids.each(function( index ) {
// console.log(ids[index].id)
if(!(ids[index].id.split('-')[1] in menu_options)){
menu_options[ids[index].id.split('-')[1]]={}
}
menu_options[ids[index].id.split('-')[1]][ids[index].id.split('-')[2]]=$('#'+ids[index].id).val()
})
$.ajax({
type: "PUT",
url:"/api/dashboard/menu",
data: JSON.stringify(menu_options),
success: function(data)
{
// $('#colorpicker-background').attr('data-container', data.colours.toHex());
// $('#colorpicker-background-input').val(data.colours.toHex());
// $('#colorpicker-primary').attr('data-container', data.colours.toHex());
// $('#colorpicker-primary-input').val(data.colours.toHex());
// $('#colorpicker-secondary').attr('data-container', data.colours.toHex());
// $('#colorpicker-secondary-input').val(data.colours.toHex());
},
error: function(data)
{
console.log('ERROR!')
}
});
})
})
/* CROPPER */
function init_logo_cropper() {
if (typeof ($.fn.cropper) === 'undefined') { return; }
// console.log('init_logo_cropper');
var $image = $('#image_logo');
var $dataX = $('#dataX');
var $dataY = $('#dataY');
var $dataHeight = $('#dataHeight');
var $dataWidth = $('#dataWidth');
var $dataRotate = $('#dataRotate');
var $dataScaleX = $('#dataScaleX');
var $dataScaleY = $('#dataScaleY');
var cropWidth = 80;
var cropHeight = 45;
var aspectRatio = cropWidth / cropHeight;
var options = {
aspectRatio: aspectRatio,
preview: '.img-preview-logo',
crop: function (e) {
$dataX.val(Math.round(e.x));
$dataY.val(Math.round(e.y));
$dataHeight.val(Math.round(e.height));
$dataWidth.val(Math.round(e.width));
$dataRotate.val(e.rotate);
$dataScaleX.val(e.scaleX);
$dataScaleY.val(e.scaleY);
}
};
// Tooltip
$('[data-toggle="tooltip"]').tooltip();
// Cropper
$image.on({
'build.cropper': function (e) {
// console.log(e.type);
},
'built.cropper': function (e) {
// console.log(e.type);
},
'cropstart.cropper': function (e) {
// console.log(e.type, e.action);
},
'cropmove.cropper': function (e) {
// console.log(e.type, e.action);
},
'cropend.cropper': function (e) {
// console.log(e.type, e.action);
},
'crop.cropper': function (e) {
// console.log(e.type, e.x, e.y, e.width, e.height, e.rotate, e.scaleX, e.scaleY);
},
'zoom.cropper': function (e) {
// console.log(e.type, e.ratio);
}
}).cropper(options);
$('#save-logo-crop').click(function () {
$image.data('cropper').getCroppedCanvas({ width: cropWidth, height: cropHeight }).toBlob(function (blob) {
var uri = URL.createObjectURL(blob);
var img = new Image();
img.src = uri;
$('.nav_menu_logo').attr('src', img.src)
var formData = new FormData();
formData.append('croppedImage', blob);
$.ajax('/api/dashboard/logo', {
method: "PUT",
data: formData,
processData: false,
contentType: false,
success: function () {
// Update logo image
$('.nav_menu_logo').attr('src', img.src)
console.log('Upload success');
},
error: function () {
console.log('Upload error');
}
});
});
})
// Methods
$('.docs-buttons-logo').on('click', '[data-method]', function () {
var $this = $(this);
var data = $this.data();
var $target;
var result;
if ($this.prop('disabled') || $this.hasClass('disabled')) {
return;
}
if ($image.data('cropper') && data.method) {
data = $.extend({}, data); // Clone a new one
if (typeof data.target !== 'undefined') {
$target = $(data.target);
if (typeof data.option === 'undefined') {
try {
data.option = JSON.parse($target.val());
} catch (e) {
console.log(e.message);
}
}
}
result = $image.cropper(data.method, data.option, data.secondOption);
switch (data.method) {
case 'scaleX':
case 'scaleY':
$(this).data('option', -data.option);
break;
}
if ($.isPlainObject(result) && $target) {
try {
$target.val(JSON.stringify(result));
} catch (e) {
console.log(e.message);
}
}
}
});
// Import image
var $inputImage = $('#inputImageLogo');
var URL = window.URL || window.webkitURL;
var blobURL;
if (URL) {
$inputImage.change(function () {
var files = this.files;
var file;
if (!$image.data('cropper')) {
return;
}
if (files && files.length) {
file = files[0];
if (/^image\/\w+$/.test(file.type)) {
blobURL = URL.createObjectURL(file);
$image.one('built.cropper', function () {
// Revoke when load complete
URL.revokeObjectURL(blobURL);
}).cropper('reset').cropper('replace', blobURL);
$inputImage.val('');
} else {
window.alert('Please choose an image file.');
}
}
});
} else {
$inputImage.prop('disabled', true).parent().addClass('disabled');
}
};
/* CROPPER --- end */
function init_background_cropper() {
if (typeof ($.fn.cropper) === 'undefined') { return; }
// console.log('init_background_cropper');
var $image = $('#image_background');
var $dataX = $('#dataX');
var $dataY = $('#dataY');
var $dataHeight = $('#dataHeight');
var $dataWidth = $('#dataWidth');
var $dataRotate = $('#dataRotate');
var $dataScaleX = $('#dataScaleX');
var $dataScaleY = $('#dataScaleY');
var cropWidth = 1920;
var cropHeight = 1080;
var aspectRatio = cropWidth / cropHeight;
var options = {
aspectRatio: aspectRatio,
preview: '.img-preview-background',
crop: function (e) {
$dataX.val(Math.round(e.x));
$dataY.val(Math.round(e.y));
$dataHeight.val(Math.round(e.height));
$dataWidth.val(Math.round(e.width));
$dataRotate.val(e.rotate);
$dataScaleX.val(e.scaleX);
$dataScaleY.val(e.scaleY);
}
};
// Tooltip
$('[data-toggle="tooltip"]').tooltip();
// Cropper
$image.on({
'build.cropper': function (e) {
// console.log(e.type);
},
'built.cropper': function (e) {
// console.log(e.type);
},
'cropstart.cropper': function (e) {
// console.log(e.type, e.action);
},
'cropmove.cropper': function (e) {
// console.log(e.type, e.action);
},
'cropend.cropper': function (e) {
// console.log(e.type, e.action);
},
'crop.cropper': function (e) {
// console.log(e.type, e.x, e.y, e.width, e.height, e.rotate, e.scaleX, e.scaleY);
},
'zoom.cropper': function (e) {
// console.log(e.type, e.ratio);
}
}).cropper(options);
$('#save-background-crop').click(function () {
$image.data('cropper').getCroppedCanvas({ width: cropWidth, height: cropHeight }).toBlob(function (blob) {
var uri = URL.createObjectURL(blob);
var img = new Image();
img.src = uri;
var formData = new FormData();
formData.append('croppedImage', blob);
$.ajax('/api/dashboard/background', {
method: "PUT",
data: formData,
processData: false,
contentType: false,
success: function () {
// Update background image
// console.log('Upload success');
},
error: function () {
// console.log('Upload error');
}
});
});
})
// Methods
$('.docs-buttons-background').on('click', '[data-method]', function () {
var $this = $(this);
var data = $this.data();
var $target;
var result;
if ($this.prop('disabled') || $this.hasClass('disabled')) {
return;
}
if ($image.data('cropper') && data.method) {
data = $.extend({}, data); // Clone a new one
if (typeof data.target !== 'undefined') {
$target = $(data.target);
if (typeof data.option === 'undefined') {
try {
data.option = JSON.parse($target.val());
} catch (e) {
console.log(e.message);
}
}
}
result = $image.cropper(data.method, data.option, data.secondOption);
switch (data.method) {
case 'scaleX':
case 'scaleY':
$(this).data('option', -data.option);
break;
}
if ($.isPlainObject(result) && $target) {
try {
$target.val(JSON.stringify(result));
} catch (e) {
console.log(e.message);
}
}
}
});
// Import image
var $inputImage = $('#inputImageBackground');
var URL = window.URL || window.webkitURL;
var blobURL;
if (URL) {
$inputImage.change(function () {
var files = this.files;
var file;
if (!$image.data('cropper')) {
return;
}
if (files && files.length) {
file = files[0];
if (/^image\/\w+$/.test(file.type)) {
blobURL = URL.createObjectURL(file);
$image.one('built.cropper', function () {
// Revoke when load complete
URL.revokeObjectURL(blobURL);
}).cropper('reset').cropper('replace', blobURL);
$inputImage.val('');
} else {
window.alert('Please choose an image file.');
}
}
});
} else {
$inputImage.prop('disabled', true).parent().addClass('disabled');
}
};

View File

@ -0,0 +1,228 @@
$(document).on('shown.bs.modal', '#modalAddDesktop', function () {
modal_add_desktops.columns.adjust().draw();
});
$(document).ready(function() {
update_modal_groups();
$('.btn-global-resync').on('click', function () {
$.ajax({
type: "GET",
url:"api/resync",
success: function(data)
{
table.ajax.reload();
// $("#modalImport").modal('hide');
// users_table.ajax.reload();
// groups_table.ajax.reload();
},
error: function(data)
{
alert('Something went wrong on our side...')
}
});
});
// Open new group modal
$('.btn-new').on('click', function () {
update_modal_groups()
$('#modalAddGroup').modal({
backdrop: 'static',
keyboard: false
}).modal('show');
});
// Send new group form
$('#modalAddGroup #send').on('click', function () {
var form = $('#modalAddGroupForm');
formdata = form.serializeObject()
console.log('NEW GROUP')
console.log(formdata)
$.ajax({
type: "POST",
"url": "/api/group",
data: JSON.stringify(formdata),
complete: function(jqXHR, textStatus) {
switch (jqXHR.status) {
case 200:
$("#modalAddGroup").modal('hide');
table.ajax.reload();
break;
case 409:
new PNotify({
title: "Add group error",
text: $.parseJSON(jqXHR.responseText)['msg'],
hide: true,
delay: 3000,
icon: 'fa fa-alert-sign',
opacity: 1,
type: 'error'
});
break;
case 412:
new PNotify({
title: "Add group error",
text: $.parseJSON(jqXHR.responseText)['msg'],
hide: true,
delay: 3000,
icon: 'fa fa-alert-sign',
opacity: 1,
type: 'error'
});
break;
default:
alert("Server error.");
}
}
});
});
$('.btn-delete_keycloak').on('click', function () {
$.ajax({
type: "DELETE",
url:"/api/groups/keycloak",
success: function(data)
{
console.log('SUCCESS')
// $("#modalImport").modal('hide');
// users_table.ajax.reload();
// groups_table.ajax.reload();
},
error: function(data)
{
alert('Something went wrong on our side...')
}
});
});
//DataTable Main renderer
var table = $('#groups').DataTable({
"ajax": {
"url": "/api/groups",
"dataSrc": ""
},
"language": {
"loadingRecords": '<i class="fa fa-spinner fa-pulse fa-3x fa-fw"></i><span class="sr-only">Loading...</span>',
"emptyTable": "<h1>You don't have any group created yet.</h1><br><h2>Create one using the +Add new button on top right of this page.</h2>"
},
"rowId": "id",
"deferRender": true,
"columns": [
{
"className": 'actions-control',
"orderable": false,
"data": null,
"width": "80px",
"defaultContent": '<button id="btn-delete" class="btn btn-xs" type="button" data-placement="top" ><i class="fa fa-times" style="color:darkred"></i></button>'
// \
// <button id="btn-edit" class="btn btn-xs" type="button" data-placement="top" ><i class="fa fa-pencil" style="color:darkblue"></i></button>'
},
{ "data": "name", "width": "10px" },
{ "data": "path", "width": "10px" },
],
"order": [[2, 'asc']],
// "columnDefs": [ ]
} );
$('#groups').find(' tbody').on( 'click', 'button', function () {
var data = table.row( $(this).parents('tr') ).data();
// var closest=$(this).closest("div").parent();
// var pk=closest.attr("data-pk");
// console.log(pk)
switch($(this).attr('id')){
case 'btn-edit':
$("#modalEditGroupForm")[0].reset();
$('#modalEditGroup').modal({
backdrop: 'static',
keyboard: false
}).modal('show');
// $('#modalEditGroup #user-avatar').attr("src","/avatar/"+data.id)
// setUserDefault('#modalEditGroup', data.id);
$('#modalEdit').parsley();
break;
case 'btn-delete':
new PNotify({
title: 'Confirmation Needed',
text: "Are you sure you want to delete group: "+data['name']+"?",
hide: false,
opacity: 0.9,
confirm: {
confirm: true
},
buttons: {
closer: false,
sticker: false
},
history: {
history: false
},
addclass: 'pnotify-center'
}).get().on('pnotify.confirm', function() {
console.log(data)
if(data.id == false){
$.ajax({
type: "DELETE",
url:"/api/group",
data: JSON.stringify(data),
success: function(data)
{
table.ajax.reload();
},
error: function(data)
{
alert('Something went wrong on our side...')
}
});
}else{
$.ajax({
type: "DELETE",
url:"/api/group/"+data.id,
success: function(data)
{
table.ajax.reload();
},
error: function(data)
{
alert('Something went wrong on our side...')
}
});
}
}).on('pnotify.cancel', function() {
});
break;
}
});
})
function update_modal_groups(){
$.ajax({
type: "GET",
"url": "/api/groups",
success: function(data)
{
$(".groups-select").empty().append(
'<option value="" default>None</option>'
)
data.forEach(element => {
var groupOrigins = [];
['keycloak'].forEach(o => {
if (element[o]) {
groupOrigins.push(o)
}
})
$(".groups-select").append(
'<option value="' + element.name + '">' + element.name + '</option>'
)
});
$('.groups-select').select2();
},
error: function(data)
{
alert('Something went wrong on our side...')
}
});
}

View File

@ -0,0 +1,100 @@
$(document).ready(function () {
init_wysiwyg();
var lang = getCookie('KEYCLOAK_LOCALE') ? getCookie('KEYCLOAK_LOCALE') : 'ca'
$('#legal-lang').val(lang)
getLangLegal(lang)
// $('#privacy-lang').val(lang)
// $.ajax({
// type: "GET",
// url: "/api/legal/privacy",
// data: {
// lang: lang
// },
// success: function (data) {
// $('#editor-privacy').html(data.html)
// }
// })
$("#save-legal").click(function () {
console.log($('#editor-legal').cleanHtml())
console.log($('#legal-lang').val())
$.ajax({
type: "POST",
url: "/api/legal/legal",
data: JSON.stringify({
'html': $('#editor-legal').cleanHtml(),
'lang': $('#legal-lang').val()
}),
success: function () {
new PNotify({
title: "Legal text",
text: "Updated for "+$('#legal-lang').val()+" language",
hide: true,
delay: 3000,
icon: 'fa fa-alert-sign',
opacity: 1,
type: 'info'
});
},
});
});
$('#legal-lang').on('change', function() {
getLangLegal(this.value)
});
// $("#save-privacy").click(function () {
// $.ajax({
// type: "POST",
// url: "/api/legal/privacy",
// data: {
// 'html': $('#editor-privacy').cleanHtml(),
// 'lang': $('#legal-lang').val()
// },
// success: function () {
// },
// });
// });
});
function getLangLegal(lang) {
$.ajax({
type: "GET",
url: "/api/legal/legal",
data: {
lang: lang
},
success: function (data) {
$('#editor-legal').html(data.html)
}
})
}
function getCookie(cname) {
let name = cname + "=";
let decodedCookie = decodeURIComponent(document.cookie);
let ca = decodedCookie.split(';');
for(let i = 0; i <ca.length; i++) {
let c = ca[i];
while (c.charAt(0) == ' ') {
c = c.substring(1);
}
if (c.indexOf(name) == 0) {
return c.substring(name.length, c.length);
}
}
return "";
}
function init_wysiwyg() {
$("#editor-legal").wysiwyg({
toolbarSelector: '[data-target="#editor-legal"]'
});
// $("#editor-privacy").wysiwyg({
// toolbarSelector: '[data-target="#editor-privacy"]'
// });
window.prettyPrint;
prettyPrint();
}

View File

@ -0,0 +1,59 @@
$(document).on('shown.bs.modal', '#modalAddDesktop', function () {
modal_add_desktops.columns.adjust().draw();
});
$(document).ready(function() {
$('.btn-global-resync').on('click', function () {
$.ajax({
type: "GET",
url:"/api/resync",
success: function(data)
{
table.ajax.reload();
// $("#modalImport").modal('hide');
// users_table.ajax.reload();
// groups_table.ajax.reload();
},
error: function(data)
{
alert('Something went wrong on our side...')
}
});
});
$('.btn-new').on('click', function () {
$("#modalAdd")[0].reset();
$('#modalAddDesktop').modal({
backdrop: 'static',
keyboard: false
}).modal('show');
$('#modalAdd').parsley();
});
//DataTable Main renderer
var table = $('#roles').DataTable({
"ajax": {
"url": "/api/roles",
"dataSrc": ""
},
"language": {
"loadingRecords": '<i class="fa fa-spinner fa-pulse fa-3x fa-fw"></i><span class="sr-only">Loading...</span>',
"emptyTable": "<h1>You don't have any role created yet.</h1><br><h2>Create one using the +Add new button on top right of this page.</h2>"
},
"rowId": "id",
"deferRender": true,
"columns": [
{ "data": "id", "width": "10px" },
{ "data": "name", "width": "10px" },
],
"order": [[1, 'asc']],
// "columnDefs": [ {
// "targets": 0,
// "render": function ( data, type, full, meta ) {
// // return '<object data="/static/img/missing.jpg" type="image/jpeg" width="25" height="25"><img src="/avatar/'+full.id+'" title="'+full.id+'" width="25" height="25"></object>'
// return '<img src="/avatar/'+full.name+'" title="'+full.name+'" width="25" height="25" onerror="if (this.src != \'/static/img/missing.jpg\') this.src = \'/static/img/missing.jpg\';">'
// }}]
} );
});

View File

@ -0,0 +1,100 @@
notice={}
$lost=0;
socket = io.connect(location.protocol+'//' + document.domain +'/sio');
console.log(location.protocol+'//' + document.domain +'/sio')
socket.on('connect', function() {
if($lost){location.reload();}
console.log('Listening status socket');
});
socket.on('connect_error', function(data) {
$lost=$lost+1;
$('#modal-lostconnection').modal({
backdrop: 'static',
keyboard: false
}).modal('show');
});
socket.on('notify-create', function(data) {
var data = JSON.parse(data);
notice[data.id] = new PNotify({
title: data.title,
text: data.text,
hide: false,
type: data.type
});
});
socket.on('notify-destroy', function(data) {
var data = JSON.parse(data);
notice[data.id].remove()
});
socket.on('notify-increment', function(data) {
var data = JSON.parse(data);
if(!( data.id in notice)){
notice[data.id] = new PNotify({
title: data.title,
text: data.text,
hide: false,
type: data.type
});
}
// console.log(data.text)
notice[data.id].update({
text: data.text
})
if(! data.table == false){
dtUpdateInsert(data.table,data['data']['data'])
}
});
// new PNotify({
// title: "Quota for creating desktops full.",
// text: "Can't create another desktop, user quota full.",
// hide: true,
// delay: 3000,
// icon: 'fa fa-alert-sign',
// opacity: 1,
// type: 'error'
// });
// socket.on('update', function(data) {
// var data = JSON.parse(data);
// console.log('Status update')
// console.log(data)
// // var data = JSON.parse(data);
// // drawUserQuota(data);
// });
socket.on('update', function(data) {
var data = JSON.parse(data);
console.log('Status update')
console.log(data)
// var data = JSON.parse(data);
// drawUserQuota(data);
});
// {'event':'traceback',
// 'id':u['id'],
// 'item':'group',
// 'action':'add'
// 'name':g['name'],
// 'progress':str(item)+'/'+str(total),
// 'status':False,
// 'msg':,
// 'payload':{'traceback':traceback.format_exc(),
// 'data':g})
socket.on('progress', function(data) {
var data = JSON.parse(data);
console.log(data)
// $('.modal-progress #item').html(data.item)
});
////

View File

@ -0,0 +1,379 @@
$(document).on('shown.bs.modal', '#modalAddDesktop', function () {
modal_add_desktops.columns.adjust().draw();
});
$(document).ready(function() {
$('#action_role option[value=""]').prop("selected",true);
var path = "";
items = [];
document.getElementById('file-upload').addEventListener('change', readFile, false);
$('.btn-upload').on('click', function () {
$('#modalImport').modal({backdrop: 'static', keyboard: false}).modal('show');
$('#modalImportForm')[0].reset();
});
$('.btn-sync').on('click', function () {
ids={}
$.each(users_table.rows().data(),function(key, value){
ids[value['id']]=value['roles']
});
// console.log(ids)
$.ajax({
type: "PUT",
url:"/api/external",
data: JSON.stringify(ids),
success: function(data)
{
console.log('SUCCESS')
// $("#modalImport").modal('hide');
users_table.ajax.reload();
groups_table.ajax.reload();
},
error: function(data)
{
alert('Something went wrong on our side...')
}
});
});
$('.btn-clear-upload').on('click', function () {
new PNotify({
title: 'Cleaning imported data',
text: 'Are you sure you want to clean imported data?',
hide: false,
opacity: 0.9,
confirm: {
confirm: true
},
buttons: {
closer: false,
sticker: false
},
history: {
history: false
},
addclass: 'pnotify-center'
}).get().on('pnotify.confirm', function() {
$.ajax({
type: "DELETE",
url:"/api/external",
success: function(data)
{
console.log('SUCCESS')
users_table.ajax.reload();
groups_table.ajax.reload();
},
error: function(data)
{
alert('Something went wrong on our side...')
}
});
}).on('pnotify.cancel', function() {
$('#action_role option[value=""]').prop("selected",true);
});
});
$('.btn-sample').on('click', function () {
var viewerFile = new Blob(["groups;firstname;lastname;email;username;password;password_temporal;role;quota\n/alumnes/6è;John;Doe;jdoe@digitaldemocratic.net;jdoe;SuperSecret;no;student;1GB\n/alumnes/6è;Magdalena;Martí;mm_profe@email.cat;mm_profe;SuperSecret;no;teacher;3GB\n/managers;Pere;Isard;pisardmgr@email.cat;pisardmgr;SuperSecret;no;manager;Unlimited\n/alumnes/4t,/alumnes/5è;Marc;Gómez;marcgt@email.cat;marcgt;SuperSecret;no;student;1GB"], {type: "text/csv"});
var a = document.createElement('a');
a.download = 'dd_sample_upload.csv';
a.href = window.URL.createObjectURL(viewerFile);
var ev = document.createEvent("MouseEvents");
ev.initMouseEvent("click", true, false, self, 0, 0, 0, 0, 0, false, false, false, false, 0, null);
a.dispatchEvent(ev);
});
$('.btn-download').on('click', function () {
data=users_table.rows().data()
csv_data='TYPE,EXT_ID,EMAIL,FIRST,LAST,USERNAME,PATHS,GROUPS,QUOTA,ROLE,PASS_TEMP,PASSWORD'+ '\r\n'
csv_data=csv_data+convertToCSV(data)
console.log(csv_data)
exportCSVFile(csv_data, 'users_data')
})
$("#modalImport #send").on('click', function(e){
// console.log(users_table.rows().data())
var form = $('#modalImportForm');
form.parsley().validate();
if (form.parsley().isValid()){
formdata = form.serializeObject()
if($('#format').val() == 'csv-ug'){
formdata['data']=parseCSV(filecontents)
}else{
formdata['data']=JSON.parse(filecontents)
}
$.ajax({
type: "POST",
url:"/api/external",
data: JSON.stringify(formdata),
success: function(data)
{
console.log('SUCCESS')
$("#modalImport").modal('hide');
users_table.ajax.reload();
groups_table.ajax.reload();
},
error: function(xhr, status, error) {
var err = eval("(" + xhr.responseText + ")");
alert(JSON.parse(xhr.responseText).msg)
users_table.ajax.reload();
groups_table.ajax.reload();
}
});
}
});
$('#action_role').on('change', function () {
action=$(this).val();
names=''
ids=[]
if(users_table.rows('.active').data().length){
$.each(users_table.rows('.active').data(),function(key, value){
names+=value['name']+'\n';
ids.push(value['id']);
});
var text = "You are about to assign role "+action+" these users:\n\n "+names
}else{
$.each(users_table.rows({filter: 'applied'}).data(),function(key, value){
ids.push(value['id']);
});
var text = "You are about to assign role "+action+" "+users_table.rows({filter: 'applied'}).data().length+" users!\n All the users in list!"
}
new PNotify({
title: 'Role assignment!',
text: 'You will asign the role '+action,
hide: false,
opacity: 0.9,
confirm: {
confirm: true
},
buttons: {
closer: false,
sticker: false
},
history: {
history: false
},
addclass: 'pnotify-center'
}).get().on('pnotify.confirm', function() {
$.ajax({
type: "PUT",
url:"/api/external/roles",
data: JSON.stringify({'ids':ids,'action':action}),
success: function(data)
{
console.log('SUCCESS')
users_table.ajax.reload();
groups_table.ajax.reload();
},
error: function(data)
{
alert('Something went wrong on our side...')
}
});
}).on('pnotify.cancel', function() {
});
$('#action_role option[value=""]').prop("selected",true);
} );
//DataTable Main renderer
var users_table = $('#users').DataTable({
"ajax": {
"url": "/api/external/users",
"dataSrc": ""
},
"language": {
"loadingRecords": '<i class="fa fa-spinner fa-pulse fa-3x fa-fw"></i><span class="sr-only">Loading...</span>',
"emptyTable": "<h2>No users imported yet.</h2><br><h2>Import with the Upload button on top right of this page.</h2>"
},
"rowId": "id",
"deferRender": true,
"columns": [
{
"className": 'details-control',
"orderable": false,
"data": null,
"width": "10px",
"defaultContent": '<button class="btn btn-xs btn-info" type="button" data-placement="top" ><i class="fa fa-plus"></i></button>'
},
// { "data": "provider", "width": "10px" },
{ "data": "id", "width": "10px" },
{ "data": "username", "width": "10px"},
{ "data": "first", "width": "10px"},
{ "data": "last", "width": "10px"},
{ "data": "email", "width": "10px"},
{ "data": "gids", "width": "10px"},
{ "data": "groups", "width": "10px"},
{ "data": "roles", "width": "10px"},
{ "data": "quota", "width": "10px"},
{ "data": "password", "width": "10px"},
],
"order": [[3, 'asc']],
"columnDefs": [ {
"targets": 1,
"render": function ( data, type, full, meta ) {
return '<img src="/custom/avatars/'+full.roles+'.jpg" title="'+full.id+'" width="25" height="25" onerror="if (this.src != \'/static/img/missing.jpg\') this.src = \'/static/img/missing.jpg\';">'
return '<img src="/avatar/'+full.id+'" title="'+full.id+'" width="25" height="25">'
}},
{
"targets": 6,
"render": function ( data, type, full, meta ) {
return "<li>" + full.gids.join("</li><li>") + "</li>"
}},
{
"targets": 7,
"render": function ( data, type, full, meta ) {
return "<li>" + full.groups.join("</li><li>") + "</li>"
}}
]
} );
var groups_table = $('#groups').DataTable({
"ajax": {
"url": "/api/external/groups",
"dataSrc": ""
},
"language": {
"loadingRecords": '<i class="fa fa-spinner fa-pulse fa-3x fa-fw"></i><span class="sr-only">Loading...</span>',
"emptyTable": "<h2>No groups imported yet.</h2>"
},
"rowId": "id",
"deferRender": true,
"columns": [
{
"className": 'details-control',
"orderable": false,
"data": null,
"width": "10px",
"defaultContent": '<button class="btn btn-xs btn-info" type="button" data-placement="top" ><i class="fa fa-plus"></i></button>'
},
// { "data": "id", "width": "10px" },
// { "data": "provider", "width": "10px" },
{ "data": "name", "width": "10px" },
{ "data": "description", "width": "10px"},
],
"order": [[2, 'asc']],
"columnDefs": [ ]
} );
});
function readFile (evt) {
if($('#format').val() == 'json-ga'){
path = "";
items = [];
var files = evt.target.files;
var file = files[0];
var reader = new FileReader();
reader.onload = function(event) {
filecontents=event.target.result;
$.each(JSON.parse(filecontents), walker);
populate_path(items)
}
reader.readAsText(file, 'UTF-8')
}
if($('#format').val() == 'csv-ug'){
var files = evt.target.files;
var file = files[0];
var reader = new FileReader();
reader.onload = function(event) {
filecontents=event.target.result;
// $.each(JSON.parse(filecontents), walker);
// populate_path(items)
}
reader.readAsText(file, 'UTF-8')
}
}
function parseCSV(){
lines=filecontents.split('\n')
header=lines[0].split(';')
users=[]
$.each(lines, function(n, l){
if(n!=0 && l.length > 10){
usr=toObject(header,l.split(';'))
usr['id']=usr['username']
users.push(usr)
}
})
return users;
}
function toObject(names, values) {
var result = {};
for (var i = 0; i < names.length; i++)
result[names[i]] = values[i];
return result;
}
function walker(key, value) {
var savepath = path;
path = path ? (path + "/" + key) : key;
items.push({path:path})
if (typeof value === "object") {
// Recurse into children
if(value.constructor === Array){
value=value[0]
}
if(typeof value == "object"){
$.each(value, walker);
}
}
path = savepath;
}
function populate_path(){
$.each(items, function(key, value) {
$(".populate").append('<option value=' + value['path']+ '>' + value['path'] + '</option>');
})
}
function convertToCSV(objArray) {
var array = typeof objArray != 'object' ? JSON.parse(objArray) : objArray;
var str = '';
for (var i = 0; i < array.length; i++) {
var line = '';
for (var index in array[i]) {
if (line != '') line += ','
if (Array.isArray(array[i][index])){
line += '"'+array[i][index]+'"'
}else{
line += array[i][index];
}
}
str += line + '\r\n';
}
return str;
}
function exportCSVFile(csv, fileTitle) {
var exportedFilenmae = fileTitle + '.csv' || 'export.csv';
var blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
if (navigator.msSaveBlob) { // IE 10+
navigator.msSaveBlob(blob, exportedFilenmae);
} else {
var link = document.createElement("a");
if (link.download !== undefined) { // feature detection
// Browsers that support HTML5 download attribute
var url = URL.createObjectURL(blob);
link.setAttribute("href", url);
link.setAttribute("download", exportedFilenmae);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
}
}

View File

@ -0,0 +1,130 @@
$(document).on('shown.bs.modal', '#modalAddDesktop', function () {
modal_add_desktops.columns.adjust().draw();
});
$(document).ready(function() {
$('.btn-global-resync').on('click', function () {
$.ajax({
type: "GET",
url:"api/resync",
success: function(data)
{
table.ajax.reload();
// $("#modalImport").modal('hide');
// users_table.ajax.reload();
// groups_table.ajax.reload();
},
error: function(data)
{
alert('Something went wrong on our side...')
}
});
});
// Open new group modal
$('.btn-new').on('click', function () {
$('#modalAddGroup').modal({
backdrop: 'static',
keyboard: false
}).modal('show');
});
// Send new group form
$('#modalAddGroup #send').on('click', function () {
var form = $('#modalAddGroupForm');
formdata = form.serializeObject()
console.log('NEW GROUP')
console.log(formdata)
// $.ajax({
// type: "POST",
// "url": "/groups_list",
// success: function(data)
// {
// console.log('SUCCESS')
// // $("#modalAddGroup").modal('hide');
// },
// error: function(data)
// {
// alert('Something went wrong on our side...')
// }
// });
});
$('.btn-delete_keycloak').on('click', function () {
$.ajax({
type: "DELETE",
url:"/api/groups/keycloak",
success: function(data)
{
console.log('SUCCESS')
// $("#modalImport").modal('hide');
// users_table.ajax.reload();
// groups_table.ajax.reload();
},
error: function(data)
{
alert('Something went wrong on our side...')
}
});
});
//DataTable Main renderer
var table = $('#groups').DataTable({
"ajax": {
"url": "/api/groups",
"dataSrc": ""
},
"language": {
"loadingRecords": '<i class="fa fa-spinner fa-pulse fa-3x fa-fw"></i><span class="sr-only">Loading...</span>',
"emptyTable": "<h1>You don't have any group created yet.</h1><br><h2>Create one using the +Add new button on top right of this page.</h2>"
},
"rowId": "id",
"deferRender": true,
"columns": [
{
"className": 'details-control',
"orderable": false,
"data": null,
"width": "10px",
"defaultContent": '<button class="btn btn-xs btn-info" type="button" data-placement="top" ><i class="fa fa-plus"></i></button>'
},
{ "data": "id", "width": "10px" },
{ "data": "keycloak", "width": "10px" },
{ "data": "moodle", "width": "10px" },
{ "data": "nextcloud", "width": "10px" },
{ "data": "name", "width": "10px" },
{ "data": "path", "width": "10px" },
],
"order": [[3, 'asc']],
"columnDefs": [ {
"targets": 2,
"render": function ( data, type, full, meta ) {
if(full.keycloak){
return '<i class="fa fa-check" style="color:lightgreen"></i>'
}else{
return '<i class="fa fa-close" style="color:darkred"></i>'
};
}},
{
"targets": 3,
"render": function ( data, type, full, meta ) {
if(full.moodle){
return '<i class="fa fa-check" style="color:lightgreen"></i>'
}else{
return '<i class="fa fa-close" style="color:darkred"></i>'
};
}},
{
"targets": 4,
"render": function ( data, type, full, meta ) {
if(full.nextcloud){
return '<i class="fa fa-check" style="color:lightgreen"></i>'
}else{
return '<i class="fa fa-close" style="color:darkred"></i>'
};
}},
]
} );
})

View File

@ -0,0 +1,525 @@
$(document).on('shown.bs.modal', '#modalAddDesktop', function () {
modal_add_desktops.columns.adjust().draw();
});
$(document).ready(function() {
$.ajax({
type: "GET",
"url": "/api/groups",
success: function(data)
{
data.forEach(element => {
var groupOrigins = [];
['keycloak', 'moodle', 'nextcloud'].forEach(o => {
if (element[o]) {
groupOrigins.push(o)
}
})
$(".groups-select").append(
'<option value=' + element.path + '>' + element.name + ' (' + groupOrigins.join(',') + ') </option>'
)
});
$('.groups-select').select2();
},
error: function(data)
{
alert('Something went wrong on our side...')
}
});
$.ajax({
type: "GET",
"url": "/api/roles",
success: function(data)
{
console.log('ROLES')
console.log(data)
data.forEach(element => {
$(".role-moodle-select, .role-nextcloud-select, .role-keycloak-select").append(
'<option value=' + element.id + '>' + element.name + '</option>'
)
})
},
error: function(data)
{
alert('Something went wrong on our side...')
}
});
$('.btn-global-resync').on('click', function () {
$.ajax({
type: "GET",
url:"/api/resync",
success: function(data)
{
console.log('Reloaded')
table.ajax.reload();
// $("#modalImport").modal('hide');
// users_table.ajax.reload();
// groups_table.ajax.reload();
},
error: function(data)
{
alert('Something went wrong on our side...')
}
});
});
// Open new user modal
$('.btn-new-user').on('click', function () {
$('#modalAddUser').modal({
backdrop: 'static',
keyboard: false
}).modal('show');
});
// Send new user form
$('#modalAddUser #send').on('click', function () {
var form = $('#modalAddUserForm');
formdata = form.serializeObject()
console.log('NEW USER')
console.log(formdata)
// $.ajax({
// type: "POST",
// "url": "/groups_list",
// success: function(data)
// {
// console.log('SUCCESS')
// // $("#modalAddUser").modal('hide');
// },
// error: function(data)
// {
// alert('Something went wrong on our side...')
// }
// });
});
$('.btn-delete_keycloak').on('click', function () {
new PNotify({
title: 'Confirmation Needed',
text: "Are you sure you want to DELETE ALL USERS IN KEYCLOAK???",
hide: false,
opacity: 0.9,
confirm: {
confirm: true
},
buttons: {
closer: false,
sticker: false
},
history: {
history: false
},
addclass: 'pnotify-center'
}).get().on('pnotify.confirm', function() {
console.log('Updating user password...')
$.ajax({
type: "DELETE",
url:"/api/users/keycloak",
success: function(data)
{
console.log('SUCCESS')
// $("#modalImport").modal('hide');
// users_table.ajax.reload();
// groups_table.ajax.reload();
},
error: function(data)
{
alert('Something went wrong on our side...')
}
});
}).on('pnotify.cancel', function() {
});
});
$('.btn-delete_nextcloud').on('click', function () {
new PNotify({
title: 'Confirmation Needed',
text: "Are you sure you want to DELETE ALL USERS IN NEXTCLOUD?",
hide: false,
opacity: 0.9,
confirm: {
confirm: true
},
buttons: {
closer: false,
sticker: false
},
history: {
history: false
},
addclass: 'pnotify-center'
}).get().on('pnotify.confirm', function() {
console.log('Updating user password...')
$.ajax({
type: "DELETE",
url:"/api/users/nextcloud",
success: function(data)
{
console.log('SUCCESS')
// $("#modalImport").modal('hide');
// users_table.ajax.reload();
// groups_table.ajax.reload();
},
error: function(data)
{
alert('Something went wrong on our side...')
}
});
}).on('pnotify.cancel', function() {
});
});
$('.btn-delete_moodle').on('click', function () {
new PNotify({
title: 'Confirmation Needed',
text: "Are you sure you want to DELETE ALL USERS IN MOODLE?",
hide: false,
opacity: 0.9,
confirm: {
confirm: true
},
buttons: {
closer: false,
sticker: false
},
history: {
history: false
},
addclass: 'pnotify-center'
}).get().on('pnotify.confirm', function() {
console.log('Updating user password...')
$.ajax({
type: "DELETE",
url:"/api/users/moodle",
success: function(data)
{
console.log('SUCCESS')
// $("#modalImport").modal('hide');
// users_table.ajax.reload();
// groups_table.ajax.reload();
},
error: function(data)
{
alert('Something went wrong on our side...')
}
});
}).on('pnotify.cancel', function() {
});
});
$('.btn-sync_to_moodle').on('click', function () {
$.ajax({
type: "POST",
url:"/api/users/moodle",
success: function(data)
{
console.log('SUCCESS')
// $("#modalImport").modal('hide');
// users_table.ajax.reload();
// groups_table.ajax.reload();
},
error: function(data)
{
alert('Something went wrong on our side...')
}
});
});
$('.btn-sync_from_keycloak').on('click', function () {
$.ajax({
type: "PUT",
url:"/api/users",
success: function(data)
{
console.log('SUCCESS')
// $("#modalImport").modal('hide');
// users_table.ajax.reload();
// groups_table.ajax.reload();
},
error: function(data)
{
alert('Something went wrong on our side...')
}
});
});
$('.btn-sync_to_nextcloud').on('click', function () {
$.ajax({
type: "POST",
url:"/api/users/nextcloud",
success: function(data)
{
console.log('SUCCESS')
// $("#modalImport").modal('hide');
// users_table.ajax.reload();
// groups_table.ajax.reload();
},
error: function(data)
{
alert('Something went wrong on our side...')
}
});
});
//DataTable Main renderer
var table = $('#users').DataTable({
"ajax": {
"url": "/api/users",
"dataSrc": ""
},
"language": {
"loadingRecords": '<i class="fa fa-spinner fa-pulse fa-3x fa-fw"></i><span class="sr-only">Loading...</span>',
"emptyTable": "<h1>You don't have any user created yet.</h1><br><h2>Create one using the +Add new button on top right of this page.</h2>"
},
"rowId": "id",
"deferRender": true,
"columns": [
{
"className": 'details-control',
"orderable": false,
"data": null,
"width": "10px",
"defaultContent": '<button class="btn btn-xs btn-info" type="button" data-placement="top" ><i class="fa fa-plus"></i></button>'
},
{ "data": "id", "width": "10px" },
{ "data": "username", "width": "10px"},
{ "data": "first", "width": "10px"},
{ "data": "last", "width": "10px"},
{ "data": "email", "width": "10px"},
{ "data": "keycloak", "width": "10px" },
{ "data": "keycloak_groups", "width": "10px" },
{ "data": "roles", "width": "10px" },
{ "data": "moodle", "width": "10px" },
{ "data": "moodle_groups", "width": "10px" },
{ "data": "nextcloud", "width": "10px" },
{ "data": "nextcloud_groups", "width": "10px" },
],
"order": [[4, 'asc']],
"columnDefs": [ {
"targets": 1,
"render": function ( data, type, full, meta ) {
return '<img src="/avatar/'+full.id+'" title="'+full.id+'" width="25" height="25">'
}},
{
"targets": 6,
"render": function ( data, type, full, meta ) {
if(full.keycloak){
return '<i class="fa fa-check" style="color:lightgreen"></i>'
}else{
return '<i class="fa fa-close" style="color:darkred"></i>'
};
}},
{
"targets": 7,
"render": function ( data, type, full, meta ) {
return "<li>" + full.keycloak_groups.join("</li><li>") + "</li>"
}},
{
"targets": 9,
"render": function ( data, type, full, meta ) {
if(full.moodle){
return '<i class="fa fa-check" style="color:lightgreen"></i>'
}else{
return '<i class="fa fa-close" style="color:darkred"></i>'
};
}},
{
"targets": 10,
"render": function ( data, type, full, meta ) {
return "<li>" + full.moodle_groups.join("</li><li>") + "</li>"
}},
{
"targets": 11,
"render": function ( data, type, full, meta ) {
if(full.nextcloud){
return '<i class="fa fa-check" style="color:lightgreen"></i>'
}else{
return '<i class="fa fa-close" style="color:darkred"></i>'
};
}},
{
"targets": 12,
"render": function ( data, type, full, meta ) {
return "<li>" + full.nextcloud_groups.join("</li><li>") + "</li>"
}},
]
} );
$template = $(".template-detail-users");
$('#users').find('tbody').on('click', 'td.details-control', function () {
var tr = $(this).closest('tr');
var row = table.row( tr );
if ( row.child.isShown() ) {
// This row is already open - close it
row.child.hide();
tr.removeClass('shown');
}
else {
// Close other rows
if ( table.row( '.shown' ).length ) {
$('.details-control', table.row( '.shown' ).node()).click();
}
// Open this row
row.child( addUserDetailPannel(row.data()) ).show();
tr.addClass('shown');
actionsUserDetail()
}
} );
function addUserDetailPannel ( d ) {
$newPanel = $template.clone();
$newPanel.html(function(i, oldHtml){
return oldHtml.replace(/d.id/g, d.id).replace(/d.username/g, d.username);
});
return $newPanel
}
function actionsUserDetail(){
$('.btn-passwd').on('click', function () {
var closest=$(this).closest("div").parent();
var pk=closest.attr("data-pk");
$("#modalPasswdUserForm")[0].reset();
$('#modalPasswdUser').modal({
backdrop: 'static',
keyboard: false
}).modal('show');
$('#modalPasswdUserForm #id').val(pk);
});
$("#modalPasswdUser #send").on('click', function(e){
var form = $('#modalPasswdUserForm');
form.parsley().validate();
if (form.parsley().isValid()){
data=$('#modalPasswdUserForm').serializeObject();
data['id']=$('#modalPasswdUserForm #id').val();
new PNotify({
title: 'Confirmation Needed',
text: "Are you sure you want to update password for the user "+ username+"?",
hide: false,
opacity: 0.9,
confirm: {
confirm: true
},
buttons: {
closer: false,
sticker: false
},
history: {
history: false
},
addclass: 'pnotify-center'
}).get().on('pnotify.confirm', function() {
console.log('Updating user password...')
$.ajax({
type: "PUT",
url:"/api/user" + id,
success: function(data)
{
$(div_id + ' #id').val(data.id);
$(div_id + ' #username').val(data.username);
$(div_id + ' #email').val(data.email);
$(div_id + ' #firstname').val(data.firstname);
$(div_id + ' #lastname').val(data.lastname);
$(div_id + ' .groups-select').val(data.groups);
$(div_id + ' .role-moodle-select').val(data.roles);
$(div_id + ' .role-nextcloud-select').val(data.roles);
$(div_id + ' .role-keycloak-select').val(data.roles);
$('.groups-select, .role-moodle-select, .role-nextcloud-select, .role-keycloak-select').trigger('change');
}
});
}).on('pnotify.cancel', function() {
});
}
});
$('.btn-edit').on('click', function () {
var closest=$(this).closest("div").parent();
var pk=closest.attr("data-pk");
$("#modalEditUserForm")[0].reset();
$('#modalEditUser').modal({
backdrop: 'static',
keyboard: false
}).modal('show');
setUserDefault('#modalEditUser', pk);
$('#modalEdit').parsley();
});
$("#modalEditUser #send").on('click', function(e){
var form = $('#modalEditUserForm');
form.parsley().validate();
if (form.parsley().isValid()){
data=$('#modalEditUserForm').serializeObject();
data['id']=$('#modalEditUserForm #id').val();
console.log('Editing user...')
console.log(data)
}
});
$('.btn-delete').on('click', function () {
var closest=$(this).closest("div").parent();
var pk=closest.attr("data-pk");
var username=closest.attr("data-username");
console.log(username)
new PNotify({
title: 'Confirmation Needed',
text: "Are you sure you want to delete the user: "+ username+"?",
hide: false,
opacity: 0.9,
confirm: {
confirm: true
},
buttons: {
closer: false,
sticker: false
},
history: {
history: false
},
addclass: 'pnotify-center'
}).get().on('pnotify.confirm', function() {
console.log('Deleting user...')
}).on('pnotify.cancel', function() {
});
});
}
function setUserDefault(div_id, user_id) {
// $.ajax({
// type: "GET",
// url:"/api/user/" + id,
// success: function(data)
// {
// $(div_id + ' #id').val(data.id);
// $(div_id + ' #username').val(data.username);
// $(div_id + ' #email').val(data.email);
// $(div_id + ' #firstname').val(data.firstname);
// $(div_id + ' #lastname').val(data.lastname);
// $(div_id + ' .groups-select').val(data.groups);
// $(div_id + ' .role-moodle-select').val(data.roles);
// $(div_id + ' .role-nextcloud-select').val(data.roles);
// $(div_id + ' .role-keycloak-select').val(data.roles);
// $('.groups-select, .role-moodle-select, .role-nextcloud-select, .role-keycloak-select').trigger('change');
// }
// });
// MOCK
$(div_id + ' #id').val('b57c8d3f-ee08-4a1d-9873-f40c082b9c69');
$(div_id + ' #user-avatar').attr('src', '/static/img/usera.jpg');
$(div_id + ' #username').val('yedcaqwvt');
$(div_id + ' #email').val('yedcaqwvt@institutmariaespinalt.cat');
$(div_id + ' #firstname').val('Ymisno');
$(div_id + ' #lastname').val('Edcaqwvt tavnuoes');
$(div_id + ' .groups-select').val(['student', 'manager']);
$(div_id + ' .role-moodle-select').val('51cc1a95-94b7-48eb-aebb-1eba6745e09f');
$(div_id + ' .role-nextcloud-select').val('1e21ec95-b8c7-43b8-baad-1a31ad33f388');
$(div_id + ' .role-keycloak-select').val('13da53d5-c50b-42d9-8fbf-84f2ed7cbf9e');
$('.groups-select, .role-moodle-select, .role-nextcloud-select, .role-keycloak-select').trigger('change');
}
});

View File

@ -0,0 +1,644 @@
$(document).on('shown.bs.modal', '#modalAddDesktop', function () {
modal_add_desktops.columns.adjust().draw();
});
$(document).ready(function() {
$('#bulk_actions option[value=""]').prop("selected",true);
update_groups();
$.ajax({
type: "GET",
"url": "/api/roles",
success: function(data)
{
data.forEach(element => {
$(".role-moodle-select, .role-nextcloud-select, .role-keycloak-select").append(
'<option value="' + element.name + '">' + element.name + '</option>'
)
})
},
error: function(data)
{
alert('Something went wrong on our side...')
}
});
$('.btn-global-resync').on('click', function () {
$.ajax({
type: "GET",
url:"/api/resync",
success: function(data)
{
table.ajax.reload();
},
error: function(data)
{
alert('Something went wrong on our side...')
table.ajax.reload();
}
});
});
$('#bulk_actions').on('change', function () {
action=$(this).val();
names=''
ids=[]
if(table.rows('.active').data().length){
$.each(table.rows('.active').data(),function(key, value){
names+=value['username']+'\n';
ids.push({'id':value['id'],'username':value['username']});
});
var text = "You are about to "+action+" these users:\n\n "+names
}else{
$.each(table.rows({filter: 'applied'}).data(),function(key, value){
ids.push({'id':value['id'],'username':value['username']});
});
var text = "You are about to "+action+" "+table.rows({filter: 'applied'}).data().length+" users!\n To all the users in list!"
}
console.log(ids)
new PNotify({
title: 'Bulk actions on users',
text: text,
hide: false,
opacity: 0.9,
confirm: {
confirm: true
},
buttons: {
closer: false,
sticker: false
},
history: {
history: false
},
addclass: 'pnotify-center'
}).get().on('pnotify.confirm', function() {
$.ajax({
type: "PUT",
url:"/api/users_bulk/"+$('#bulk_actions').val(),
data: JSON.stringify(ids),
success: function(data)
{
console.log('SUCCESS')
$('#bulk_actions option[value=""]').prop("selected",true);
table.ajax.reload();
},
error: function(data)
{
alert('Something went wrong on our side...')
$('#bulk_actions option[value=""]').prop("selected",true);
table.ajax.reload();
}
});
}).on('pnotify.cancel', function() {
$('#bulk_actions option[value=""]').prop("selected",true);
});
} );
// Open new user modal
$('.btn-new-user').on('click', function () {
$("#modalAddUserForm")[0].reset();
update_groups();
$.ajax({
type: "GET",
"url": "/api/user_password",
success: function(data)
{
$('#modalAddUser #password').val(data)
},
error: function(data)
{
alert('Something went wrong on our side...')
}
});
$('#modalAddUser #enabled').prop('checked',true).iCheck('update');
$('#modalAddUser').modal({
backdrop: 'static',
keyboard: false
}).modal('show');
});
//has uppercase
window.Parsley.addValidator('uppercase', {
requirementType: 'number',
validateString: function(value, requirement) {
var uppercases = value.match(/[A-Z]/g) || [];
return uppercases.length >= requirement;
},
messages: {
en: 'Your password must contain at least (%s) uppercase letter.'
}
});
//has lowercase
window.Parsley.addValidator('lowercase', {
requirementType: 'number',
validateString: function(value, requirement) {
var lowecases = value.match(/[a-z]/g) || [];
return lowecases.length >= requirement;
},
messages: {
en: 'Your password must contain at least (%s) lowercase letter.'
}
});
// Send new user form
$('#modalAddUser #send').on('click', function () {
var form = $('#modalAddUserForm');
form.parsley().validate();
if (form.parsley().isValid()){
formdata = form.serializeObject()
// console.log('NEW USER')
// console.log(formdata)
$.ajax({
type: "POST",
"url": "/api/user",
data: JSON.stringify(formdata),
complete: function(jqXHR, textStatus) {
switch (jqXHR.status) {
case 200:
$("#modalAddUser").modal('hide');
break;
case 409:
new PNotify({
title: "Add user error",
text: $.parseJSON(jqXHR.responseText)['msg'],
hide: true,
delay: 3000,
icon: 'fa fa-alert-sign',
opacity: 1,
type: 'error'
});
break;
case 412:
new PNotify({
title: "Add user error",
text: $.parseJSON(jqXHR.responseText)['msg'],
hide: true,
delay: 3000,
icon: 'fa fa-alert-sign',
opacity: 1,
type: 'error'
});
break;
default:
alert("Server error.");
}
}
});
}
table.ajax.reload();
});
// $("#modalEditUser #send").on('click', function(e){
// var form = $('#modalEditUserForm');
// form.parsley().validate();
// if (form.parsley().isValid()){
// data=$('#modalEditUserForm').serializeObject();
// data['id']=$('#modalEditUserForm #id').val();
// console.log('Editing user...')
// console.log(data)
// }
// });
$('#modalEditUser #send').on('click', function () {
var form = $('#modalEditUserForm');
form.parsley().validate();
if (form.parsley().isValid()){
formdata = form.serializeObject()
formdata['id']=$('#modalEditUserForm #id').val();
formdata['username']=$('#modalEditUserForm #username').val();
console.log('UPDATE USER')
console.log(formdata)
$.ajax({
type: "PUT",
"url": "/api/user/"+formdata['id'],
data: JSON.stringify(formdata),
complete: function(jqXHR, textStatus) {
table.ajax.reload();
switch (jqXHR.status) {
case 200:
$("#modalEditUser").modal('hide');
break;
case 404:
new PNotify({
title: "Update user error",
text: $.parseJSON(jqXHR.responseText)['msg'],
hide: true,
delay: 3000,
icon: 'fa fa-alert-sign',
opacity: 1,
type: 'error'
});
break;
case 409:
new PNotify({
title: "Add user error",
text: $.parseJSON(jqXHR.responseText)['msg'],
hide: true,
delay: 3000,
icon: 'fa fa-alert-sign',
opacity: 1,
type: 'error'
});
break;
case 412:
new PNotify({
title: "Add user error",
text: $.parseJSON(jqXHR.responseText)['msg'],
hide: true,
delay: 3000,
icon: 'fa fa-alert-sign',
opacity: 1,
type: 'error'
});
break;
default:
alert("Server error.");
}
}
});
}
});
//DataTable Main renderer
var table = $('#users').DataTable({
"ajax": {
"url": "/api/users",
"dataSrc": ""
},
"language": {
"loadingRecords": '<i class="fa fa-spinner fa-pulse fa-3x fa-fw"></i><span class="sr-only">Loading...</span>',
"emptyTable": "<h1>You don't have any user created yet.</h1><br><h2>Create one using the +Add new button on top right of this page.</h2>"
},
"rowId": "id",
"deferRender": true,
"columns": [
// {
// "className": 'details-control',
// "orderable": false,
// "data": null,
// "width": "10px",
// "defaultContent": '<button class="btn btn-xs btn-info" type="button" data-placement="top" ><i class="fa fa-plus"></i></button>'
// },
{ "data": "enabled", "width": "1px" },
{ "data": "id", "width": "10px" },
{ "data": "roles", "width": "10px" },
{
"className": 'actions-control',
"orderable": false,
"data": null,
"width": "80px",
"defaultContent": '<button id="btn-delete" class="btn btn-xs" type="button" data-placement="top" ><i class="fa fa-times" style="color:darkred"></i></button> \
<button id="btn-password" class="btn btn-xs" type="button" data-placement="top" ><i class="fa fa-lock" style="color:orange"></i></button> \
<button id="btn-edit" class="btn btn-xs" type="button" data-placement="top" ><i class="fa fa-pencil" style="color:darkblue"></i></button>'
},
{
"className": 'text-center',
"data": null,
"orderable": false,
"defaultContent": '<input type="checkbox" class="form-check-input"></input>',
"width": "10px"
},
{ "data": "username", "width": "10px"},
{ "data": "first", "width": "10px"},
{ "data": "last", "width": "150px"},
{ "data": "email", "width": "10px"},
{ "data": "keycloak_groups", "width": "50px" },
{ "data": "quota", "width": "10px", "default": "-"},
],
"order": [[6, 'asc']],
"columnDefs": [ {
"targets": 1,
"render": function ( data, type, full, meta ) {
// return '<object data="/static/img/missing.jpg" type="image/jpeg" width="25" height="25"><img src="/avatar/'+full.id+'" title="'+full.id+'" width="25" height="25"></object>'
return '<img src="/avatar/'+full.id+'" title="'+full.id+'" width="25" height="25" onerror="if (this.src != \'/static/img/missing.jpg\') this.src = \'/static/img/missing.jpg\';">'
}},
{
"targets": 0,
"render": function ( data, type, full, meta ) {
if(full.enabled){
return '<i class="fa fa-check" style="color:lightgreen"></i>'
}else{
return '<i class="fa fa-close" style="color:darkred"></i>'
};
}},
{
"targets": 2,
"render": function ( data, type, full, meta ) {
if(full.roles.length){
return full.roles[0][0].toUpperCase() + full.roles[0].slice(1);
}else{
return '-'
}
}},
{
"targets": 5,
"render": function ( data, type, full, meta ) {
return '<b>'+full.username+'</b>'
}},
{
"targets": 9,
"render": function ( data, type, full, meta ) {
grups = ''
full.keycloak_groups.forEach(element => {
grups += '<span class="label label-primary" style="margin: 5px;">' + element + '</span>'
})
return grups
}},
{
"targets": 10,
"render": function ( data, type, full, meta ) {
if(full.quota == false){
return 'Unlimited'
}else{
return full.quota
}
}},
]
} );
table.on( 'click', 'tr', function () {
$(this).toggleClass('active');
if ($(this).hasClass('active')) {
$(this).find('input').prop('checked', true);
} else {
$(this).find('input').prop('checked', false);
}
} );
// $template = $(".template-detail-users");
// $('#users').find('tbody').on('click', 'td.details-control', function () {
// var tr = $(this).closest('tr');
// var row = table.row( tr );
// if ( row.child.isShown() ) {
// // This row is already open - close it
// row.child.hide();
// tr.removeClass('shown');
// }
// else {
// // Close other rows
// if ( table.row( '.shown' ).length ) {
// $('.details-control', table.row( '.shown' ).node()).click();
// }
// // Open this row
// row.child( addUserDetailPannel(row.data()) ).show();
// tr.addClass('shown');
// actionsUserDetail()
// }
// } );
$('#users').find(' tbody').on( 'click', 'button', function () {
var data = table.row( $(this).parents('tr') ).data();
// var closest=$(this).closest("div").parent();
// var pk=closest.attr("data-pk");
// console.log(pk)
switch($(this).attr('id')){
case 'btn-edit':
$("#modalEditUserForm")[0].reset();
$('#modalEditUser').modal({
backdrop: 'static',
keyboard: false
}).modal('show');
$('#modalEditUser #user-avatar').attr("src","/avatar/"+data.id)
setUserDefault('#modalEditUser', data.id);
$('#modalEdit').parsley();
break;
case 'btn-delete':
new PNotify({
title: 'Confirmation Needed',
text: "Are you sure you want to delete user: "+data['username']+"?",
hide: false,
opacity: 0.9,
confirm: {
confirm: true
},
buttons: {
closer: false,
sticker: false
},
history: {
history: false
},
addclass: 'pnotify-center'
}).get().on('pnotify.confirm', function() {
$.ajax({
type: "DELETE",
url:"/api/user/"+data.id,
success: function(data)
{
table.ajax.reload();
},
error: function(data)
{
alert('Something went wrong on our side...')
table.ajax.reload();
}
});
}).on('pnotify.cancel', function() {
});
break;
case 'btn-password':
$("#modalPasswdUserForm")[0].reset();
$('#modalPasswdUser').modal({
backdrop: 'static',
keyboard: false
}).modal('show');
$('#modalPasswdUserForm #id').val(data.id);
$.ajax({
type: "GET",
url:"/api/user_password",
success: function(data)
{
$('#modalPasswdUserForm #password').val(data);
table.ajax.reload();
},
error: function(data)
{
alert('Something went wrong on our side...')
table.ajax.reload();
}
});
break;
}
});
$("#modalPasswdUser #send").on('click', function(e){
var form = $('#modalPasswdUserForm');
form.parsley().validate();
if (form.parsley().isValid()){
formdata=$('#modalPasswdUserForm').serializeObject();
id=$('#modalPasswdUserForm #id').val();
$.ajax({
type: "PUT",
url:"/api/user_password/" + id,
data: JSON.stringify(formdata),
success: function(data)
{
$("#modalPasswdUser").modal('hide');
table.ajax.reload();
// groups_table.ajax.reload();
},
error: function(data)
{
alert('Something went wrong on our side...')
table.ajax.reload();
}
// statusCode: {
// 404: function(data) {
// // {'error': 'description}. Not able to get responseJSON from received object
// alert('User not exists in system!')
// },
// 200: function() {
// console.log("Success");
// }
// },
// error: function(data)
// {
// alert('Something went wrong on our side...')
// }
});
}
});
// function addUserDetailPannel ( d ) {
// $newPanel = $template.clone();
// $newPanel.html(function(i, oldHtml){
// return oldHtml.replace(/d.id/g, d.id).replace(/d.username/g, d.username);
// });
// return $newPanel
// }
// function actionsUserDetail(){
// $('.btn-passwd').on('click', function () {
// var closest=$(this).closest("div").parent();
// var pk=closest.attr("data-pk");
// $("#modalPasswdUserForm")[0].reset();
// $('#modalPasswdUser').modal({
// backdrop: 'static',
// keyboard: false
// }).modal('show');
// $('#modalPasswdUserForm #id').val(pk);
// });
// $('.btn-edit').on('click', function () {
// var closest=$(this).closest("div").parent();
// var pk=closest.attr("data-pk");
// $("#modalEditUserForm")[0].reset();
// $('#modalEditUser').modal({
// backdrop: 'static',
// keyboard: false
// }).modal('show');
// setUserDefault('#modalEditUser', pk);
// $('#modalEdit').parsley();
// });
// $('.btn-delete').on('click', function () {
// var closest=$(this).closest("div").parent();
// var pk=closest.attr("data-pk");
// var username=closest.attr("data-username");
// console.log(username)
// new PNotify({
// title: 'Confirmation Needed',
// text: "Are you sure you want to delete the user: "+ username+"?",
// hide: false,
// opacity: 0.9,
// confirm: {
// confirm: true
// },
// buttons: {
// closer: false,
// sticker: false
// },
// history: {
// history: false
// },
// addclass: 'pnotify-center'
// }).get().on('pnotify.confirm', function() {
// console.log('Deleting user...')
// }).on('pnotify.cancel', function() {
// });
// });
// }
function setUserDefault(div_id, user_id) {
$.ajax({
type: "GET",
url:"/api/user/" + user_id,
success: function(data)
{
console.log(data)
if (data.enabled) {
$(div_id + ' #enabled').iCheck('check')
}
$(div_id + ' #id').val(data.id);
$(div_id + ' #username').val(data.username);
$(div_id + ' #email').val(data.email);
$(div_id + ' #firstname').val(data.first);
$(div_id + ' #lastname').val(data.last);
if(data.quota == false){
$(div_id + ' #quota').val('false')
}else{
$(div_id + ' #quota').val(data.quota);
}
$(div_id + ' .groups-select').val(data.keycloak_groups);
// $(div_id + ' .role-moodle-select').val(data.keycloak_roles);
// $(div_id + ' .role-nextcloud-select').val(data.roles);
$(div_id + ' .role-keycloak-select').val(data.roles[0]);
$('.groups-select').trigger('change');
// $('.groups-select, .role-moodle-select, .role-nextcloud-select, .role-keycloak-select').trigger('change');
}
});
// MOCK
// $(div_id + ' #id').val('b57c8d3f-ee08-4a1d-9873-f40c082b9c69');
// $(div_id + ' #user-avatar').attr('src', 'static/img/usera.jpg');
// $(div_id + ' #username').val('yedcaqwvt');
// $(div_id + ' #email').val('yedcaqwvt@institutmariaespinalt.cat');
// $(div_id + ' #firstname').val('Ymisno');
// $(div_id + ' #lastname').val('Edcaqwvt tavnuoes');
// $(div_id + ' .groups-select').val(['student', 'manager']);
// $(div_id + ' .role-moodle-select').val('51cc1a95-94b7-48eb-aebb-1eba6745e09f');
// $(div_id + ' .role-nextcloud-select').val('1e21ec95-b8c7-43b8-baad-1a31ad33f388');
// $(div_id + ' .role-keycloak-select').val('13da53d5-c50b-42d9-8fbf-84f2ed7cbf9e');
// $('.groups-select, .role-moodle-select, .role-nextcloud-select, .role-keycloak-select').trigger('change');
}
});
function update_groups(){
$(".groups-select").empty()
$.ajax({
type: "GET",
"url": "/api/groups",
success: function(data)
{
data.forEach(element => {
var groupOrigins = [];
['keycloak'].forEach(o => {
if (element[o]) {
groupOrigins.push(o)
}
})
$(".groups-select").append(
'<option value="' + element.name + '">' + element.name + '</option>'
)
});
$('.groups-select').select2();
},
error: function(data)
{
alert('Something went wrong on our side...')
}
});
}

View File

@ -0,0 +1,98 @@
<div class="form-group row">
<div id="alerts"></div>
<div
class="btn-toolbar editor"
data-role="editor-toolbar_{{ type }}"
data-target="#editor-{{ type }}"
>
<div class="col-md-1 col-sm-1 ">
<select id="{{type}}-lang" class="form-control">
{% for lang in [{'text': 'Español', 'code': 'es'}, {'text': 'English', 'code': 'en'}, {'text': 'Català', 'code': 'ca'}, {'text': 'Français', 'code': 'fr'}] %}
<option value="{{ lang['code'] }}">{{ lang['text'] }}</option>
{% endfor %}
</select>
</div>
<div class="btn-group">
<a class="btn dropdown-toggle" data-toggle="dropdown" title="Font Size"
><i class="fa fa-text-height"></i>&nbsp;<b class="caret"></b
></a>
<ul class="dropdown-menu">
<li>
<a data-edit="fontSize 5">
<p style="font-size: 17px">Huge</p>
</a>
</li>
<li>
<a data-edit="fontSize 3">
<p style="font-size: 14px">Normal</p>
</a>
</li>
<li>
<a data-edit="fontSize 1">
<p style="font-size: 11px">Small</p>
</a>
</li>
</ul>
</div>
<div class="btn-group">
<a class="btn" data-edit="bold" title="Bold (Ctrl/Cmd+B)"
><i class="fa fa-bold"></i
></a>
<a class="btn" data-edit="italic" title="Italic (Ctrl/Cmd+I)"
><i class="fa fa-italic"></i
></a>
<a class="btn" data-edit="strikethrough" title="Strikethrough"
><i class="fa fa-strikethrough"></i
></a>
<a class="btn" data-edit="underline" title="Underline (Ctrl/Cmd+U)"
><i class="fa fa-underline"></i
></a>
</div>
<div class="btn-group">
<a class="btn" data-edit="insertunorderedlist" title="Bullet list"
><i class="fa fa-list-ul"></i
></a>
<a class="btn" data-edit="insertorderedlist" title="Number list"
><i class="fa fa-list-ol"></i
></a>
<a class="btn" data-edit="outdent" title="Reduce indent (Shift+Tab)"
><i class="fa fa-dedent"></i
></a>
<a class="btn" data-edit="indent" title="Indent (Tab)"
><i class="fa fa-indent"></i
></a>
</div>
<div class="btn-group">
<a class="btn" data-edit="justifyleft" title="Align Left (Ctrl/Cmd+L)"
><i class="fa fa-align-left"></i
></a>
<a class="btn" data-edit="justifycenter" title="Center (Ctrl/Cmd+E)"
><i class="fa fa-align-center"></i
></a>
<a class="btn" data-edit="justifyright" title="Align Right (Ctrl/Cmd+R)"
><i class="fa fa-align-right"></i
></a>
<a class="btn" data-edit="justifyfull" title="Justify (Ctrl/Cmd+J)"
><i class="fa fa-align-justify"></i
></a>
</div>
<div class="btn-group">
<a class="btn" data-edit="undo" title="Undo (Ctrl/Cmd+Z)"
><i class="fa fa-undo"></i
></a>
<a class="btn" data-edit="redo" title="Redo (Ctrl/Cmd+Y)"
><i class="fa fa-repeat"></i
></a>
</div>
</div>
<div id="editor-{{ type }}" type="{{ type }}" class="editor-wrapper"></div>
<textarea name="descr" id="descr" style="display: none"></textarea>
</div>

View File

@ -0,0 +1,138 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<!-- Meta, title, CSS, favicons, etc. -->
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="no-cache">
<meta http-equiv="Expires" content="-1">
<meta http-equiv="Cache-Control" content="no-cache">
<title>{{ title }} | Digital Democratic</title>
<link rel="icon" type="image/png" href="favicon-32x32.png" sizes="32x32" />
<link rel="icon" type="image/png" href="favicon-16x16.png" sizes="16x16" />
<!-- Fancytree -->
<link href="/static/vendor/fancytree/dist/skin-win8/ui.fancytree.css" rel="stylesheet">
<!-- Bootstrap -->
<link href="/vendors/bootstrap/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Font Awesome -->
<link href="/vendors/font-awesome/css/font-awesome.min.css" rel="stylesheet">
<!-- ion.rangeSlider -->
<link href="/vendors/ion.rangeSlider/css/ion.rangeSlider.css" rel="stylesheet">
<link href="/vendors/ion.rangeSlider/css/ion.rangeSlider.skinFlat.css" rel="stylesheet">
<!-- Datatables -->
<link href="/vendors/datatables.net-bs/css/dataTables.bootstrap.min.css" rel="stylesheet">
<!-- PNotify -->
<link href="/vendors/pnotify/dist/pnotify.css" media="all" rel="stylesheet" type="text/css" />
<link href="/vendors/pnotify/dist/pnotify.buttons.css" media="all" rel="stylesheet" type="text/css" />
<!-- iCheck -->
<link href="/vendors/iCheck/skins/flat/green.css" rel="stylesheet">
<link href="/vendors/select2/dist/css/select2.min.css" rel="stylesheet">
{% block css %}{% endblock %}
<!-- Custom Theme Style -->
<link href="/build/css/custom.css" rel="stylesheet">
<!-- Isard Style Sheet-->
<link href="/static/dd.css" rel="stylesheet">
</head>
<body class="nav-md">
<div class="container body" style="margin-top: 50px;">
<div class="main_container">
<div class="col-md-3 left_col">
<div class="left_col scroll-view">
{% include 'sidebar.html' %}
</div>
</div>
<!-- top navigation -->
{% include 'header.html' %}
<!-- /top navigation -->
<div class="right_col" role="main">
<!-- page content -->
{% block content %}
{% endblock %}
<!-- /page content -->
</div>
<!-- footer content -->
{% include 'footer.html' %}
<!-- /footer content -->
</div>
</div>
{% include 'pages/modals/common_modals.html' %}
<!-- jQuery -->
<script src="/vendors/jquery/dist/jquery.js"></script>
<!-- Bootstrap -->
<script src="/vendors/bootstrap/dist/js/bootstrap.min.js"></script>
<!-- NProgress -->
<script src="/vendors/nprogress/nprogress.js"></script>
<!-- Datatables -->
<script src="/vendors/datatables.net/js/jquery.dataTables.min.js"></script>
<script src="/vendors/datatables.net-bs/js/dataTables.bootstrap.min.js"></script>
<!-- Ion.RangeSlider -->
<script src="/vendors/ion.rangeSlider/js/ion.rangeSlider.min.js"></script>
<!-- PNotify -->
<script type="text/javascript" src="/vendors/pnotify/dist/pnotify.js"></script>
<script type="text/javascript" src="/vendors/pnotify/dist/pnotify.confirm.js"></script>
<script type="text/javascript" src="/vendors/pnotify/dist/pnotify.buttons.js"></script>
<!-- validator -->
<script src="/vendors/validator/validator.js"></script>
<!-- Parsley -->
<script src="/vendors/parsleyjs/dist/parsley.min.js"></script>
<!-- moment -->
<script src="/vendors/moment/min/moment.min.js"></script>
<!-- validator -->
<script src="/vendors/iCheck/icheck.min.js"></script>
<!-- bootstrap-progressbar -->
<script src="/vendors/bootstrap-progressbar/bootstrap-progressbar.min.js"></script>
<!-- ECharts -->
<script src="/vendors/echarts/dist/echarts.min.js"></script>
<!-- Select2 -->
<script src="/vendors/select2/dist/js/select2.full.min.js"></script>
<!-- SocketIO -->
<script src="/node_modules/socket.io/client-dist/socket.io.min.js"></script>
<script src="/static/js/status_socket.js"></script>
<!-- isard initializers -->
<script src="/static/dd.js"></script>
<!-- Requirements for fancy tree -->
<script src="/static/vendor/fancytree/src/jquery-ui-dependencies/jquery-ui.min.js"></script>
<script src="/static/vendor/fancytree/dist/jquery.fancytree.min.js"></script>
<script src="/static/vendor/fancytree/src/jquery.fancytree.table.js"></script>
<!-- Header render -->
<script type="text/javascript">
$(document).ready(function() {
$.ajax({
type: "GET",
url: "https://api."+document.domain.split(/\.(.+)/)[1]+"/header/html/admin",
success: function(data) {
$('#header').html(data)
}
})
})
</script>
<!-- flashed messages with pnotify -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<script type="text/javascript">
new PNotify({
title: "{{ nav }}",
text: "{{ message }}",
hide: true,
delay: 2000,
/~ icon: 'fa fa-alert-sign',
opacity: 1,
type: "{{ category }}",
addclass: "pnotify-center"
});
</script>
{% endfor %}
{% endif %}
{% endwith %}
{% block pagescript %}{% endblock %}
</body>
</html>

View File

@ -0,0 +1,6 @@
<footer>
<div class="pull-right">
Digital Democratic - Administration | <a href="https:/gitlab.com/digitaldemocratic/digitaldemocratic">gitlab</a>
</div>
<div class="clearfix"></div>
</footer>

View File

@ -0,0 +1,2 @@
<div id="header">
</div>

View File

@ -0,0 +1,88 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<!-- Meta, title, CSS, favicons, etc. -->
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Login | Digital Democratic</title>
<!-- Bootstrap -->
<link href="/vendors/bootstrap/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Font Awesome -->
<link href="/vendors/font-awesome/css/font-awesome.min.css" rel="stylesheet">
<!-- Animate.css -->
<link href="/vendors/animate.css/animate.min.css" rel="stylesheet">
<!-- PNotify -->
<link href="/vendors/pnotify/dist/pnotify.css" media="all" rel="stylesheet" type="text/css" />
<link href="/vendors/pnotify/dist/pnotify.buttons.css" media="all" rel="stylesheet" type="text/css" />
<!-- Custom Theme Style -->
<link href="/build/css/custom.min.css" rel="stylesheet">
</head>
<body class="login">
<div>
<a class="hiddenanchor" id="signup"></a>
<a class="hiddenanchor" id="signin"></a>
<div class="login_wrapper">
<div class="animate form login_form">
<section class="login_content">
<form id="login-form" action="{{ url_for('login') }}" method="POST" novalidate>
<img src="https://nextcloud.digitaldemocratic.net/themes/digitaldemocratic/core/img/dd.svg" height="75px">
<h1></h1>
<div>
<input type="text" name="user" class="form-control" placeholder="Username" required="" autofocus />
</div>
<div>
<input type="password" name="password" class="form-control" placeholder="Password" required="" />
</div>
<div>
<button type="submit" class="btn btn-default submit">Login</button>
</div>
<div class="clearfix"></div>
<div class="separator">
<div class="clearfix"></div>
<br />
<div>
<!-- <h1><i class="fa fa-user"></i> Digital Democratic</h1> -->
<p>©2022 All Rights Reserved. <a href="https://gitlab.com/digitaldemocratic/digitaldemocratic/-/blob/master/LICENSE" target="_blank">AGPLv3</a></p>
</div>
</div>
</form>
</section>
</div>
</div>
</div>
</body>
<!-- jQuery -->
<script src="/vendors/jquery/dist/jquery.min.js"></script>
<!-- PNotify -->
<script type="text/javascript" src="/vendors/pnotify/dist/pnotify.js"></script>
<script type="text/javascript" src="/vendors/pnotify/dist/pnotify.confirm.js"></script>
<script type="text/javascript" src="/vendors/pnotify/dist/pnotify.buttons.js"></script>
<script>PNotify.prototype.options.styling = "bootstrap3";</script>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<script type="text/javascript">
new PNotify({
title: "{{ nav }}",
text: "{{ message }}",
hide: true,
// icon: 'fa fa-alert-sign',
opacity: 1,
type: "error",
addclass: "pnotify-center"
});
</script>
{% endfor %}
{% endif %}
{% endwith %}
<!-- Isard restful ajax calls -->
<script src="/static/js/restful.js"></script>
</html>

View File

@ -0,0 +1,69 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<!-- Meta, title, CSS, favicons, etc. -->
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Page not found! | Digital Democratic</title>
<!-- Bootstrap -->
<link href="../vendors/bootstrap/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Font Awesome -->
<link href="../vendors/font-awesome/css/font-awesome.min.css" rel="stylesheet">
<!-- NProgress -->
<link href="../vendors/nprogress/nprogress.css" rel="stylesheet">
<!-- Custom Theme Style -->
<link href="../build/css/custom.min.css" rel="stylesheet">
</head>
<body class="nav-md">
<div class="container body">
<div class="main_container">
<!-- page content -->
<div class="col-md-12">
<div class="col-middle">
<div class="text-center text-center">
<h1 class="error-number">404</h1>
<h2>Sorry but we couldn't find this page</h2>
<p>This page you are looking for does not exist <a href="https:/gitlab.com/digitaldemocratic/digitaldemocratic">Report this?</a>
<a href="/login">Go back to login page</a>
</p>
<!--
<div class="mid_center">
<h3>Search</h3>
<form>
<div class="col-xs-12 form-group pull-right top_search">
<div class="input-group">
<input type="text" class="form-control" placeholder="Search for...">
<span class="input-group-btn">
<button class="btn btn-default" type="button">Go!</button>
</span>
</div>
</div>
</form>
</div>
-->
</div>
</div>
</div>
<!-- /page content -->
</div>
</div>
<!-- jQuery -->
<script src="../vendors/jquery/dist/jquery.min.js"></script>
<!-- Bootstrap -->
<script src="../vendors/bootstrap/dist/js/bootstrap.min.js"></script>
<!-- FastClick -->
<script src="../vendors/fastclick/lib/fastclick.js"></script>
<!-- NProgress -->
<script src="../vendors/nprogress/nprogress.js"></script>
<!-- Custom Theme Scripts -->
<script src="../build/js/custom.min.js"></script>
</body>
</html>

View File

@ -0,0 +1,69 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<!-- Meta, title, CSS, favicons, etc. -->
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Page not allowed! | Digital Democratic</title>
<!-- Bootstrap -->
<link href="../vendors/bootstrap/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Font Awesome -->
<link href="../vendors/font-awesome/css/font-awesome.min.css" rel="stylesheet">
<!-- NProgress -->
<link href="../vendors/nprogress/nprogress.css" rel="stylesheet">
<!-- Custom Theme Style -->
<link href="../build/css/custom.min.css" rel="stylesheet">
</head>
<body class="nav-md">
<div class="container body">
<div class="main_container">
<!-- page content -->
<div class="col-md-12">
<div class="col-middle">
<div class="text-center">
<h1 class="error-number">500</h1>
<h2>Internal Server Error</h2>
<p>We track these errors automatically, but if the problem persists feel free to contact us. In the meantime, try refreshing. <a href="https:/gitlab.com/digitaldemocratic/digitaldemocratic">Report this?</a>
<a href="/login">Go back to login page</a>
</p>
<!--
<div class="mid_center">
<h3>Search</h3>
<form>
<div class="col-xs-12 form-group pull-right top_search">
<div class="input-group">
<input type="text" class="form-control" placeholder="Search for...">
<span class="input-group-btn">
<button class="btn btn-default" type="button">Go!</button>
</span>
</div>
</div>
</form>
</div>
-->
</div>
</div>
</div>
<!-- /page content -->
</div>
</div>
<!-- jQuery -->
<script src="../vendors/jquery/dist/jquery.min.js"></script>
<!-- Bootstrap -->
<script src="../vendors/bootstrap/dist/js/bootstrap.min.js"></script>
<!-- FastClick -->
<script src="../vendors/fastclick/lib/fastclick.js"></script>
<!-- NProgress -->
<script src="../vendors/nprogress/nprogress.js"></script>
<!-- Custom Theme Scripts -->
<script src="../build/js/custom.min.js"></script>
</body>
</html>

View File

@ -0,0 +1,57 @@
<!-- extend base layout -->
{% extends "base.html" %}
{% block content %}
<div class="row">
<div class="col-md-12 col-sm-12 col-xs-12">
<div class="x_panel">
<div class="container for-about text-center" style="margin-top: 15px;">
<img src="/static/img/dd.svg" width="250px" height="250px">
<h1>Digital Democratic</h1>
<h1><small>Schools apps integrations</small></h1>
<div class="row" style="margin-top: 40px;">
<div class="col-lg-2 col-md-12 col-sm-12 col-xs-12"></div>
<div class="col-lg-2 col-md-6 col-sm-6 col-xs-12">
<a href="https:/gitlab.com/digitaldemocratic/digitaldemocratic" target="_blank" style="color: deepskyblue">
<i class="fa fa-globe" style="font-size: 125px;" aria-hidden="true"></i>
<h1><small>Visit website</small></h1>
</a>
</div>
<div class="col-lg-2 col-md-6 col-sm-6 col-xs-12">
<a href="https:/gitlab.com/digitaldemocratic/digitaldemocratic/-/issues" target="_blank" style="color: orange">
<i class="fa fa-gitlab fa-5x" style="font-size: 125px;" aria-hidden="true"></i>
<h1><small>Open an issue</small></h1>
</a>
</div>
<div class="col-lg-2 col-md-12 col-sm-12 col-xs-12"></div>
</div>
<div class="row" style="margin-top: 25px;">
<div class="col-md-4 col-sm-4 col-xs-4"></div>
<div class="col-md-2 col-sm-2 col-xs-12">
<i class="fa fa-envelope-o fa-5x" style="font-size: 125px;" aria-hidden="true"></i>
<h1>
<small>
Contact us at:
<br/>
info@digitaldemocratic.net
</small>
</h1>
</div>
<div class="col-md-2 col-sm-6 col-xs-12">
<p><img src="/static/img/agplv3-155x51.png" style="margin-top: 60px;"></p>
<h1 style="margin-top: 25px;"><small>License</small></h1>
</div>
<div class="col-md-4 col-sm-4 col-xs-4"></div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block pagescript %}
<script src="/static/js/restful.js"></script>
<script src="/static/js/status_socket.js"></script>
{% endblock %}

View File

@ -0,0 +1,436 @@
<!-- extend base layout -->
{% extends "base.html" %}
{% block css %}
<!-- Ion.RangeSlider -->
<link href="/vendors/normalize-css/normalize.css" rel="stylesheet">
<!-- Switchery -->
<link href="/vendors/switchery/dist/switchery.min.css" rel="stylesheet">
<!-- Bootstrap Colorpicker -->
<link href="/vendors/mjolnic-bootstrap-colorpicker/dist/css/bootstrap-colorpicker.min.css" rel="stylesheet">
<link href="/vendors/cropper/dist/cropper.min.css" rel="stylesheet">
{% endblock %}
{% block content %}
{% set icons = [ 'fa-500px', 'fa-address-book', 'fa-address-book-o', 'fa-address-card', 'fa-address-card-o', 'fa-adjust', 'fa-adn',
'fa-align-center', 'fa-align-justify', 'fa-align-left', 'fa-align-right', 'fa-amazon', 'fa-ambulance', 'fa-american-sign-language-interpreting',
'fa-anchor', 'fa-android', 'fa-angellist', 'fa-angle-double-down', 'fa-angle-double-left', 'fa-angle-double-right', 'fa-angle-double-up',
'fa-angle-down', 'fa-angle-left', 'fa-angle-right', 'fa-angle-up', 'fa-apple', 'fa-archive', 'fa-area-chart', 'fa-arrow-circle-down',
'fa-arrow-circle-left', 'fa-arrow-circle-o-down', 'fa-arrow-circle-o-left', 'fa-arrow-circle-o-right', 'fa-arrow-circle-o-up', 'fa-arrow-circle-right',
'fa-arrow-circle-up', 'fa-arrow-down', 'fa-arrow-left', 'fa-arrow-right', 'fa-arrow-up', 'fa-arrows', 'fa-arrows-alt', 'fa-arrows-h',
'fa-arrows-v', 'fa-asl-interpreting', 'fa-assistive-listening-systems', 'fa-asterisk', 'fa-at', 'fa-audio-description', 'fa-automobile',
'fa-backward', 'fa-balance-scale', 'fa-ban', 'fa-bandcamp', 'fa-bank', 'fa-bar-chart', 'fa-bar-chart-o', 'fa-barcode', 'fa-bars', 'fa-bath',
'fa-bathtub', 'fa-battery', 'fa-battery-0', 'fa-battery-1', 'fa-battery-2', 'fa-battery-3', 'fa-battery-4', 'fa-battery-empty',
'fa-battery-full', 'fa-battery-half', 'fa-battery-quarter', 'fa-battery-three-quarters', 'fa-bed', 'fa-beer', 'fa-behance', 'fa-behance-square', 'fa-bell', 'fa-bell-o', 'fa-bell-slash', 'fa-bell-slash-o', 'fa-bicycle', 'fa-binoculars', 'fa-birthday-cake', 'fa-bitbucket', 'fa-bitbucket-square', 'fa-bitcoin', 'fa-black-tie', 'fa-blind', 'fa-bluetooth', 'fa-bold', 'fa-bolt', 'fa-bomb', 'fa-book', 'fa-bookmark', 'fa-bookmark-o', 'fa-braille', 'fa-briefcase', 'fa-btc', 'fa-bug', 'fa-building', 'fa-building-o', 'fa-bullhorn', 'fa-bullseye', 'fa-bus', 'fa-buysellads', 'fa-cab', 'fa-calculator', 'fa-calendar', 'fa-calendar-check-o', 'fa-calendar-minus-o', 'fa-calendar-o', 'fa-calendar-plus-o', 'fa-calendar-times-o', 'fa-camera', 'fa-camera-retro',
'fa-car', 'fa-caret-down', 'fa-caret-left', 'fa-caret-right', 'fa-caret-square-o-down', 'fa-caret-square-o-left', 'fa-caret-square-o-right',
'fa-caret-square-o-up', 'fa-caret-up', 'fa-cart-arrow-down', 'fa-cart-plus', 'fa-cc', 'fa-cc-amex', 'fa-cc-diners-club', 'fa-cc-discover',
'fa-cc-jcb', 'fa-cc-mastercard', 'fa-cc-paypal', 'fa-cc-stripe', 'fa-cc-visa', 'fa-certificate', 'fa-chain', 'fa-chain-broken', 'fa-check',
'fa-check-circle', 'fa-check-circle-o', 'fa-check-square', 'fa-check-square-o', 'fa-chevron-circle-down', 'fa-chevron-circle-left',
'fa-chevron-circle-right', 'fa-chevron-circle-up', 'fa-chevron-down', 'fa-chevron-left', 'fa-chevron-right', 'fa-chevron-up', 'fa-child',
'fa-chrome', 'fa-circle', 'fa-circle-o', 'fa-circle-o-notch', 'fa-circle-thin', 'fa-clipboard', 'fa-clock-o', 'fa-clone', 'fa-close',
'fa-cloud', 'fa-cloud-download', 'fa-cloud-upload', 'fa-cny', 'fa-code', 'fa-code-fork', 'fa-codepen', 'fa-codiepie', 'fa-coffee', 'fa-cog',
'fa-cogs', 'fa-columns', 'fa-comment', 'fa-comment-o', 'fa-commenting', 'fa-commenting-o', 'fa-comments', 'fa-comments-o', 'fa-compass',
'fa-compress', 'fa-connectdevelop', 'fa-contao', 'fa-copy', 'fa-copyright', 'fa-creative-commons', 'fa-credit-card', 'fa-credit-card-alt',
'fa-crop', 'fa-crosshairs', 'fa-css3', 'fa-cube', 'fa-cubes', 'fa-cut', 'fa-cutlery', 'fa-dashboard', 'fa-dashcube', 'fa-database', 'fa-deaf', 'fa-deafness', 'fa-dedent', 'fa-delicious', 'fa-desktop', 'fa-deviantart', 'fa-diamond', 'fa-digg', 'fa-dollar', 'fa-dot-circle-o', 'fa-download', 'fa-dribbble', 'fa-drivers-license', 'fa-drivers-license-o', 'fa-dropbox', 'fa-drupal', 'fa-edge', 'fa-edit', 'fa-eercast', 'fa-eject', 'fa-ellipsis-h', 'fa-ellipsis-v', 'fa-empire', 'fa-envelope', 'fa-envelope-o', 'fa-envelope-open',
'fa-envelope-open-o', 'fa-envelope-square', 'fa-envira', 'fa-eraser', 'fa-etsy', 'fa-eur', 'fa-euro', 'fa-exchange', 'fa-exclamation',
'fa-exclamation-circle', 'fa-exclamation-triangle', 'fa-expand', 'fa-expeditedssl', 'fa-external-link', 'fa-external-link-square', 'fa-eye',
'fa-eye-slash', 'fa-eyedropper', 'fa-fa', 'fa-facebook', 'fa-facebook-f', 'fa-facebook-official', 'fa-facebook-square', 'fa-fast-backward',
'fa-fast-forward', 'fa-fax', 'fa-feed', 'fa-female', 'fa-fighter-jet', 'fa-file', 'fa-file-archive-o', 'fa-file-audio-o', 'fa-file-code-o',
'fa-file-excel-o', 'fa-file-image-o', 'fa-file-movie-o', 'fa-file-o', 'fa-file-pdf-o', 'fa-file-photo-o', 'fa-file-picture-o',
'fa-file-powerpoint-o', 'fa-file-sound-o', 'fa-file-text', 'fa-file-text-o', 'fa-file-video-o', 'fa-file-word-o', 'fa-file-zip-o',
'fa-files-o', 'fa-film', 'fa-filter', 'fa-fire', 'fa-fire-extinguisher', 'fa-firefox', 'fa-first-order', 'fa-flag', 'fa-flag-checkered',
'fa-flag-o', 'fa-flash', 'fa-flask', 'fa-flickr', 'fa-floppy-o', 'fa-folder', 'fa-folder-o', 'fa-folder-open', 'fa-folder-open-o', 'fa-font',
'fa-font-awesome', 'fa-fonticons', 'fa-fort-awesome', 'fa-forumbee', 'fa-forward', 'fa-foursquare', 'fa-free-code-camp', 'fa-frown-o',
'fa-futbol-o', 'fa-gamepad', 'fa-gavel', 'fa-gbp', 'fa-ge', 'fa-gear', 'fa-gears', 'fa-genderless', 'fa-get-pocket', 'fa-gg', 'fa-gg-circle',
'fa-gift', 'fa-git', 'fa-git-square', 'fa-github', 'fa-github-alt', 'fa-github-square', 'fa-gitlab', 'fa-gittip', 'fa-glass', 'fa-glide',
'fa-glide-g', 'fa-globe', 'fa-google', 'fa-google-plus', 'fa-google-plus-circle', 'fa-google-plus-official', 'fa-google-plus-square',
'fa-google-wallet', 'fa-graduation-cap', 'fa-gratipay', 'fa-grav', 'fa-group', 'fa-h-square', 'fa-hacker-news', 'fa-hand-grab-o',
'fa-hand-lizard-o', 'fa-hand-o-down', 'fa-hand-o-left', 'fa-hand-o-right', 'fa-hand-o-up', 'fa-hand-paper-o', 'fa-hand-peace-o',
'fa-hand-pointer-o', 'fa-hand-rock-o', 'fa-hand-scissors-o', 'fa-hand-spock-o', 'fa-hand-stop-o', 'fa-handshake-o', 'fa-hard-of-hearing',
'fa-hashtag', 'fa-hdd-o', 'fa-header', 'fa-headphones', 'fa-heart', 'fa-heart-o', 'fa-heartbeat', 'fa-history', 'fa-home', 'fa-hospital-o',
'fa-hotel', 'fa-hourglass', 'fa-hourglass-1', 'fa-hourglass-2', 'fa-hourglass-3', 'fa-hourglass-end', 'fa-hourglass-half', 'fa-hourglass-o',
'fa-hourglass-start', 'fa-houzz', 'fa-html5', 'fa-i-cursor', 'fa-id-badge', 'fa-id-card', 'fa-id-card-o', 'fa-ils', 'fa-image', 'fa-imdb',
'fa-inbox', 'fa-indent', 'fa-industry', 'fa-info', 'fa-info-circle', 'fa-inr', 'fa-instagram', 'fa-institution', 'fa-internet-explorer',
'fa-intersex', 'fa-ioxhost', 'fa-italic', 'fa-joomla', 'fa-jpy', 'fa-jsfiddle', 'fa-key', 'fa-keyboard-o', 'fa-krw', 'fa-language',
'fa-laptop', 'fa-lastfm', 'fa-lastfm-square', 'fa-leaf', 'fa-leanpub', 'fa-legal', 'fa-lemon-o', 'fa-level-down', 'fa-level-up',
'fa-life-bouy', 'fa-life-buoy', 'fa-life-ring', 'fa-life-saver', 'fa-lightbulb-o', 'fa-line-chart', 'fa-link', 'fa-linkedin',
'fa-linkedin-square', 'fa-linode', 'fa-linux', 'fa-list', 'fa-list-alt', 'fa-list-ol', 'fa-list-ul', 'fa-location-arrow', 'fa-lock',
'fa-long-arrow-down', 'fa-long-arrow-left', 'fa-long-arrow-right', 'fa-long-arrow-up', 'fa-low-vision', 'fa-magic', 'fa-magnet',
'fa-mail-forward', 'fa-mail-reply', 'fa-mail-reply-all', 'fa-male', 'fa-map', 'fa-map-marker', 'fa-map-o', 'fa-map-pin', 'fa-map-signs',
'fa-mars', 'fa-mars-double', 'fa-mars-stroke', 'fa-mars-stroke-h', 'fa-mars-stroke-v', 'fa-maxcdn', 'fa-meanpath', 'fa-medium', 'fa-medkit',
'fa-meetup', 'fa-meh-o', 'fa-mercury', 'fa-microchip', 'fa-microphone', 'fa-microphone-slash', 'fa-minus', 'fa-minus-circle', 'fa-minus-square',
'fa-minus-square-o', 'fa-mixcloud', 'fa-mobile', 'fa-mobile-phone', 'fa-modx', 'fa-money', 'fa-moon-o', 'fa-mortar-board', 'fa-motorcycle', 'fa-mouse-pointer', 'fa-music', 'fa-navicon', 'fa-neuter', 'fa-newspaper-o', 'fa-object-group', 'fa-object-ungroup',
'fa-odnoklassniki', 'fa-odnoklassniki-square', 'fa-opencart', 'fa-openid', 'fa-opera', 'fa-optin-monster', 'fa-outdent', 'fa-pagelines',
'fa-paint-brush', 'fa-paper-plane', 'fa-paper-plane-o', 'fa-paperclip', 'fa-paragraph', 'fa-paste', 'fa-pause', 'fa-pause-circle',
'fa-pause-circle-o', 'fa-paw', 'fa-paypal', 'fa-pencil', 'fa-pencil-square', 'fa-pencil-square-o', 'fa-percent', 'fa-phone', 'fa-phone-square',
'fa-photo', 'fa-picture-o', 'fa-pie-chart', 'fa-pied-piper', 'fa-pied-piper-alt', 'fa-pied-piper-pp', 'fa-pinterest', 'fa-pinterest-p',
'fa-pinterest-square', 'fa-plane', 'fa-play', 'fa-play-circle', 'fa-play-circle-o', 'fa-plug', 'fa-plus', 'fa-plus-circle', 'fa-plus-square',
'fa-plus-square-o', 'fa-podcast', 'fa-power-off', 'fa-print', 'fa-product-hunt', 'fa-puzzle-piece', 'fa-qq', 'fa-qrcode', 'fa-question',
'fa-question-circle', 'fa-question-circle-o', 'fa-quora', 'fa-quote-left', 'fa-quote-right', 'fa-ra', 'fa-random', 'fa-ravelry', 'fa-rebel',
'fa-recycle', 'fa-reddit', 'fa-reddit-alien', 'fa-reddit-square', 'fa-refresh', 'fa-registered', 'fa-remove', 'fa-renren', 'fa-reorder',
'fa-repeat', 'fa-reply', 'fa-reply-all', 'fa-resistance', 'fa-retweet', 'fa-rmb', 'fa-road', 'fa-rocket', 'fa-rotate-left', 'fa-rotate-right',
'fa-rouble', 'fa-rss', 'fa-rss-square', 'fa-rub', 'fa-ruble', 'fa-rupee', 'fa-s15', 'fa-safari', 'fa-save', 'fa-scissors', 'fa-scribd',
'fa-search', 'fa-search-minus', 'fa-search-plus', 'fa-sellsy', 'fa-send', 'fa-send-o', 'fa-server', 'fa-share', 'fa-share-alt',
'fa-share-alt-square', 'fa-share-square', 'fa-share-square-o', 'fa-shekel', 'fa-sheqel', 'fa-shield', 'fa-ship', 'fa-shirtsinbulk',
'fa-shopping-bag', 'fa-shopping-basket', 'fa-shopping-cart', 'fa-shower', 'fa-sign-in', 'fa-sign-language', 'fa-sign-out', 'fa-signal',
'fa-signing', 'fa-simplybuilt', 'fa-sitemap', 'fa-skyatlas', 'fa-skype', 'fa-slack', 'fa-sliders', 'fa-slideshare', 'fa-smile-o', 'fa-snapchat',
'fa-snapchat-ghost', 'fa-snapchat-square', 'fa-snowflake-o', 'fa-soccer-ball-o', 'fa-sort', 'fa-sort-alpha-asc', 'fa-sort-alpha-desc',
'fa-sort-amount-asc', 'fa-sort-amount-desc', 'fa-sort-asc', 'fa-sort-desc', 'fa-sort-down', 'fa-sort-numeric-asc', 'fa-sort-numeric-desc',
'fa-sort-up', 'fa-soundcloud', 'fa-space-shuttle', 'fa-spinner', 'fa-spoon', 'fa-spotify', 'fa-square', 'fa-square-o', 'fa-stack-exchange',
'fa-stack-overflow', 'fa-star', 'fa-star-half', 'fa-star-half-empty', 'fa-star-half-full', 'fa-star-half-o', 'fa-star-o', 'fa-steam',
'fa-steam-square', 'fa-step-backward', 'fa-step-forward', 'fa-stethoscope', 'fa-sticky-note', 'fa-sticky-note-o', 'fa-stop', 'fa-stop-circle',
'fa-stop-circle-o', 'fa-street-view', 'fa-strikethrough', 'fa-stumbleupon', 'fa-stumbleupon-circle', 'fa-subscript', 'fa-subway', 'fa-suitcase',
'fa-sun-o', 'fa-superpowers', 'fa-superscript', 'fa-support', 'fa-table', 'fa-tablet', 'fa-tachometer', 'fa-tag', 'fa-tags', 'fa-tasks',
'fa-taxi', 'fa-telegram', 'fa-television', 'fa-tencent-weibo', 'fa-terminal', 'fa-text-height', 'fa-text-width', 'fa-th', 'fa-th-large',
'fa-th-list', 'fa-themeisle', 'fa-thermometer', 'fa-thermometer-0', 'fa-thermometer-1', 'fa-thermometer-2', 'fa-thermometer-3', 'fa-thermometer-4',
'fa-thermometer-empty', 'fa-thermometer-full', 'fa-thermometer-half', 'fa-thermometer-quarter', 'fa-thermometer-three-quarters', 'fa-thumb-tack',
'fa-thumbs-down', 'fa-thumbs-o-down', 'fa-thumbs-o-up', 'fa-thumbs-up', 'fa-ticket', 'fa-times', 'fa-times-circle', 'fa-times-circle-o',
'fa-times-rectangle', 'fa-times-rectangle-o', 'fa-tint', 'fa-toggle-down', 'fa-toggle-left', 'fa-toggle-off', 'fa-toggle-on', 'fa-toggle-right',
'fa-toggle-up', 'fa-trademark', 'fa-train', 'fa-transgender', 'fa-transgender-alt', 'fa-trash', 'fa-trash-o', 'fa-tree', 'fa-trello', 'fa-tripadvisor',
'fa-trophy', 'fa-truck', 'fa-try', 'fa-tty', 'fa-tumblr', 'fa-tumblr-square', 'fa-turkish-lira', 'fa-tv', 'fa-twitch', 'fa-twitter', 'fa-twitter-square',
'fa-umbrella', 'fa-underline', 'fa-undo', 'fa-universal-access', 'fa-university', 'fa-unlink', 'fa-unlock', 'fa-unlock-alt', 'fa-unsorted',
'fa-upload', 'fa-usb', 'fa-usd', 'fa-user', 'fa-user-circle', 'fa-user-circle-o', 'fa-user-md', 'fa-user-o', 'fa-user-plus', 'fa-user-secret',
'fa-user-times', 'fa-users', 'fa-vcard', 'fa-vcard-o', 'fa-venus', 'fa-venus-double', 'fa-venus-mars', 'fa-viacoin', 'fa-viadeo',
'fa-viadeo-square', 'fa-video-camera', 'fa-vimeo', 'fa-vimeo-square', 'fa-vine', 'fa-vk', 'fa-volume-control-phone', 'fa-volume-down',
'fa-volume-off', 'fa-volume-up', 'fa-warning', 'fa-wechat', 'fa-weibo', 'fa-weixin', 'fa-whatsapp', 'fa-wheelchair', 'fa-wheelchair-alt',
'fa-wifi', 'fa-wikipedia-w', 'fa-window-close', 'fa-window-close-o', 'fa-window-maximize', 'fa-window-minimize', 'fa-window-restore',
'fa-windows', 'fa-won', 'fa-wordpress', 'fa-wpbeginner', 'fa-wpexplorer', 'fa-wpforms', 'fa-wrench', 'fa-xing', 'fa-xing-square',
'fa-y-combinator', 'fa-y-combinator-square', 'fa-yahoo', 'fa-yc', 'fa-yc-square', 'fa-yelp', 'fa-yen', 'fa-yoast', 'fa-youtube',
'fa-youtube-play', 'fa-youtube-square' ]
%}
<style>
#select_fa{
font-family:"FontAwesome","Liberation Sans";
font-size:14px;
}
#select_fa::before{
vertical-align:middle;
}
</style>
<div class="row">
<!-- form color picker -->
<div class="col-lg-3 col-md-12 col-sm-12 col-xs-12">
<div class="x_panel">
<div class="x_title">
<h2>System colors</h2>
<div class="clearfix"></div>
</div>
<div class="x_content">
<div class="form-group row text-center">
<div class="col-md-12 col-sm-4 col-xs-6">
<label class="control-label col-xs-12">
Background
</label>
<p>It will appear in Moodle's (Aules/Aulas) background. We recomend using a grey color.</p>
<div id="colorpicker-background" class="demo demo-auto inl-bl colorpicker-background" data-container="#colorpicker-background" data-color="{{data.colours.background}}" data-inline="true"></div>
<input id="colorpicker-background-input" type="text" value="{{data.colours.background}}" class="form-control" />
</div>
<div class="col-md-12 col-sm-4 col-xs-6">
<label class="control-label col-xs-12">Primary</label>
<p>It will appear in main top bar menú and Moodle's (Aules/Aulas) buttons. We recomend using a corporate color.</p>
<div id="colorpicker-primary" class="demo demo-auto inl-bl colorpicker-primary" data-container="#colorpicker-primary" data-color="{{data.colours.primary}}" data-inline="true"></div>
<input id="colorpicker-primary-input" type="text" value="{{data.colours.primary}}" class="form-control" />
</div>
<div class="col-md-12 col-sm-4 col-xs-6">
<label class="control-label col-xs-12">Secondary</label>
<p>It will appear in some secondary buttons in Moodle. We recommend white, unless corporate color is clear.</p>
<div id="colorpicker-secondary" class="demo demo-auto inl-bl colorpicker-secondary" data-container="#colorpicker-secondary" data-color="{{data.colours.secondary}}" data-inline="true"></div>
<input id="colorpicker-secondary-input" type="text" value="{{data.colours.secondary}}" class="form-control" />
</div>
</div>
<div class="form-group row">
<ul class="nav navbar-right panel_toolbox">
<li><button id="save-colors" type="button" class="btn btn-primary">Save changes</button></li>
</ul>
</div>
</div>
</div>
</div>
<!-- /form color picker -->
<!-- custom menu -->
<div class="col-lg-9 col-md-12 col-sm-12 col-xs-12">
<div class="x_panel">
<div class="x_title">
<h2>Custom menu</h2>
<!-- <button class="btn btn-info pull-right">
<i class='fa fa-plus'></i>
Add entry
</button> -->
<div class="clearfix"></div>
</div>
<div class="x_content">
{% for menu_item in data.apps_external %}
<div class="form-group row ml-4">
<div class="col-md-12 col-xs-12">
<!-- <button class="btn btn-danger pull-right">
<i class='fa fa-trash'></i>
</button> -->
<h3>
<i class='fa {{ menu_item.icon }}'></i>
- {{ menu_item.name }}
</h3>
</div>
</div>
<div class="form-group row">
<div hidden>
<label class="control-label" for="apps_external-{{ menu_item.shortname }}-shortname">Shortname
</label>
<input readonly="readonly" id="apps_external-{{ menu_item.shortname }}-shortname" value="{{ menu_item.shortname }}" class="roundbox form-control" name="apps_external-{{ menu_item.shortname }}-shortname" placeholder="" type="text" required style="width: 100%;">
</div>
<div class="col-md-4 col-xs-12">
<label class="control-label" for="apps_external-{{ menu_item.shortname }}-name">Name <span class="required">*</span>
</label>
<input id="apps_external-{{ menu_item.shortname }}-name" value="{{ menu_item.name }}" class="roundbox" name="apps_external-{{ menu_item.shortname }}-name" placeholder="" type="text" required style="width: 100%;">
</div>
<div class="col-md-4 col-xs-12">
<label class="control-label" for="apps_external-{{ menu_item.shortname }}-href">Url <span class="required">*</span>
</label>
<input id="apps_external-{{ menu_item.shortname }}-href" value="{{ menu_item.href }}" class="roundbox" name="apps_external-{{ menu_item.href }}-href" placeholder="" type="text" required style="width: 100%;">
</div>
<div class="col-md-4 col-xs-12">
<label class="control-label" for="apps_external-{{ menu_item.shortname }}-icon">Icon <span class="required">*</span>
</label>
<select class="icon-dropdown" id="apps_external-{{ menu_item.shortname }}-icon" name="apps_external-{{ menu_item.shortname }}-icon">
{% for icon in icons %}
<option value='fa {{ icon }}' data-icon="{{ icon }}" {% if 'fa ' + icon == menu_item.icon %} selected='selected' {% endif %}>
{{ icon }}
</option>
{% endfor %}
</select>
</div>
</div>
{% endfor %}
<div class="form-group row">
<ul class="nav navbar-right panel_toolbox">
<li><button id="save-menu" type="button" class="btn btn-primary">Save changes</button></li>
</ul>
</div>
</div>
</div>
</div>
<!-- /custom menu -->
</div>
<!-- Logo crop -->
<div class="row">
<div class="col-lg-12 col-md-12 col-sm-12 col-xs-12">
<div class="x_panel">
<div class="x_title">
<h2>Logo Image</h2>
<div class="clearfix"></div>
</div>
<div class="x_content">
<div class="form-group row">
<!-- Image cropping -->
<div class="container cropper">
<div class="row">
<div class="col-md-9">
<div class="img-container">
<img id="image_logo" src="{{ data.logo }}" alt="Background login">
</div>
</div>
<div class="col-md-3">
<div class="docs-preview clearfix">
<div class="img-preview img-preview-logo preview-lg"></div>
</div>
<div class="alert alert-info" role="alert">
Your organization logo will appear in the top bar of all Digital Democratic Work Environments and in the login page.<br/>
Recommended Format:
<strong>
<ul>
<li>Size: 80 x 45 píxels</li>
<li>Weight: 30 KB</li>
<li>Format: .png or .jpg</li>
</ul>
</strong>
</div>
</div>
</div>
<div class="row">
<div class="col-md-9 docs-buttons-logo">
<div class="btn-group">
<button type="button" class="btn btn-primary" data-method="setDragMode" data-option="move" title="Move">
<span class="docs-tooltip" data-toggle="tooltip" title="Move">
<span class="fa fa-arrows"></span>
</span>
</button>
<button type="button" class="btn btn-primary" data-method="setDragMode" data-option="crop" title="Crop">
<span class="docs-tooltip" data-toggle="tooltip" title="Crop">
<span class="fa fa-crop"></span>
</span>
</button>
</div>
<div class="btn-group">
<button type="button" class="btn btn-primary" data-method="zoom" data-option="0.1" title="Zoom In">
<span class="docs-tooltip" data-toggle="tooltip" title="Zoom In">
<span class="fa fa-search-plus"></span>
</span>
</button>
<button type="button" class="btn btn-primary" data-method="zoom" data-option="-0.1" title="Zoom Out">
<span class="docs-tooltip" data-toggle="tooltip" title="Zoom Out">
<span class="fa fa-search-minus"></span>
</span>
</button>
</div>
<div class="btn-group">
<button type="button" class="btn btn-primary" data-method="rotate" data-option="-45" title="Rotate Left">
<span class="docs-tooltip" data-toggle="tooltip" title="$().cropper(&quot;rotate&quot;, -45)">
<span class="fa fa-rotate-left"></span>
</span>
</button>
<button type="button" class="btn btn-primary" data-method="rotate" data-option="45" title="Rotate Right">
<span class="docs-tooltip" data-toggle="tooltip" title="$().cropper(&quot;rotate&quot;, 45)">
<span class="fa fa-rotate-right"></span>
</span>
</button>
</div>
<div class="btn-group">
<button type="button" class="btn btn-primary" data-method="reset" title="Reset">
<span class="docs-tooltip" data-toggle="tooltip" title="Reset">
<span class="fa fa-refresh"></span>
</span>
</button>
<label class="btn btn-primary btn-upload" for="inputImageLogo" title="Upload image file">
<input type="file" class="sr-only" id="inputImageLogo" name="file" accept="image/*">
<span class="docs-tooltip" data-toggle="tooltip" title="Import image with Blob URLs">
<span class="fa fa-upload"></span>
</span>
</label>
</div>
</div>
</div>
</div>
<!-- /image cropping -->
<div class="form-group row docs-buttons">
<ul class="nav navbar-right panel_toolbox">
<li><button id="save-logo-crop" type="button" class="btn btn-primary">Save changes</button></li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Background crop -->
<div class="row">
<div class="col-lg-12 col-md-12 col-sm-12 col-xs-12">
<div class="x_panel">
<div class="x_title">
<h2>Background Image</h2>
<div class="clearfix"></div>
</div>
<div class="x_content">
<div class="form-group row">
<!-- Image cropping -->
<div class="container cropper">
<div class="row">
<div class="col-md-9">
<div class="img-container">
<img id="image_background" src="{{ data.background_login }}" alt="Background login">
</div>
</div>
<div class="col-md-3">
<div class="docs-preview clearfix">
<div class="img-preview img-preview-background preview-lg"></div>
</div>
<div class="alert alert-info" role="alert">
The background image of your organization will appear in the Digital Democratic Login Page.<br/>
Recommended Format:
<strong>
<ul>
<li>Size: 1920 x 1080 píxels</li>
<li>Weight: 1.75 MB</li>
<li>Format: .png or .jpg</li>
</ul>
</strong>
</div>
</div>
</div>
<div class="row">
<div class="col-md-9 docs-buttons-background">
<div class="btn-group">
<button type="button" class="btn btn-primary" data-method="setDragMode" data-option="move" title="Move">
<span class="docs-tooltip" data-toggle="tooltip" title="Move">
<span class="fa fa-arrows"></span>
</span>
</button>
<button type="button" class="btn btn-primary" data-method="setDragMode" data-option="crop" title="Crop">
<span class="docs-tooltip" data-toggle="tooltip" title="Crop">
<span class="fa fa-crop"></span>
</span>
</button>
</div>
<div class="btn-group">
<button type="button" class="btn btn-primary" data-method="zoom" data-option="0.1" title="Zoom In">
<span class="docs-tooltip" data-toggle="tooltip" title="Zoom In">
<span class="fa fa-search-plus"></span>
</span>
</button>
<button type="button" class="btn btn-primary" data-method="zoom" data-option="-0.1" title="Zoom Out">
<span class="docs-tooltip" data-toggle="tooltip" title="Zoom Out">
<span class="fa fa-search-minus"></span>
</span>
</button>
</div>
<div class="btn-group">
<button type="button" class="btn btn-primary" data-method="rotate" data-option="-45" title="Rotate Left">
<span class="docs-tooltip" data-toggle="tooltip" title="$().cropper(&quot;rotate&quot;, -45)">
<span class="fa fa-rotate-left"></span>
</span>
</button>
<button type="button" class="btn btn-primary" data-method="rotate" data-option="45" title="Rotate Right">
<span class="docs-tooltip" data-toggle="tooltip" title="$().cropper(&quot;rotate&quot;, 45)">
<span class="fa fa-rotate-right"></span>
</span>
</button>
</div>
<div class="btn-group">
<button type="button" class="btn btn-primary" data-method="reset" title="Reset">
<span class="docs-tooltip" data-toggle="tooltip" title="Reset">
<span class="fa fa-refresh"></span>
</span>
</button>
<label class="btn btn-primary btn-upload" for="inputImageBackground" title="Upload image file">
<input type="file" class="sr-only" id="inputImageBackground" name="file" accept="image/*">
<span class="docs-tooltip" data-toggle="tooltip" title="Import image with Blob URLs">
<span class="fa fa-upload"></span>
</span>
</label>
</div>
</div>
</div>
</div>
<!-- /image cropping -->
</div>
<div class="form-group row">
<ul class="nav navbar-right panel_toolbox">
<li><button id="save-background-crop" type="button" class="btn btn-primary">Save changes</button></li>
</ul>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block pagescript %}
<!-- Ion.RangeSlider -->
<script src="/vendors/ion.rangeSlider/js/ion.rangeSlider.min.js"></script>
<!-- iCheck -->
<script src="/vendors/iCheck/icheck.min.js"></script>
<!-- Switchery -->
<script src="/vendors/switchery/dist/switchery.min.js"></script>
<!-- Desktops sse & modals -->
<!-- Bootstrap Colorpicker -->
<script src="/vendors/mjolnic-bootstrap-colorpicker/dist/js/bootstrap-colorpicker.min.js"></script>
<!-- Cropper -->
<script src="/vendors/cropper/dist/cropper.min.js"></script>
<script src="/static/js/dashboard.js"></script>
{% endblock %}

View File

@ -0,0 +1,55 @@
<!-- extend base layout -->
{% extends "base.html" %}
{% block css %}
<!-- Ion.RangeSlider -->
<link href="/vendors/normalize-css/normalize.css" rel="stylesheet">
<!-- Switchery -->
<link href="/vendors/switchery/dist/switchery.min.css" rel="stylesheet">
{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-12 col-sm-12 col-xs-12">
<div class="x_panel">
<div class="x_title">
<h3><i class="fa fa-users"></i> Groups</h3>
<ul class="nav navbar-right panel_toolbox">
<li>
<a class="btn-new"><span style="color: #5499c7; "><i class="fa fa-plus"></i> Add new</span></a>
<!-- <a class="btn-delete_keycloak"><span style="color: #c75454; "><i class="fa fa-cross"></i> Delete all keycloak</span></a> -->
</li>
</ul>
<div class="clearfix"></div>
</div>
<div class="x_content">
<p class="text-muted font-13 m-b-30"></p>
<table id="groups" class="table" width="100%">
<thead>
<tr>
<th></th>
<th>Name</th>
<th>Path</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
</div>
</div>
{% include 'pages/modals/groups_modals.html' %}
{% endblock %}
{% block pagescript %}
<!-- Ion.RangeSlider -->
<script src="/vendors/ion.rangeSlider/js/ion.rangeSlider.min.js"></script>
<!-- iCheck -->
<script src="/vendors/iCheck/icheck.min.js"></script>
<!-- Switchery -->
<script src="/vendors/switchery/dist/switchery.min.js"></script>
<!-- Desktops sse & modals -->
<script src="/static/js/groups.js"></script>
{% endblock %}

View File

@ -0,0 +1,81 @@
<!-- extend base layout -->
{% extends "base.html" %} {% block css %}
<!-- Ion.RangeSlider -->
<link href="/vendors/normalize-css/normalize.css" rel="stylesheet" />
<!-- Switchery -->
<link href="/vendors/switchery/dist/switchery.min.css" rel="stylesheet" />
<!-- Bootstrap Colorpicker -->
<link
href="/vendors/mjolnic-bootstrap-colorpicker/dist/css/bootstrap-colorpicker.min.css"
rel="stylesheet"
/>
{% endblock %} {% block content %}
<style>
#select_fa {
font-family: "FontAwesome", "Liberation Sans";
font-size: 14px;
}
#select_fa::before {
vertical-align: middle;
}
</style>
<div class="row">
<!-- form color picker -->
<div class="col-md-12 col-sm-12">
<div class="x_panel">
<div class="x_title">
<h2>Legal<small>Company/School</small></h2>
<div class="clearfix"></div>
</div>
<div class="x_content">
{% with type = 'legal' %}
{% include 'aux/text-editor.html' %}
{% endwith %}
<div class="form-group row docs-buttons">
<ul class="nav navbar-right panel_toolbox">
<li>
<button id="save-legal" type="button" class="btn btn-primary">
Save changes
</button>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- <div class="row">
<div class="col-md-12 col-sm-12">
<div class="x_panel">
<div class="x_title">
<h2>Privacy policy<small></small></h2>
<div class="clearfix"></div>
</div>
<div class="x_content">
{% with type = 'privacy' %}
{% include 'aux/text-editor.html' %}
{% endwith %}
<div class="form-group row docs-buttons">
<ul class="nav navbar-right panel_toolbox">
<li>
<button id="save-privacy" type="button" class="btn btn-primary">
Save changes
</button>
</li>
</ul>
</div>
</div>
</div>
</div>
</div> -->
{% endblock %} {% block pagescript %}
<script src="/static/js/legal.js"></script>
<!-- bootstrap-wysiwyg -->
<script src="/vendors/bootstrap-wysiwyg/js/bootstrap-wysiwyg.min.js"></script>
<script src="/vendors/jquery.hotkeys/jquery.hotkeys.js"></script>
<script src="/vendors/google-code-prettify/src/prettify.js"></script>
{% endblock %}
</div>

View File

@ -0,0 +1,44 @@
<div id="modal-lostconnection" class="modal fade" role="dialog" style="width:50%;margin-left:30%;margin-top:10%;z-index: 100000;">
<div class="modal-admin">
<div class="modal-content">
<div class="row text-center"><h2 style="margin-bottom:5px">Connection lost</h2></div>
<hr>
<div class="row">
<div class="col-md-1 col-sm-1 col-xs-12"></div>
<div class="col-md-10 col-sm-10 col-xs-12">
<div class="row text-center">Unable to contact server. There should be a problem with network or a heavy load.</div>
<br>
<div class="row text-center">
<i class="fa fa-circle-o-notch fa-spin fa-fw"></i> Trying to reconnect...
</div>
<br>
<div class="col-md-1 col-sm-1 col-xs-12">
</div>
</div>
</div>
</div>
</div>
</div>
<div id="modal-info" class="modal fade" role="dialog" style="width:100%;margin-left:10%;margin-top:10%;z-index: 100000;">
<div class="modal-admin">
<div class="modal-content">
<div class="row text-center"><h2 style="margin-bottom:5px">Connection lost</h2></div>
<hr>
<div class="row">
<div class="col-md-10 col-sm-10 col-xs-12">
<div class="row text-center">Unable to contact server. There should be a problem with network or a heavy load.</div>
<br>
<div class="row text-center">
<i class="fa fa-circle-o-notch fa-spin fa-fw"></i> Trying to reconnect...
</div>
<br>
<div class="col-md-1 col-sm-1 col-xs-12">
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,68 @@
<div class="modal fade" id="modalAddGroup" tabindex="-1" role="dialog" aria-labelledby="modalAddGroup" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<!-- Modal Header -->
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal">
<span aria-hidden="true">&times;</span>
<span class="sr-only">Close</span>
</button>
<h4 class="modal-title">
<i class="fa fa-plus fa-1x"> </i> <i class="fa fa-users"> </i> Add new group
</h4>
</div>
<!-- Modal Body -->
<div class="modal-body">
<form id="modalAddGroupForm" class="form-inline form-label-left">
<div class="x_panel">
<div class="x_title">
<h4><i class="fa fa-info-circle" aria-hidden="true"></i> Group info</h4>
<div class="clearfix"></div>
</div>
<div class="x_content" style="padding: 0px;">
<div class="row">
<div class="col-md-6 col-xs-12">
<label class="control-label" for="name">Name <span class="required">*</span>
</label>
<input id="name" class="roundbox" maxlength="40" pattern="^[-_àèìòùáéíóúñçÀÈÌÒÙÁÉÍÓÚÑÇ .a-zA-Z0-9]+$" data-parsley-length="[4, 40]" name="name" placeholder="Name" data-parsley-trigger="change" required type="text" style="width:100%">
</div>
<div class="col-md-6 col-xs-12">
<label class="control-label" for="name">Description <span class="required">*</span>
</label>
<input id="description" class="roundbox" name="description" placeholder="Description" data-parsley-trigger="change" required type="text" style="width:100%">
</div>
</div>
</div>
</div>
<div class="x_panel">
<div class="x_title">
<h4><i class="fa fa-users" aria-hidden="true"></i> Parent group</h4>
<div class="clearfix"></div>
</div>
<div class="x_content" style="padding: 0px;">
<div class="row">
<div class="col-md-12 col-xs-12">
<label class="control-label" for="id">Select parent group
</label>
<select class="groups-select roundbox" id="parent" name="parent" style="width:100%" required>
</select>
</div>
</div>
</div>
</div>
</form>
<!-- Modal Footer -->
<div class="modal-footer">
<ul class="nav navbar-left panel_toolbox">
<li><button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button></li>
</ul>
<ul class="nav navbar-right panel_toolbox">
<li><button id="send" type="button" class="btn btn-success">Create group</button></li>
</ul>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,342 @@
<div class="modal fade" id="modalAddUser" tabindex="-1" role="dialog" aria-labelledby="modalAddUser" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<!-- Modal Header -->
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal">
<span aria-hidden="true">&times;</span>
<span class="sr-only">Close</span>
</button>
<h4 class="modal-title" id="myModalLabel">
<i class="fa fa-plus fa-1x"> </i> <i class="fa fa-user"> </i> Add new user
</h4>
</div>
<!-- Modal Body -->
<div class="modal-body">
<form id="modalAddUserForm" class="form-inline form-label-left">
<div class="x_panel">
<div class="x_content">
<div class="row">
<div class="col-md-12 col-sm-12 col-xs-12">
<label>Enabled</label>
<div class="checkbox">
<label class="">
<div class="icheckbox_flat-green" style="position: relative;">
<input type="checkbox" id="enabled" name="enabled" class="flat" style="position: absolute; opacity: 0;">
<ins class="iCheck-helper" style="position: absolute; top: 0%; left: 0%; display: block; width: 100%; height: 100%; margin: 0px; padding: 0px; background: rgb(255, 255, 255); border: 0px; opacity: 0;">
</ins>
</div>
</label>
</div>
</div>
</div>
</div>
<div class="x_title">
<h4><i class="fa fa-info-circle" aria-hidden="true"></i> User info</h4>
<div class="clearfix"></div>
</div>
<div class="x_content" style="padding: 0px;">
<div class="row">
<div class="col-md-6 col-xs-12">
<label class="control-label" for="name">Username <span class="required">*</span>
</label>
<input id="username" class="roundbox" maxlength="40" pattern="^[-_.a-z0-9]+$" data-parsley-length="[4, 40]" name="username" placeholder="Username" data-parsley-trigger="change" required type="text" style="width:100%">
</div>
<div class="col-md-6 col-xs-12">
<label class="control-label" for="email">Email <span class="required">*</span>
</label>
<input id="email" class="roundbox" data-validate-length-range="4,40" name="email" placeholder="Email" type="email" data-parsley-trigger="change" style="width:100%" required>
</div>
</div>
<div class="row">
<div class="col-md-6 col-xs-12">
<label class="control-label" for="first">First name <span class="required">*</span>
</label>
<input id="first" class="roundbox" name="first" placeholder="First name" type="text" style="width:100%" required>
</div>
<div class="col-md-6 col-xs-12">
<label class="control-label" for="last">Last name <span class="required">*</span>
</label>
<input id="last" class="roundbox" name="last" placeholder="Last name" type="text" style="width:100%" required>
</div>
</div>
<div class="row">
<div class="col-md-6 col-xs-12">
<label class="control-label" for="quota">Quota <span class="required">*</span>
</label>
<select class="roundbox" name="quota" style="width:100%" required>
<option value="500 MB">500 MB</option>
<option value="1 GB">1 GB</option>
<option value="3 GB">3 GB</option>
<option value="5 GB">5 GB</option>
<option value=false>Unlimited</option>
</select>
</div>
<div class="col-md-6 col-xs-12">
<label class="control-label" for="password">Password <span class="required">*</span>
</label>
<input id="password" class="roundbox" name="password" placeholder="Password" type="text" style="width:100%"
data-parsley-minlength="10"
data-parsley-uppercase="2"
data-parsley-lowercase="2"
required>
</div>
</div>
</div>
</div>
<div class="x_panel">
<div class="x_title">
<h4><i class="fa fa-users" aria-hidden="true"></i> Groups</h4>
<div class="clearfix"></div>
</div>
<div class="x_content" style="padding: 0px;">
<div class="row">
<div class="col-md-12 col-xs-12">
<label class="control-label" for="id">Select group(s)
</label>
<select class="groups-select roundbox" name="groups[]" multiple="multiple" style="width:100%" required>
</select>
</div>
</div>
</div>
</div>
<div class="x_panel">
<div class="x_title">
<h4><i class="fa fa-user-secret" aria-hidden="true"></i> Role</h4>
<div class="clearfix"></div>
</div>
<div class="x_content" style="padding: 0px;">
<div class="row">
<!-- <div class="col-md-4 col-xs-12">
<label class="control-label" for="id">Moodle
</label>
<select class="role-moodle-select" name="moodle" style="width:100%">
</select>
</div>
<div class="col-md-4 col-xs-12">
<label class="control-label" for="id">Nextcloud
</label>
<select class="role-nextcloud-select" name="nextcloud" style="width:100%">
</select>
</div> -->
<div class="col-md-12 col-xs-12">
<label class="control-label" for="id">Select role
</label>
<select class="role-keycloak-select" name="role" style="width:100%" required>
</select>
</div>
</div>
</div>
</div>
</form>
<!-- Modal Footer -->
<div class="modal-footer">
<ul class="nav navbar-left panel_toolbox">
<li><button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button></li>
</ul>
<ul class="nav navbar-right panel_toolbox">
<li><button id="send" type="button" class="btn btn-success">Create user</button></li>
</ul>
</div>
</div>
</div>
</div>
</div>
<div class="modal fade" id="modalPasswdUser" tabindex="-1" role="dialog" aria-labelledby="modalPasswdUser" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<!-- Modal Header -->
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal">
<span aria-hidden="true">&times;</span>
<span class="sr-only">Close</span>
</button>
<h4 class="modal-title" id="myModalLabel">
<i class="fa fa-plus fa-1x"> </i> <i class="fa fa-key"> </i> Change user password
</h4>
</div>
<!-- Modal Body -->
<div class="modal-body">
<form id="modalPasswdUserForm" class="form-inline form-label-left">
<div class="x_panel">
<div class="x_title">
<h4>REMEMBER TO COPY THIS PASSWORD AS IT IS THE ONLY TIME YOU WILL SEE IT</h4>
<div class="clearfix"></div>
</div>
<div class="x_content" style="padding: 0px;">
<div class="row">
<div class="col-md-12 col-xs-12">
This is a proposed password. Change it for the desired one for this user. <br>
</div>
</div>
<div class="row">
<div class="col-md-12 col-xs-12">
<input id="id" hidden/>
<label class="control-label" for="password">Password <span class="required">*</span>
</label>
<input id="password" class="roundbox" name="password" placeholder="Password" data-parsley-trigger="change" required type="text" style="width:100%">
</div>
</div>
</div>
</div>
</form>
<!-- Modal Footer -->
<div class="modal-footer">
<ul class="nav navbar-left panel_toolbox">
<li><button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button></li>
</ul>
<ul class="nav navbar-right panel_toolbox">
<li><button id="send" type="button" class="btn btn-success">Change user password</button></li>
</ul>
</div>
</div>
</div>
</div>
</div>
<div class="modal fade" id="modalEditUser" tabindex="-1" role="dialog" aria-labelledby="modalEditUser" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<!-- Modal Header -->
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal">
<span aria-hidden="true">&times;</span>
<span class="sr-only">Close</span>
</button>
<h4 class="modal-title" id="myModalLabel">
<i class="fa fa-plus fa-1x"> </i> <i class="fa fa-user"> </i> Edit user
</h4>
</div>
<!-- Modal Body -->
<div class="modal-body">
<form id="modalEditUserForm" class="form-inline form-label-left">
<div class="x_panel">
<div class="x_content">
<div class="row">
<div class="col-md-12 col-sm-12 col-xs-12">
<label>Enabled</label>
<div class="checkbox">
<label class="">
<div class="icheckbox_flat-green" style="position: relative;">
<input type="checkbox" id="enabled" name="enabled" class="flat" style="position: absolute; opacity: 0;">
<ins class="iCheck-helper" style="position: absolute; top: 0%; left: 0%; display: block; width: 100%; height: 100%; margin: 0px; padding: 0px; background: rgb(255, 255, 255); border: 0px; opacity: 0;">
</ins>
</div>
</label>
</div>
</div>
</div>
</div>
<div class="x_title">
<h4><i class="fa fa-info-circle" aria-hidden="true"></i> User info</h4>
<div class="clearfix"></div>
</div>
<div class="x_content" style="padding: 0px;">
<input id="id" hidden/>
<div class="row">
<div class="col-md-2 col-sm-2 col-xs-12 text-center">
<img id="user-avatar" src="" width="100" height="100"/>
</div>
<div class="col-md-10 col-sm-10 col-xs-12">
<div class="row">
<div class="row">
<div class="col-md-6 col-xs-12">
<label class="control-label" for="name">Username <span class="required">*</span>
</label>
<input disabled id="username" class="roundbox" maxlength="40" pattern="^[-_àèìòùáéíóúñçÀÈÌÒÙÁÉÍÓÚÑÇ .a-zA-Z0-9]+$" data-parsley-length="[4, 40]" name="name" placeholder="Username" data-parsley-trigger="change" required type="text" style="width:100%">
</div>
<div class="col-md-6 col-xs-12">
<label class="control-label" for="email">Email <span class="required">*</span>
</label>
<input id="email" class="roundbox" data-validate-length-range="4,40" name="email" placeholder="Email" type="email" data-parsley-trigger="change" style="width:100%">
</div>
</div>
<div class="row">
<div class="col-md-6 col-xs-12">
<label class="control-label" for="firstname">First name <span class="required">*</span>
</label>
<input id="firstname" class="roundbox" name="firstname" placeholder="First name" type="text" style="width:100%">
</div>
<div class="col-md-6 col-xs-12">
<label class="control-label" for="lastname">Last name <span class="required">*</span>
</label>
<input id="lastname" class="roundbox" name="lastname" placeholder="First name" type="text" style="width:100%">
</div>
</div>
<div class="row">
<div class="col-md-6 col-xs-12">
<label class="control-label" for="quota">Quota <span class="required">*</span>
</label>
<select class="roundbox" id="quota" name="quota" style="width:100%" required>
<option value="500 MB">500 MB</option>
<option value="1 GB">1 GB</option>
<option value="3 GB">3 GB</option>
<option value="5 GB">5 GB</option>
<option value=false>Unlimited</option>
</select>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="x_panel">
<div class="x_title">
<h4><i class="fa fa-users" aria-hidden="true"></i> Groups</h4>
<div class="clearfix"></div>
</div>
<div class="x_content" style="padding: 0px;">
<div class="row">
<div class="col-md-12 col-xs-12">
<label class="control-label" for="id">Select group(s)
</label>
<select class="groups-select roundbox" name="groups[]" multiple="multiple" style="width:100%">
</select>
</div>
</div>
</div>
</div>
<div class="x_panel">
<div class="x_title">
<h4><i class="fa fa-user-secret" aria-hidden="true"></i> Roles</h4>
<div class="clearfix"></div>
</div>
<div class="x_content" style="padding: 0px;">
<div class="row">
<!-- <div class="col-md-4 col-xs-12">
<label class="control-label" for="id">Moodle</label>
<select class="role-moodle-select" name="moodle" style="width:100%">
</select>
</div>
<div class="col-md-4 col-xs-12">
<label class="control-label" for="id">Nextcloud</label>
<select class="role-nextcloud-select" name="nextcloud" style="width:100%">
</select>
</div> -->
<div class="col-md-12 col-xs-12">
<label class="control-label" for="id">Role</label>
<select class="role-keycloak-select roundbox" name="role-keycloak" style="width:100%">
</select>
</div>
</div>
</div>
</div>
</form>
<!-- Modal Footer -->
<div class="modal-footer">
<ul class="nav navbar-left panel_toolbox">
<li><button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button></li>
</ul>
<ul class="nav navbar-right panel_toolbox">
<li><button id="send" type="button" class="btn btn-success">Edit user</button></li>
</ul>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,51 @@
<!-- extend base layout -->
{% extends "base.html" %}
{% block css %}
<!-- Ion.RangeSlider -->
<link href="/vendors/normalize-css/normalize.css" rel="stylesheet">
<!-- Switchery -->
<link href="/vendors/switchery/dist/switchery.min.css" rel="stylesheet">
{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-12 col-sm-12 col-xs-12">
<div class="x_panel">
<div class="x_title">
<h3><i class="fa fa-user-secret"></i> Roles</h3>
<ul class="nav navbar-right panel_toolbox">
<li>
<!-- <a class="btn-new"><span style="color: #5499c7; "><i class="fa fa-plus"></i> Add new</span></a> -->
</li>
</ul>
<div class="clearfix"></div>
</div>
<div class="x_content">
<p class="text-muted font-13 m-b-30"></p>
<table id="roles" class="table" width="100%">
<thead>
<tr>
<th>Id</th>
<th>Name</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}
{% block pagescript %}
<!-- Ion.RangeSlider -->
<script src="/vendors/ion.rangeSlider/js/ion.rangeSlider.min.js"></script>
<!-- iCheck -->
<script src="/vendors/iCheck/icheck.min.js"></script>
<!-- Switchery -->
<script src="/vendors/switchery/dist/switchery.min.js"></script>
<!-- Desktops sse & modals -->
<script src="/static/js/roles.js"></script>
{% endblock %}

View File

@ -0,0 +1,92 @@
<!-- extend base layout -->
{% extends "base.html" %}
{% block css %}
<!-- Ion.RangeSlider -->
<link href="/vendors/normalize-css/normalize.css" rel="stylesheet">
<!-- Switchery -->
<link href="/vendors/switchery/dist/switchery.min.css" rel="stylesheet">
{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-12 col-sm-12 col-xs-12">
<div class="x_panel">
<div class="x_title">
<h3><i class="fa fa-desktop"></i> External</h3>
<ul class="nav navbar-right panel_toolbox">
<li>
<div class="item form-group">
<label class="control-label col-md-3 col-sm-3 col-xs-12" for="action_role">Assign role: <span class="required">*</span></label>
<div class="col-md-6 col-sm-6 col-xs-12">
<select id="action_role" name="action_role" class="form-control action" required>
<option value=''>Select role</option>
<option value='manager'>Manager</option>
<option value='teacher'>Teacher</option>
<option value='student'>Student</option>
</select>
</div>
</div>
</li>
<li>
<a class="btn-sync"><span style="color: #5499c7; "><i class="fa fa-rocket"></i> Sync to system</span></a>
<a class="btn-upload"><span style="color: #5499c7; "><i class="fa fa-upload"></i> Upload</span></a>
<a class="btn-sample"><span style="color: #a9ddff; "><i class="fa fa-download"></i> (Sample upload)</span></a>
<a class="btn-download"><span style="color: #5499c7; "><i class="fa fa-download"></i> Download</span></a>
<a class="btn-clear-upload"><span style="color: #5499c7; "><i class="fa fa-cross"></i> Clear upload</span></a>
</li>
</ul>
<div class="clearfix"></div>
</div>
<div class="x_content">
<p class="text-muted font-13 m-b-30"></p>
<table id="users" class="table" width="100%">
<thead>
<tr>
<th></th>
<th>Avatar</th>
<th>Username</th>
<th>First</th>
<th>Last</th>
<th>email</th>
<th>groups</th>
<th>paths</th>
<th>roles</th>
<th>quota</th>
<th>password</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
<div class="x_content">
<p class="text-muted font-13 m-b-30"></p>
<table id="groups" class="table" width="100%">
<thead>
<tr>
<th></th>
<th>Name</th>
<th>Description</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
</div>
</div>
{% include 'pages/sysadmin/modals/external_modals.html' %}
{% endblock %}
{% block pagescript %}
<!-- Ion.RangeSlider -->
<script src="/vendors/ion.rangeSlider/js/ion.rangeSlider.min.js"></script>
<!-- iCheck -->
<script src="/vendors/iCheck/icheck.min.js"></script>
<!-- Switchery -->
<script src="/vendors/switchery/dist/switchery.min.js"></script>
<!-- Desktops sse & modals -->
<script src="/static/js/sysadmin/external.js"></script>
{% endblock %}

View File

@ -0,0 +1,59 @@
<!-- extend base layout -->
{% extends "base.html" %}
{% block css %}
<!-- Ion.RangeSlider -->
<link href="/vendors/normalize-css/normalize.css" rel="stylesheet">
<!-- Switchery -->
<link href="/vendors/switchery/dist/switchery.min.css" rel="stylesheet">
{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-12 col-sm-12 col-xs-12">
<div class="x_panel">
<div class="x_title">
<h3><i class="fa fa-users"></i> Groups</h3>
<ul class="nav navbar-right panel_toolbox">
<li>
<a class="btn-new"><span style="color: #5499c7; "><i class="fa fa-plus"></i> Add new</span></a>
<!-- <a class="btn-delete_keycloak"><span style="color: #c75454; "><i class="fa fa-cross"></i> Delete all keycloak</span></a> -->
</li>
</ul>
<div class="clearfix"></div>
</div>
<div class="x_content">
<p class="text-muted font-13 m-b-30"></p>
<table id="groups" class="table" width="100%">
<thead>
<tr>
<th></th>
<th>Id</th>
<th>Keycloak</th>
<th>Moodle</th>
<th>Nextcloud</th>
<th>Name</th>
<th>Path</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
</div>
</div>
{% include 'pages/modals/groups_modals.html' %}
{% endblock %}
{% block pagescript %}
<!-- Ion.RangeSlider -->
<script src="/vendors/ion.rangeSlider/js/ion.rangeSlider.min.js"></script>
<!-- iCheck -->
<script src="/vendors/iCheck/icheck.min.js"></script>
<!-- Switchery -->
<script src="/vendors/switchery/dist/switchery.min.js"></script>
<!-- Desktops sse & modals -->
<script src="/static/js/sysadmin/groups.js"></script>
{% endblock %}

View File

@ -0,0 +1,134 @@
<div class="modal fade" id="modalImport" tabindex="-1" role="dialog" aria-labelledby="myModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<!-- Modal Header -->
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal">
<span aria-hidden="true">&times;</span>
<span class="sr-only">Close</span>
</button>
<h4 class="modal-title">
<i class="fa fa-plus fa-1x"> </i> <i class="fa fa-users"> </i> Import
</h4>
</div>
<!-- Modal Body -->
<div class="modal-body">
<form id="modalImportForm" class="form-horizontal form-label-left">
<div class="x_panel">
<div class="x_content">
<!--
<input id="id" hidden/>
-->
<!-- <div class="item form-group">
<label class="control-label col-md-3 col-sm-3 col-xs-12" for="provider">Provider name: <span class="required">*</span></label>
<div class="col-md-6 col-sm-6 col-xs-12">
<input id="provider" name="provider" placeholder="" type="text" style="width:100%">
</div>
</div> -->
<div class="item form-group">
<label class="control-label col-md-3 col-sm-3 col-xs-12" for="format">Format: <span class="required">*</span></label>
<div class="col-md-6 col-sm-6 col-xs-12">
<select id="format" name="format" class="form-control format" required>
<option value="json-ga">GAdminconsole JSON</option>
<option value="csv-ug">CSV user with groups</option>
</select>
</div>
</div>
<div class="item form-group">
<label class="control-label col-md-3 col-sm-3 col-xs-12" for="name">Import JSON <span class="required">*</span>
</label>
<div class="col-md-6 col-sm-6 col-xs-12">
<input type="file" id="file-upload" name="file-upload" enctype="multipart/form-data" />
</div>
</div>
</div>
<!-- <div class="x_panela" id="bulkusers-quota" style="padding: 5px;">
<p style="font-size: 18px;margin-bottom:0px;">Map User keys</p>
<div class="item form-group">
<div class="item form-group">
<label class="control-label col-md-3 col-sm-3 col-xs-12" for="userid">id: <span class="required">*</span></label>
<div class="col-md-6 col-sm-6 col-xs-12">
<select id="userid" name="userid" class="form-control userid populate" required>
</select>
</div>
</div>
<div class="item form-group">
<label class="control-label col-md-3 col-sm-3 col-xs-12" for="username">username: <span class="required">*</span></label>
<div class="col-md-6 col-sm-6 col-xs-12">
<select id="username" name="username" class="form-control username populate" required>
</select>
</div>
</div>
<div class="item form-group">
<label class="control-label col-md-3 col-sm-3 col-xs-12" for="firstname">first name: <span class="required">*</span></label>
<div class="col-md-6 col-sm-6 col-xs-12">
<select id="firstname" name="firstname" class="form-control firstname populate" required>
</select>
</div>
</div>
<div class="item form-group">
<label class="control-label col-md-3 col-sm-3 col-xs-12" for="lastname">last name: <span class="required">*</span></label>
<div class="col-md-6 col-sm-6 col-xs-12">
<select id="lastname" name="lastname" class="form-control lastname populate" required>
</select>
</div>
</div>
<div class="item form-group">
<label class="control-label col-md-3 col-sm-3 col-xs-12" for="email">email: <span class="required">*</span></label>
<div class="col-md-6 col-sm-6 col-xs-12">
<select id="email" name="email" class="form-control email populate" required>
</select>
</div>
</div>
<div class="item form-group">
<label class="control-label col-md-3 col-sm-3 col-xs-12" for="usergroup">group:<span class="required">*</span></label>
<div class="col-md-6 col-sm-6 col-xs-12">
<select id="usergroup" name="usergroup" class="form-control usergroup populate" required>
</select>
</div>
</div>
</div>
</div>
<div class="x_panela" id="bulkusers-quota" style="padding: 5px;">
<p style="font-size: 18px;margin-bottom:0px;">Map Group keys</p>
<div class="item form-group">
<div class="item form-group">
<label class="control-label col-md-3 col-sm-3 col-xs-12" for="groupid">id:<span class="required">*</span></label>
<div class="col-md-6 col-sm-6 col-xs-12">
<select id="groupid" name="groupid" class="form-control groupid populate" required>
</select>
</div>
</div>
<div class="item form-group">
<label class="control-label col-md-3 col-sm-3 col-xs-12" for="groupname">name:<span class="required">*</span></label>
<div class="col-md-6 col-sm-6 col-xs-12">
<select id="groupname" name="groupname" class="form-control groupname populate" required>
</select>
</div>
</div>
</div>
</div> -->
</div>
<!-- Modal Footer -->
<div class="modal-footer">
<div class="form-group">
<div class="col-md-6 col-md-offset-3">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button>
<button id="send" type="button" class="btn btn-success">Process</button>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,84 @@
<!-- extend base layout -->
{% extends "base.html" %}
{% block css %}
<!-- Ion.RangeSlider -->
<link href="/vendors/normalize-css/normalize.css" rel="stylesheet">
<!-- Switchery -->
<link href="/vendors/switchery/dist/switchery.min.css" rel="stylesheet">
{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-12 col-sm-12 col-xs-12">
<div class="x_panel">
<div class="x_title">
<h3><i class="fa fa-user"></i> Users</h3>
<ul class="nav navbar-right panel_toolbox">
<li>
<a class="btn-new-user"><span style="color: #5499c7; "><i class="fa fa-plus"></i> Add new</span></a>
</li>
</ul>
<div class="clearfix"></div>
</div>
<div class="x_content">
<button class="btn btn-success btn-xs btn-sync_from_keycloak">
<i class="fa fa-refresh" aria-hidden="true"></i> Sync all from keycloak
</button>
<button class="btn btn-primary btn-xs btn-sync_to_nextcloud">
<i class="fa fa-refresh" aria-hidden="true"></i> Sync to Nextcloud
</button>
<button class="btn btn-primary btn-xs btn-sync_to_moodle">
<i class="fa fa-refresh" aria-hidden="true"></i> Sync to Moodle
</button>
{% if current_user.role =='admin' %}
<button class="btn btn-danger btn-xs btn-delete_keycloak">
<i class="fa fa-trash"></i> Delete all keycloak
</button>
<button class="btn btn-danger btn-xs btn-delete_nextcloud">
<i class="fa fa-trash"></i> Delete missing keycloak in nextcloud
</button>
<button class="btn btn-danger btn-xs btn-delete_moodle">
<i class="fa fa-trash"></i> Delete missing keycloak in moodle
</button>
{% endif %}
<table id="users" class="table" width="100%">
<thead>
<tr>
<th></th>
<th>Avatar</th>
<th>Username</th>
<th>First</th>
<th>Last</th>
<th>email</th>
<th>Keycloak</th>
<th>K.Groups</th>
<th>K.Roles</th>
<th>Moodle</th>
<th>M.Groups</th>
<th>Nextcloud</th>
<th>N.Groups</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
{% include 'pages/modals/users_modals.html' %}
{% include 'pages/users_detail.html' %}
</div>
</div>
{% endblock %}
{% block pagescript %}
<!-- Ion.RangeSlider -->
<script src="/vendors/ion.rangeSlider/js/ion.rangeSlider.min.js"></script>
<!-- iCheck -->
<script src="/vendors/iCheck/icheck.min.js"></script>
<!-- Switchery -->
<script src="/vendors/switchery/dist/switchery.min.js"></script>
<!-- Desktops sse & modals -->
<script src="/static/js/sysadmin/users.js"></script>
{% endblock %}

View File

@ -0,0 +1,90 @@
<!-- extend base layout -->
{% extends "base.html" %}
{% block css %}
<!-- Ion.RangeSlider -->
<link href="/vendors/normalize-css/normalize.css" rel="stylesheet">
<!-- Switchery -->
<link href="/vendors/switchery/dist/switchery.min.css" rel="stylesheet">
{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-12 col-sm-12 col-xs-12">
<div class="x_panel">
<div class="x_title">
<h3><i class="fa fa-user"></i> Users</h3>
<ul class="nav navbar-right panel_toolbox">
<li>
<div class="item form-group">
<label class="control-label col-md-3 col-sm-3 col-xs-12" for="bulk_actions">Bulk actions: <span class="required">*</span></label>
<div class="col-md-6 col-sm-6 col-xs-12">
<select id="bulk_actions" name="bulk_actions" class="form-control action" required>
<option value=''>Select action</option>
<option value='enable'>Enable</option>
<option value='disable'>Disable</option>
<option value='delete'>Delete</option>
</select>
</div>
</div>
</li>
<li>
<a class="btn-new-user"><span style="color: #5499c7; "><i class="fa fa-plus"></i> Add new</span></a>
</li>
</ul>
<div class="clearfix"></div>
</div>
<div class="x_content">
<table id="users" class="table" width="100%">
<thead>
<tr>
<th>Enabled</th>
<th>Avatar</th>
<th>Role</th>
<th>Actions</th>
<th>Selected</th>
<th>Username</th>
<th>First</th>
<th>Last</th>
<th>Email</th>
<th>Groups</th>
<th>Quota</th>
</tr>
</thead>
<tbody>
</tbody>
<tfoot>
<tr>
<th>Enabled</th>
<th>Avatar</th>
<th>Role</th>
<th>Actions</th>
<th>Selected</th>
<th>Username</th>
<th>First</th>
<th>Last</th>
<th>Email</th>
<th>Groups</th>
<th>Quota</th>
</tr>
</tfoot>
</table>
</div>
</div>
{% include 'pages/modals/users_modals.html' %}
{% include 'pages/users_detail.html' %}
</div>
</div>
{% endblock %}
{% block pagescript %}
<!-- Ion.RangeSlider -->
<script src="/vendors/ion.rangeSlider/js/ion.rangeSlider.min.js"></script>
<!-- iCheck -->
<script src="/vendors/iCheck/icheck.min.js"></script>
<!-- Switchery -->
<script src="/vendors/switchery/dist/switchery.min.js"></script>
<!-- Desktops sse & modals -->
<script src="/static/js/users.js"></script>
{% endblock %}

View File

@ -0,0 +1,15 @@
<div style="display:none">
<div class="row template-detail-users">
<div class="col-md-12 col-sm-12 col-xs-12">
<div class="row">
<div class="col-md-12 col-md-12 col-xs-12" id="actions-d.id" data-pk="d.id" data-username="d.username">
<div class="row">
<button class="btn btn-info btn-xs btn-passwd" type="button" data-placement="top" ><i class="fa fa-key m-right-xs"></i>Reset password</button>
<button class="btn btn-info btn-xs btn-edit" type="button" data-placement="top" ><i class="fa fa-pencil m-right-xs"></i>Edit</button>
<button class="btn btn-danger btn-xs btn-delete" type="button" data-placement="top" ><i class="fa fa-remove m-right-xs"></i>Delete</button>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,32 @@
<div class="navbar nav_title" style="border: 0;">
</div>
<div class="clearfix"></div>
<!-- sidebar menu -->
<div id="sidebar-menu" class="main_menu_side hidden-print main_menu mt-4">
<div class="menu_section">
<h3>Administration</h3>
<div class="clearfix"></div>
<ul class="nav side-menu">
<li><a class="btn-global-resync"><span style="color: #c75454; "><i class="fa fa-refresh"></i> Resync</span></a></li>
<li><a href="/users"><i class="fa fa-user"></i> Users</a></li>
<li><a href="/groups"><i class="fa fa-users"></i> Groups</a></li>
<li><a href="/roles"><i class="fa fa-user-secret"></i> Roles</a></li>
<li><a href="/sysadmin/external"><i class="fa fa-external-link"></i> Import</a></li>
<li><a href="/dashboard"><i class="fa fa-paint-brush"></i> Customization</a></li>
<li><a href="/legal"><i class="fa fa-legal"></i> Legal</a></li>
</ul>
{% if current_user.role == 'admin' %}
<h3>System Admin</h3>
<div class="clearfix"></div>
<ul class="nav side-menu">
<li><a href="/sysadmin/users"><i class="fa fa-user"></i> SysAdminUsers</span></a></li>
<li><a href="/sysadmin/groups"><i class="fa fa-user"></i> SysAdminGroups</span></a></li>
</ul>
{% endif %}
</div>
</div>

View File

@ -0,0 +1,32 @@
# This file is for unifying the coding style for different editors and IDEs
# editorconfig.org
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.{json,html,css}]
indent_style = space
indent_size = 2
[*.js]
# indent_style = space
# indent_size = 2
indent_style = tab
indent_size = 4
[*.coffee]
indent_style = space
indent_size = 2
[*.md]
indent_style = space
[*.py]
indent_style = space

View File

@ -0,0 +1,10 @@
bin
doc
lib
node_modules
test/**
!test/unit/**/*.js
*.min.js
build/**
dist/**
!dist/jquery.js

View File

@ -0,0 +1,113 @@
extends:
- "eslint:recommended"
- "jquery"
- "plugin:prettier/recommended"
# Accept `window`, etc.
env:
browser: true
# Known globals (`false`: read-only)
globals:
define: false
require: false
module: false
jQuery: false
# Custom rules (see https://eslint.org/docs/rules/ )
# 0:off, 1:warn, 2:error
rules:
# --- Best Practices ---------------------------------------------------------
block-scoped-var: warn
# complexity: [warn, 20]
# consistent-return: warn
no-alert: error
no-caller: error
guard-for-in: warn
linebreak-style: warn
no-else-return: warn
# no-empty-function: warn
no-extend-native: error
no-eval: error
no-floating-decimal: error
no-implied-eval: error
# no-invalid-this: warn
no-labels: warn
no-lone-blocks: warn
no-loop-func: warn
no-new: error
no-new-func: warn
no-new-wrappers: warn
no-octal-escape: warn
no-return-assign: warn
no-script-url: warn
no-self-compare: warn
no-sequences: warn
no-throw-literal: error
no-unmodified-loop-condition: warn
no-unused-expressions: error
# Not enabled because we want to allow `self._superApply(self, args)`:
# no-useless-call: warn
no-useless-catch: warn
no-useless-return: warn
no-with: warn
prefer-promise-reject-errors: warn
radix: error
# vars-on-top: warn
wrap-iife:
- error
- any
yoda: warn
# --- Strict Mode ------------------------------------------------------------
# strict: error
# --- Variables --------------------------------------------------------------
# init-declarations: ["warn", "always"]
no-label-var: error
# no-shadow: warn
no-shadow-restricted-names: error
no-undef: error
no-undef-init: warn
# no-undefined: warn
no-use-before-define: error
# - error
# - functions: false
# --- Stylistic Issues -------------------------------------------------------
camelcase: error
# Not enabled because sometimes we set `node = this`:
# consistent-this: [warn, self] # use `self = this`
func-name-matching: warn
new-cap:
- error
- { "capIsNewExceptionPattern": "^\\$\\.." } # Allow `d = $.Deferred()`
no-bitwise: error
# no-multi-assign: warn
no-negated-condition: warn
no-unneeded-ternary: warn
no-new-object: error
one-var: # see also no-use-before-define
- warn
- consecutive
# one-var-declaration-per-line: warn
# --- Possible Errors --------------------------------------------------------
curly: error
eqeqeq: ["error", "always", {"null": "ignore"}]
no-cond-assign:
- error
- except-parens
no-constant-condition:
- error
- { "checkLoops": false }
no-empty:
- error
- {allowEmptyCatch: true}
# no-extra-parens: [warn, all, {conditionalAssign: false }]
no-nested-ternary: warn
no-unused-vars:
- error
# Allow unused vars in catch() and if start with '_'
- {args: none, caughtErrors: none, varsIgnorePattern: "^_" }

View File

@ -0,0 +1,27 @@
# Style guide rationale:
# Width 80 is default (and explicitly recommended) by prettier
# - 2 space indentation and trailing semicolons seem to be most popular
# https://hackernoon.com/what-javascript-code-style-is-the-most-popular-5a3f5bec1f6f
# It is also the prettier's default
# - Double quotes are default in prettier and mandatory in Black
# - Trailing comma produces smaller diffs
# BUT:
# As a first step, we keep the current whitespace setting:
# - use tabs
# - tabWitdh 4
printWidth: 80
useTabs: true
tabWidth: 4
# useTabs: false
# tabWidth: 2
semi: true
singleQuote: false
trailingComma: "es5"
bracketSpacing: true # because it's prettier's default
#requirePragma: true
#overrides:
# - files: "*.test.js"
# options:
# semi: true

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<projectDescription>
<name>fancytree</name>
<comment></comment>
<projects>
<project>fancytree_wiki</project>
<project>fancytree.wiki</project>
</projects>
<buildSpec>
</buildSpec>
<natures>
</natures>
<filteredResources>
<filter>
<id>1373147473034</id>
<name></name>
<type>10</type>
<matcher>
<id>org.eclipse.ui.ide.multiFilter</id>
<arguments>1.0-name-matches-false-false-node_modules</arguments>
</matcher>
</filter>
</filteredResources>
</projectDescription>

View File

@ -0,0 +1,31 @@
# Configuration for `pyftpsync run ...` command line tool.
# See https://pyftpsync.readthedocs.io/ for details.
# This task is used by `pyftpsync run` if no task is passed as argument:
default_task: deploy
# Default settings shared by all tasks:
common_config:
exclude: archive,build,node_modules,.*,_*
progress: true
remote: ftp://wwwendt.de/tech/fancytree
# Make --root default (can be overridden by --here):
root: true
# verbose: 4
# List of task definitions:
tasks:
# sync_all:
# command: sync
# remote: ftp://wwwendt.de/tech/fancytree
deploy:
command: upload
delete_unmatched: true
deploy_force:
command: upload
delete_unmatched: true
delete: true
resolve: local
force: true

View File

@ -0,0 +1,26 @@
language: node_js
sudo: false
node_js:
- "10"
before_script:
- npm install -g grunt-cli
addons:
hosts:
- travis.dev
- localhost
matrix:
include:
- env: GRUNT_TASK=travis
# - env: GRUNT_TASK=travis-optional
# allow_failures:
# - env: GRUNT_TASK=travis-optional
script: grunt $GRUNT_TASK --verbose
env:
global:
- secure: VmlzKmxE+V+QZpvDuj5W41u2HTu2uTvW0aUi2p+2yhCHd7J5TFdOoECwIhTa/4VDEpnZwjLJXPd2q9kEn3+G0HpEqRMtKVTP/sM8y0JKUkprSCWV/y+pVX+0B9jQBAhEcjtkLDEGI3xVI8n+WV0Fig4kWecSCcSSUN5Mlbq5glQ=
- secure: ITp8qeoTyowtRqqFKPSjKq1tenmjt5ezNG/8ybEJQzxAMVGJ8bnyRPV1Aep0HB0ULP+GcYzDzGj5UeKM3hfWAJEfx+z1/HiHIMpJjEuGBz1JPfHx0lKcB7QfbhsRY3r8DVYptxdK9SyMisdIfCKApNMDW90RGDuALUdkLNPPXhI=

View File

@ -0,0 +1,128 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
<title>Fancytree - 3rd Party Example: Context menu</title>
<script src="../../../lib/jquery.js"></script>
<script src="../../../lib/jquery-ui.custom.js"></script>
<link href="../../../src/skin-win8/ui.fancytree.css" rel="stylesheet">
<script src="../../../src/jquery.fancytree.js"></script>
<!-- jquery-contextmenu (https://github.com/swisnl/jQuery-contextMenu) -->
<link rel="stylesheet"
href="//cdn.jsdelivr.net/npm/jquery-contextmenu@2.9.2/dist/jquery.contextMenu.min.css" />
<script src="//cdn.jsdelivr.net/npm/jquery-contextmenu@2.9.2/dist/jquery.contextMenu.min.js">
</script>
<script src="js/jquery.fancytree.contextMenu.js"></script>
<!-- Start_Exclude: This block is not part of the sample code -->
<link href="../../../lib/prettify.css" rel="stylesheet">
<script src="../../../lib/prettify.js"></script>
<link href="../../../demo/sample.css" rel="stylesheet">
<script src="../../../demo/sample.js"></script>
<!-- End_Exclude -->
<!-- Add code to initialize the tree when the document is loaded: -->
<script type="text/javascript">
$(function() {
$("#tree").fancytree({
extensions: ["contextMenu"],
source: {
url: "../../../demo/ajax-tree-local.json"
},
contextMenu: {
menu: {
"edit": { "name": "Edit", "icon": "edit" },
"cut": { "name": "Cut", "icon": "cut" },
"copy": { "name": "Copy", "icon": "copy" },
"paste": { "name": "Paste", "icon": "paste" },
"delete": { "name": "Delete", "icon": "delete", "disabled": true },
"sep1": "---------",
"quit": { "name": "Quit", "icon": "quit" },
"sep2": "---------",
"fold1": {
"name": "Sub group",
"items": {
"fold1-key1": { "name": "Foo bar" },
"fold2": {
"name": "Sub group 2",
"items": {
"fold2-key1": { "name": "alpha" },
"fold2-key2": { "name": "bravo" },
"fold2-key3": { "name": "charlie" }
}
},
"fold1-key3": { "name": "delta" }
}
},
"fold1a": {
"name": "Other group",
"items": {
"fold1a-key1": { "name": "echo" },
"fold1a-key2": { "name": "foxtrot" },
"fold1a-key3": { "name": "golf" }
}
}
},
actions: function(node, action, options) {
$("#selected-action")
.text("Selected action '" + action + "' on node " + node + ".");
}
},
lazyLoad: function(event, data) {
data.result = { url: "../../ajax-sub2.json" }
}
});
});
</script>
</head>
<body class="example">
<h1>Example: 'contextMenu' extension</h1>
<div class="description">
<p>
Integrate the external
<a href="https://github.com/swisnl/jQuery-contextMenu/" target="_blank" class="external">jQuery contextMenu plugin</a>
as Fancytree extension.
(<a href="https://github.com/mar10/fancytree/pull/3">Contributed by Tomas Norkūnas</a>.)
</p>
<p>
This is only one of more options. See the
<a href="../../../demo/index.html#sample-ext-menu.html">menu overview</a> for details.
</p>
<p>
Please click right mouse button on a node.
</p>
</div>
<!--
<div>
<label for="skinswitcher">Skin:</label> <select id="skinswitcher"></select>
</div>
-->
<!-- Tree wrapper -->
<div id="tree"></div>
<hr />
<div id="selected-action">Click right mouse button on a node.</div>
<!-- Start_Exclude: This block is not part of the sample code -->
<hr>
<p class="sample-links no_code">
<a class="hideInsideFS" href="https://github.com/mar10/fancytree">jquery.fancytree.js project home</a>
<a class="hideOutsideFS" href="#">Link to this page</a>
<a class="hideInsideFS" href="index.html">Example Browser</a>
<a href="#" id="codeExample">View source code</a>
</p>
<pre id="sourceCode" class="prettyprint" style="display:none"></pre>
<!-- End_Exclude -->
</body>
</html>

View File

@ -0,0 +1,85 @@
/**!
* jquery.fancytree.contextmenu.js
*
* Integrate the 'jQuery contextMenu' plugin as Fancytree extension:
* https://github.com/swisnl/jQuery-contextMenu
*
* Copyright (c) 2008-2018, Martin Wendt (https://wwWendt.de)
* Released under the MIT license
* https://github.com/mar10/fancytree/wiki/LicenseInfo
*/
(function($, document) {
"use strict";
var initContextMenu = function(tree, selector, menu, actions) {
tree.$container.on("mousedown.contextMenu", function(event) {
var node = $.ui.fancytree.getNode(event);
if (node) {
$.contextMenu("destroy", "." + selector);
// node.setFocus(true);
node.setActive(true);
$.contextMenu({
selector: "." + selector,
events: {
show: function(options) {
options.prevKeyboard = tree.options.keyboard;
tree.options.keyboard = false;
},
hide: function(options) {
tree.options.keyboard = options.prevKeyboard;
node.setFocus(true);
},
},
build: function($trigger, e) {
node = $.ui.fancytree.getNode($trigger);
var menuItems = {};
if ($.isFunction(menu)) {
menuItems = menu(node);
} else if ($.isPlainObject(menu)) {
menuItems = menu;
}
return {
callback: function(action, options) {
if ($.isFunction(actions)) {
actions(node, action, options);
} else if ($.isPlainObject(actions)) {
if (
actions.hasOwnProperty(action) &&
$.isFunction(actions[action])
) {
actions[action](node, options);
}
}
},
items: menuItems,
};
},
});
}
});
};
$.ui.fancytree.registerExtension({
name: "contextMenu",
version: "@VERSION",
contextMenu: {
selector: "fancytree-title",
menu: {},
actions: {},
},
treeInit: function(ctx) {
this._superApply(arguments);
initContextMenu(
ctx.tree,
ctx.options.contextMenu.selector || "fancytree-title",
ctx.options.contextMenu.menu,
ctx.options.contextMenu.actions
);
},
});
})(jQuery, document);

View File

@ -0,0 +1,104 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="content-type" content="text/html; charset=ISO-8859-1">
<title>Fancytree - 3rd Party Example: Hotkeys</title>
<script src="../../../lib/jquery.js"></script>
<script src="../../../lib/jquery-ui.custom.js"></script>
<link href="../../../src/skin-win8/ui.fancytree.css" rel="stylesheet">
<script src="../../../src/jquery.fancytree.js"></script>
<script src="js/jquery.hotkeys.js"></script>
<script src="js/jquery.fancytree.hotkeys.js"></script>
<!-- Start_Exclude: This block is not part of the sample code -->
<link href="../../../lib/prettify.css" rel="stylesheet">
<script src="../../../lib/prettify.js"></script>
<link href="../../../demo/sample.css" rel="stylesheet">
<script src="../../../demo/sample.js"></script>
<!-- End_Exclude -->
<!-- Add code to initialize the tree when the document is loaded: -->
<script type="text/javascript">
$(function() {
$("#tree").fancytree({
extensions: ["hotkeys"],
source: {
url: "../../../demo/ajax-tree-local.json"
},
hotkeys: {
keyup: {
"shift+a": function(node) {
$("#selected-action").append(document.createTextNode("Key up 'Shift + a' on node " + node)).append("<br />");
}
},
keydown: {
"shift+a": function(node) {
$("#selected-action").append(document.createTextNode("Key down 'Shift + a' on node " + node)).append("<br />");
},
'ctrl+d': function(node, evt) {
$('#selected-action').append(document.createTextNode('Key down "Ctrl + d" on node ' + node)).append('<br />');
var new_node = $.extend(node.toDict(), {key: new Date().getTime().toString()}); // timestamp for dummy key
node.appendSibling(new_node);
evt.stopPropagation();
return false;
}
},
keypress: {
"shift+a": function(node) {
$("#selected-action").append(document.createTextNode("Key press 'Shift + a' on node " + node)).append("<br />");
}
}
},
lazyLoad: function(event, data) {
data.result = { url: "../../ajax-sub2.json" }
}
});
});
</script>
</head>
<body class="example">
<h1>Example: 'hotkeys' extension</h1>
<div class="description">
<p>
Integrate John Resig's
<a href="https://github.com/jeresig/jquery.hotkeys" target="_blank" class="external">'jQuery.Hotkeys' plugin</a>
as Fancytree extension.
</p>
<p>
Please activate one node and click "Shift + a".
</p>
</div>
<div>
<label for="skinswitcher">Skin:</label> <select id="skinswitcher"></select>
</div>
<!-- Tree wrapper -->
<div id="tree"></div>
<hr />
<div id="selected-action">Activate one node and click "Shift + a" or "Ctrl + d":<br /></div>
<!-- Start_Exclude: This block is not part of the sample code -->
<hr>
<p class="sample-links no_code">
<a class="hideInsideFS" href="https://github.com/mar10/fancytree">jquery.fancytree.js project home</a>
<a class="hideOutsideFS" href="#">Link to this page</a>
<a class="hideInsideFS" href="index.html">Example Browser</a>
<a href="#" id="codeExample">View source code</a>
</p>
<pre id="sourceCode" class="prettyprint" style="display:none"></pre>
<!-- End_Exclude -->
</body>
</html>

View File

@ -0,0 +1,35 @@
/**!
* jquery.fancytree.hotkeys.js
*
* Integrate the 'jQuery.Hotkeys' plugin as Fancytree extension:
* https://github.com/jeresig/jquery.hotkeys/
*
* Copyright (c) 2008-2018, Martin Wendt (https://wwWendt.de)
* Released under the MIT license
* https://github.com/mar10/fancytree/wiki/LicenseInfo
*/
(function($, document) {
"use strict";
var initHotkeys = function(tree, data) {
$.each(data, function(event, keys) {
$.each(keys, function(key, handler) {
$(tree.$container).on(event, null, key, function(evt) {
var node = tree.getActiveNode();
return handler(node, evt);
// return false from the handler will stop default handling.
});
});
});
};
$.ui.fancytree.registerExtension({
name: "hotkeys",
version: "@VERSION",
hotkeys: {},
treeInit: function(ctx) {
this._superApply(arguments);
initHotkeys(this, ctx.options.hotkeys);
},
});
})(jQuery, document);

View File

@ -0,0 +1,196 @@
/*jslint browser: true*/
/*jslint jquery: true*/
/*
* jQuery Hotkeys Plugin
* Copyright 2010, John Resig
* Dual licensed under the MIT or GPL Version 2 licenses.
*
* Based upon the plugin by Tzury Bar Yochay:
* http://github.com/tzuryby/hotkeys
*
* Original idea by:
* Binny V A, http://www.openjs.com/scripts/events/keyboard_shortcuts/
*/
/*
* One small change is: now keys are passed by object { keys: '...' }
* Might be useful, when you want to pass some other data to your handler
*/
(function(jQuery) {
jQuery.hotkeys = {
version: "0.8",
specialKeys: {
8: "backspace",
9: "tab",
10: "return",
13: "return",
16: "shift",
17: "ctrl",
18: "alt",
19: "pause",
20: "capslock",
27: "esc",
32: "space",
33: "pageup",
34: "pagedown",
35: "end",
36: "home",
37: "left",
38: "up",
39: "right",
40: "down",
45: "insert",
46: "del",
59: ";",
61: "=",
96: "0",
97: "1",
98: "2",
99: "3",
100: "4",
101: "5",
102: "6",
103: "7",
104: "8",
105: "9",
106: "*",
107: "+",
109: "-",
110: ".",
111: "/",
112: "f1",
113: "f2",
114: "f3",
115: "f4",
116: "f5",
117: "f6",
118: "f7",
119: "f8",
120: "f9",
121: "f10",
122: "f11",
123: "f12",
144: "numlock",
145: "scroll",
173: "-",
186: ";",
187: "=",
188: ",",
189: "-",
190: ".",
191: "/",
192: "`",
219: "[",
220: "\\",
221: "]",
222: "'"
},
shiftNums: {
"`": "~",
"1": "!",
"2": "@",
"3": "#",
"4": "$",
"5": "%",
"6": "^",
"7": "&",
"8": "*",
"9": "(",
"0": ")",
"-": "_",
"=": "+",
";": ": ",
"'": "\"",
",": "<",
".": ">",
"/": "?",
"\\": "|"
},
// excludes: button, checkbox, file, hidden, image, password, radio, reset, search, submit, url
textAcceptingInputTypes: [
"text", "password", "number", "email", "url", "range", "date", "month", "week", "time", "datetime",
"datetime-local", "search", "color", "tel"],
options: {
filterTextInputs: true
}
};
function keyHandler(handleObj) {
if (typeof handleObj.data === "string") {
handleObj.data = {
keys: handleObj.data
};
}
// Only care when a possible input has been specified
if (!handleObj.data || !handleObj.data.keys || typeof handleObj.data.keys !== "string") {
return;
}
var origHandler = handleObj.handler,
keys = handleObj.data.keys.toLowerCase().split(" ");
handleObj.handler = function(event) {
// Don't fire in text-accepting inputs that we didn't directly bind to
if (this !== event.target && (/textarea|select/i.test(event.target.nodeName) ||
(jQuery.hotkeys.options.filterTextInputs &&
jQuery.inArray(event.target.type, jQuery.hotkeys.textAcceptingInputTypes) > -1))) {
return;
}
var special = event.type !== "keypress" && jQuery.hotkeys.specialKeys[event.which],
character = String.fromCharCode(event.which).toLowerCase(),
modif = "",
possible = {};
jQuery.each(["alt", "ctrl", "shift"], function(index, specialKey) {
if (event[specialKey + 'Key'] && special !== specialKey) {
modif += specialKey + '+';
}
});
// metaKey is triggered off ctrlKey erronously
if (event.metaKey && !event.ctrlKey && special !== "meta") {
modif += "meta+";
}
if (event.metaKey && special !== "meta" && modif.indexOf("alt+ctrl+shift+") > -1) {
modif = modif.replace("alt+ctrl+shift+", "hyper+");
}
if (special) {
possible[modif + special] = true;
}
else {
possible[modif + character] = true;
possible[modif + jQuery.hotkeys.shiftNums[character]] = true;
// "$" can be triggered as "Shift+4" or "Shift+$" or just "$"
if (modif === "shift+") {
possible[jQuery.hotkeys.shiftNums[character]] = true;
}
}
for (var i = 0, l = keys.length; i < l; i++) {
if (possible[keys[i]]) {
return origHandler.apply(this, arguments);
}
}
};
}
jQuery.each(["keydown", "keyup", "keypress"], function() {
jQuery.event.special[this] = {
add: keyHandler
};
});
})(jQuery || this.jQuery || window.jQuery);

View File

@ -0,0 +1,686 @@
# 2.36.1 / Unreleased
# 2.36.0 / 2020-07-15
* [Changed] #1005 Cast key to string in getNodeByKey()
* [Changed] #1013 ext-dnd5: log warning when jQuery is too old
* [Added] #1012 `dnd5.dropMarkerParent` allows usage in Webcomponents (i.e. shadow DOM)
* [Added] #1017 `copyFunctionsToData` allows also copying functions to the data property of the node
* [Fixed] #921 ext-edit / focus handling: Internet Explorer scrolls briefly
to the top/left after editing if the tree container is partially outside the viewport
* [Fixed] #1001 Invalid urls in skin-xp CSS
* [Fixed] ext-dnd5: dropEffectCallback=none was not reset in some cases
* [Fixed] #1018 ContextMenu extension always focuses the first node in the tree
# 2.35.0 / 2020-03-27
* [Changed] The `enableAspx` option will default to 'false' in the future.
For now, a warning is emitted, to explicitly set it or use the `postProcess`
event instead.
* [Added] #988 New option `dnd5.preventLazyParents` prevents dropping items on
unloaded lazy nodes (defaults to true)
* [Fixed] #983 lazyLoad with promise not calling postProcess
* [Fixed] #984 ext-edit: Exception when cancelling addSibling() or addChildren()
* [Fixed] #987 Lazy load puts "Load error" for content outside tree div
if parent folder is removed before loads ends
* [Fixed] #989 `node.toDict()` keeps empty `children` array
* [Fixed] #998 dnd5 triggering multiple loads of lazy nodes on hover
# 2.34.0 / 2019-12-26
* [DEPRECATED] jQuery UI widget methods:
Use `tree.METHOD()` instead of `$().fancytree("METHOD")`.
* [Added] `tree.debugTime()`, `tree.debugTimeEnd()` for debugging.
* [Added] `tree.destroy()` as alternative for `tree.widget.destroy()`.
* [Fixed] `$.ui.fancytree.getTree()` for Element arg.
* [Fixed] #973 when use ext-grid in one tree, other tree not use ext-grid has error on click.
* [Fixed] #974 ext-grid: too much output in production mode.
* [Fixed] #975 ext-grid: fix `tree.visitRows()` for empty tree.
* [Fixed] #978 ext-grid: addChildren() throws error when grid is hidden.
# 2.33.0 / 2019-10-29
* [Added] event `preInit` (fired before nodes are loaded).
* [Changed] jQuery is now a peerDependency (>=1.9), so users can install or re-use their own version.
* [Changed] ext-grid: `updateViewport` event is now also triggered for 'renumber' (i.e. expand, collapse)
* [Fixed] #963: tree.setExpanded() fails when autoScroll is enabled
* [Fixed] #964: handle case when `source` is not passed and no `<ul>` is embedded.
* [Fixed] #966: ext-dnd5: bug in function onDropEvent (case 'dragover')
* [Fixed] ext-filter: sub-match counter is one too high.
# 2.32.0 / 2019-09-10
* [Added] `node.hasClass()`
* [Added] `tree.applyCommand()` and `node.applyCommand()` (experimental!)
* [Added] `tree.isLoading()`
* [Added] `tree.toDict(includeRoot, callback)` and `node.toDict(recursive, callback)`:
callback can now return `false` or `"skip"` to skip nodes.
* [Fixed] #951 Hover issue in unselectable radio
* ext-dnd5: allow autoExpand even if dropping is prevented
* [Fixed] ext-filter: tree.rootNode.subMatchCount is now set correctly
* [Fixed] #955 node.navigate($.ui.keyCode.DOWN, false) does not return promise
* Stop testing with jQuery UI 1.10 and 1.11 (only jQuery UI 1.12 remains)
# 2.31.0 / 2019-05-30
* New extension **ext-grid** (experimental)<br>
This is a variant of `ext-table` that introduces viewport support, which
allows to maintain *huge* data models while only rendering as many DOM elements as necessary.<br>
Main changes:
- A viewport is defined by the number of visible rows (`tree.viewport.count`) and the index of the first visible row (`.start`)
- When scrolling, rows are not hidden, but removed and replaced. (This implies that the contents of embedded input fields should be written into the model immediately.)
* Refactored **ext-dnd5**<br>
Some **breaking changes** were made, mainly to improve handling of the dropEffect
(note that ext-dnd5 was and still is experimental and in progress).
- Remove `dnd5.dropEffect` callback option (set `data.dropEffect` instead)
- Remove `dnd5.dragImage` callback option (call `data.dataTransfer.setDragImage()`
- and set `data.useDefaultImage = false` instead)
- Rename `dnd5.preventRecursiveMoves` to `dnd5.preventRecursion`
- `dnd5.preventVoidMoves` now only aplies to 'move' operations, so we can *copy* before self
- [Added] `dnd5.preventSameParent` option
* [Added] hook `treeStructureChanged`
* [Added] methods `tree.findRelatedNode()`, `node.findRelatedNode()`
* [Added] method `node.getPath()`
* [Added] methods `$.ui.fancytree.getDragNode()`, `$.ui.fancytree.getDragNodeList()`
* [Added] event `updateViewport`
* [Added] tree option `.checkboxAutoHide` to hide checkboxes unless selected or hovered.
* [Added] tree option `.treeId` to prevent generation of a new sequence if the tree is re-initialized on a page.
* [Changed] `.getTree()` now also accepts the tree id string
* [Changed] #939: Keep a `partsel` flag that was explicitly set on a lazy node
* [Changed] ext-clones: make default key generation more robust against collisions
* [DEPRECATED] loaderror and lazyload options now throw an error instead of falling back to the correct loadError and lazyLoad
* [DEPRECATED] `tree.applyFilter` was removed
* [Fixed] #918 SVG font awesome 5 glyphs remove badge counter when parent node is collapsed
* [Fixed] #921 ext-edit respectively focus handling: Internet Explorer scrolls briefly
to the top/left of the tree container element after editing a node title if the
tree container is partially outside the viewport
* [Fixed] #931 Selecting grandparent selects all nodes of radiogroup in selectMode=3
* [Fixed] #946 dnd5 - Counter badge shows up, although the drag was cancelled from dragStart callback
* [Fixed] #947 dnd5 - dragEnd is fired only when re-ordering nodes within the same parent
* [Fixed] missing tree.error() and broken node.error()
* [Fixed] a bug in ext-logger
* Optimized performance of `expandAll()` and `ext-filter`
* Replace jshint/jscs with eslint
* Now testing on Puppeteer/Chromium instead of PhantonJS
* Use LF on Windows when checking out from git (added .gitattributes)
* Update to jQuery 3.4
# 2.30.2 / 2019-01-13
* Stop testing on IE 8 (no longer available on Saucelabs)
* [Fixed] #910 ext-dnd5 throws error for draggable column headers
* [Fixed] overrideMethod()'s calling context
* [Fixed] #912 ext-dnd5 + ext-glyph awesome5 does not show the icons when dragging an item
* [Fixed] #919 ext-multi: JavaScript error (event is not defined) in nodeKeydown
* [Fixed] #922 scrollIntoView for plain trees that don't have a scrollbar
* [Fixed] #924 ext-edit: Fix caret position for mouse-click in input
* [Fixed] #928 ext-dnd5: Fix `preventNonNodes` option
* [Fixed] #929 Fix `.getTree()` for jQuery 3
* [Fixed] #930 ext-dnd5: If drag does not start, no drag data should be stored
# 2.30.1 / 2018-11-13
* [Changed] Apply and enforce 'prettier' codestyle
* [Changed] #897 Set font for table extension
* [Fixed] #883: Font Awesome 4 animation spinner stays visible
* [Fixed] #894: Fancytree assertion failed: scrollParent should be a simple element or `window`, not document or body.
* [Fixed] #896 _requireExtension: order managment
* [Fixed] #899 Creating duplicate icon when removing node using extension columnview
* [Fixed] #900 ColumnView Extension - Toggle between parent and children not working
* [Fixed] #909 With quicksearch enabled, does not search for non-Latin character
# 2.30.0 / 2018-09-02
* [Changed] ext-edit trigger 'clickActive' now only triggers if no modifier keys
(shift, meta, control, ...) are pressed.<br>
Trigger 'shift+click' now only triggers if no other modifier key (control, ...)
is pressed.
* [Changed] #879 Rename ext-debug to ext-logger
(jquery.fancytree.debug.js => jquery.fancytree.logger.js)
* [Added] ext-multi is now deployed with jquery.fancytree-all.js (still experimental)
* [Added] tree.activateKey(key, opts) now has an `opts` argument
* [Added] `nodata` option (bool, string, or callback)
* [Added] ext-table `mergeStatusColumns` option
* [Added] new method `tree.enable(flag)`
* [Added] new method `tree.expandAll(flag, opts)`
* [Added] new methods `tree.setOption(name, value)` and `tree.getOption(name)`
* [Fixed] ES6 import dependency on jquery for jquery.fancytree.ui-deps.js
* [Fixed] #863 setActive() sometimes does not scroll node into view
* [Fixed] #877 postProcess may now also return the object form `{..., children: []}`
* [Fixed] #884 ReferenceError: jQuery is not defined at _simpleDeepMerge
* [Fixed] autoScroll, node.scrollIntoView(), and .makeVisible() now work for tables as well.
# 2.29.1 / 2018-06-27
* [Fixed] #848 Drag End Error with dnd5 extension (again):
fancytree-drag-remove class not removed on drop/dragend
* [Fixed] #875 ext-dnd5: Unwanted expanding of folder node when a node is dragged
before/after it
* [Fixed] #876 `triggerStart: []` does not override the default settings.<br>
**NOTE:** Options of type `Array` will now override the default option.
Before, arrays were merged with the default.
* [Fixed] ext-ariagrid default actions
# 2.29.0 / 2018-06-16
* [Changed]
`toggleEffect` now also accepts "toggle" or "slideToggle" to use jQuery effects instead of jQueryUI.<br>
`toggleEffect: { effect: "slideToggle", duration: 200 }` is now the default.<br>
'effects' component was removed from the bundled jquery.fancytree.ui-deps.js
* [Fixed] #746 Animation bug when expanding/collapsing nodes
* [Fixed] #848 Drag End Error with dnd5 extension
* [Fixed] #850 ext-childcounter doesn't work with custom icons
* [Fixed] #859 Fix log level configuration problem
* [Fixed] #865 toggleEffect animation (effect: blind) sometimes got stuck.
* Stop testing jQuery UI 1.9
* Update to jQuery 3.3.1
# 2.28.1 / 2018-03-19
* [Fixed] #844 Fix RTL for ext-table
* [Fixed] #845 Fix RTL for ext-dnd/ext-dnd5
* [Fixed] #764 Fix clicks on embedded <a> tags when filter is on
# 2.28.0 / 2018-03-02
* [Added] New extension ext-multi (experimental).
* [Added] ext-dnd5 support for dragging multiple selected nodes.
* [Added] #830 support for Font Awesome 5 (ext-glyph preset).
* [Added] ext-glyph supports SVG icons.
* [Added] `icon` option supports `{html: "..."}` content (also available for glyph-ext mapping).
* [Added] New method tree.visitRows()
* [Added] New method tree.selectAll()
* [Added] New method node.isBelowOf()
* [Added] New extension ext-fixed (experimental).
* [Changed] Re-rename clearData() to clearPersistData()
* [Changed] #828 Re-scale debugLevel from 0:quiet to 4:verbose, allowing to suppress warnings
and even errors.
* [Added] CSS helper classes:<br>
`.fancytree-helper-disabled`<br>
`.fancytree-helper-hidden` (replaces `ui-helper-hidden`)<br>
`.fancytree-helper-indeterminate-cb`<br>
`fancytree-helper-spin` for icon animations (replaces `glyphicon-spin`)
* [Fixed] #819: ext-filter: Handle nodes without title.
* [Fixed] #835: ext-dnd5: Accept drop externals after drag.
# 2.27.0 / 2017-12-16
* **BREAKING CHANGES:**
- `node.type` is now a first-class property of FancytreeNode.
Node data `{..., type: "foo"}` is now available as `node.type` (before: `node.data.type`).
- The properties `tree.types` and `tree.columns` have been added to Fancytree.
If passed with source data, they are now available directly instead of
`tree.data.types` or `tree.data.columns`.
* **Support patterns for node types:**
- The properties `node.type` and `tree.types` are recommended to implement node-type
specific configuration ([details](https://github.com/mar10/fancytree/wiki/TutorialNodeTypes)).
- Event `data` argument contains `typeInfo == tree.types[node.type]`.
* **Improved ext-glyph:**
- [Added] support for ligature icons (e.g. [material icons](https://material.io/icons/)).
- [Added] `icon` option can now return a dict to create a ligature icon.
* **Improved tree.loadKeyPath():**
- [Added] support for a custom path segment matcher.
This allows to have key paths with segments other than `node.key`.
- [Improved] the returned deferred promise now triggers `progress()` events which can
be used instead of the callback.
* The property `tree.columns` was added to Fancytree. Currently only reserved as
recommended pattern to pass global meta-data for ext-table.
* [Added] ext-edit: new trigger mode `clickActive` for option `triggerStart: [...]`.
* [Added] #798 Tooltip support for icons (dynamic option `iconTooltip`).
* [Added] #808 Pass custom storage providers to ext-persist.
* [Improved] ext-table no longer needs empty tbody/tr if thead is present.
* [Fixed] #796 UMD requirements for node/CommonJS
* [Fixed] #803 jquery.fancytree.ui-deps.js does not override existing widgets.
* [Fixed] #815 `<mark>` element missing in filtered nodes (minified bundle, IE 11).
* [Fixed] #816 findNextNode() doesn't set default for 'startNode' argument.
* [Added] Material Design demo
* [Added] Demo for Fancytree inside a jquery-confirm popup
* [Changed] String representation is now `"FancytreeNode@_4[title='My name']"`
* [DEPRECATED] `tree.clearCookies()`. Use <del>`tree.clearData()`</del> `tree.clearPersistData()` instead.
# 2.26.0 / 2017-11-04
* **BREAKING CHANGES:**
- [Fixed] #792 postProcess is now also called for non-Ajax sources.
* [Improved] LESS now compiles with webpack
* [Added] #791 ext-glyph support for radio buttons
* [Added] Color definitions for skin-awesome (taken from skin-lion)
* [Fixed] `$.ui.fancytree.getNode()` for ES6 environments
* [Fixed] #789 Wrong node is activated in IE, when clicking in unfocused container
# 2.25.0 / 2017-10-31
* **BREAKING CHANGES:**
- The `dist/src/` folder was renamed to `dist/modules`.
- Some directories like `demo/` are no longer part of the npm install.
* **Improved Module Support and Distribution**<br>
- The `dist/` folder now includes a `modules/` directory with fancytree core
and all extensions.
- All modules have UMD wrappers with defined dependencies.
- Internal jQuery UI dependencies are deployed as module and implicitly loaded.
- `jquery.fancytree/dist/modules/jquery.fancytree` is defined as
package main module, so Fancytree can be included using a simple<br>
`fancytree = require('jquery.fancytree')`.<br>
See [the docs](https://github.com/mar10/fancytree/wiki/TutorialIntegration)
for details.
- All modules now return the
[$.ui.fancytree object](https://wwWendt.de/tech/fancytree/doc/jsdoc/Fancytree_Static.html).
- [Added] new static method `$.ui.fancytree.createTree(elem, opts)`
* [Added] Source map files for `jquery.fancytree-all-deps.min.js`
* [Added] New extension ext-fixed (work-in-progress, experimental)
* [Fixed] #767: Input inside table head not working
* [Fixed] #768: Can't use keyboard to select nodes when `checkbox` option is false
* [Fixed] #782: wide extension - padding is off when checkbox option is changed
* [Fixed] #787: Fix getEventTarget() for custom icons
# 2.24.0 / 2017-08-26
* [Added] ext-glyph option `preset` (making the `map` option optional)
* [Fixed] Drop marker for ext-glyph + ext-dnd5
* [Fixed] #695: List AMD dependency on jQuery UI
* [Fixed] #735: Trying to set root node selected throws an error
* [Fixed] #740: Filtering must not consider escaped html entities
* [Fixed] #741: Passing an empty string ("") as filter calls clearFilter()
* [Fixed] #748: Drag start should not activate a node
* [Fixed] #761: ext-dnd5 throws exception when tree is empty
* [Fixed] #764: ext-filter breaks links
* Updated jsdoc to 3.5
# 2.23.0 / 2017-05-27
* **The external dependency on jQuery UI was removed**.<br>
A new library `jquery.fancytree-all-deps.min.js` is now added to the
distribution. It includes all dependencies on jQuery UI, so the only
remaining external dependency is jQuery.<br>
Continue to use `jquery.fancytree-all.min.js` if jQuery UI is already
included anyway.
* **Refactored the select behavior**<br>
[details](https://github.com/mar10/fancytree/wiki/SpecSelect):
<!-- [details](https://github.com/mar10/fancytree/wiki#selection-and-checkboxes) -->
* [Added] Allow control of selection status propagation with new options:
`unselectable`, `unselectableIgnore`, `unselectableStatus`.
* [Added] node option `radiogroup` to enable single-select for child nodes
* [Added] option `opts.noEvents` to `setSelected(flag, opts)`
* [Improved] Option 'checkbox' can have the string value "radio" (only has
the visual effect of replacing the icon)
* **BREAKING CHANGES:**
* The `hideCheckbox` option was removed. Use `checkbox: false` instead.<br>
Note that the `<li class='hideCheckbox'>` is still parsed from input
HTML and converted accordingly.
* The optional modifier class `<div class='fancytree-radio'>` was removed.
This class was used on the *container* to turn all checkbox items into
radio buttons.<br>
Instead, this class is now added to `<span class="fancytree-checkbox fancytree-radio">`.
Use the `tree.checkox: "radio"` option to activate this for the whole tree.
* The callback signature for the `tree.tooltip` option has changed to
`tooltip(event, data)`
* [Improved] `aria` option is now on by default
* Use the new dynamic options pattern for
`checkbox`, `icon`, `tooltip`, `unselectable`, `unselectableIgnore`,
`unselectableStatus`.<br>
See also <a href="https://github.com/mar10/fancytree/wiki#dynamic-options">dynamic options</a>.
* [Added] New method `node.visitSiblings()`
* [Added] #730 ext-persist option `expandOpts` is passed to setExpanded()
Allows to suppress animation or event generation.
# 2.22.5 / 2017-05-11
* [Improved] #709 experimental ext-ariagrid
# 2.22.4 / 2017-05-06
* [Improved] #709 experimental ext-ariagrid
# 2.22.3 / 2017-05-05
* [Improved] #709 experimental ext-ariagrid
# 2.22.2 / 2017-04-29
* [Fixed] #729 Fix regression with addChild performance improvements (#708)
# 2.22.1 / 2017-04-21
* [Fixed] #722 Fix regression with addChild performance improvements (#708)
# 2.22.0 / 2017-04-11
* [Added] ext-dnd5 now part of standard distribution
* [Added] #693 ext-dnd/dnd5: configurable drop marker offset
* [Added] #616 ext-wide: configurable left padding
* [Added] New method $.ui.fancytree.evalOption()
* [Improved] #601 ext-filter: improve performance (don't render hidden nodes)
* [Improved] ext-contextMenu: disable keyboard while popup is open and restore focus
* [Improved] #701 ext-hotkeys: Prevent default behavior on hot key combination
* [Improved] #708 speedup improvement for addChildren
* [Fixed] #680 ext-dnd5: top level nodes not draggable
* [Fixed] #681 ext-table: exception when a lazy node has `children: []`
* [Fixed] #699 ext-dnd5: Icon remains after dnd is cancelled
* [Fixed] #702 $.ui.fancytree.getNode(jQuery)' for jQuery v3.x
* [Fixed] #706 Fix DND where fancytree-title span is not a direct child due to custom layouts
* [Fixed] #712 When clicking in a scrolled tree for the first time, focus is not set properly
* [Fixed] #716 ext-wide: animation 'jumps' (jQuery UI 1.12)
* [Fixed] #717, #719 expand/collapse shows displaced child nodes when scrolled (jQuery UI 1.12)
* Update demos to jQuery 3.2.1 / jQuery UI 1.12.1
# 2.21.0 / 2017-01-15
* [Added] New extension 'ext-dnd5' (beta) for native HTML5 drag'n'drop support
* [Added] `rtl` option for right-to-left script support
* [Added] Add $.ui.fancytree.overrideMethod()
* [Added] hook `treeSetOption` allows extensions to update on option changes
* [Changed] standard CSS no longer defines `overflow: auto` for the container.
If the tree container has a fixed height, `overflow: auto` or `overflow: scroll`
should be added to make it scrollable.
(Otherwise this always would be the scroll parent for ext-dnd5.)
* [Improved] better support for initializing from embedded JSON using the
`data-type="json"` attribute
* [Fixed] corner case of #658 when ext-edit is loaded, but inactive
* [Fixed] #396 Don't load 'loading.gif' for glyph skins
* [Fixed] #675 ext-table: node.render(false) puts first node at end
# 2.20.0 / 2016-11-13
* [Added] #419 `modifyChild` event. This event is also a good place to
implement auto sorting (#559)
* [Added] #419 node.triggerModifyChild() and node.triggerModify()
* [Added] #595 add custom node filter to `generateFormElements()`
* [Added] #610 `tree.tooltip` option allows automatic or custom tooltips
* [Added] #620 improved tooltip escaping to allow newlines
* [DEPRECATED] `removeNode` event. Listen for `modifyChild` with operation
'remove' instead (which is fired on the parent)
* [Improved] ThemeRoller theme
* [Improved] ext-filter
- #297 add filter option 'hideExpanders' to remove expanders if all child
nodes are hidden by filter
- Filter options and the `opts` argument of `filterNodes()` / `filterBranches()`
have been unified
- [Fixed] #230 themeroller theme compatible with ext-filter
- [Fixed] #528 autoCollapse option blocks filter's autoExpand option
- [Fixed] #529 Filter: Mark matching nodes even if parent was matched in branch mode
- [Fixed] #643 Exceptions in ext-filter if expression contains special chars
- [Fixed] #658 ext-filter does not work with ext-edit `editCreateNode()`
* [Improved] #656 WAI-ARIA support
- Set focus to first node on first tab-in
- Support [home] and [end] keys
- Set aria-activedescendant on container to active ID
- Set aria-multiselectable on container if selectMode != 1
- Set aria-treeitem, -selected, -expanded, on title span instead `<li>`
* [Fixed] #576 `loadKeyPath()` sometimes gets the root wrong
* [Fixed] #615 Drag & drop helper icons lose indentation with table extension
* [Fixed] #632 Tabbing is not working if there is an anchor tag in treeview
* [Fixed] #644 New nodes created with ext-edit, are hidden in filtered trees
* [Fixed] #647 ext-table: tree.render(true) does not discard existing markup
* [Fixed] #659 handling of function keys, when quicksearch is on
* Use QUnit 2.0
# 2.19.0 / 2016-08-11
* [Added] #607 tree.enableUpdate() to temporarily disable rendering to improve
performance on bulk updates
* [Added] modifier class `.fancytree-connectors` to be set on container<br>
Note: Experimental! Not required for skin-xp and not compatible with ext-table
* [Added] #623 ext-edit: `data.originalEvent` is now passed to `beforeClose`
* [Fixed] #604 Set `source` option does not update tree
* [Fixed] #609 node.load(true); doesn't maintain expanded
* [Fixed] #621 Cannot focus embedded input controls
* [Improved] #611 Keyboard navigation honors autoScroll option
* Extensions inherit main version number
# 2.18.0 / 2016-05-02
* [Added] #586 node.discardMarkup() (useful in the `collapsed` event)
* [Added] #171 new option `.escapeTitles`
* [Added] new callback `.enhanceTitle()`
* [Fixed] #515 Html tags included in filter results
* [Fixed] #593 ext-dnd revert position fails for tables
# 2.17.0 / 2016-04-11
* [Added] `node.addClass()`, `.removeClass()`, and `.toggleClass()`
* [Added] ext-filter: matcher-callback for `tree.filterNodes()` may now return
`"branch"` and `"skip"`
* [Added] ext-filter: new option`nodata` allows to configure a status node for
empty results
* [Added] `digits` argument to `node.getIndexHier(separator, digits)`
* [Added] tree option `.tabindex`, default is "0". Pass "" to resolve #577
* [DEPRECATED] tree option `.tabbable`. Use `.tabindex` instead
* [Added] New option `mode='firstChild'` for `node.moveTo()`
* [Added] New option `digits=<int>` for `node.getIndexHier()`
* [Fixed] ext-filter: branch mode honors `autoExpand: true`
* [Fixed] #584: aria-labelledby ids not unique
* Update to jQuery UI 1.11.4
# 2.16.1 / 2016-03-18
* [Added] ext-glyph: new icon for 'nodata' status nodes
* [Fixed] #575 missing loading icon in non-bootstrap themes.<br>
Glyph themes now display status images in icon span (was expander span before)
# 2.16.0 / 2016-03-16
* [Added] ext-clones: new method node.setRefKey(refKey)
* [Added] modifier class `.fancytree-fade-expander` to be set on container
* [Added] ext-dnd: `.dragExpand()` callback to prevent auto-expand
* [Improved] load error reporting
* [Improved] bootstrap theme icons and style (samples use bootstrap 3.3)
* [Improved] status nodes don't have icons
* [Improved] pass data argument to `source` callback
* [Improved] Handle exceptions inside `postProcess`
* [Improved] #568 ext-dnd: Auto-expanding of collapsed nodes should also work
when dropping is not allowed
* [Improved] #567 ext-dnd: fix revert position
* [Improved] #565 ext-dnd: fix intermediate display of wrong icon (sending 'over' after 'enter')
* [Fixed] #569 node.navigate does not return a Promise object
* [Fixed] #563 `tree.reactivate(false)` sets fancytree-treefocus and `tree.reactivate(true)`
doesn't set keyboard focus
* [Fixed] #562 Node span tag leaks outside table cell
* [Fixed] #526 tree.setFocus() does not set keyboard focus
* Updated to jQuery 1.12.1
* Updated grunt devDependencies
* Add jQuery 3.0 beta to test suite
* Added LICENSE.txt to dist
# 2.15.0 / 2016-01-11
* [Changed] Renamed class `fancytree-statusnode-wait` to `fancytree-statusnode-loading`
* [Added] new event `renderStatusColumns`
* [DEPRECATED] ext-table option `customStatus`. Use `renderStatusColumns` instead
* [Added] new event `clickPaging`
* [Added] new mode `nodata` for use with node.setStatus()
* [Added] new method `node.addPagingNode()`
* [Added] new method `node.replaceWith()`
* [Added] new type 'paging' for `node.statusNodeType`
* [Added] #542 new method `node.getSelectedNodes()`
* [Added] Helper class `glyphicon-spin` to allow rotating loading icon with bootstrap3
* [Improved] #356: serialize load requests
* [Improved] #538: Be more robust if site css defines custom li:before
* [Improved] ext-table: Define table row templates in `<tbody>`
* [Improved] ext-table: `<thead>` is now optional if `<tbody>` contains `<td>`s
# 2.14.0 / 2015-12-19
* [CHANGED] #519 Refactored custom icon configuration:<br>
(see also the [theming tutorial](https://github.com/mar10/fancytree/wiki/TutorialTheming))
* [Added] `options.icon` option/callback.<br>
Valid values are true, false, a string containing a class name or image
url, or a callback returning that.
* [Changed] `node.icon` option. Valid values are true, false, or a string
containing a class name or image url.<br>
This option existed before, but was stored in the `node.data.icon` namespace,
and did not accept class names.
* [DEPRECATED] `options.iconClass` callback: use `options.icon` instead
* [DEPRECATED] `options.icons`: use `options.icon` instead
* [DEPRECATED] `node.data.iconclass` option: use `node.icon` instead
* [DEPRECATED] `node.data.icon` option: use `node.icon` instead
* [Added] `tree.clear()` method.
* [Added] #520 ext-persist: new event `beforeRestore`
* [Fixed] #533 table-ext: nodeSetExpanded triggers redundant events
# 2.13.0 / 2015-11-16
* [Changed] If a node is initalized as `lazy: true`, and `children: []`,
treat it as 'loaded leaf node'.<br>
This is consistent with a lazy node that has no children property at all (i.e.
`undefined`). This would issue a lazyLoad event and a resopnse of `[]` would
mark the node as leaf node.
* [Added] new function $.ui.fancytree.getTree()
* [Added] ext-filter methods node.isMatched() and tree.isFilterActive()
* [Added] CSS for ext-childcounter badges is now part of the standard themes
* [Added] ext-childcounter method node.updateCounter()`
* [Fixed] #507 data-hideCheckbox="true"
* [Fixed] #513 activeVisible option does not work on init
* [Fixed] #516 ExtPersist requires cookie.js even when not using cookies
# 2.12.0 / 2015-09-10
* [Changed] Documented `iconClass` callback and changed signature from
`iconClass(node)` to `iconClass(event, data)`
* [Added] ext-dnd events `initHelper` and `updateHelper`
* [Added] ext-dnd option `smartRevert`
* [Added] #146 sample for multi-node drag'n'drop
* [Added] Sample for modifier keys to control copy/move behavior while dragging
* [Added] `highlight` and `fuzzy` options to ext-filter
* [Added] `fireActivate` option to ext-persist (default: true)
* [Added] #496 new methods tree.findFirst() / .findAll()
* [Improved] clearFilter() performance #491
* [Improved] dnd registers global handlers to cancel on ESC and mousedown
* [Fixed] #475 Font color while editing node title with bootstrap skin
* [Fixed] #484 Glyph plugin: Missing margin-left for span.fancytree-custom-icon
* [Fixed] #486 node.render(true) moves the node to the end of the list
* [Fixed] #489 `focusOnClick` option is ignored for tables if 'dnd' is listed after 'table' extension
* [Fixed] #495 Double clicking on expander with lazy-load causes assertion error
# 2.11.0 / 2015-07-26
* [Changed] Adding `fancytree-plain` class to container (if not table), allowing for more efficient css
* [Changed] #434: Use data-uris to inline loading.gif image
* [Changed] #460: Use padding-left instead of margin-left for table indent
* [Changed] #465: Add `node` argument to the `toDict()` callback
* [Improved] Nicer bootstrap theme and added table to the example
* [Improved] #464: ext-dnd supports ext-glyph
* [Improved] #466: Add counter badges to ext-filter
* [Fixed] Win8 theme jumpy hover effects
* [Fixed] #411: ext-edit fails with ext-table, when edit was cancelled
* [Fixed] #463: ext-table: render(deep) does not work
* [Fixed] #470: Wide plugin not present in jquery.fancytree-all.min.js
# 2.10.2 / 2015-07-02
* [Fixed] Add `dist/skin-custom-1` sample (again)
* [Fixed] #459 Don't collapse root folder when last node is removed
# 2.10.1 / 2015-06-27
* [Changed] Undo #340: Revert dist folder layout to v2.9.0, but add
dist/skin-common.less
# 2.10.0 / 2015-06-26 [YANKED]
* [Changed] #340: New dist folder layout: moved skin-* folders into src/ folder
(**Note:** this change was reverted in v2.10.1)
* [Improved] Update to jQuery UI 1.11.4, jQuery 1.11.3
* [Improved] #340: add `dist/skin-common.less` to fix theme imports
* [Improved] #443 Support js-cookie (still compatible with jquery-cookie)
* [Fixed] #415 selected and unselectable shows unchecked checkbox
* [Fixed] #427 table + themeroller: apply color to TR
* [Fixed] #442 filterBranches shall use opts to allow autoExpand
* [Fixed] #445 enter key not handled correctly
* [Fixed] #449 After deleting last child, parent node remains expanded
* [Fixed] #452 destroy not removing nodes with ext-table
* [Fixed] #457 Autoscroll fails with lazyloading returning empty list
# 2.9.0 / 2015-04-19
* [Changed] ext-filter: `tree.filterNodes(filter, opts)` now accept an `opts`
object instead of `leavesOnly`
* [Improved] #417 only raise exception about data being a string if dataType is "json"
* [Added] #394 New option `autoExpand` for [ext-filter]
* [Fixed] #402, #405 rare exception in dnd events
* [Fixed] #420 nodeSetActive not returning promise
* [Fixed] #270 Keyboard focus not working when using dnd extension
# 2.8.1 / 2015-03-01
* [Improved] generateFormElements() new argument `opts`, default: `{stopOnParents: true}`
* [Fixed] #393 ext-table: checkboxColumnIdx not working
* [Fixed] #397 ext-edit: Creating sub category fails
* [Fixed] #403 generateFormElements() doesn't work with string args
# 2.8.0 / 2015-02-08
* [Changed] Deprecated ext-menu (was never officially supported, see http://localhost:8080/demo/index.html#sample-ext-menu.html)
* [Improved] Bluring the widget will now blur the focused node too.
* [Improved] Persistence will only set node focus if widget had focus (otherwise only activate the node).
* [Improved] Set default focus on first keypress to active node (first node otherwise)
* [Improved] #383 Accept [ECMAScript 6 Promise](https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Promise) as source
* [Added] `_superApply()` for hook handlers.
* [Added] eventToString() supports mouse events
* [Fixed] persistence for focus (when using non-cookie storage)
* [Fixed] #391 Exception on autoscrolling filtered trees
# 2.7.0 / 2014-12-21
* [CHANGED] Dropped `fx` option. Use `toggleEffect` instead.
* [CHANGED] 'win8' and 'bootstrap' skins where modified to highlight the
title span instead of the node span, in order to be compatible with
[ext-wide]. The original skins are available as 'skin-win8-n' and
'skin-bootstrap-n' respectively.
* [Added] ext-wide extension (experimental)
* [Added] LESS files to distribution
* [Added] Publish on [cdnjs](https://cdnjs.com/libraries/jquery.fancytree)
* [Improved] tree.reactivate() returns a promise
* [Fixed] #246 Gaps when filtering in hide mode (patch by @lefunque)
* [Fixed] #287 wrong image on hovers
* [Fixed] #368 Standard browser behavior prevented (e.g. zoom with Ctrl+'+'/'-')
* [Fixed] #369 Suppress warning, when dropping top- on top-node
# 2.6.0 / 2014-11-29
* [Added] Option `focusOnSelect` to set focus when node is checked by a mouse
click (default: false)
* [Added] `restore` event, sent after ext-persist has restored the tree state
* [Improved] #359 Better navigation performance when skipping hidden nodes
* Publish on npm Registry
# 2.5.0 / 2014-11-23
* [CHANGED] [ext-persist] overrideSource option now defaults to true
* [Added] [ext-filter] Option `autoApply` re-applies filter on lazy loading
(on by default)
* [Added] quicksearch: navigate to next node by typing the first letters
* [Improved] [ext-dnd] Make draggable helper and parent configurable
* [Improved] #153 Add class `fancytree-unselectable` to respective nodes and
dim unselectable checkboxes
* [Improved] Update to jQuery 1.1.11, jQuery UI 1.11.2
* [Improved] New mode 'firstChild' for node.addNode()
* [Fixed] #324 Fix problem where minExpandLevel was not expanding root node
* [Fixed] #300 dnd.focusOnClick for jQuery UI 1.11
* [Fixed] #354 [ext-persist] with selectMode 3
# 2.4.1 / 2014-09-23
* [Fixed] Regression #323
# 2.4.0 / 2014-09-21
* [CHANGED] Renamed dist/jquery.fancytree-custom.min.js to jquery.fancytree-all.min.js
* [CHANGED] ext-edit callbacks no longer pass `data.value` (use `data.input.val()` instead).
* [Added] CDN support (http://www.jsdelivr.com/#!jquery.fancytree)
* [Added] New method `node.visitAndLoad()`
* [Added] New method `node.editCreateNode()` (ext-edit)
* [Added] New method `node.isRootNode()`
* [Added] New method `node.isTopLevel()`
* [Added] New option `id` to override default tree id
* [Added] New argument `stopOnParents` for tree.generateFormElements()
* [Improved] #294 node.load() should resolve 'ok', if node is already loaded
* [Improved] #293 minExpandLevel does not auto-expand
* [Improved] #313 Allow HTML in tooltips
* [Fixed] crash in scrollIntoView() when parent is `window`
* [Fixed] #305 Checkbox doesn't show with Glyph + Table
* [Fixed] #316 Fix hasChildren() when children = []
* [Fixed] #237 Ajax LoadError not updated in StatusNode with Table ext
* [Fixed] #295 loadKeyPath with multiple paths
* [DEPRECATED] node.isRoot(). Use node.isRootNode() instead
# 2.3.0 / 2014-08-17
* [CHANGED] renamed (undocumented) event 'loaderror' to 'loadError'
* [Added] postProcess now allows to signal error conditions (so it becomes easy to handle custom Ajax response formats)
* [Added] node.setStatus()
* [Added] ext-clones to the standard distribution.
* [Improved] loadError allows to return `false` to prevent default handling
* [Fixed] #258 Fix moveTo when moving a node to same parent
* [Fixed] #257 Glyph expander sometimes disappears
# 2.2.0 / 2014-06-28
* [Added] Option dnd.focusOnClick sets focus to tree widget, even when dragging
is enabled
* [Added] node.info()
* [Improved] #245 tree.generateInput() now returns data using PHPs array
convention, i.e. by appending brackets to the name: 'ft_1[]'.
* [Fixed] #250: Children lazy empty nodes remain checked when parent is
unchecked with hierarchical multi-selection
* [Fixed] #272 Navigation in filtered trees
# 2.1.0 / 2014-05-29
* [Added] #210: [ext-persist] optionally store information in sessionStorage or localStorage
* [Added] #64 [ext-filter] filterBranches() will restrict display to sub-trees
* [Added] New options 'scrollParent' and 'scrollOfs' are evaluated by node.scrollIntoView()
(which is also called on expand). This allows autoScroll to work with
[ext-table]: set scrollParent to `window` or a wrapper DIV with overflow: auto
* [Added] [ext-wide] use 100% wide selection bar (experimental)
* [Added] $.ui.fancytree.debounce()
* [Improved] [ext-columnview] css
* [Improved] skin-win8 now includes the loading.gif as inline CSS for faster response
* [Improved] Add 'fancytree-icon' class to icon IMG
* [Improved] css v-align for checkboxes and icons
* [Fixed] #217: persistence when node keys are numeric
* [Fixed] #228: html in node title prevents click
* [Fixed] #235: D'n'd helper is displaced, when window is scrolled
* [Fixed] #241: fromDict() does not update node title
* [Fixed] relative custom imagePath option
* [DEPRECATED] [ext-filter] Use filterNodes() instead of applyFilter()
* [DEPRECATED] [ext-filter] 'leavesOnly' option removed (see filterNodes())
# 2.0.0 / 2014-05-01
* Released 2.0.0
# 1.x
* See [Dynatree](https://code.google.com/p/dynatree/)

View File

@ -0,0 +1,629 @@
###
Build scripts for Fancytree
###
"use strict"
module.exports = (grunt) ->
grunt.initConfig
pkg:
grunt.file.readJSON("package.json")
# Project metadata, used by the <banner> directive.
meta:
banner: "/*! <%= pkg.title || pkg.name %> - @VERSION - @DATE\n" +
# "<%= grunt.template.today('yyyy-mm-dd HH:mm') %>\n" +
"<%= pkg.homepage ? ' * ' + pkg.homepage + '\\n' : '' %>" +
" * Copyright (c) <%= grunt.template.today('yyyy') %> <%= pkg.author.name %>;" +
" Licensed <%= _.map(pkg.licenses, 'type').join(', ') %>\n" +
" */\n"
# separator: "\n/*! --- Fancytree Plugin --- */\n"
clean:
build:
src: [ "build" ]
dist:
src: [ "dist" ]
post_build: # Remove unwanted files from build folder
src: [
"build/jquery.fancytree.*.min.js"
"build/jquery.fancytree.js"
"build/jquery-ui-dependencies/"
]
concat:
core_to_build:
options:
stripBanners: true
src: ["<banner:meta.banner>"
"src/jquery.fancytree.js"
]
dest: "build/jquery.fancytree.js"
bundle_to_build:
options:
stripBanners: true
src: [
"<%= meta.banner %>"
"src/jquery.fancytree.js"
# "src/jquery.fancytree.ariagrid.js"
"src/jquery.fancytree.childcounter.js"
"src/jquery.fancytree.clones.js"
# "src/jquery.fancytree.columnview.js"
"src/jquery.fancytree.dnd.js"
"src/jquery.fancytree.dnd5.js"
"src/jquery.fancytree.edit.js"
"src/jquery.fancytree.filter.js"
# "src/jquery.fancytree.fixed.js"
"src/jquery.fancytree.glyph.js"
# "src/jquery.fancytree.grid.js"
"src/jquery.fancytree.gridnav.js"
"src/jquery.fancytree.multi.js"
"src/jquery.fancytree.persist.js"
"src/jquery.fancytree.table.js"
"src/jquery.fancytree.themeroller.js"
"src/jquery.fancytree.wide.js"
]
dest: "build/jquery.fancytree-all.js"
amd_bundle_min:
options:
banner: "<%= meta.banner %>"
stripBanners: true
process: (src, fspec) ->
# Remove all comments, including /*! ... */
src = src.replace(/\/\*(.|\n)*\*\//g, "")
if /fancytree..+.min.js/.test(fspec)
# If it is an extension:
# Prepend a one-liner instead
fspec = fspec.substr(6) # strip 'build/'
src = "\n/*! Extension '" + fspec + "' */" + src
return src
src: [
"lib/amd-intro-require-native-ui.js"
"build/jquery.fancytree.min.js"
# "build/jquery.fancytree.ariagrid.min.js"
"build/jquery.fancytree.childcounter.min.js"
"build/jquery.fancytree.clones.min.js"
# "build/jquery.fancytree.columnview.min.js"
"build/jquery.fancytree.dnd.min.js"
"build/jquery.fancytree.dnd5.min.js"
"build/jquery.fancytree.edit.min.js"
"build/jquery.fancytree.filter.min.js"
# "build/jquery.fancytree.fixed.min.js"
"build/jquery.fancytree.glyph.min.js"
# "build/jquery.fancytree.grid.min.js"
"build/jquery.fancytree.gridnav.min.js"
"build/jquery.fancytree.multi.min.js"
"build/jquery.fancytree.persist.min.js"
"build/jquery.fancytree.table.min.js"
"build/jquery.fancytree.themeroller.min.js"
"build/jquery.fancytree.wide.min.js"
"lib/amd-outro.js"
]
dest: "build/jquery.fancytree-all.min.js"
all_deps: # un-minified, so we can generate a map file
options:
banner: "<%= meta.banner %>"
stripBanners: true
process: (src, fspec) ->
# Remove all comments, including /*! ... */
# (but keep disclaimer for jQuery-UI)
# if not /jquery-ui..+.js/.test(fspec)
# # if not /jquery-ui..+.min.js/.test(fspec)
# src = src.replace(/\/\*(.|\n)*\*\//g, "")
# # strip out AMD related code from jQuery-UI and make it an IIFE
# if /jquery-ui..+.min.js/.test(fspec)
# src = src.replace(/\(function.+jQuery\)}\)\((.+\)}\)})\);/, "!$1(jQuery);")
if /jquery.fancytree.js/.test(fspec)
src = "\n/*! Fancytree Core */" + src
if /fancytree..+.js/.test(fspec)
# If it is an extension:
# Prepend a one-liner instead
fspec = fspec.substr(4) # strip 'src/'
src = "\n/*! Extension '" + fspec + "' */" + src
return src
src: [
"<%= meta.banner %>"
# Inline jQuery UI custom (AMD header removed: IIFE only)
"src/jquery-ui-dependencies/jquery-ui-iife.js"
# Fancytree core and extensions, wrapped in UMD pattern
"lib/amd-intro-require-jquery.js"
"src/jquery.fancytree.js"
# "src/jquery.fancytree.ariagrid.js"
"src/jquery.fancytree.childcounter.js"
"src/jquery.fancytree.clones.js"
# "src/jquery.fancytree.columnview.js"
# "src/jquery.fancytree.dnd.js" # Draggable widget is not part of our custom jQuery UI dependencies
"src/jquery.fancytree.dnd5.js"
"src/jquery.fancytree.edit.js"
"src/jquery.fancytree.filter.js"
# "src/jquery.fancytree.fixed.js"
"src/jquery.fancytree.glyph.js"
# "src/jquery.fancytree.grid.js"
"src/jquery.fancytree.gridnav.js"
"src/jquery.fancytree.multi.js"
"src/jquery.fancytree.persist.js"
"src/jquery.fancytree.table.js"
"src/jquery.fancytree.themeroller.js"
"src/jquery.fancytree.wide.js"
"lib/amd-outro.js"
]
dest: "build/jquery.fancytree-all-deps.js"
connect:
forever:
options:
port: 8080
base: "./"
keepalive: true
dev: # pass on, so subsequent tasks (like watch) can start
options:
port: 8080
base: "./"
keepalive: false
# middleware: (connect) ->
# return [
# (req, res, next) ->
# res.setHeader('Access-Control-Allow-Origin', '*')
# res.setHeader('Access-Control-Allow-Methods', '*')
# next()
# ]
sauce: # Used by sauce tasks, see https://wiki.saucelabs.com/display/DOCS/Grunt-Saucelabs+Set+Up%2C+Configuration%2C+and+Usage
options:
# hostname: "localhost"
# hostname: "127.0.0.1"
# port: 8080
port: 9999
base: ""
keepalive: false
localhost_9999: # Start web server for Sauce live testing w/ manuallly run bin/sc.exe
options:
# hostname: "localhost"
port: 9999
base: ""
keepalive: true
copy:
build: # Copy development files to build folder
files: [{ # src/ => build/
expand: true # required for cwd
cwd: "src/"
src: [
"skin-**/*.{css,gif,md,png,less}"
"skin-common.less"
"*.txt"
]
dest: "build/"
}, { # src/ => build/modules/
expand: true
cwd: "src/"
src: [ "jquery.*.js" ]
dest: "build/modules/"
}, { # Top-level => build/
src: ["LICENSE.txt"]
dest: "build/"
}]
ui_deps: #
files: [{
src: "src/jquery-ui-dependencies/jquery.fancytree.ui-deps.js"
dest: "build/modules/jquery.fancytree.ui-deps.js"
}]
dist: # Copy build folder to dist/ (recursive)
files: [
{expand: true, cwd: "build/", src: ["**"], dest: "dist/"}
]
cssmin:
options:
report: "min"
build:
expand: true
cwd: "build/"
src: ["**/*.fancytree.css", "!*.min.css"]
dest: "build/"
ext: ".fancytree.min.css"
devUpdate:
main:
options:
reportUpdated: true
updateType: 'prompt' # 'report'
docco:
docs:
src: ["src/jquery.fancytree.childcounter.js"]
options:
output: "doc/annotated-src"
eslint:
options:
maxWarnings: 100
# format: "stylish"
# options:
# # See https://github.com/sindresorhus/grunt-eslint/issues/119
# quiet: true
# We have to explicitly declare "src" property otherwise "newer"
# task wouldn't work properly :/
build:
options:
ignore: false
src: [
"build/jquery.fancytree.js"
"build/jquery.fancytree-all.js"
"build/modules/*.js"
]
dev:
src: [
"src/*.js"
"3rd-party/**/jquery.fancytree.*.js"
# "test/**/test-*.js"
"demo/**/*.js"
]
fix:
options:
fix: true
src: [
"src/*.js"
"3rd-party/**/jquery.fancytree.*.js"
# "test/**/test-*.js"
"demo/**/*.js"
]
exec:
upload:
# FTP upload the demo files (requires https://github.com/mar10/pyftpsync)
stdin: true # Allow interactive console
cmd: "pyftpsync upload . ftp://www.wwwendt.de/tech/fancytree --progress --exclude build,node_modules,.*,_* --delete-unmatched"
upload_force:
# FTP upload the demo files (requires https://github.com/mar10/pyftpsync)
cmd: "pyftpsync upload . ftp://www.wwwendt.de/tech/fancytree --progress --exclude build,node_modules,.*,_* --delete-unmatched --resolve=local --force"
jsdoc:
build:
src: ["src/*.js", "doc/README.md"]
options:
destination: "doc/jsdoc"
template: "bin/jsdoc3-moogle",
configure: "doc/jsdoc.conf.json"
verbose: true
less:
development:
options:
# paths: ["src/"]
# report: "min"
compress: false
yuicompress: false
# optimization: 10
# webpack uses /dist/skin-common.less as root path
# grunt-less uses /dist/skin-Xxx/ui.fancyree.less as root path
# So we define our theme LESS files for webpack compatibility
# and fix it for grunt-less here:
rootpath: ".."
files: [
{expand: true, cwd: "src/", src: "**/ui.fancytree.less", dest: "src/", ext: ".fancytree.css"}
]
qunit:
options:
httpBase: "http://localhost:8080"
# httpBase: "http://127.0.0.1:8080"
build: [
"test/unit/test-core-build.html"
]
develop: [
"test/unit/test-core.html"
"test/unit/test-ext-filter.html"
"test/unit/test-ext-table.html"
"test/unit/test-ext-misc.html"
]
dist: [
"test/unit/test-core-dist.html"
]
replace: # grunt-text-replace
production:
src: ["build/**/*.{js,less,css}"]
overwrite : true
replacements: [ {
from : /@DATE/g
# https://github.com/felixge/node-dateformat
to : "<%= grunt.template.today('isoUtcDateTime') %>"
},{
from : /buildType:\s*\"[a-zA-Z]+\"/g
to : "buildType: \"production\""
},{
from : /debugLevel:\s*[0-9]/g
to : "debugLevel: 3"
} ]
release:
src: ["dist/**/*.{js,less,css}"]
overwrite : true
replacements: [ {
from : /@VERSION/g
to : "<%= pkg.version %>"
} ]
"saucelabs-qunit":
options:
build: process.env.TRAVIS_JOB_ID
throttled: 5
framework: "qunit"
# Map of extra parameters to be passed to sauce labs. example:
# {'video-upload-on-pass': false, 'idle-timeout': 60}
sauceConfig:
"video-upload-on-pass": false
recordVideo: true
# Needed for Edge/Windows (as of 2019-06-02) and Firefox(?)
iedriverVersion: "3.141.59"
seleniumVersion: "3.141.59"
# Array of optional arguments to be passed to the Sauce Connect tunnel.
# See https://saucelabs.com/docs/additional-config
tunnelArgs: [
'-v',
'--logfile', 'saucelabs-tunnel.log',
'--tunnel-domains', 'localhost,travis.dev'
# '--direct-domains', 'google.com'
]
triage:
options:
testname: "Triage"
build: "triage"
# urls: ["http://wwwendt.de/tech/fancytree/test/unit/test-core.html"]
# urls: ["http://127.0.0.1:9999/test/unit/test-jQuery19-ui19.html"]
# urls: ["http://127.0.0.1:9999/test/unit/test-jQuery1x-mig-ui1x.html"]
urls: ["http://localhost:9999/test/unit/test-core.html"]
# urls: ["http://127.0.0.1:9999/test/unit/test-core.html"]
# tunneled: false # Use bin/sc manually
browsers: [
# Issue #825
# { browserName: "chrome", version: "dev", platform: "Windows 10" }
# { browserName: "internet explorer", version: "9", platform: "Windows 7" }
# { browserName: "internet explorer", version: "8", platform: "Windows 7" }
# { browserName: "chrome", version: "latest", platform: "Windows 10" }
# { browserName: "microsoftedge", version: "latest", platform: "Windows 10" }
# { browserName: "safari", version: "12", platform: "macOS 10.14" }
{ browserName: "firefox", version: "latest", platform: "Windows 10" }
]
ui_112:
options:
testname: "Fancytree qunit tests (jQuery 3, jQuery UI 1.12)"
# urls: ["http://wwwendt.de/tech/fancytree/test/unit/test-core.html"]
urls: ["http://localhost:9999/test/unit/test-core.html"]
# urls: ["http://127.0.0.1:9999/test/unit/test-core.html"]
# jQuery 3 supports IE 9+ and latest Chrome/Edge/Firefox/Safari (-1)
# jQuery UI 1.12 supports IE 11 and latest Chrome/Edge/Firefox/Safari (-1)
browsers: [
{ browserName: "chrome", version: "latest", platform: "Windows 10" }
{ browserName: "chrome", version: "latest-1", platform: "Windows 10" }
{ browserName: "firefox", version: "latest", platform: "Windows 10" }
{ browserName: "firefox", version: "latest-1", platform: "Windows 10" }
{ browserName: "firefox", version: "latest", platform: "Linux" }
{ browserName: "microsoftedge", version: "latest", platform: "Windows 10" }
{ browserName: "microsoftedge", version: "latest-1", platform: "Windows 10" }
{ browserName: "internet explorer", version: "11", platform: "Windows 8.1" }
{ browserName: "internet explorer", version: "10", platform: "Windows 8" }
{ browserName: "internet explorer", version: "9", platform: "Windows 7" }
# Test Saucelabs:
# { browserName: "chrome", version: "latest", platform: "macOS 10.14" }
# { browserName: "firefox", version: "latest", platform: "macOS 10.14" }
]
# ui_111:
# options:
# testname: "Fancytree qunit tests (jQuery 1.11, jQuery UI 1.11)"
# # urls: ["http://wwwendt.de/tech/fancytree/test/unit/test-jQuery111-ui111.html"]
# urls: ["http://127.0.0.1:9999/test/unit/test-jQuery111-ui111.html"]
# # jQuery 1.11 supports IE + and latest Chrome/Edge/Firefox/Safari (-1)
# # jQuery UI 1.11 supports IE 7+ and ?
# browsers: [
# { browserName: "internet explorer", version: "10", platform: "Windows 8" }
# # Issue #842:
# # { browserName: "safari", version: "7", platform: "OS X 10.9" }
# { browserName: "safari", version: "8", platform: "OS X 10.10" }
# ]
# ui_110:
# options:
# testname: "Fancytree qunit tests (jQuery 1.10, jQuery UI 1.10)"
# # urls: ["http://wwwendt.de/tech/fancytree/test/unit/test-jQuery110-ui110.html"]
# urls: ["http://127.0.0.1:9999/test/unit/test-jQuery110-ui110.html"]
# # jQuery 1.10 dropped support for IE 6
# # jQuery UI 1.10 supports IE 7+ and ?
# browsers: [
# # { browserName: "internet explorer", version: "8", platform: "Windows 7" }
# { browserName: "internet explorer", version: "9", platform: "Windows 7" }
# ]
beta: # This tests are allowed to fail in the travis matrix
options:
testname: "Fancytree qunit tests ('dev' browser versions)"
# urls: ["http://wwwendt.de/tech/fancytree/test/unit/test-core.html"]
urls: ["http://localhost:9999/test/unit/test-core.html"]
# urls: ["http://127.0.0.1:9999/test/unit/test-core.html"]
browsers: [
# Issue #825
{ browserName: "chrome", version: "dev", platform: "Windows 10" } #, chromedriverVersion: "2.46.0" }
# FF.dev is problematic: https://support.saucelabs.com/hc/en-us/articles/225253808-Firefox-Dev-Beta-Browser-Won-t-Start
{ browserName: "firefox", version: "dev", platform: "Windows 10" }
# 2019-06-02: known problem with Saucelabs using localhost on macOS:
{ browserName: "safari", version: "12", platform: "macOS 10.14" }
{ browserName: "safari", version: "11", platform: "macOS 10.13" }
{ browserName: "safari", version: "10", platform: "macOS 10.12" }
{ browserName: "safari", version: "9", platform: "OS X 10.11" }
# { browserName: "safari", version: "8", platform: "OS X 10.10" }
]
uglify:
src_to_build:
options: # see https://github.com/gruntjs/grunt-contrib-uglify/issues/366
report: "min"
# preserveComments: "some"
preserveComments: /(?:^!|@(?:license|preserve|cc_on))/
output:
ascii_only: true # #815
files: [
{
src: ["**/jquery.fancytree*.js", "!*.min.js"]
cwd: "src/"
dest: "build/"
expand: true
rename: (dest, src) ->
folder = src.substring(0, src.lastIndexOf("/"))
filename = src.substring(src.lastIndexOf("/"), src.length)
filename = filename.substring(0, filename.lastIndexOf("."))
return dest + folder + filename + ".min.js"
}
]
all_deps:
options: # see https://github.com/gruntjs/grunt-contrib-uglify/issues/366
report: "min"
sourceMap: true
# preserveComments: "some"
preserveComments: /(?:^!|@(?:license|preserve|cc_on))/
output:
ascii_only: true # #815
files: [
{
src: ["jquery.fancytree-all-deps.js"]
cwd: "build/"
dest: "build/"
expand: true
rename: (dest, src) ->
folder = src.substring(0, src.lastIndexOf("/"))
filename = src.substring(src.lastIndexOf("/"), src.length)
filename = filename.substring(0, filename.lastIndexOf("."))
return dest + folder + filename + ".min.js"
}
]
watch:
less:
files: "src/**/*.less"
tasks: ["less:development"]
eslint:
options:
atBegin: true
files: ["src/*.js", "test/unit/*.js", "demo/**/*.js"]
tasks: ["eslint:dev"]
yabs:
release:
common: # defaults for all tools
manifests: ['package.json', 'bower.json']
# The following tools are run in order:
run_test: { tasks: ['test'] }
check: { branch: ['master'], canPush: true, clean: true, cmpVersion: 'gte' }
bump: {} # 'bump' also uses the increment mode `yabs:release:MODE`
run_build: { tasks: ['make_dist'] }
commit: { add: '.' }
tag: {}
push: { tags: true, useFollowTags: true },
githubRelease:
repo: 'mar10/fancytree'
draft: false
npmPublish: {}
bump_develop: { inc: 'prepatch' }
commit_develop: { message: 'Bump prerelease ({%= version %}) [ci skip]' }
push_develop: {}
# ----------------------------------------------------------------------------
# Load "grunt*" dependencies
for key of grunt.file.readJSON("package.json").devDependencies
grunt.loadNpmTasks key if key isnt "grunt" and key.indexOf("grunt") is 0
# Register tasks
grunt.registerTask "server", ["connect:forever"]
grunt.registerTask "dev", ["connect:dev", "watch"]
# grunt.registerTask "prettier", ["eslint:fix"]
grunt.registerTask "format", ["eslint:fix"]
grunt.registerTask "test", [
"eslint:dev",
# "csslint",
# "htmllint",
"connect:dev" # start server
"qunit:develop"
]
grunt.registerTask "sauce", [
"connect:sauce",
"saucelabs-qunit:ui_112",
# "saucelabs-qunit:ui_111",
# "saucelabs-qunit:ui_110",
]
grunt.registerTask "sauce-optional", [
"connect:sauce",
"saucelabs-qunit:beta",
]
grunt.registerTask "sauce-triage", ["connect:sauce", "saucelabs-qunit:triage"]
# 2020-01-26 Saucelabs tests don't work.
# Disable them in travis for now:
grunt.registerTask "travis", ["test"]
grunt.registerTask "travis-optional", []
# if parseInt(process.env.TRAVIS_PULL_REQUEST, 10) > 0
# # saucelab keys do not work on forks
# # http://support.saucelabs.com/entries/25614798
# grunt.registerTask "travis", ["test"]
# grunt.registerTask "travis-optional", []
# else
# grunt.registerTask "travis", ["test", "sauce"]
# grunt.registerTask "travis-optional", ["sauce-optional"]
grunt.registerTask "default", ["test"]
grunt.registerTask "ci", ["test"] # Called by 'npm test'
# Update package.json to latest versions (interactive)
grunt.registerTask "dev-update", ["devUpdate"]
grunt.registerTask "build", [
"less:development"
"format"
# `test` also starts the connect:dev server
"test"
"jsdoc:build"
"docco:docs"
"clean:build"
"copy:build"
"cssmin:build"
"concat:core_to_build"
"concat:bundle_to_build"
"uglify:src_to_build"
"concat:amd_bundle_min"
"concat:all_deps"
"uglify:all_deps"
"clean:post_build"
"replace:production"
"eslint:build"
"copy:ui_deps"
"qunit:build"
]
grunt.registerTask "make_dist", [
# `build` also starts the connect:dev server
"build"
"clean:dist"
"copy:dist"
"clean:build"
"replace:release"
# "eslint:dist" # should rather use grunt-jsvalidate for minified output
"qunit:dist"
]
grunt.registerTask "upload", [
"build"
"exec:upload"
]
grunt.registerTask "upload_force", [
"build"
"exec:upload_force"
]

View File

@ -0,0 +1,21 @@
Copyright 2008-2020 Martin Wendt,
https://wwWendt.de/
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@ -0,0 +1,71 @@
# ![logo](doc/logo.png?raw=true) Fancytree
[![GitHub version](https://badge.fury.io/gh/mar10%2Ffancytree.svg)](https://github.com/mar10/fancytree/releases/latest)
[![Build Status](https://travis-ci.org/mar10/fancytree.svg?branch=master)](https://travis-ci.org/mar10/fancytree)
[![npm](https://img.shields.io/npm/dm/jquery.fancytree.svg)](https://www.npmjs.com/package/jquery.fancytree)
[![jsDelivr](https://data.jsdelivr.com/v1/package/npm/jquery.fancytree/badge)](https://www.jsdelivr.com/package/npm/jquery.fancytree)
[![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square)](https://github.com/prettier/prettier)
[![StackOverflow: fancytree](https://img.shields.io/badge/StackOverflow-fancytree-blue.svg)](https://stackoverflow.com/questions/tagged/fancytree)
<!--
[![Selenium Test Status](https://saucelabs.com/buildstatus/sauce-fancytree)](https://saucelabs.com/u/sauce-fancytree)
-->
Fancytree (sequel of [DynaTree 1.x](https://code.google.com/p/dynatree/)) is a
JavaScript tree view / tree grid plugin with support for keyboard, inline editing,
filtering, checkboxes, drag'n'drop, and lazy loading.
[ ![sample](doc/teaser2.png?raw=true) ](https://wwWendt.de/tech/fancytree/demo "Live demo")
### Status
[![GitHub version](https://badge.fury.io/gh/mar10%2Ffancytree.svg)](https://github.com/mar10/fancytree/releases/latest)
See the [change log](https://github.com/mar10/fancytree/blob/master/CHANGELOG.md)
for details.
### Get Started
* [Try the live demo](https://wwWendt.de/tech/fancytree/demo).
* [Read the documentation](https://github.com/mar10/fancytree/wiki).
* [Check the Q&A forum](https://groups.google.com/forum/#!forum/fancytree) or
[Stackoverflow](http://stackoverflow.com/questions/tagged/fancytree) if you have questions.
* Play with [jsFiddle](http://jsfiddle.net/mar10/KcxRd/),
[CodePen](https://codepen.io/mar10/pen/WMWrbq),
or [Plunker](http://plnkr.co/edit/8sdy3r?p=preview).
* [Contribute](https://github.com/mar10/fancytree/wiki/HowtoContribute)
### ES6 Quickstart
```js
import $ from "jquery";
import 'jquery.fancytree/dist/skin-lion/ui.fancytree.less'; // CSS or LESS
import {createTree} from 'jquery.fancytree';
import 'jquery.fancytree/dist/modules/jquery.fancytree.edit';
import 'jquery.fancytree/dist/modules/jquery.fancytree.filter';
const tree = createTree('#tree', {
extensions: ['edit', 'filter'],
source: {...},
...
});
// Note: Loading and initialization may be asynchronous, so the nodes may not be accessible yet.
```
See [module loader support](https://github.com/mar10/fancytree/wiki#use-a-module-loader) and
[API docs](https://wwWendt.de/tech/fancytree/doc/jsdoc/Fancytree_Static.html#createTree).
### Credits
Thanks to all [contributors](https://github.com/mar10/fancytree/contributors).
<!--
### Browser Status Matrix
[![Selenium Test Status](https://saucelabs.com/browser-matrix/sauce-fancytree.svg)](https://saucelabs.com/u/sauce-fancytree)
-->

View File

@ -0,0 +1,22 @@
The default template for JSDoc 3 uses: [the Taffy Database library](http://taffydb.com/) and the [Underscore Template library](http://underscorejs.org/).
## Customized
Modified by Martin Wendt in 2017-08.
Changes are marked with `<!-- Start MOOGLE Changes -->` or `// --- Start MOOGLE Changes`
* Add a 'Methods:' section for class views, between 'Properties:' and 'Methods details'
* Add google analytics hooks
* Add a 'Fork me on Github'
## Generating Typeface Fonts
The default template uses the [OpenSans](https://www.google.com/fonts/specimen/Open+Sans) typeface. The font files can be regenerated as follows:
1. Open the [OpenSans page at Font Squirrel](<http://www.fontsquirrel.com/fonts/open-sans>).
2. Click on the 'Webfont Kit' tab.
3. Either leave the subset drop-down as 'Western Latin (Default)', or, if we decide we need more glyphs, than change it to 'No Subsetting'.
4. Click the 'DOWNLOAD @FONT-FACE KIT' button.
5. For each typeface variant we plan to use, copy the 'eot', 'svg' and 'woff' files into the 'templates/default/static/fonts' directory.

View File

@ -0,0 +1,701 @@
'use strict';
var doop = require('jsdoc/util/doop');
var env = require('jsdoc/env');
var fs = require('jsdoc/fs');
var helper = require('jsdoc/util/templateHelper');
var logger = require('jsdoc/util/logger');
var path = require('jsdoc/path');
var taffy = require('taffydb').taffy;
var template = require('jsdoc/template');
var util = require('util');
var htmlsafe = helper.htmlsafe;
var linkto = helper.linkto;
var resolveAuthorLinks = helper.resolveAuthorLinks;
var hasOwnProp = Object.prototype.hasOwnProperty;
var data;
var view;
var outdir = path.normalize(env.opts.destination);
function find(spec) {
return helper.find(data, spec);
}
function tutoriallink(tutorial) {
return helper.toTutorial(tutorial, null, {
tag: 'em',
classname: 'disabled',
prefix: 'Tutorial: '
});
}
function getAncestorLinks(doclet) {
return helper.getAncestorLinks(data, doclet);
}
function hashToLink(doclet, hash) {
var url;
if ( !/^(#.+)/.test(hash) ) {
return hash;
}
url = helper.createLink(doclet);
url = url.replace(/(#.+|$)/, hash);
return '<a href="' + url + '">' + hash + '</a>';
}
function needsSignature(doclet) {
var needsSig = false;
// function and class definitions always get a signature
if (doclet.kind === 'function' || doclet.kind === 'class') {
needsSig = true;
}
// typedefs that contain functions get a signature, too
else if (doclet.kind === 'typedef' && doclet.type && doclet.type.names &&
doclet.type.names.length) {
for (var i = 0, l = doclet.type.names.length; i < l; i++) {
if (doclet.type.names[i].toLowerCase() === 'function') {
needsSig = true;
break;
}
}
}
// and namespaces that are functions get a signature (but finding them is a
// bit messy)
else if (doclet.kind === 'namespace' && doclet.meta && doclet.meta.code &&
doclet.meta.code.type && doclet.meta.code.type.match(/[Ff]unction/)) {
needsSig = true;
}
return needsSig;
}
function getSignatureAttributes(item) {
var attributes = [];
if (item.optional) {
attributes.push('opt');
}
if (item.nullable === true) {
attributes.push('nullable');
}
else if (item.nullable === false) {
attributes.push('non-null');
}
return attributes;
}
function updateItemName(item) {
var attributes = getSignatureAttributes(item);
var itemName = item.name || '';
if (item.variable) {
itemName = '&hellip;' + itemName;
}
if (attributes && attributes.length) {
itemName = util.format( '%s<span class="signature-attributes">%s</span>', itemName,
attributes.join(', ') );
}
return itemName;
}
function addParamAttributes(params) {
return params.filter(function(param) {
return param.name && param.name.indexOf('.') === -1;
}).map(updateItemName);
}
function buildItemTypeStrings(item) {
var types = [];
if (item && item.type && item.type.names) {
item.type.names.forEach(function(name) {
types.push( linkto(name, htmlsafe(name)) );
});
}
return types;
}
function buildAttribsString(attribs) {
var attribsString = '';
if (attribs && attribs.length) {
attribsString = htmlsafe( util.format('(%s) ', attribs.join(', ')) );
}
return attribsString;
}
function addNonParamAttributes(items) {
var types = [];
items.forEach(function(item) {
types = types.concat( buildItemTypeStrings(item) );
});
return types;
}
function addSignatureParams(f) {
var params = f.params ? addParamAttributes(f.params) : [];
f.signature = util.format( '%s(%s)', (f.signature || ''), params.join(', ') );
}
function addSignatureReturns(f) {
var attribs = [];
var attribsString = '';
var returnTypes = [];
var returnTypesString = '';
var source = f.yields || f.returns;
// jam all the return-type attributes into an array. this could create odd results (for example,
// if there are both nullable and non-nullable return types), but let's assume that most people
// who use multiple @return tags aren't using Closure Compiler type annotations, and vice-versa.
if (source) {
source.forEach(function(item) {
helper.getAttribs(item).forEach(function(attrib) {
if (attribs.indexOf(attrib) === -1) {
attribs.push(attrib);
}
});
});
attribsString = buildAttribsString(attribs);
}
if (source) {
returnTypes = addNonParamAttributes(source);
}
if (returnTypes.length) {
returnTypesString = util.format( ' &rarr; %s{%s}', attribsString, returnTypes.join('|') );
}
f.signature = '<span class="signature">' + (f.signature || '') + '</span>' +
'<span class="type-signature">' + returnTypesString + '</span>';
}
function addSignatureTypes(f) {
var types = f.type ? buildItemTypeStrings(f) : [];
f.signature = (f.signature || '') + '<span class="type-signature">' +
(types.length ? ' :' + types.join('|') : '') + '</span>';
}
function addAttribs(f) {
var attribs = helper.getAttribs(f);
var attribsString = buildAttribsString(attribs);
f.attribs = util.format('<span class="type-signature">%s</span>', attribsString);
}
function shortenPaths(files, commonPrefix) {
Object.keys(files).forEach(function(file) {
files[file].shortened = files[file].resolved.replace(commonPrefix, '')
// always use forward slashes
.replace(/\\/g, '/');
});
return files;
}
function getPathFromDoclet(doclet) {
if (!doclet.meta) {
return null;
}
return doclet.meta.path && doclet.meta.path !== 'null' ?
path.join(doclet.meta.path, doclet.meta.filename) :
doclet.meta.filename;
}
function generate(title, docs, filename, resolveLinks) {
var docData;
var html;
var outpath;
resolveLinks = resolveLinks !== false;
docData = {
env: env,
title: title,
docs: docs
};
outpath = path.join(outdir, filename);
html = view.render('container.tmpl', docData);
if (resolveLinks) {
html = helper.resolveLinks(html); // turn {@link foo} into <a href="foodoc.html">foo</a>
}
fs.writeFileSync(outpath, html, 'utf8');
}
function generateSourceFiles(sourceFiles, encoding) {
encoding = encoding || 'utf8';
Object.keys(sourceFiles).forEach(function(file) {
var source;
// links are keyed to the shortened path in each doclet's `meta.shortpath` property
var sourceOutfile = helper.getUniqueFilename(sourceFiles[file].shortened);
helper.registerLink(sourceFiles[file].shortened, sourceOutfile);
try {
source = {
kind: 'source',
code: helper.htmlsafe( fs.readFileSync(sourceFiles[file].resolved, encoding) )
};
}
catch (e) {
logger.error('Error while generating source file %s: %s', file, e.message);
}
generate('Source: ' + sourceFiles[file].shortened, [source], sourceOutfile,
false);
});
}
/**
* Look for classes or functions with the same name as modules (which indicates that the module
* exports only that class or function), then attach the classes or functions to the `module`
* property of the appropriate module doclets. The name of each class or function is also updated
* for display purposes. This function mutates the original arrays.
*
* @private
* @param {Array.<module:jsdoc/doclet.Doclet>} doclets - The array of classes and functions to
* check.
* @param {Array.<module:jsdoc/doclet.Doclet>} modules - The array of module doclets to search.
*/
function attachModuleSymbols(doclets, modules) {
var symbols = {};
// build a lookup table
doclets.forEach(function(symbol) {
symbols[symbol.longname] = symbols[symbol.longname] || [];
symbols[symbol.longname].push(symbol);
});
modules.forEach(function(module) {
if (symbols[module.longname]) {
module.modules = symbols[module.longname]
// Only show symbols that have a description. Make an exception for classes, because
// we want to show the constructor-signature heading no matter what.
.filter(function(symbol) {
return symbol.description || symbol.kind === 'class';
})
.map(function(symbol) {
symbol = doop(symbol);
if (symbol.kind === 'class' || symbol.kind === 'function') {
symbol.name = symbol.name.replace('module:', '(require("') + '"))';
}
return symbol;
});
}
});
}
function buildMemberNav(items, itemHeading, itemsSeen, linktoFn) {
var nav = '';
if (items.length) {
var itemsNav = '';
items.forEach(function(item) {
var displayName;
if ( !hasOwnProp.call(item, 'longname') ) {
itemsNav += '<li>' + linktoFn('', item.name) + '</li>';
}
else if ( !hasOwnProp.call(itemsSeen, item.longname) ) {
if (env.conf.templates.default.useLongnameInNav) {
displayName = item.longname;
} else {
displayName = item.name;
}
itemsNav += '<li>' + linktoFn(item.longname, displayName.replace(/\b(module|event):/g, '')) + '</li>';
itemsSeen[item.longname] = true;
}
});
if (itemsNav !== '') {
nav += '<h3>' + itemHeading + '</h3><ul>' + itemsNav + '</ul>';
}
}
return nav;
}
function linktoTutorial(longName, name) {
return tutoriallink(name);
}
function linktoExternal(longName, name) {
return linkto(longName, name.replace(/(^"|"$)/g, ''));
}
/**
* Create the navigation sidebar.
* @param {object} members The members that will be used to create the sidebar.
* @param {array<object>} members.classes
* @param {array<object>} members.externals
* @param {array<object>} members.globals
* @param {array<object>} members.mixins
* @param {array<object>} members.modules
* @param {array<object>} members.namespaces
* @param {array<object>} members.tutorials
* @param {array<object>} members.events
* @param {array<object>} members.interfaces
* @return {string} The HTML for the navigation sidebar.
*/
function buildNav(members) {
var globalNav;
var nav = '<h2><a href="index.html">Home</a></h2>';
var seen = {};
var seenTutorials = {};
nav += buildMemberNav(members.modules, 'Modules', {}, linkto);
nav += buildMemberNav(members.externals, 'Externals', seen, linktoExternal);
nav += buildMemberNav(members.classes, 'Classes', seen, linkto);
nav += buildMemberNav(members.events, 'Events', seen, linkto);
nav += buildMemberNav(members.namespaces, 'Namespaces', seen, linkto);
nav += buildMemberNav(members.mixins, 'Mixins', seen, linkto);
nav += buildMemberNav(members.tutorials, 'Tutorials', seenTutorials, linktoTutorial);
nav += buildMemberNav(members.interfaces, 'Interfaces', seen, linkto);
if (members.globals.length) {
globalNav = '';
members.globals.forEach(function(g) {
if ( g.kind !== 'typedef' && !hasOwnProp.call(seen, g.longname) ) {
globalNav += '<li>' + linkto(g.longname, g.name) + '</li>';
}
seen[g.longname] = true;
});
if (!globalNav) {
// turn the heading into a link so you can actually get to the global page
nav += '<h3>' + linkto('global', 'Global') + '</h3>';
}
else {
nav += '<h3>Global</h3><ul>' + globalNav + '</ul>';
}
}
return nav;
}
/**
@param {TAFFY} taffyData See <http://taffydb.com/>.
@param {object} opts
@param {Tutorial} tutorials
*/
exports.publish = function(taffyData, opts, tutorials) {
var classes;
var conf;
var externals;
var files;
var fromDir;
var globalUrl;
var indexUrl;
var interfaces;
var members;
var mixins;
var modules;
var namespaces;
var outputSourceFiles;
var packageInfo;
var packages;
var sourceFilePaths = [];
var sourceFiles = {};
var staticFileFilter;
var staticFilePaths;
var staticFiles;
var staticFileScanner;
var templatePath;
data = taffyData;
conf = env.conf.templates || {};
conf.default = conf.default || {};
templatePath = path.normalize(opts.template);
view = new template.Template( path.join(templatePath, 'tmpl') );
// claim some special filenames in advance, so the All-Powerful Overseer of Filename Uniqueness
// doesn't try to hand them out later
indexUrl = helper.getUniqueFilename('index');
// don't call registerLink() on this one! 'index' is also a valid longname
globalUrl = helper.getUniqueFilename('global');
helper.registerLink('global', globalUrl);
// set up templating
view.layout = conf.default.layoutFile ?
path.getResourcePath(path.dirname(conf.default.layoutFile),
path.basename(conf.default.layoutFile) ) :
'layout.tmpl';
// set up tutorials for helper
helper.setTutorials(tutorials);
data = helper.prune(data);
data.sort('longname, version, since');
helper.addEventListeners(data);
data().each(function(doclet) {
var sourcePath;
doclet.attribs = '';
if (doclet.examples) {
doclet.examples = doclet.examples.map(function(example) {
var caption;
var code;
if (example.match(/^\s*<caption>([\s\S]+?)<\/caption>(\s*[\n\r])([\s\S]+)$/i)) {
caption = RegExp.$1;
code = RegExp.$3;
}
return {
caption: caption || '',
code: code || example
};
});
}
if (doclet.see) {
doclet.see.forEach(function(seeItem, i) {
doclet.see[i] = hashToLink(doclet, seeItem);
});
}
// build a list of source files
if (doclet.meta) {
sourcePath = getPathFromDoclet(doclet);
sourceFiles[sourcePath] = {
resolved: sourcePath,
shortened: null
};
if (sourceFilePaths.indexOf(sourcePath) === -1) {
sourceFilePaths.push(sourcePath);
}
}
});
// update outdir if necessary, then create outdir
packageInfo = ( find({kind: 'package'}) || [] )[0];
if (packageInfo && packageInfo.name) {
outdir = path.join( outdir, packageInfo.name, (packageInfo.version || '') );
}
fs.mkPath(outdir);
// copy the template's static files to outdir
fromDir = path.join(templatePath, 'static');
staticFiles = fs.ls(fromDir, 3);
staticFiles.forEach(function(fileName) {
var toDir = fs.toDir( fileName.replace(fromDir, outdir) );
fs.mkPath(toDir);
fs.copyFileSync(fileName, toDir);
});
// copy user-specified static files to outdir
if (conf.default.staticFiles) {
// The canonical property name is `include`. We accept `paths` for backwards compatibility
// with a bug in JSDoc 3.2.x.
staticFilePaths = conf.default.staticFiles.include ||
conf.default.staticFiles.paths ||
[];
staticFileFilter = new (require('jsdoc/src/filter')).Filter(conf.default.staticFiles);
staticFileScanner = new (require('jsdoc/src/scanner')).Scanner();
staticFilePaths.forEach(function(filePath) {
var extraStaticFiles;
filePath = path.resolve(env.pwd, filePath);
extraStaticFiles = staticFileScanner.scan([filePath], 10, staticFileFilter);
extraStaticFiles.forEach(function(fileName) {
var sourcePath = fs.toDir(filePath);
var toDir = fs.toDir( fileName.replace(sourcePath, outdir) );
fs.mkPath(toDir);
fs.copyFileSync(fileName, toDir);
});
});
}
if (sourceFilePaths.length) {
sourceFiles = shortenPaths( sourceFiles, path.commonPrefix(sourceFilePaths) );
}
data().each(function(doclet) {
var docletPath;
var url = helper.createLink(doclet);
helper.registerLink(doclet.longname, url);
// add a shortened version of the full path
if (doclet.meta) {
docletPath = getPathFromDoclet(doclet);
docletPath = sourceFiles[docletPath].shortened;
if (docletPath) {
doclet.meta.shortpath = docletPath;
}
}
});
data().each(function(doclet) {
var url = helper.longnameToUrl[doclet.longname];
if (url.indexOf('#') > -1) {
doclet.id = helper.longnameToUrl[doclet.longname].split(/#/).pop();
}
else {
doclet.id = doclet.name;
}
if ( needsSignature(doclet) ) {
addSignatureParams(doclet);
addSignatureReturns(doclet);
addAttribs(doclet);
}
});
// do this after the urls have all been generated
data().each(function(doclet) {
doclet.ancestors = getAncestorLinks(doclet);
if (doclet.kind === 'member') {
addSignatureTypes(doclet);
addAttribs(doclet);
}
if (doclet.kind === 'constant') {
addSignatureTypes(doclet);
addAttribs(doclet);
doclet.kind = 'member';
}
});
members = helper.getMembers(data);
members.tutorials = tutorials.children;
// output pretty-printed source files by default
outputSourceFiles = conf.default && conf.default.outputSourceFiles !== false;
// add template helpers
view.find = find;
view.linkto = linkto;
view.resolveAuthorLinks = resolveAuthorLinks;
view.tutoriallink = tutoriallink;
view.htmlsafe = htmlsafe;
view.outputSourceFiles = outputSourceFiles;
// once for all
view.nav = buildNav(members);
attachModuleSymbols( find({ longname: {left: 'module:'} }), members.modules );
// generate the pretty-printed source files first so other pages can link to them
if (outputSourceFiles) {
generateSourceFiles(sourceFiles, opts.encoding);
}
if (members.globals.length) { generate('Global', [{kind: 'globalobj'}], globalUrl); }
// index page displays information from package.json and lists files
files = find({kind: 'file'});
packages = find({kind: 'package'});
generate('Home',
packages.concat(
[{
kind: 'mainpage',
readme: opts.readme,
longname: (opts.mainpagetitle) ? opts.mainpagetitle : 'Main Page'
}]
).concat(files), indexUrl);
// set up the lists that we'll use to generate pages
classes = taffy(members.classes);
modules = taffy(members.modules);
namespaces = taffy(members.namespaces);
mixins = taffy(members.mixins);
externals = taffy(members.externals);
interfaces = taffy(members.interfaces);
Object.keys(helper.longnameToUrl).forEach(function(longname) {
var myClasses = helper.find(classes, {longname: longname});
var myExternals = helper.find(externals, {longname: longname});
var myInterfaces = helper.find(interfaces, {longname: longname});
var myMixins = helper.find(mixins, {longname: longname});
var myModules = helper.find(modules, {longname: longname});
var myNamespaces = helper.find(namespaces, {longname: longname});
if (myModules.length) {
generate('Module: ' + myModules[0].name, myModules, helper.longnameToUrl[longname]);
}
if (myClasses.length) {
generate('Class: ' + myClasses[0].name, myClasses, helper.longnameToUrl[longname]);
}
if (myNamespaces.length) {
generate('Namespace: ' + myNamespaces[0].name, myNamespaces, helper.longnameToUrl[longname]);
}
if (myMixins.length) {
generate('Mixin: ' + myMixins[0].name, myMixins, helper.longnameToUrl[longname]);
}
if (myExternals.length) {
generate('External: ' + myExternals[0].name, myExternals, helper.longnameToUrl[longname]);
}
if (myInterfaces.length) {
generate('Interface: ' + myInterfaces[0].name, myInterfaces, helper.longnameToUrl[longname]);
}
});
// TODO: move the tutorial functions to templateHelper.js
function generateTutorial(title, tutorial, filename) {
var tutorialData = {
title: title,
header: tutorial.title,
content: tutorial.parse(),
children: tutorial.children
};
var tutorialPath = path.join(outdir, filename);
var html = view.render('tutorial.tmpl', tutorialData);
// yes, you can use {@link} in tutorials too!
html = helper.resolveLinks(html); // turn {@link foo} into <a href="foodoc.html">foo</a>
fs.writeFileSync(tutorialPath, html, 'utf8');
}
// tutorials can have only one parent so there is no risk for loops
function saveChildren(node) {
node.children.forEach(function(child) {
generateTutorial('Tutorial: ' + child.title, child, helper.tutorialToUrl(child.name));
saveChildren(child);
});
}
saveChildren(tutorials);
};

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 116 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 118 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 120 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 114 KiB

Some files were not shown because too many files have changed in this diff Show More