[sso-admin] Disentangle module and add type hints

With this commit, code from the admin module can be re-used and thanks
to adding type-hints in most places we are able to discover some bugs.

This commit attempts to fix only that which was necessary to:

- Add a reasonable amount of type hints
- Disentangle the module

There are already some issues that have been discovered by mypy.
Xnet-DigitalDemocratic-main-patch-41273
Evilham 2022-07-29 14:02:49 +02:00
parent e98323913d
commit 81fff214d5
No known key found for this signature in database
GPG Key ID: AE3EE30D970886BF
27 changed files with 1773 additions and 1664 deletions

View File

@ -1,5 +1,6 @@
# #
# Copyright © 2021,2022 IsardVDI S.L. # Copyright © 2021,2022 IsardVDI S.L.
# Copyright © 2022 Evilham <contact@evilham.com>
# #
# This file is part of DD # This file is part of DD
# #
@ -20,108 +21,24 @@
import logging as log import logging as log
import os import os
import os.path
from flask import Flask, render_template, send_from_directory from admin.flaskapp import AdminFlaskApp
app = Flask(__name__, static_url_path="") def get_app() -> AdminFlaskApp:
app = Flask(__name__, template_folder="static/templates") app = AdminFlaskApp(__name__, template_folder="static/templates")
app.url_map.strict_slashes = False
""" """
App secret key for encrypting cookies Debug should be removed on production!
You can generate one with: """
import os if app.debug:
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 dd-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)) log.warning("Debug mode: {}".format(app.debug))
else: else:
log.info("Debug mode: {}".format(app.debug)) log.info("Debug mode: {}".format(app.debug))
""" return app
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 Import all views
""" """

View File

@ -1,5 +1,6 @@
# #
# Copyright © 2021,2022 IsardVDI S.L. # Copyright © 2021,2022 IsardVDI S.L.
# Copyright © 2022 Evilham <contact@evilham.com>
# #
# This file is part of DD # This file is part of DD
# #
@ -21,13 +22,9 @@ import os
from flask_login import LoginManager, UserMixin from flask_login import LoginManager, UserMixin
from admin import app from typing import TYPE_CHECKING, Dict
if TYPE_CHECKING:
from admin.flaskapp import AdminFlaskApp
login_manager = LoginManager()
login_manager.init_app(app)
login_manager.login_view = "login"
ram_users = { ram_users = {
os.environ["ADMINAPP_USER"]: { os.environ["ADMINAPP_USER"]: {
@ -49,13 +46,19 @@ ram_users = {
class User(UserMixin): class User(UserMixin):
def __init__(self, dict): def __init__(self, id : str, password : str, role : str, active : bool = True) -> None:
self.id = dict["id"] self.id = id
self.username = dict["id"] self.username = id
self.password = dict["password"] self.password = password
self.role = dict["role"] self.role = role
self.active = active
def setup_auth(app : "AdminFlaskApp") -> None:
login_manager = LoginManager()
login_manager.init_app(app)
login_manager.login_view = "login"
@login_manager.user_loader @login_manager.user_loader
def user_loader(username): def user_loader(username : str) -> User:
return User(ram_users[username]) u = ram_users[username]
return User(id = u["id"], password = u["password"], role = u["role"])

View File

@ -1,5 +1,6 @@
# #
# Copyright © 2021,2022 IsardVDI S.L. # Copyright © 2021,2022 IsardVDI S.L.
# Copyright © 2022 Evilham <contact@evilham.com>
# #
# This file is part of DD # This file is part of DD
# #
@ -30,17 +31,18 @@ from functools import wraps
from flask import request from flask import request
from jose import jwt from jose import jwt
import jose.exceptions
from admin import app from typing import Any
from ..lib.api_exceptions import Error from admin.lib.api_exceptions import Error
def get_header_jwt_payload(): def get_header_jwt_payload() -> Any:
return get_token_payload(get_token_auth_header()) return get_token_payload(get_token_auth_header())
def get_token_header(header): def get_token_header(header : str) -> str:
"""Obtains the Access Token from the a Header""" """Obtains the Access Token from the a Header"""
auth = request.headers.get(header, None) auth = request.headers.get(header, None)
if not auth: if not auth:
@ -70,15 +72,15 @@ def get_token_header(header):
return parts[1] # Token return parts[1] # Token
def get_token_auth_header(): def get_token_auth_header() -> str:
return get_token_header("Authorization") return get_token_header("Authorization")
def get_token_payload(token): def get_token_payload(token : str) -> Any:
# log.warning("The received token in get_token_payload is: " + str(token)) # log.warning("The received token in get_token_payload is: " + str(token))
try: try:
claims = jwt.get_unverified_claims(token) claims = jwt.get_unverified_claims(token)
secret = app.config["API_SECRET"] secret = os.environ["API_SECRET"]
except: except:
log.warning( log.warning(
@ -97,11 +99,11 @@ def get_token_payload(token):
algorithms=["HS256"], algorithms=["HS256"],
options=dict(verify_aud=False, verify_sub=False, verify_exp=True), options=dict(verify_aud=False, verify_sub=False, verify_exp=True),
) )
except jwt.ExpiredSignatureError: except jose.exceptions.ExpiredSignatureError:
log.warning("Token expired") log.warning("Token expired")
raise Error("unauthorized", "Token is expired", traceback.format_stack()) raise Error("unauthorized", "Token is expired", traceback.format_stack())
except jwt.JWTClaimsError: except jose.exceptions.JWTClaimsError:
raise Error( raise Error(
"unauthorized", "unauthorized",
"Incorrect claims, please check the audience and issuer", "Incorrect claims, please check the audience and issuer",

View File

@ -0,0 +1,225 @@
#
# Copyright © 2021,2022 IsardVDI S.L.
# Copyright © 2022 Evilham <contact@evilham.com>
#
# This file is part of DD
#
# DD is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or (at your
# option) any later version.
#
# DD is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License
# along with DD. If not, see <https://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: AGPL-3.0-or-later
import logging as log
import os
import os.path
import secrets
import traceback
from typing import TYPE_CHECKING, Any, Callable, Dict
import yaml
from cerberus import Validator
from flask import Flask, Response, jsonify, render_template, send_from_directory
from admin.lib.api_exceptions import Error
from admin.views.decorators import OptionalJsonResponse
from admin.views.ApiViews import setup_api_views
from admin.views.AppViews import setup_app_views
from admin.views.LoginViews import setup_login_views
from admin.views.WebViews import setup_web_views
from admin.views.WpViews import setup_wp_views
from admin.auth.authentication import setup_auth
if TYPE_CHECKING:
from admin.lib.admin import Admin
from admin.lib.postup import Postup
class AdminValidator(Validator): # type: ignore # cerberus type hints MIA
# TODO: What's the point of this class?
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()
class AdminFlaskApp(Flask):
"""
Subclass Flask app to ease customisation and type checking.
In order for an instance of this class to be useful,
the setup method should be called after instantiating.
"""
admin: "Admin"
secrets_dir: str
ready: bool = False
def __init__(self, *args: Any, **kwargs: Any):
super().__init__(*args, **kwargs)
self.url_map.strict_slashes = False
from admin.lib.admin import Admin
self.admin = Admin(self)
# Minor setup tasks
self._load_validators()
self._load_config()
self._setup_routes()
setup_api_views(self)
setup_app_views(self)
setup_login_views(self)
setup_web_views(self)
setup_wp_views(self)
setup_auth(self)
@property
def legal_path(self) -> str:
return os.path.join(self.root_path, "static/templates/pages/legal/")
@property
def avatars_path(self) -> str:
return os.path.join(self.root_path, "../custom/avatars/")
def setup(self) -> None:
"""
Perform setup tasks that might do network
"""
from admin.lib.postup import Postup
Postup(self)
def json_route(self, rule: str, **options: Any) -> Callable[..., OptionalJsonResponse]:
return self.route(rule, **options) # type: ignore # mypy issue #7187
def _load_validators(self, purge_unknown: bool = True) -> Dict[str, Validator]:
validators = {}
schema_path = os.path.join(self.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
def _load_config(self) -> None:
try:
# Handle secrets like Flask's session key
self.secrets_dir = os.environ.get("SECRETS", "secret")
secret_key_file = os.path.join(self.secrets_dir, "secret_key")
if not os.path.exists(self.secrets_dir):
os.mkdir(self.secrets_dir)
if not os.path.exists(secret_key_file):
# Generate as needed
# https://flask.palletsprojects.com/en/2.1.x/config/#SECRET_KEY
with os.fdopen(
os.open(secret_key_file, os.O_WRONLY | os.O_CREAT, 0o600), "w"
) as f:
f.write(secrets.token_hex())
self.secret_key = open(secret_key_file, "r").read()
# Move on with ISARD's settings
self.config.setdefault("DOMAIN", os.environ["DOMAIN"])
self.config.setdefault(
"KEYCLOAK_POSTGRES_USER", os.environ["KEYCLOAK_DB_USER"]
)
self.config.setdefault(
"KEYCLOAK_POSTGRES_PASSWORD", os.environ["KEYCLOAK_DB_PASSWORD"]
)
self.config.setdefault(
"MOODLE_POSTGRES_USER", os.environ["MOODLE_POSTGRES_USER"]
)
self.config.setdefault(
"MOODLE_POSTGRES_PASSWORD", os.environ["MOODLE_POSTGRES_PASSWORD"]
)
self.config.setdefault(
"NEXTCLOUD_POSTGRES_USER", os.environ["NEXTCLOUD_POSTGRES_USER"]
)
self.config.setdefault(
"NEXTCLOUD_POSTGRES_PASSWORD", os.environ["NEXTCLOUD_POSTGRES_PASSWORD"]
)
self.config.setdefault(
"VERIFY", True if os.environ["VERIFY"] == "true" else False
)
self.config.setdefault("API_SECRET", os.environ.get("API_SECRET"))
except Exception as e:
log.error(traceback.format_exc())
raise
def _setup_routes(self) -> None:
"""
Setup routes to Serve static files
"""
@self.route("/build/<path:path>")
def send_build(path: str) -> Response:
return send_from_directory(
os.path.join(self.root_path, "node_modules/gentelella/build"), path
)
@self.route("/vendors/<path:path>")
def send_vendors(path: str) -> Response:
return send_from_directory(
os.path.join(self.root_path, "node_modules/gentelella/vendors"), path
)
@self.route("/node_modules/<path:path>")
def send_nodes(path: str) -> Response:
return send_from_directory(
os.path.join(self.root_path, "node_modules"), path
)
@self.route("/templates/<path:path>")
def send_templates(path: str) -> Response:
return send_from_directory(os.path.join(self.root_path, "templates"), path)
# @self.route('/templates/<path:path>')
# def send_templates(path):
# return send_from_directory(os.path.join(self.root_path, 'static/templates'), path)
@self.route("/static/<path:path>")
def send_static_js(path: str) -> Response:
return send_from_directory(os.path.join(self.root_path, "static"), path)
@self.route("/avatars/<path:path>")
def send_avatars_img(path: str) -> Response:
return send_from_directory(
os.path.join(self.root_path, "../avatars/master-avatars"), path
)
@self.route("/custom/<path:path>")
def send_custom(path: str) -> Response:
return send_from_directory(os.path.join(self.root_path, "../custom"), path)
# @self.errorhandler(404)
# def not_found_error(error):
# return render_template('page_404.html'), 404
# @self.errorhandler(500)
# def internal_error(error):
# return render_template('page_500.html'), 500
@self.errorhandler(Error)
def handle_user_error(ex : Error) -> Response:
response : Response = jsonify(ex.error)
response.status_code = ex.status_code
response.headers.extend(ex.content_type) # type: ignore # werkzeug type hint MIA
return response

View File

@ -1,5 +1,6 @@
# #
# Copyright © 2021,2022 IsardVDI S.L. # Copyright © 2021,2022 IsardVDI S.L.
# Copyright © 2022 Evilham <contact@evilham.com>
# #
# This file is part of DD # This file is part of DD
# #
@ -26,8 +27,6 @@ from time import sleep
import diceware import diceware
from admin import app
from .avatars import Avatars from .avatars import Avatars
from .helpers import ( from .helpers import (
filter_roles_list, filter_roles_list,
@ -61,14 +60,27 @@ from .helpers import (
rand_password, rand_password,
) )
from typing import TYPE_CHECKING, cast, Any, Dict, Iterable, List, Optional
if TYPE_CHECKING:
from admin.flaskapp import AdminFlaskApp
MANAGER = os.environ["CUSTOM_ROLE_MANAGER"] MANAGER = os.environ["CUSTOM_ROLE_MANAGER"]
TEACHER = os.environ["CUSTOM_ROLE_TEACHER"] TEACHER = os.environ["CUSTOM_ROLE_TEACHER"]
STUDENT = os.environ["CUSTOM_ROLE_STUDENT"] STUDENT = os.environ["CUSTOM_ROLE_STUDENT"]
DDUser = Dict[str, Any]
DDGroup = Dict[str, Any]
DDRole = Dict[str, Any]
class Admin: class Admin:
def __init__(self): app : "AdminFlaskApp"
self.check_connections() internal : Dict[str, Any]
external : Dict[str, Any]
def __init__(self, app : "AdminFlaskApp") -> None:
self.app = app
self.check_connections(app)
self.set_custom_roles() self.set_custom_roles()
self.overwrite_admins() self.overwrite_admins()
@ -90,13 +102,13 @@ class Admin:
self.external = {"users": [], "groups": [], "roles": []} self.external = {"users": [], "groups": [], "roles": []}
log.warning(" Updating missing user avatars with defaults") log.warning(" Updating missing user avatars with defaults")
self.av = Avatars() self.av = Avatars(app.avatars_path)
# av.minio_delete_all_objects() # This will reset all avatars on usres # av.minio_delete_all_objects() # This will reset all avatars on usres
self.av.update_missing_avatars(self.internal["users"]) self.av.update_missing_avatars(self.internal["users"])
log.warning(" SYSTEM READY TO HANDLE CONNECTIONS") log.warning(" SYSTEM READY TO HANDLE CONNECTIONS")
def check_connections(self): def check_connections(self, app : "AdminFlaskApp") -> None:
ready = False ready = False
while not ready: while not ready:
try: try:
@ -111,7 +123,7 @@ class Admin:
ready = False ready = False
while not ready: while not ready:
try: try:
self.moodle = Moodle(verify=app.config["VERIFY"]) self.moodle = Moodle(app)
ready = True ready = True
except: except:
log.error("Could not connect to moodle, waiting to be online...") log.error("Could not connect to moodle, waiting to be online...")
@ -136,18 +148,18 @@ class Admin:
ready = False ready = False
while not ready: while not ready:
try: try:
self.nextcloud = Nextcloud(verify=app.config["VERIFY"]) self.nextcloud = Nextcloud(verify=app.config["VERIFY"], app=app)
ready = True ready = True
except: except:
log.error("Could not connect to nextcloud, waiting to be online...") log.error("Could not connect to nextcloud, waiting to be online...")
sleep(2) sleep(2)
log.warning("Nextcloud connected.") log.warning("Nextcloud connected.")
def set_custom_roles(self): def set_custom_roles(self) -> None:
pass pass
## This function should be moved to postup.py ## This function should be moved to postup.py
def overwrite_admins(self): def overwrite_admins(self) -> None:
log.warning("Setting defaults...") log.warning("Setting defaults...")
dduser = os.environ["DDADMIN_USER"] dduser = os.environ["DDADMIN_USER"]
ddpassword = os.environ["DDADMIN_PASSWORD"] ddpassword = os.environ["DDADMIN_PASSWORD"]
@ -223,7 +235,7 @@ class Admin:
log.error(traceback.format_exc()) log.error(traceback.format_exc())
exit(1) exit(1)
def default_setup(self): def default_setup(self) -> None:
### Add default roles ### Add default roles
try: try:
log.warning("KEYCLOAK: Adding default roles") log.warning("KEYCLOAK: Adding default roles")
@ -324,7 +336,7 @@ class Admin:
# except: # except:
# log.warning("KEYCLOAK: Seems to be there already") # log.warning("KEYCLOAK: Seems to be there already")
def resync_data(self): def resync_data(self) -> bool:
self.internal = { self.internal = {
"users": self._get_mix_users(), "users": self._get_mix_users(),
"groups": self._get_mix_groups(), "groups": self._get_mix_groups(),
@ -332,7 +344,7 @@ class Admin:
} }
return True return True
def get_moodle_users(self): def get_moodle_users(self) -> List[Any]:
return [ return [
u u
for u in self.moodle.get_users_with_groups_and_roles() for u in self.moodle.get_users_with_groups_and_roles()
@ -353,7 +365,7 @@ class Admin:
# "roles": u['roles']} # "roles": u['roles']}
# for u in users] # for u in users]
def get_keycloak_users(self): def get_keycloak_users(self) -> List[DDUser]:
# log.warning('Loading keycloak users... can take a long time...') # log.warning('Loading keycloak users... can take a long time...')
users = self.keycloak.get_users_with_groups_and_roles() users = self.keycloak.get_users_with_groups_and_roles()
@ -372,7 +384,7 @@ class Admin:
if not system_username(u["username"]) if not system_username(u["username"])
] ]
def get_nextcloud_users(self): def get_nextcloud_users(self) -> List[DDUser]:
return [ return [
{ {
"id": u["username"], "id": u["username"],
@ -414,11 +426,11 @@ class Admin:
# "roles": []}) # "roles": []})
# return users_list # return users_list
def get_mix_users(self): def get_mix_users(self) -> Any:
sio_event_send("get_users", {"you_win": "you got the users!"}) sio_event_send(self.app, "get_users", {"you_win": "you got the users!"})
return self.internal["users"] return self.internal["users"]
def _get_mix_users(self): def _get_mix_users(self) -> List[DDUser]:
kgroups = self.keycloak.get_groups() kgroups = self.keycloak.get_groups()
kusers = self.get_keycloak_users() kusers = self.get_keycloak_users()
@ -481,32 +493,32 @@ class Admin:
users.append(theuser) users.append(theuser)
return users return users
def get_roles(self): def get_roles(self) -> Any:
return self.internal["roles"] return self.internal["roles"]
def _get_roles(self): def _get_roles(self) -> List[DDRole]:
return filter_roles_listofdicts(self.keycloak.get_roles()) return filter_roles_listofdicts(self.keycloak.get_roles())
def get_group_by_name(self, group_name): def get_group_by_name(self, group_name : str) -> Any:
group = [g for g in self.internal["groups"] if g["name"] == group_name] group = [g for g in self.internal["groups"] if g["name"] == group_name]
return group[0] if len(group) else False return group[0] if len(group) else False
def get_keycloak_groups(self): def get_keycloak_groups(self) -> Any:
log.warning("Loading keycloak groups...") log.warning("Loading keycloak groups...")
return self.keycloak.get_groups() return self.keycloak.get_groups()
def get_moodle_groups(self): def get_moodle_groups(self) -> Any:
log.warning("Loading moodle groups...") log.warning("Loading moodle groups...")
return self.moodle.get_cohorts() return self.moodle.get_cohorts()
def get_nextcloud_groups(self): def get_nextcloud_groups(self) -> Any:
log.warning("Loading nextcloud groups...") log.warning("Loading nextcloud groups...")
return self.nextcloud.get_groups_list() return self.nextcloud.get_groups_list()
def get_mix_groups(self): def get_mix_groups(self) -> Any:
return self.internal["groups"] return self.internal["groups"]
def _get_mix_groups(self): def _get_mix_groups(self) -> List[Dict[str, Any]]:
kgroups = self.get_keycloak_groups() kgroups = self.get_keycloak_groups()
mgroups = self.get_moodle_groups() mgroups = self.get_moodle_groups()
ngroups = self.get_nextcloud_groups() ngroups = self.get_nextcloud_groups()
@ -564,7 +576,7 @@ class Admin:
groups.append(thegroup) groups.append(thegroup)
return groups return groups
def sync_groups_from_keycloak(self): def sync_groups_from_keycloak(self) -> None:
self.resync_data() self.resync_data()
for group in self.internal["groups"]: for group in self.internal["groups"]:
if not group["keycloak"]: if not group["keycloak"]:
@ -586,22 +598,22 @@ class Admin:
self.nextcloud.add_group(group["name"]) self.nextcloud.add_group(group["name"])
self.resync_data() self.resync_data()
def get_external_users(self): def get_external_users(self) -> Any:
return self.external["users"] return self.external["users"]
def get_external_groups(self): def get_external_groups(self) -> Any:
return self.external["groups"] return self.external["groups"]
def get_external_roles(self): def get_external_roles(self) -> Any:
return self.external["roles"] return self.external["roles"]
def upload_csv_ug(self, data): def upload_csv_ug(self, data : Dict[str, Any]) -> bool:
log.warning("Processing uploaded users...") log.warning("Processing uploaded users...")
users = [] users = []
total = len(data["data"]) total = len(data["data"])
item = 1 item = 1
ev = Events("Processing uploaded users", total=len(data["data"])) ev = Events(self.app, "Processing uploaded users", total=len(data["data"]))
groups = [] groups : List[str] = []
for u in data["data"]: for u in data["data"]:
log.warning( log.warning(
"Processing (" "Processing ("
@ -680,18 +692,18 @@ class Admin:
self.external["groups"] = sysgroups self.external["groups"] = sysgroups
return True return True
def get_dice_pwd(self): def get_dice_pwd(self) -> str:
return diceware.get_passphrase(options=options) return cast(str, diceware.get_passphrase(options=options))
def reset_external(self): def reset_external(self) -> bool:
self.external = {"users": [], "groups": [], "roles": []} self.external = {"users": [], "groups": [], "roles": []}
return True return True
def upload_json_ga(self, data): def upload_json_ga(self, data : Dict[str, Any]) -> bool:
groups = [] groups = []
log.warning("Processing uploaded groups...") log.warning("Processing uploaded groups...")
try: try:
ev = Events( ev = Events(self.app,
"Processing uploaded groups", "Processing uploaded groups",
"Group:", "Group:",
total=len(data["data"]["groups"]), total=len(data["data"]["groups"]),
@ -718,7 +730,7 @@ class Admin:
users = [] users = []
total = len(data["data"]["users"]) total = len(data["data"]["users"])
item = 1 item = 1
ev = Events( ev = Events(self.app,
"Processing uploaded users", "Processing uploaded users",
"User:", "User:",
total=len(data["data"]["users"]), total=len(data["data"]["users"]),
@ -757,7 +769,7 @@ class Admin:
u["groups"] = u["groups"] + [g["name"]] u["groups"] = u["groups"] + [g["name"]]
return True return True
def sync_external(self, ids): def sync_external(self, ids : Any) -> None:
# self.resync_data() # self.resync_data()
log.warning("Starting sync to keycloak") log.warning("Starting sync to keycloak")
self.sync_to_keycloak_external() self.sync_to_keycloak_external()
@ -769,10 +781,10 @@ class Admin:
log.warning("All syncs finished. Resyncing from apps...") log.warning("All syncs finished. Resyncing from apps...")
self.resync_data() self.resync_data()
def add_keycloak_groups(self, groups): def add_keycloak_groups(self, groups : List[Any]) -> None:
total = len(groups) total = len(groups)
i = 0 i = 0
ev = Events( ev = Events(self.app,
"Syncing import groups to keycloak", "Adding group:", total=len(groups) "Syncing import groups to keycloak", "Adding group:", total=len(groups)
) )
for g in groups: for g in groups:
@ -790,8 +802,8 @@ class Admin:
def sync_to_keycloak_external( def sync_to_keycloak_external(
self, self,
): ### This one works from the external, moodle and nextcloud from the internal ) -> None: ### This one works from the external, moodle and nextcloud from the internal
groups = [] groups : List[DDGroup] = []
for u in self.external["users"]: for u in self.external["users"]:
groups = groups + u["groups"] groups = groups + u["groups"]
groups = list(dict.fromkeys(groups)) groups = list(dict.fromkeys(groups))
@ -800,7 +812,7 @@ class Admin:
total = len(self.external["users"]) total = len(self.external["users"])
index = 0 index = 0
ev = Events( ev = Events(self.app,
"Syncing import users to keycloak", "Syncing import users to keycloak",
"Adding user:", "Adding user:",
total=len(self.external["users"]), total=len(self.external["users"]),
@ -855,11 +867,11 @@ class Admin:
u["groups"].append(u["roles"][0]) u["groups"].append(u["roles"][0])
self.resync_data() self.resync_data()
def add_moodle_groups(self, groups): def add_moodle_groups(self, groups : List[Any]) -> None:
### Create all groups. Skip / in system groups ### Create all groups. Skip / in system groups
total = len(groups) total = len(groups)
log.warning(groups) log.warning(groups)
ev = Events("Syncing groups from external to moodle", total=len(groups)) ev = Events(self.app, "Syncing groups from external to moodle", total=len(groups))
i = 1 i = 1
for g in groups: for g in groups:
moodle_groups = kpath2gids(g) moodle_groups = kpath2gids(g)
@ -880,9 +892,9 @@ class Admin:
) )
i = i + 1 i = i + 1
def sync_to_moodle_external(self): # works from the internal (keycloak) def sync_to_moodle_external(self) -> None: # works from the internal (keycloak)
### Process all groups from the users keycloak_groups key ### Process all groups from the users keycloak_groups key
groups = [] groups : List[DDGroup] = []
for u in self.external["users"]: for u in self.external["users"]:
groups = groups + u["groups"] groups = groups + u["groups"]
groups = list(dict.fromkeys(groups)) groups = list(dict.fromkeys(groups))
@ -893,7 +905,7 @@ class Admin:
cohorts = self.moodle.get_cohorts() cohorts = self.moodle.get_cohorts()
### Create users in moodle ### Create users in moodle
ev = Events( ev = Events(self.app,
"Syncing users from external to moodle", total=len(self.internal["users"]) "Syncing users from external to moodle", total=len(self.internal["users"])
) )
for u in self.external["users"]: for u in self.external["users"]:
@ -920,7 +932,7 @@ class Admin:
# self.resync_data() # self.resync_data()
### Add user to their cohorts (groups) ### Add user to their cohorts (groups)
ev = Events( ev = Events(self.app,
"Syncing users groups from external to moodle cohorts", "Syncing users groups from external to moodle cohorts",
total=len(self.internal["users"]), total=len(self.internal["users"]),
) )
@ -938,16 +950,16 @@ class Admin:
log.error(self.moodle.get_user_by("username", u["username"])) log.error(self.moodle.get_user_by("username", u["username"]))
# self.resync_data() # self.resync_data()
def delete_all_moodle_cohorts(self): def delete_all_moodle_cohorts(self) -> None:
cohorts = self.moodle.get_cohorts() cohorts = self.moodle.get_cohorts()
ids = [c["id"] for c in cohorts] ids = [c["id"] for c in cohorts]
self.moodle.delete_cohorts(ids) self.moodle.delete_cohorts(ids)
def add_nextcloud_groups(self, groups): def add_nextcloud_groups(self, groups : List[Any]) -> None:
### Create all groups. Skip / in system groups ### Create all groups. Skip / in system groups
total = len(groups) total = len(groups)
log.warning(groups) log.warning(groups)
ev = Events("Syncing groups from external to nextcloud", total=len(groups)) ev = Events(self.app, "Syncing groups from external to nextcloud", total=len(groups))
i = 1 i = 1
for g in groups: for g in groups:
nextcloud_groups = kpath2gids(g) nextcloud_groups = kpath2gids(g)
@ -968,15 +980,15 @@ class Admin:
) )
i = i + 1 i = i + 1
def sync_to_nextcloud_external(self): def sync_to_nextcloud_external(self) -> None:
groups = [] groups : List[DDGroup] = []
for u in self.external["users"]: for u in self.external["users"]:
groups = groups + u["gids"] groups = groups + u["gids"]
groups = list(dict.fromkeys(groups)) groups = list(dict.fromkeys(groups))
self.add_nextcloud_groups(groups) self.add_nextcloud_groups(groups)
ev = Events( ev = Events(self.app,
"Syncing users from external to nextcloud", "Syncing users from external to nextcloud",
total=len(self.internal["users"]), total=len(self.internal["users"]),
) )
@ -1009,14 +1021,14 @@ class Admin:
except: except:
log.error(traceback.format_exc()) log.error(traceback.format_exc())
def sync_to_moodle(self): # works from the internal (keycloak) def sync_to_moodle(self) -> None: # works from the internal (keycloak)
### Process all groups from the users keycloak_groups key ### Process all groups from the users keycloak_groups key
groups = [] groups : List[str] = []
for u in self.internal["users"]: for u in self.internal["users"]:
groups = groups + u["keycloak_groups"] groups = groups + u["keycloak_groups"]
groups = list(dict.fromkeys(groups)) groups = list(dict.fromkeys(groups))
ev = Events("Syncing groups from keycloak to moodle", total=len(groups)) ev = Events(self.app, "Syncing groups from keycloak to moodle", total=len(groups))
pathslist = [] pathslist = []
for group in groups: for group in groups:
pathpart = "" pathpart = ""
@ -1040,7 +1052,7 @@ class Admin:
cohorts = self.moodle.get_cohorts() cohorts = self.moodle.get_cohorts()
### Create users in moodle ### Create users in moodle
ev = Events( ev = Events(self.app,
"Syncing users from keycloak to moodle", total=len(self.internal["users"]) "Syncing users from keycloak to moodle", total=len(self.internal["users"])
) )
for u in self.internal["users"]: for u in self.internal["users"]:
@ -1067,7 +1079,7 @@ class Admin:
self.resync_data() self.resync_data()
ev = Events( ev = Events(self.app,
"Syncing users with moodle cohorts", total=len(self.internal["users"]) "Syncing users with moodle cohorts", total=len(self.internal["users"])
) )
cohorts = self.moodle.get_cohorts() cohorts = self.moodle.get_cohorts()
@ -1106,15 +1118,15 @@ class Admin:
self.resync_data() self.resync_data()
def sync_to_nextcloud(self): def sync_to_nextcloud(self) -> None:
groups = [] groups : List[str] = []
for u in self.internal["users"]: for u in self.internal["users"]:
groups = groups + u["keycloak_groups"] groups = groups + u["keycloak_groups"]
groups = list(dict.fromkeys(groups)) groups = list(dict.fromkeys(groups))
total = len(groups) total = len(groups)
i = 0 i = 0
ev = Events("Syncing groups from keycloak to nextcloud", total=len(groups)) ev = Events(self.app, "Syncing groups from keycloak to nextcloud", total=len(groups))
for g in groups: for g in groups:
parts = g.split("/") parts = g.split("/")
subpath = "" subpath = ""
@ -1137,7 +1149,7 @@ class Admin:
) )
i = i + 1 i = i + 1
ev = Events( ev = Events(self.app,
"Syncing users from keycloak to nextcloud", "Syncing users from keycloak to nextcloud",
total=len(self.internal["users"]), total=len(self.internal["users"]),
) )
@ -1167,13 +1179,13 @@ class Admin:
except: except:
log.error(traceback.format_exc()) log.error(traceback.format_exc())
def delete_keycloak_user(self, userid): def delete_keycloak_user(self, userid : str) -> None:
user = [u for u in self.internal["users"] if u["id"] == userid] users : List[DDUser] = [u for u in self.internal["users"] if u["id"] == userid]
if len(user) and user[0]["keycloak"]: if len(users) and users[0]["keycloak"]:
user = user[0] user = users[0]
keycloak_id = user["id"] keycloak_id = user["id"]
else: else:
return False return
log.warning("Removing keycloak user: " + user["username"]) log.warning("Removing keycloak user: " + user["username"])
try: try:
self.keycloak.delete_user(keycloak_id) self.keycloak.delete_user(keycloak_id)
@ -1183,10 +1195,10 @@ class Admin:
self.av.delete_user_avatar(userid) self.av.delete_user_avatar(userid)
def delete_keycloak_users(self): def delete_keycloak_users(self) -> None:
total = len(self.internal["users"]) total = len(self.internal["users"])
i = 0 i = 0
ev = Events( ev = Events(self.app,
"Deleting users from keycloak", "Deleting users from keycloak",
"Deleting user:", "Deleting user:",
total=len(self.internal["users"]), total=len(self.internal["users"]),
@ -1217,13 +1229,13 @@ class Admin:
) )
self.av.minio_delete_all_objects() self.av.minio_delete_all_objects()
def delete_nextcloud_user(self, userid): def delete_nextcloud_user(self, userid : str) -> None:
user = [u for u in self.internal["users"] if u["id"] == userid] users : List[DDUser] = [u for u in self.internal["users"] if u["id"] == userid]
if len(user) and user[0]["nextcloud"]: if len(users) and users[0]["nextcloud"]:
user = user[0] user = users[0]
nextcloud_id = user["nextcloud_id"] nextcloud_id = user["nextcloud_id"]
else: else:
return False return
log.warning("Removing nextcloud user: " + user["username"]) log.warning("Removing nextcloud user: " + user["username"])
try: try:
self.nextcloud.delete_user(nextcloud_id) self.nextcloud.delete_user(nextcloud_id)
@ -1231,8 +1243,8 @@ class Admin:
log.error(traceback.format_exc()) log.error(traceback.format_exc())
log.warning("Could not remove users: " + user["username"]) log.warning("Could not remove users: " + user["username"])
def delete_nextcloud_users(self): def delete_nextcloud_users(self) -> None:
ev = Events("Deleting users from nextcloud", total=len(self.internal["users"])) ev = Events(self.app, "Deleting users from nextcloud", total=len(self.internal["users"]))
for u in self.internal["users"]: for u in self.internal["users"]:
if u["nextcloud"] and not u["keycloak"]: if u["nextcloud"] and not u["keycloak"]:
@ -1246,13 +1258,13 @@ class Admin:
log.error(traceback.format_exc()) log.error(traceback.format_exc())
log.warning("Could not remove user: " + u["username"]) log.warning("Could not remove user: " + u["username"])
def delete_moodle_user(self, userid): def delete_moodle_user(self, userid : str) -> None:
user = [u for u in self.internal["users"] if u["id"] == userid] users : List[DDUser] = [u for u in self.internal["users"] if u["id"] == userid]
if len(user) and user[0]["moodle"]: if len(users) and users[0]["moodle"]:
user = user[0] user = users[0]
moodle_id = user["moodle_id"] moodle_id = user["moodle_id"]
else: else:
return False return
log.warning("Removing moodle user: " + user["username"]) log.warning("Removing moodle user: " + user["username"])
try: try:
self.moodle.delete_users([moodle_id]) self.moodle.delete_users([moodle_id])
@ -1260,7 +1272,7 @@ class Admin:
log.error(traceback.format_exc()) log.error(traceback.format_exc())
log.warning("Could not remove users: " + user["username"]) log.warning("Could not remove users: " + user["username"])
def delete_moodle_users(self): def delete_moodle_users(self, app : "AdminFlaskApp") -> None:
userids = [] userids = []
usernames = [] usernames = []
for u in self.internal["users"]: for u in self.internal["users"]:
@ -1288,7 +1300,7 @@ class Admin:
log.error(traceback.format_exc()) log.error(traceback.format_exc())
log.warning("Could not remove users: " + ",".join(usernames)) log.warning("Could not remove users: " + ",".join(usernames))
def delete_keycloak_groups(self): def delete_keycloak_groups(self) -> None:
for g in self.internal["groups"]: for g in self.internal["groups"]:
if not g["keycloak"]: if not g["keycloak"]:
continue continue
@ -1302,7 +1314,7 @@ class Admin:
log.error(traceback.format_exc()) log.error(traceback.format_exc())
log.warning("Could not remove group: " + g["name"]) log.warning("Could not remove group: " + g["name"])
def external_roleassign(self, data): def external_roleassign(self, data : Dict[str, Any]) -> bool:
for newuserid in data["ids"]: for newuserid in data["ids"]:
for externaluser in self.external["users"]: for externaluser in self.external["users"]:
if externaluser["id"] == newuserid: if externaluser["id"] == newuserid:
@ -1316,10 +1328,10 @@ class Admin:
externaluser["gids"].append(data["action"]) externaluser["gids"].append(data["action"])
return True return True
def user_update_password(self, userid, password, password_temporary): def user_update_password(self, userid : str, password : str, password_temporary : bool) -> Any:
return self.keycloak.update_user_pwd(userid, password, password_temporary) return self.keycloak.update_user_pwd(userid, password, password_temporary)
def update_users_from_keycloak(self): def update_users_from_keycloak(self) -> None:
kgroups = self.keycloak.get_groups() kgroups = self.keycloak.get_groups()
users = [ users = [
{ {
@ -1339,15 +1351,15 @@ class Admin:
] ]
for user in users: for user in users:
ev = Events( ev = Events(self.app,
"Updating users from keycloak", "User:", total=len(users), table="users" "Updating users from keycloak", "User:", total=len(users), table="users"
) )
self.user_update(user) self.user_update(user)
ev.increment({"name": user["username"], "data": user["groups"]}) ev.increment({"name": user["username"], "data": user["groups"]})
def user_update(self, user): def user_update(self, user : DDUser) -> bool:
log.warning("Updating user moodle, nextcloud keycloak") log.warning("Updating user moodle, nextcloud keycloak")
ev = Events("Updating user", "Updating user in keycloak") ev = Events(self.app, "Updating user", "Updating user in keycloak")
## Get actual user role ## Get actual user role
try: try:
@ -1505,7 +1517,7 @@ class Admin:
ev.update_text("User updated") ev.update_text("User updated")
return True return True
def update_keycloak_user(self, user_id, user, kdelete, kadd): def update_keycloak_user(self, user_id : str, user : DDUser, kdelete : List[Any], kadd : List[Any]) -> bool:
# pprint(self.keycloak.get_user_realm_roles(user_id)) # pprint(self.keycloak.get_user_realm_roles(user_id))
self.keycloak.remove_user_realm_roles(user_id, "student") self.keycloak.remove_user_realm_roles(user_id, "student")
self.keycloak.assign_realm_roles(user_id, user["roles"][0]) self.keycloak.assign_realm_roles(user_id, user["roles"][0])
@ -1521,24 +1533,24 @@ class Admin:
self.resync_data() self.resync_data()
return True return True
def enable_users(self, data): def enable_users(self, data : List[DDUser]) -> None:
# data={'id':'','username':''} # data={'id':'','username':''}
ev = Events("Bulk actions", "Enabling user:", total=len(data)) ev = Events(self.app, "Bulk actions", "Enabling user:", total=len(data))
for user in data: for user in data:
ev.increment({"name": user["username"], "data": user["username"]}) ev.increment({"name": user["username"], "data": user["username"]})
self.keycloak.user_enable(user["id"]) self.keycloak.user_enable(user["id"])
self.resync_data() self.resync_data()
def disable_users(self, data): def disable_users(self, data : List[DDUser]) -> None:
# data={'id':'','username':''} # data={'id':'','username':''}
ev = Events("Bulk actions", "Disabling user:", total=len(data)) ev = Events(self.app, "Bulk actions", "Disabling user:", total=len(data))
for user in data: for user in data:
ev.increment({"name": user["username"], "data": user["username"]}) ev.increment({"name": user["username"], "data": user["username"]})
self.keycloak.user_disable(user["id"]) self.keycloak.user_disable(user["id"])
self.resync_data() self.resync_data()
def update_moodle_user(self, user_id, user, mdelete, madd): def update_moodle_user(self, user_id : str, user : DDUser, mdelete : Iterable[Any], madd : Iterable[Any]) -> bool:
internaluser = [u for u in self.internal["users"] if u["id"] == user_id][0] internaluser : DDUser = [u for u in self.internal["users"] if u["id"] == user_id][0]
cohorts = self.moodle.get_cohorts() cohorts = self.moodle.get_cohorts()
for group in mdelete: for group in mdelete:
cohort = [c for c in cohorts if c["name"] == group[0]] cohort = [c for c in cohorts if c["name"] == group[0]]
@ -1576,29 +1588,29 @@ class Admin:
def add_moodle_user( def add_moodle_user(
self, self,
username, username : str,
email, email : str,
first_name, first_name : str,
last_name, last_name : str,
password="*12" + secrets.token_urlsafe(16), password : str="*12" + secrets.token_urlsafe(16),
): ) -> None:
log.warning("Creating moodle user: " + username) log.warning("Creating moodle user: " + username)
ev = Events("Add user", username) ev = Events(self.app, "Add user", username)
try: try:
self.moodle.create_user(email, username, password, first_name, last_name) self.moodle.create_user(email, username, password, first_name, last_name)
ev.update_text({"name": "Added to moodle", "data": []}) ev.update_text(str({"name": "Added to moodle", "data": []}))
except UserExists: except UserExists:
log.error(" -->> User already exists") log.error(" -->> User already exists")
error = Events("User already exists.", str(se), type="error") error = Events(self.app, "User already exists.", str(se), type="error")
except SystemError as se: except SystemError as se:
log.error("Moodle create user error: " + str(se)) log.error("Moodle create user error: " + str(se))
error = Events("Moodle create user error", str(se), type="error") error = Events(self.app, "Moodle create user error", str(se), type="error")
except: except:
log.error(" -->> Error creating on moodle the user: " + username) log.error(" -->> Error creating on moodle the user: " + username)
print(traceback.format_exc()) print(traceback.format_exc())
error = Events("Internal error", "Check logs", type="error") error = Events(self.app, "Internal error", "Check logs", type="error")
def update_nextcloud_user(self, user_id, user, ndelete, nadd): def update_nextcloud_user(self, user_id : str, user : DDUser, ndelete : Iterable[Any], nadd : Iterable[Any]) -> None:
## TODO: Disable de user? Is really needed? it is disabled in keycloak, so can't login again ## TODO: Disable de user? Is really needed? it is disabled in keycloak, so can't login again
## ocs/v1.php/cloud/users/{userid}/disable ## ocs/v1.php/cloud/users/{userid}/disable
@ -1648,21 +1660,21 @@ class Admin:
def add_nextcloud_user( def add_nextcloud_user(
self, self,
username, username : str,
email, email : str,
quota, quota : Any,
first_name, first_name : str,
last_name, last_name : str,
groups, groups : str,
password="*12" + secrets.token_urlsafe(16), password : str = "*12" + secrets.token_urlsafe(16),
): ) -> None:
log.warning( log.warning(
" NEXTCLOUD USERS: Creating nextcloud user: " " NEXTCLOUD USERS: Creating nextcloud user: "
+ username + username
+ " in groups " + " in groups "
+ str(groups) + str(groups)
) )
ev = Events("Add user", username) ev = Events(self.app, "Add user", username)
try: try:
# Quota is "1 GB", "500 MB" # Quota is "1 GB", "500 MB"
self.nextcloud.add_user_with_groups( self.nextcloud.add_user_with_groups(
@ -1676,16 +1688,16 @@ class Admin:
except: except:
log.error(traceback.format_exc()) log.error(traceback.format_exc())
def delete_users(self, data): def delete_users(self, data : List[DDUser]) -> None:
ev = Events("Bulk actions", "Deleting users:", total=len(data)) ev = Events(self.app, "Bulk actions", "Deleting users:", total=len(data))
for user in data: for user in data:
ev.increment({"name": user["username"], "data": user["username"]}) ev.increment({"name": user["username"], "data": user["username"]})
self.delete_user(user["id"]) self.delete_user(user["id"])
self.resync_data() self.resync_data()
def delete_user(self, userid): def delete_user(self, userid : str) -> bool:
log.warning("Deleting user moodle, nextcloud keycloak") log.warning("Deleting user moodle, nextcloud keycloak")
ev = Events("Deleting user", "Deleting from moodle") ev = Events(self.app, "Deleting user", "Deleting from moodle")
self.delete_moodle_user(userid) self.delete_moodle_user(userid)
ev.update_text("Deleting from nextcloud") ev.update_text("Deleting from nextcloud")
self.delete_nextcloud_user(userid) self.delete_nextcloud_user(userid)
@ -1694,23 +1706,22 @@ class Admin:
ev.update_text("Syncing data from applications...") ev.update_text("Syncing data from applications...")
self.resync_data() self.resync_data()
ev.update_text("User deleted") ev.update_text("User deleted")
sio_event_send("delete_user", {"userid": userid}) sio_event_send(self.app, "delete_user", {"userid": userid})
return True return True
def get_user(self, userid): def get_user(self, userid : str) -> Optional[DDUser]:
user = [u for u in self.internal["users"] if u["id"] == userid] user : List[DDUser] = [u for u in self.internal["users"] if u["id"] == userid]
if not len(user): if not len(user):
return False return None
return user[0] return user[0]
def get_user_username(self, username): def get_user_username(self, username : str) -> Optional[DDUser]:
user = [u for u in self.internal["users"] if u["username"] == username] user : List[DDUser] = [u for u in self.internal["users"] if u["username"] == username]
if not len(user): if not len(user):
return False return None
return user[0] return user[0]
def add_user(self, u): def add_user(self, u : DDUser) -> Any:
pathslist = [] pathslist = []
for group in u["groups"]: for group in u["groups"]:
pathpart = "" pathpart = ""
@ -1739,7 +1750,7 @@ class Admin:
### KEYCLOAK ### KEYCLOAK
####################### #######################
ev = Events("Add user", u["username"], total=5) ev = Events(self.app, "Add user", u["username"], total=5)
log.warning(" KEYCLOAK USERS: Adding user: " + u["username"]) log.warning(" KEYCLOAK USERS: Adding user: " + u["username"])
uid = self.keycloak.add_user( uid = self.keycloak.add_user(
u["username"], u["username"],
@ -1784,14 +1795,14 @@ class Admin:
ev.increment({"name": "Added to moodle", "data": []}) ev.increment({"name": "Added to moodle", "data": []})
except UserExists: except UserExists:
log.error(" -->> User already exists") log.error(" -->> User already exists")
error = Events("User already exists.", str(se), type="error") error = Events(self.app, "User already exists.", str(se), type="error")
except SystemError as se: except SystemError as se:
log.error("Moodle create user error: " + str(se)) log.error("Moodle create user error: " + str(se))
error = Events("Moodle create user error", str(se), type="error") error = Events(self.app, "Moodle create user error", str(se), type="error")
except: except:
log.error(" -->> Error creating on moodle the user: " + u["username"]) log.error(" -->> Error creating on moodle the user: " + u["username"])
print(traceback.format_exc()) print(traceback.format_exc())
error = Events("Internal error", "Check logs", type="error") error = Events(self.app, "Internal error", "Check logs", type="error")
# Add user to cohort # Add user to cohort
## Get all existing moodle cohorts ## Get all existing moodle cohorts
@ -1847,30 +1858,29 @@ class Admin:
log.error(traceback.format_exc()) log.error(traceback.format_exc())
self.resync_data() self.resync_data()
sio_event_send("new_user", u) sio_event_send(self.app, "new_user", u)
return uid return uid
def add_group(self, g): def add_group(self, g : DDGroup) -> str:
# TODO: Check if exists # TODO: Check if exists
# We add in keycloak with his name, will be shown in app with full path with dots # We add in keycloak with his name, will be shown in app with full path with dots
if g["parent"] != None: if g["parent"] != None:
g["parent"] = gid2kpath(g["parent"]) g["parent"] = gid2kpath(g["parent"])
new_path = self.keycloak.add_group(g["name"], g["parent"]) new_path_kc = self.keycloak.add_group(g["name"], g["parent"])
new_path : str = g["name"]
if g["parent"] != None: if g["parent"] != None:
new_path = kpath2gid(new_path["path"]) new_path = kpath2gid(new_path_kc["path"])
else:
new_path = g["name"]
self.moodle.add_system_cohort(new_path, description=g["description"]) self.moodle.add_system_cohort(new_path, description=g["description"])
self.nextcloud.add_group(new_path) self.nextcloud.add_group(new_path)
self.resync_data() self.resync_data()
return new_path return new_path
def delete_group_by_id(self, group_id): def delete_group_by_id(self, group_id : str) -> None:
ev = Events("Deleting group", "Deleting from keycloak") ev = Events(self.app, "Deleting group", "Deleting from keycloak")
try: try:
keycloak_group = self.keycloak.get_group_by_id(group_id) keycloak_group = self.keycloak.get_group_by_id(group_id)
except Exception as e: except Exception as e:
@ -1904,7 +1914,7 @@ class Admin:
self.nextcloud.delete_group(sg_gid) self.nextcloud.delete_group(sg_gid)
self.resync_data() self.resync_data()
def delete_group_by_path(self, path): def delete_group_by_path(self, path : str) -> None:
group = self.keycloak.get_group_by_path(path) group = self.keycloak.get_group_by_path(path)
to_be_deleted = [] to_be_deleted = []
@ -1926,5 +1936,5 @@ class Admin:
self.nextcloud.delete_group(gid) self.nextcloud.delete_group(gid)
self.resync_data() self.resync_data()
def set_nextcloud_user_mail(self, data): def set_nextcloud_user_mail(self, data : Any) -> None:
self.nextcloud.set_user_mail(data) self.nextcloud.set_user_mail(data)

View File

@ -1,5 +1,6 @@
# #
# Copyright © 2021,2022 IsardVDI S.L. # Copyright © 2021,2022 IsardVDI S.L.
# Copyright © 2022 Evilham <contact@evilham.com>
# #
# This file is part of DD # This file is part of DD
# #
@ -23,10 +24,11 @@ import logging as log
import os import os
import traceback import traceback
from flask import jsonify, request from typing import Any, Dict, Union, List
from admin import app from flask import request
# TODO: Improve these constants' structure
content_type = {"Content-Type": "application/json"} content_type = {"Content-Type": "application/json"}
ex = { ex = {
"bad_request": { "bad_request": {
@ -96,8 +98,10 @@ ex = {
class Error(Exception): class Error(Exception):
def __init__(self, error="bad_request", description="", debug="", data=None): status_code : int
self.error = ex[error]["error"].copy() content_type : Dict[str, str]
def __init__(self, error : str ="bad_request", description : str="", debug : Union[str, List[str]]="", data : Any =None):
self.error : Dict[str, str] = (ex[error]["error"]).copy() # type: ignore # bad struct
self.error["function"] = ( self.error["function"] = (
inspect.stack()[1][1].split(os.sep)[-1] inspect.stack()[1][1].split(os.sep)[-1]
+ ":" + ":"
@ -123,7 +127,7 @@ class Error(Exception):
"----------- REQUEST START -----------", "----------- REQUEST START -----------",
request.method + " " + request.url, request.method + " " + request.url,
"\r\n".join("{}: {}".format(k, v) for k, v in request.headers.items()), "\r\n".join("{}: {}".format(k, v) for k, v in request.headers.items()),
request.body if hasattr(request, "body") else "", getattr(request, "body", ""),
"----------- REQUEST STOP -----------", "----------- REQUEST STOP -----------",
) )
if request if request
@ -138,7 +142,7 @@ class Error(Exception):
if data if data
else "" else ""
) )
self.status_code = ex[error]["status_code"] self.status_code = ex[error]["status_code"] # type: ignore # bad struct
self.content_type = content_type self.content_type = content_type
log.debug( log.debug(
"%s - %s - [%s -> %s]\r\n%s\r\n%s\r\n%s" "%s - %s - [%s -> %s]\r\n%s\r\n%s\r\n%s"
@ -152,11 +156,3 @@ class Error(Exception):
self.error["data"], 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

@ -1,5 +1,6 @@
# #
# Copyright © 2021,2022 IsardVDI S.L. # Copyright © 2021,2022 IsardVDI S.L.
# Copyright © 2022 Evilham <contact@evilham.com>
# #
# This file is part of DD # This file is part of DD
# #
@ -26,11 +27,13 @@ from minio.commonconfig import REPLACE, CopySource
from minio.deleteobjects import DeleteObject from minio.deleteobjects import DeleteObject
from requests import get, post from requests import get, post
from admin import app from typing import Any, Callable, Dict, Iterable, List
class Avatars: class Avatars:
def __init__(self): avatars_path : str
def __init__(self, avatars_path : str):
self.avatars_path = avatars_path
self.mclient = Minio( self.mclient = Minio(
"dd-sso-avatars:9000", "dd-sso-avatars:9000",
access_key="AKIAIOSFODNN7EXAMPLE", access_key="AKIAIOSFODNN7EXAMPLE",
@ -41,21 +44,22 @@ class Avatars:
self._minio_set_realm() self._minio_set_realm()
# self.update_missing_avatars() # self.update_missing_avatars()
def add_user_default_avatar(self, userid, role="unknown"): def add_user_default_avatar(self, userid : str, role : str="unknown") -> None:
path = os.path.join(self.avatars_path, role) + ".jpg",
self.mclient.fput_object( self.mclient.fput_object(
self.bucket, self.bucket,
userid, userid,
os.path.join(app.root_path, "../custom/avatars/" + role + ".jpg"), path,
content_type="image/jpeg ", content_type="image/jpeg ",
) )
log.warning( log.warning(
" AVATARS: Updated avatar for user " + userid + " with role " + role " AVATARS: Updated avatar for user " + userid + " with role " + role
) )
def delete_user_avatar(self, userid): def delete_user_avatar(self, userid : str) -> None:
self.minio_delete_object(userid) self.minio_delete_object(userid)
def update_missing_avatars(self, users): def update_missing_avatars(self, users : Iterable[Dict[str, Any]]) -> None:
sys_roles = ["admin", "manager", "teacher", "student"] sys_roles = ["admin", "manager", "teacher", "student"]
for u in self.get_users_without_image(users): for u in self.get_users_without_image(users):
try: try:
@ -63,10 +67,11 @@ class Avatars:
except: except:
img = "unknown.jpg" img = "unknown.jpg"
path = os.path.join(self.avatars_path, img)
self.mclient.fput_object( self.mclient.fput_object(
self.bucket, self.bucket,
u["id"], u["id"],
os.path.join(app.root_path, "../custom/avatars/" + img), path,
content_type="image/jpeg ", content_type="image/jpeg ",
) )
log.warning( log.warning(
@ -76,26 +81,24 @@ class Avatars:
+ img.split(".")[0] + img.split(".")[0]
) )
def _minio_set_realm(self): def _minio_set_realm(self) -> None:
if not self.mclient.bucket_exists(self.bucket): if not self.mclient.bucket_exists(self.bucket):
self.mclient.make_bucket(self.bucket) self.mclient.make_bucket(self.bucket)
def minio_get_objects(self): def minio_get_objects(self) -> List[Any]:
return [o.object_name for o in self.mclient.list_objects(self.bucket)] return [o.object_name for o in self.mclient.list_objects(self.bucket)]
def minio_delete_all_objects(self): def minio_delete_all_objects(self) -> None:
delete_object_list = map( f : Callable[[Any], Any] = lambda x: DeleteObject(x.object_name)
lambda x: DeleteObject(x.object_name), delete_object_list = map(f, self.mclient.list_objects(self.bucket))
self.mclient.list_objects(self.bucket),
)
errors = self.mclient.remove_objects(self.bucket, delete_object_list) errors = self.mclient.remove_objects(self.bucket, delete_object_list)
for error in errors: for error in errors:
log.error(" AVATARS: Error occured when deleting avatar object: " + error) log.error(" AVATARS: Error occured when deleting avatar object: " + error)
def minio_delete_object(self, oid): def minio_delete_object(self, oid : str) -> None:
errors = self.mclient.remove_objects(self.bucket, [DeleteObject(oid)]) errors = self.mclient.remove_objects(self.bucket, [DeleteObject(oid)])
for error in errors: for error in errors:
log.error(" AVATARS: Error occured when deleting avatar object: " + error) log.error(" AVATARS: Error occured when deleting avatar object: " + error)
def get_users_without_image(self, users): def get_users_without_image(self, users : Iterable[Dict[str, Any]]) -> Iterable[Dict[str, Any]]:
return [u for u in users if u["id"] and u["id"] not in self.minio_get_objects()] return [u for u in users if u["id"] and u["id"] not in self.minio_get_objects()]

View File

@ -1,5 +1,6 @@
# #
# Copyright © 2021,2022 IsardVDI S.L. # Copyright © 2021,2022 IsardVDI S.L.
# Copyright © 2022 Evilham <contact@evilham.com>
# #
# This file is part of DD # This file is part of DD
# #
@ -29,16 +30,22 @@ import yaml
from PIL import Image from PIL import Image
from schema import And, Optional, Schema, SchemaError, Use from schema import And, Optional, Schema, SchemaError, Use
from admin import app from typing import TYPE_CHECKING, Any, Dict
if TYPE_CHECKING:
from admin.flaskapp import AdminFlaskApp
from werkzeug import FileStorage
class Dashboard: class Dashboard:
app : "AdminFlaskApp"
def __init__( def __init__(
self, self,
): app : "AdminFlaskApp",
) -> None:
self.app = app
self.custom_menu = os.path.join(app.root_path, "../custom/menu/custom.yaml") self.custom_menu = os.path.join(app.root_path, "../custom/menu/custom.yaml")
def _update_custom_menu(self, custom_menu_part): def _update_custom_menu(self, custom_menu_part : Dict[str, Any]) -> bool:
with open(self.custom_menu) as yml: with open(self.custom_menu) as yml:
menu = yaml.load(yml, Loader=yaml.FullLoader) menu = yaml.load(yml, Loader=yaml.FullLoader)
menu = {**menu, **custom_menu_part} menu = {**menu, **custom_menu_part}
@ -46,7 +53,7 @@ class Dashboard:
yml.write(yaml.dump(menu, default_flow_style=False)) yml.write(yaml.dump(menu, default_flow_style=False))
return True return True
def update_colours(self, colours): def update_colours(self, colours : Dict[str, Any]) -> bool:
schema_template = Schema( schema_template = Schema(
{ {
"background": And(Use(str)), "background": And(Use(str)),
@ -63,7 +70,7 @@ class Dashboard:
self._update_custom_menu({"colours": colours}) self._update_custom_menu({"colours": colours})
return self.apply_updates() return self.apply_updates()
def update_menu(self, menu): def update_menu(self, menu : Dict[str, Any]) -> bool:
items = [] items = []
for menu_item in menu.keys(): for menu_item in menu.keys():
for mustexist_key in ["href", "icon", "name", "shortname"]: for mustexist_key in ["href", "icon", "name", "shortname"]:
@ -73,16 +80,16 @@ class Dashboard:
self._update_custom_menu({"apps_external": items}) self._update_custom_menu({"apps_external": items})
return self.apply_updates() return self.apply_updates()
def update_logo(self, logo): def update_logo(self, logo : FileStorage) -> bool:
img = Image.open(logo.stream) img = Image.open(logo.stream)
img.save(os.path.join(app.root_path, "../custom/img/logo.png")) img.save(os.path.join(self.app.root_path, "../custom/img/logo.png"))
return self.apply_updates() return self.apply_updates()
def update_background(self, background): def update_background(self, background : FileStorage) -> bool:
img = Image.open(background.stream) img = Image.open(background.stream)
img.save(os.path.join(app.root_path, "../custom/img/background.png")) img.save(os.path.join(self.app.root_path, "../custom/img/background.png"))
return self.apply_updates() return self.apply_updates()
def apply_updates(self): def apply_updates(self) -> bool:
resp = requests.get("http://dd-sso-api:7039/restart") resp = requests.get("http://dd-sso-api:7039/restart")
return True return True

View File

@ -1,5 +1,6 @@
# #
# Copyright © 2021,2022 IsardVDI S.L. # Copyright © 2021,2022 IsardVDI S.L.
# Copyright © 2022 Evilham <contact@evilham.com>
# #
# This file is part of DD # This file is part of DD
# #
@ -38,34 +39,46 @@ from flask_socketio import (
send, send,
) )
from admin import app from typing import TYPE_CHECKING, Any, Dict
if TYPE_CHECKING:
from admin.flaskapp import AdminFlaskApp
def sio_event_send(event, data): def sio_event_send(app : "AdminFlaskApp", event : str, data : Dict[str, Any]) -> None:
app.socketio.emit( app.socketio.emit(
event, event,
json.dumps(data), json.dumps(data),
namespace="/sio/events", namespace="/sio/events",
room="events", room="events",
) )
# TODO: Why on earth do we find these all over the place?
sleep(0.001) sleep(0.001)
class Events: class Events:
def __init__(self, title, text="", total=0, table=False, type="info"): app : "AdminFlaskApp"
eid : str
title : str
text : str
total : int
table : bool
type : str
def __init__(self, app : "AdminFlaskApp", title : str, text : str="", total : int=0, table : bool=False, type : str="info") -> None:
self.app = app
# notice, info, success, and error # notice, info, success, and error
self.eid = str(base64.b64encode(os.urandom(32))[:8]) self.eid = str(base64.b64encode(os.urandom(32))[:8])
self.title = title self.title = title
self.text = text self.text = text
self.total = total self.total = total
# TODO: this is probably replacing the .table method????
self.table = table self.table = table
self.item = 0 self.item = 0
self.type = type self.type = type
self.create() self.create()
def create(self): def create(self) -> None:
log.info("START " + self.eid + ": " + self.text) log.info("START " + self.eid + ": " + self.text)
app.socketio.emit( self.app.socketio.emit(
"notify-create", "notify-create",
json.dumps( json.dumps(
{ {
@ -80,9 +93,9 @@ class Events:
) )
sleep(0.001) sleep(0.001)
def __del__(self): def __del__(self) -> None:
log.info("END " + self.eid + ": " + self.text) log.info("END " + self.eid + ": " + self.text)
app.socketio.emit( self.app.socketio.emit(
"notify-destroy", "notify-destroy",
json.dumps({"id": self.eid}), json.dumps({"id": self.eid}),
namespace="/sio", namespace="/sio",
@ -90,9 +103,9 @@ class Events:
) )
sleep(0.001) sleep(0.001)
def update_text(self, text): def update_text(self, text : str) -> None:
self.text = text self.text = text
app.socketio.emit( self.app.socketio.emit(
"notify-update", "notify-update",
json.dumps( json.dumps(
{ {
@ -105,9 +118,9 @@ class Events:
) )
sleep(0.001) sleep(0.001)
def append_text(self, text): def append_text(self, text : str) -> None:
self.text = self.text + "<br>" + text self.text = self.text + "<br>" + text
app.socketio.emit( self.app.socketio.emit(
"notify-update", "notify-update",
json.dumps( json.dumps(
{ {
@ -120,10 +133,10 @@ class Events:
) )
sleep(0.001) sleep(0.001)
def increment(self, data={"name": "", "data": []}): def increment(self, data : Dict[str, Any]={"name": "", "data": []}) -> None:
self.item += 1 self.item += 1
log.info("INCREMENT " + self.eid + ": " + self.text) log.info("INCREMENT " + self.eid + ": " + self.text)
app.socketio.emit( self.app.socketio.emit(
"notify-increment", "notify-increment",
json.dumps( json.dumps(
{ {
@ -149,10 +162,10 @@ class Events:
) )
sleep(0.0001) sleep(0.0001)
def decrement(self, data={"name": "", "data": []}): def decrement(self, data : Dict[str, Any]={"name": "", "data": []}) -> None:
self.item -= 1 self.item -= 1
log.info("DECREMENT " + self.eid + ": " + self.text) log.info("DECREMENT " + self.eid + ": " + self.text)
app.socketio.emit( self.app.socketio.emit(
"notify-decrement", "notify-decrement",
json.dumps( json.dumps(
{ {
@ -178,13 +191,13 @@ class Events:
) )
sleep(0.001) sleep(0.001)
def reload(self): def reload(self) -> None:
app.socketio.emit("reload", json.dumps({}), namespace="/sio", room="admin") self.app.socketio.emit("reload", json.dumps({}), namespace="/sio", room="admin")
sleep(0.0001) sleep(0.0001)
def table(self, event, table, data={}): def table(self, event : str, table : bool, data : Dict[str, Any]={}) -> None:
# refresh, add, delete, update # refresh, add, delete, update
app.socketio.emit( self.app.socketio.emit(
"table_" + event, "table_" + event,
json.dumps({"table": table, "data": data}), json.dumps({"table": table, "data": data}),
namespace="/sio", namespace="/sio",

View File

@ -1,5 +1,6 @@
# #
# Copyright © 2021,2022 IsardVDI S.L. # Copyright © 2021,2022 IsardVDI S.L.
# Copyright © 2022 Evilham <contact@evilham.com>
# #
# This file is part of DD # This file is part of DD
# #
@ -22,8 +23,11 @@ import string
from collections import Counter from collections import Counter
from pprint import pprint from pprint import pprint
from typing import Any, Dict, Generator, Iterable, Optional, List
def get_recursive_groups(l_groups, l): DDGroup = Dict[str, Any]
def get_recursive_groups(l_groups : Iterable[DDGroup], l : List[DDGroup]) -> List[DDGroup]:
for d_group in l_groups: for d_group in l_groups:
data = {} data = {}
for key, value in d_group.items(): for key, value in d_group.items():
@ -35,11 +39,11 @@ def get_recursive_groups(l_groups, l):
return l return l
def get_group_with_childs(keycloak_group): def get_group_with_childs(keycloak_group : DDGroup) -> List[str]:
return [g["path"] for g in get_recursive_groups([keycloak_group], [])] return [g["path"] for g in get_recursive_groups([keycloak_group], [])]
def system_username(username): def system_username(username : str) -> bool:
return ( return (
True True
if username in ["guest", "ddadmin", "admin"] or username.startswith("system_") if username in ["guest", "ddadmin", "admin"] or username.startswith("system_")
@ -47,41 +51,43 @@ def system_username(username):
) )
def system_group(groupname): def system_group(groupname : str) -> bool:
return True if groupname in ["admin", "manager", "teacher", "student"] else False return True if groupname in ["admin", "manager", "teacher", "student"] else False
def get_group_from_group_id(group_id, groups): def get_group_from_group_id(group_id : str, groups : Iterable[DDGroup]) -> Optional[DDGroup]:
return next((d for d in groups if d.get("id") == group_id), None) return next((d for d in groups if d.get("id") == group_id), None)
def get_kid_from_kpath(kpath, groups): def get_kid_from_kpath(kpath : str, groups : Iterable[DDGroup]) -> Optional[str]:
ids = [g["id"] for g in groups if g["path"] == kpath] ids : List[str] = [g["id"] for g in groups if g["path"] == kpath]
if not len(ids) or len(ids) > 1: if len(ids) != 1:
return False return None
return ids[0] return ids[0]
def get_gid_from_kgroup_id(kgroup_id, groups): def get_gid_from_kgroup_id(kgroup_id : str, groups : Iterable[DDGroup]) -> str:
return [ # TODO: Why is this interface different from get_kid_from_kpath?
o : List[str] = [
g["path"].replace("/", ".")[1:] if len(g["path"].split("/")) else g["path"][1:] g["path"].replace("/", ".")[1:] if len(g["path"].split("/")) else g["path"][1:]
for g in groups for g in groups
if g["id"] == kgroup_id if g["id"] == kgroup_id
][0] ]
return o[0]
def get_gids_from_kgroup_ids(kgroup_ids, groups): def get_gids_from_kgroup_ids(kgroup_ids : Iterable[str], groups : Iterable[DDGroup]) -> List[str]:
return [get_gid_from_kgroup_id(kgroup_id, groups) for kgroup_id in kgroup_ids] return [get_gid_from_kgroup_id(kgroup_id, groups) for kgroup_id in kgroup_ids]
def kpath2gid(path): def kpath2gid(path : str) -> str:
# print(path.replace('/','.')[1:]) # print(path.replace('/','.')[1:])
if path.startswith("/"): if path.startswith("/"):
return path.replace("/", ".")[1:] return path.replace("/", ".")[1:]
return path.replace("/", ".") return path.replace("/", ".")
def kpath2gids(path): def kpath2gids(path : str) -> List[str]:
path = kpath2gid(path) path = kpath2gid(path)
l = [] l = []
for i in range(len(path.split("."))): for i in range(len(path.split("."))):
@ -89,44 +95,45 @@ def kpath2gids(path):
return l return l
def kpath2kpaths(path): def kpath2kpaths(path : str) -> List[str]:
l = [] l = []
for i in range(len(path.split("/"))): for i in range(len(path.split("/"))):
l.append("/".join(path.split("/")[: i + 1])) l.append("/".join(path.split("/")[: i + 1]))
return l[1:] return l[1:]
def gid2kpath(gid): def gid2kpath(gid : str) -> str:
return "/" + gid.replace(".", "/") return "/" + gid.replace(".", "/")
def count_repeated(itemslist): def count_repeated(itemslist : Iterable[Any]) -> None:
print(Counter(itemslist)) print(Counter(itemslist))
def groups_kname2gid(groups): def groups_kname2gid(groups : Iterable[str]) -> List[str]:
return [name.replace(".", "/") for name in groups] return [name.replace(".", "/") for name in groups]
def groups_path2id(groups): def groups_path2id(groups : Iterable[str]) -> List[str]:
return [g.replace("/", ".")[1:] for g in groups] return [g.replace("/", ".")[1:] for g in groups]
def groups_id2path(groups): def groups_id2path(groups : Iterable[str]) -> List[str]:
return ["/" + g.replace(".", "/") for g in groups] return ["/" + g.replace(".", "/") for g in groups]
def filter_roles_list(role_list): def filter_roles_list(role_list : Iterable[str]) -> List[str]:
client_roles = ["admin", "manager", "teacher", "student"] client_roles = ["admin", "manager", "teacher", "student"]
return [r for r in role_list if r in client_roles] return [r for r in role_list if r in client_roles]
def filter_roles_listofdicts(role_listofdicts): def filter_roles_listofdicts(role_listofdicts : Iterable[Dict[str, Any]]) -> List[Dict[str, Any]]:
client_roles = ["admin", "manager", "teacher", "student"] client_roles = ["admin", "manager", "teacher", "student"]
return [r for r in role_listofdicts if r["name"] in client_roles] return [r for r in role_listofdicts if r["name"] in client_roles]
def rand_password(lenght): def rand_password(lenght : int) -> str:
# TODO: why is this not using py3's secrets?
characters = string.ascii_letters + string.digits + string.punctuation characters = string.ascii_letters + string.digits + string.punctuation
passwd = "".join(random.choice(characters) for i in range(lenght)) passwd = "".join(random.choice(characters) for i in range(lenght))
while not any(ele.isupper() for ele in passwd): while not any(ele.isupper() for ele in passwd):

View File

@ -1,5 +1,6 @@
# #
# Copyright © 2021,2022 IsardVDI S.L. # Copyright © 2021,2022 IsardVDI S.L.
# Copyright © 2022 Evilham <contact@evilham.com>
# #
# This file is part of DD # This file is part of DD
# #
@ -34,23 +35,33 @@ from .api_exceptions import Error
from .helpers import get_recursive_groups, kpath2kpaths from .helpers import get_recursive_groups, kpath2kpaths
from .postgres import Postgres from .postgres import Postgres
# from admin import app from typing import cast, Any, Dict, Iterable, List, Optional
DDUser = Dict[str, Any]
# TODO: Improve typing of these class and simplify it
class KeycloakClient: class KeycloakClient:
"""https://www.keycloak.org/docs-api/13.0/rest-api/index.html """https://www.keycloak.org/docs-api/13.0/rest-api/index.html
https://github.com/marcospereirampj/python-keycloak https://github.com/marcospereirampj/python-keycloak
https://gist.github.com/kaqfa/99829941121188d7cef8271f93f52f1f https://gist.github.com/kaqfa/99829941121188d7cef8271f93f52f1f
""" """
url : str
username : str
password : str
realm : str
verify : bool
keycloak_pg : Postgres
keycloak_admin : KeycloakAdmin
def __init__( def __init__(
self, self,
url="http://dd-sso-keycloak:8080/auth/", url : str="http://dd-sso-keycloak:8080/auth/",
username=os.environ["KEYCLOAK_USER"], username : str=os.environ["KEYCLOAK_USER"],
password=os.environ["KEYCLOAK_PASSWORD"], password : str=os.environ["KEYCLOAK_PASSWORD"],
realm="master", realm : str="master",
verify=True, verify : bool=True,
): ) -> None:
self.url = url self.url = url
self.username = username self.username = username
self.password = password self.password = password
@ -64,7 +75,7 @@ class KeycloakClient:
os.environ["KEYCLOAK_DB_PASSWORD"], os.environ["KEYCLOAK_DB_PASSWORD"],
) )
def connect(self): def connect(self) -> None:
self.keycloak_admin = KeycloakAdmin( self.keycloak_admin = KeycloakAdmin(
server_url=self.url, server_url=self.url,
username=self.username, username=self.username,
@ -78,15 +89,19 @@ class KeycloakClient:
""" USERS """ """ USERS """
def get_user_id(self, username): def get_user_id(self, username : str) -> str:
self.connect() self.connect()
return self.keycloak_admin.get_user_id(username) uid : str = self.keycloak_admin.get_user_id(username)
return uid
def get_users(self): def get_users(self) -> Iterable[Dict[str, Any]]:
# https://www.keycloak.org/docs-api/18.0/rest-api/index.html#_userrepresentation
self.connect() self.connect()
return self.keycloak_admin.get_users({}) o : Iterable[Dict[str, Any]] = self.keycloak_admin.get_users({})
return o
def get_users_with_groups_and_roles(self): # TODO: what is this actually doing?
def get_users_with_groups_and_roles(self) -> List[DDUser]:
q = """select u.id, u.username, u.email, u.first_name, u.last_name, u.realm_id, u.enabled, ua.value as quota 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(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 ,json_agg(r.name) as role
@ -125,7 +140,7 @@ class KeycloakClient:
return list_dict_users return list_dict_users
def getparent(self, group_id, data): def getparent(self, group_id : str, data : Iterable[Any]) -> str:
# Recursively get full path from any group_id in the tree # Recursively get full path from any group_id in the tree
path = "" path = ""
for item in data: for item in data:
@ -134,14 +149,14 @@ class KeycloakClient:
path = f"{path}/{item[1]}" path = f"{path}/{item[1]}"
return path return path
def get_group_path(self, group_id): def get_group_path(self, group_id : str) -> str:
# Get full path using getparent recursive func # Get full path using getparent recursive func
# RETURNS: String with full path # RETURNS: String with full path
q = """SELECT * FROM keycloak_group""" q = """SELECT * FROM keycloak_group"""
groups = self.keycloak_pg.select(q) groups = self.keycloak_pg.select(q)
return self.getparent(group_id, groups) return self.getparent(group_id, groups)
def get_user_groups_paths(self, user_id): def get_user_groups_paths(self, user_id : str) -> List[str]:
# Get full paths for user grups # Get full paths for user grups
# RETURNS list of paths # RETURNS list of paths
q = """SELECT group_id FROM user_group_membership WHERE user_id = '%s'""" % ( q = """SELECT group_id FROM user_group_membership WHERE user_id = '%s'""" % (
@ -165,20 +180,20 @@ class KeycloakClient:
def add_user( def add_user(
self, self,
username, username : str,
first, first : str,
last, last : str,
email, email : str,
password, password : str,
group=False, group : Any=False,
password_temporary=True, password_temporary : bool=True,
enabled=True, enabled : bool=True,
): ) -> Any:
# RETURNS string with keycloak user id (the main id in this app) # RETURNS string with keycloak user id (the main id in this app)
self.connect() self.connect()
username = username.lower() username = username.lower()
try: try:
uid = self.keycloak_admin.create_user( uid : Any = self.keycloak_admin.create_user(
{ {
"email": email, "email": email,
"username": username, "username": username,
@ -213,7 +228,7 @@ class KeycloakClient:
self.keycloak_admin.group_user_add(uid, gid) self.keycloak_admin.group_user_add(uid, gid)
return uid return uid
def update_user_pwd(self, user_id, password, password_temporary=True): def update_user_pwd(self, user_id : str, password : str, password_temporary : bool=True) -> Any:
# Updates # Updates
payload = { payload = {
"credentials": [ "credentials": [
@ -223,7 +238,7 @@ class KeycloakClient:
self.connect() self.connect()
return self.keycloak_admin.update_user(user_id, payload) return self.keycloak_admin.update_user(user_id, payload)
def user_update(self, user_id, enabled, email, first, last, groups=[], roles=[]): def user_update(self, user_id : str, enabled : bool, email : str, first : str, last : str, groups : Iterable[str]=[], roles : Iterable[str]=[]) -> Any:
## NOTE: Roles didn't seem to be updated/added. Also not confident with groups ## NOTE: Roles didn't seem to be updated/added. Also not confident with groups
# Updates # Updates
payload = { payload = {
@ -237,17 +252,17 @@ class KeycloakClient:
self.connect() self.connect()
return self.keycloak_admin.update_user(user_id, payload) return self.keycloak_admin.update_user(user_id, payload)
def user_enable(self, user_id): def user_enable(self, user_id : str) -> Any:
payload = {"enabled": True} payload = {"enabled": True}
self.connect() self.connect()
return self.keycloak_admin.update_user(user_id, payload) return self.keycloak_admin.update_user(user_id, payload)
def user_disable(self, user_id): def user_disable(self, user_id : str) -> Any:
payload = {"enabled": False} payload = {"enabled": False}
self.connect() self.connect()
return self.keycloak_admin.update_user(user_id, payload) return self.keycloak_admin.update_user(user_id, payload)
def group_user_remove(self, user_id, group_id): def group_user_remove(self, user_id : str, group_id : str) -> Any:
self.connect() self.connect()
return self.keycloak_admin.group_user_remove(user_id, group_id) return self.keycloak_admin.group_user_remove(user_id, group_id)
@ -255,7 +270,7 @@ class KeycloakClient:
# self.connect() # self.connect()
# return self.keycloak_admin.assign_role(client_id=client_id, user_id=user_id, role_id=role_id, role_name="test") # 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): def remove_user_realm_roles(self, user_id : str, roles : Iterable[str]) -> Any:
self.connect() self.connect()
roles = [ roles = [
r r
@ -264,66 +279,66 @@ class KeycloakClient:
] ]
return self.keycloak_admin.delete_user_realm_role(user_id, roles) return self.keycloak_admin.delete_user_realm_role(user_id, roles)
def delete_user(self, userid): def delete_user(self, userid : str) -> Any:
self.connect() self.connect()
return self.keycloak_admin.delete_user(user_id=userid) return self.keycloak_admin.delete_user(user_id=userid)
def get_user_groups(self, userid): def get_user_groups(self, userid : str) -> Any:
self.connect() self.connect()
return self.keycloak_admin.get_user_groups(user_id=userid) return self.keycloak_admin.get_user_groups(user_id=userid)
def get_user_realm_roles(self, userid): def get_user_realm_roles(self, userid : str) -> Any:
self.connect() self.connect()
return self.keycloak_admin.get_realm_roles_of_user(user_id=userid) 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): def add_user_client_role(self, client_id : str, user_id : str, role_id : str, role_name : str) -> Any:
self.connect() self.connect()
return self.keycloak_admin.assign_client_role( return self.keycloak_admin.assign_client_role(
client_id=client_id, user_id=user_id, role_id=role_id, role_name="test" client_id=client_id, user_id=user_id, role_id=role_id, role_name="test"
) )
## GROUPS ## GROUPS
def get_all_groups(self): def get_all_groups(self) -> Iterable[Any]:
## RETURNS ONLY MAIN GROUPS WITH NESTED subGroups list ## RETURNS ONLY MAIN GROUPS WITH NESTED subGroups list
self.connect() self.connect()
return self.keycloak_admin.get_groups() return cast(Iterable[Any], self.keycloak_admin.get_groups())
def get_groups(self, with_subgroups=True): def get_groups(self, with_subgroups : bool=True) -> Iterable[Any]:
## RETURNS ALL GROUPS in root list ## RETURNS ALL GROUPS in root list
self.connect() self.connect()
groups = self.keycloak_admin.get_groups() groups = self.keycloak_admin.get_groups()
return get_recursive_groups(groups, []) return get_recursive_groups(groups, [])
def get_group_by_id(self, group_id): def get_group_by_id(self, group_id : str) -> Any:
self.connect() self.connect()
return self.keycloak_admin.get_group(group_id=group_id) return self.keycloak_admin.get_group(group_id=group_id)
def get_group_by_path(self, path, recursive=True): def get_group_by_path(self, path : str, recursive : bool=True) -> Any:
self.connect() self.connect()
return self.keycloak_admin.get_group_by_path( return self.keycloak_admin.get_group_by_path(
path=path, search_in_subgroups=recursive path=path, search_in_subgroups=recursive
) )
def add_group(self, name, parent=None, skip_exists=False): def add_group(self, name : str, parent : str="", skip_exists : bool=False) -> Any:
self.connect() self.connect()
if parent != None: if parent:
parent = self.get_group_by_path(parent)["id"] parent = self.get_group_by_path(parent)["id"]
return self.keycloak_admin.create_group({"name": name}, parent=parent) return self.keycloak_admin.create_group({"name": name}, parent=parent)
def delete_group(self, group_id): def delete_group(self, group_id : str) -> Any:
self.connect() self.connect()
return self.keycloak_admin.delete_group(group_id=group_id) return self.keycloak_admin.delete_group(group_id=group_id)
def group_user_add(self, user_id, group_id): def group_user_add(self, user_id : str, group_id : str) -> Any:
self.connect() self.connect()
return self.keycloak_admin.group_user_add(user_id, group_id) return self.keycloak_admin.group_user_add(user_id, group_id)
def add_group_tree(self, path): def add_group_tree(self, path : str) -> None:
paths = kpath2kpaths(path) paths = kpath2kpaths(path)
parent = "/" parent = "/"
for path in paths: for path in paths:
try: try:
parent_path = None if parent == "/" else parent parent_path = "" if parent == "/" else parent
# print("parent: "+str(parent_path)+" path: "+path.split("/")[-1]) # print("parent: "+str(parent_path)+" path: "+path.split("/")[-1])
self.add_group(path.split("/")[-1], parent_path, skip_exists=True) self.add_group(path.split("/")[-1], parent_path, skip_exists=True)
parent = path parent = path
@ -333,8 +348,8 @@ class KeycloakClient:
parent = path parent = path
def add_user_with_groups_and_role( def add_user_with_groups_and_role(
self, username, first, last, email, password, role, groups self, username : str, first : str, last : str, email : str, password : str, role : str, groups : Iterable[str]
): ) -> None:
## Add user ## Add user
uid = self.add_user(username, first, last, email, password) uid = self.add_user(username, first, last, email, password)
## Add user to role ## Add user to role
@ -348,7 +363,7 @@ class KeycloakClient:
for g in groups: for g in groups:
log.warning("Creating keycloak group: " + g) log.warning("Creating keycloak group: " + g)
parts = g.split("/") parts = g.split("/")
parent_path = None parent_path = ""
for i in range(1, len(parts)): for i in range(1, len(parts)):
# parent_id=None if parent_path==None else self.get_group(parent_path)['id'] # parent_id=None if parent_path==None else self.get_group(parent_path)['id']
try: try:
@ -360,9 +375,6 @@ class KeycloakClient:
+ " already exists. Skipping creation" + " already exists. Skipping creation"
) )
pass pass
if parent_path is None:
thepath = "/" + parts[i]
else:
thepath = parent_path + "/" + parts[i] thepath = parent_path + "/" + parts[i]
if thepath == "/": if thepath == "/":
log.warning( log.warning(
@ -385,53 +397,51 @@ class KeycloakClient:
) )
self.keycloak_admin.group_user_add(uid, gid) self.keycloak_admin.group_user_add(uid, gid)
if parent_path == None: parent_path += "/" + parts[i]
parent_path = ""
parent_path = parent_path + "/" + parts[i]
# self.group_user_add(uid,gid) # self.group_user_add(uid,gid)
## ROLES ## ROLES
def get_roles(self): def get_roles(self) -> Iterable[Any]:
self.connect() self.connect()
return self.keycloak_admin.get_realm_roles() return cast(Iterable[Any], self.keycloak_admin.get_realm_roles())
def get_role(self, name): def get_role(self, name : str) -> Any:
self.connect() self.connect()
return self.keycloak_admin.get_realm_role(name) return self.keycloak_admin.get_realm_role(name)
def add_role(self, name, description=""): def add_role(self, name : str, description : str="") -> Any:
self.connect() self.connect()
return self.keycloak_admin.create_realm_role( return self.keycloak_admin.create_realm_role(
{"name": name, "description": description} {"name": name, "description": description}
) )
def delete_role(self, name): def delete_role(self, name : str) -> Any:
self.connect() self.connect()
return self.keycloak_admin.delete_realm_role(name) return self.keycloak_admin.delete_realm_role(name)
## CLIENTS ## CLIENTS
def get_client_roles(self, client_id): def get_client_roles(self, client_id : str) -> Any:
self.connect() self.connect()
return self.keycloak_admin.get_client_roles(client_id=client_id) return self.keycloak_admin.get_client_roles(client_id=client_id)
def add_client_role(self, client_id, name, description=""): def add_client_role(self, client_id : str, name : str, description : str="") -> Any:
self.connect() self.connect()
return self.keycloak_admin.create_client_role( return self.keycloak_admin.create_client_role(
client_id, {"name": name, "description": description, "clientRole": True} client_id, {"name": name, "description": description, "clientRole": True}
) )
## SYSTEM ## SYSTEM
def get_server_info(self): def get_server_info(self) -> Any:
self.connect() self.connect()
return self.keycloak_admin.get_server_info() return self.keycloak_admin.get_server_info()
def get_server_clients(self): def get_server_clients(self) -> Any:
self.connect() self.connect()
return self.keycloak_admin.get_clients() return self.keycloak_admin.get_clients()
def get_server_rsa_key(self): def get_server_rsa_key(self) -> Any:
self.connect() self.connect()
rsa_key = [ rsa_key = [
k for k in self.keycloak_admin.get_keys()["keys"] if k["type"] == "RSA" k for k in self.keycloak_admin.get_keys()["keys"] if k["type"] == "RSA"
@ -439,22 +449,21 @@ class KeycloakClient:
return {"name": rsa_key["kid"], "certificate": rsa_key["certificate"]} return {"name": rsa_key["kid"], "certificate": rsa_key["certificate"]}
## REALM ## REALM
def assign_realm_roles(self, user_id, role): def assign_realm_roles(self, user_id : str, role : str) -> Any:
self.connect() self.connect()
try: try:
role = [ kcroles = [
r for r in self.keycloak_admin.get_realm_roles() if r["name"] == role r for r in self.keycloak_admin.get_realm_roles() if r["name"] == role
] ]
except: except:
return False 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, roles=kcroles)
# return self.keycloak_admin.assign_realm_roles(user_id=user_id, client_id=None, roles=role)
## CLIENTS ## CLIENTS
def delete_client(self, clientid): def delete_client(self, clientid : str) -> Any:
self.connect() self.connect()
return self.keycloak_admin.delete_client(clientid) return self.keycloak_admin.delete_client(clientid)
def add_client(self, client): def add_client(self, client : str) -> Any:
self.connect() self.connect()
return self.keycloak_admin.create_client(client) return self.keycloak_admin.create_client(client)

View File

@ -1,5 +1,6 @@
# #
# Copyright © 2021,2022 IsardVDI S.L. # Copyright © 2021,2022 IsardVDI S.L.
# Copyright © 2022 Evilham <contact@evilham.com>
# #
# This file is part of DD # This file is part of DD
# #
@ -21,7 +22,6 @@ import logging as log
import os import os
import traceback import traceback
from admin import app
from pprint import pprint from pprint import pprint
from minio import Minio from minio import Minio
@ -29,18 +29,22 @@ from minio.commonconfig import REPLACE, CopySource
from minio.deleteobjects import DeleteObject from minio.deleteobjects import DeleteObject
from requests import get, post from requests import get, post
legal_path= os.path.join(app.root_path, "static/templates/pages/legal/") from typing import TYPE_CHECKING
if TYPE_CHECKING:
from admin.flaskapp import AdminFlaskApp
def get_legal(lang):
with open(legal_path+lang, "r") as languagefile: # TODO: Fix all this
def get_legal(app : "AdminFlaskApp", lang : str) -> str:
with open(app.legal_path+lang, "r") as languagefile:
return languagefile.read() return languagefile.read()
def gen_legal_if_not_exists(lang): def gen_legal_if_not_exists(app : "AdminFlaskApp", lang : str) -> None:
if not os.path.isfile(legal_path+lang): if not os.path.isfile(app.legal_path+lang):
log.debug("Creating new language file") log.debug("Creating new language file")
with open(legal_path+lang, "w") as languagefile: with open(app.legal_path+lang, "w") as languagefile:
languagefile.write("<b>Legal</b><br>This is the default legal page for language " + lang) languagefile.write("<b>Legal</b><br>This is the default legal page for language " + lang)
def new_legal(lang,html): def new_legal(app : "AdminFlaskApp", lang : str, html : str) -> None:
with open(legal_path+lang, "w") as languagefile: with open(app.legal_path+lang, "w") as languagefile:
languagefile.write(html) languagefile.write(html)

View File

@ -1,94 +0,0 @@
#
# Copyright © 2021,2022 IsardVDI S.L.
#
# This file is part of DD
#
# DD is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or (at your
# option) any later version.
#
# DD is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License
# along with DD. If not, see <https://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: AGPL-3.0-or-later
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

@ -1,5 +1,6 @@
# #
# Copyright © 2021,2022 IsardVDI S.L. # Copyright © 2021,2022 IsardVDI S.L.
# Copyright © 2022 Evilham <contact@evilham.com>
# #
# This file is part of DD # This file is part of DD
# #
@ -23,11 +24,15 @@ from pprint import pprint
from requests import get, post from requests import get, post
from admin import app
from .exceptions import UserExists, UserNotFound from .exceptions import UserExists, UserNotFound
from .postgres import Postgres from .postgres import Postgres
from typing import TYPE_CHECKING, cast, Any, Dict, Iterable, List, Optional
if TYPE_CHECKING:
from admin.flaskapp import AdminFlaskApp
# Module variables to connect to moodle api # Module variables to connect to moodle api
@ -36,18 +41,20 @@ class Moodle:
https://docs.moodle.org/dev/Web_service_API_functions https://docs.moodle.org/dev/Web_service_API_functions
https://docs.moodle.org/311/en/Using_web_services https://docs.moodle.org/311/en/Using_web_services
""" """
key: str
url : str
endpoint : str
verify : bool
moodle_pg : Postgres
def __init__( def __init__(
self, self,
key=app.config["MOODLE_WS_TOKEN"], app : "AdminFlaskApp",
url="https://moodle." + app.config["DOMAIN"], endpoint : str="/webservice/rest/server.php",
endpoint="/webservice/rest/server.php", ) -> None:
verify=app.config["VERIFY"], self.key = app.config["MOODLE_WS_TOKEN"]
): self.url = f"https://moodle.{ app.config['DOMAIN'] }"
self.key = key
self.url = url
self.endpoint = endpoint self.endpoint = endpoint
self.verify = verify self.verify = cast(bool, app.config["VERIFY"])
self.moodle_pg = Postgres( self.moodle_pg = Postgres(
"dd-apps-postgresql", "dd-apps-postgresql",
@ -56,7 +63,7 @@ class Moodle:
app.config["MOODLE_POSTGRES_PASSWORD"], app.config["MOODLE_POSTGRES_PASSWORD"],
) )
def rest_api_parameters(self, in_args, prefix="", out_dict=None): def rest_api_parameters(self, in_args : Any, prefix : str="", out_dict : Optional[Dict]=None) -> Dict[Any, Any]:
"""Transform dictionary/array structure to a flat dictionary, with key names """Transform dictionary/array structure to a flat dictionary, with key names
defining the structure. defining the structure.
Example usage: Example usage:
@ -64,24 +71,23 @@ class Moodle:
{'courses[0][id]':1, {'courses[0][id]':1,
'courses[0][name]':'course1'} 'courses[0][name]':'course1'}
""" """
if out_dict == None: o : Dict[Any, Any] = {} if out_dict is None else out_dict
out_dict = {}
if not type(in_args) in (list, dict): if not type(in_args) in (list, dict):
out_dict[prefix] = in_args o[prefix] = in_args
return out_dict return o
if prefix == "": if prefix == "":
prefix = prefix + "{0}" prefix = prefix + "{0}"
else: else:
prefix = prefix + "[{0}]" prefix = prefix + "[{0}]"
if type(in_args) == list: if type(in_args) == list:
for idx, item in enumerate(in_args): for idx, item in enumerate(in_args):
self.rest_api_parameters(item, prefix.format(idx), out_dict) self.rest_api_parameters(item, prefix.format(idx), o)
elif type(in_args) == dict: elif type(in_args) == dict:
for key, item in in_args.items(): for key, item in in_args.items():
self.rest_api_parameters(item, prefix.format(key), out_dict) self.rest_api_parameters(item, prefix.format(key), o)
return out_dict return o
def call(self, fname, **kwargs): def call(self, fname : str, **kwargs : Any) -> Any:
"""Calls moodle API function with function name fname and keyword arguments. """Calls moodle API function with function name fname and keyword arguments.
Example: Example:
>>> call_mdl_function('core_course_update_courses', >>> call_mdl_function('core_course_update_courses',
@ -97,7 +103,7 @@ class Moodle:
raise SystemError(response) raise SystemError(response)
return response return response
def create_user(self, email, username, password, first_name="-", last_name="-"): def create_user(self, email : str, username : str, password : str, first_name : str="-", last_name : str="-") -> Any:
if len(self.get_user_by("username", username)["users"]): if len(self.get_user_by("username", username)["users"]):
raise UserExists raise UserExists
try: try:
@ -115,7 +121,7 @@ class Moodle:
except SystemError as se: except SystemError as se:
raise SystemError(se.args[0]["message"]) raise SystemError(se.args[0]["message"])
def update_user(self, username, email, first_name, last_name, enabled=True): def update_user(self, username : str, email : str, first_name : str, last_name : str, enabled : bool=True) -> Any:
user = self.get_user_by("username", username)["users"][0] user = self.get_user_by("username", username)["users"][0]
if not len(user): if not len(user):
raise UserNotFound raise UserNotFound
@ -135,15 +141,15 @@ class Moodle:
except SystemError as se: except SystemError as se:
raise SystemError(se.args[0]["message"]) raise SystemError(se.args[0]["message"])
def delete_user(self, user_id): def delete_user(self, user_id : str) -> Any:
user = self.call("core_user_delete_users", userids=[user_id]) user = self.call("core_user_delete_users", userids=[user_id])
return user return user
def delete_users(self, userids): def delete_users(self, userids : List[str]) -> Any:
user = self.call("core_user_delete_users", userids=userids) user = self.call("core_user_delete_users", userids=userids)
return user return user
def get_user_by(self, key, value): def get_user_by(self, key : str, value : str) -> Any:
criteria = [{"key": key, "value": value}] criteria = [{"key": key, "value": value}]
try: try:
user = self.call("core_user_get_users", criteria=criteria) user = self.call("core_user_get_users", criteria=criteria)
@ -152,7 +158,7 @@ class Moodle:
return user 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': []} # {'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): def get_users_with_groups_and_roles(self) -> List[Dict[Any, Any]]:
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 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 from mdl_user as u
LEFT JOIN mdl_cohort_members AS hm on hm.userid = u.id LEFT JOIN mdl_cohort_members AS hm on hm.userid = u.id
@ -179,31 +185,31 @@ class Moodle:
# user['roles']=[] # user['roles']=[]
# return users # return users
def enroll_user_to_course(self, user_id, course_id, role_id=5): def enroll_user_to_course(self, user_id : str, course_id : str, role_id : int=5) -> Any:
# 5 is student # 5 is student
data = [{"roleid": role_id, "userid": user_id, "courseid": course_id}] data = [{"roleid": role_id, "userid": user_id, "courseid": course_id}]
enrolment = self.call("enrol_manual_enrol_users", enrolments=data) enrolment = self.call("enrol_manual_enrol_users", enrolments=data)
return enrolment return enrolment
def get_quiz_attempt(self, quiz_id, user_id): def get_quiz_attempt(self, quiz_id : str, user_id : str) -> Any:
attempts = self.call( attempts = self.call(
"mod_quiz_get_user_attempts", quizid=quiz_id, userid=user_id "mod_quiz_get_user_attempts", quizid=quiz_id, userid=user_id
) )
return attempts return attempts
def get_cohorts(self): def get_cohorts(self) -> Any:
cohorts = self.call("core_cohort_get_cohorts") cohorts = self.call("core_cohort_get_cohorts")
return cohorts return cohorts
def add_system_cohort(self, name, description="", visible=True): def add_system_cohort(self, name : str, description : str ="", visible : bool=True) -> Any:
visible = 1 if visible else 0 bit_visible = 1 if visible else 0
data = [ data = [
{ {
"categorytype": {"type": "system", "value": ""}, "categorytype": {"type": "system", "value": ""},
"name": name, "name": name,
"idnumber": name, "idnumber": name,
"description": description, "description": description,
"visible": visible, "visible": bit_visible,
} }
] ]
cohort = self.call("core_cohort_create_cohorts", cohorts=data) cohort = self.call("core_cohort_create_cohorts", cohorts=data)
@ -214,7 +220,7 @@ class Moodle:
# user = self.call('core_cohort_add_cohort_members', criteria=criteria) # user = self.call('core_cohort_add_cohort_members', criteria=criteria)
# return user # return user
def add_user_to_cohort(self, userid, cohortid): def add_user_to_cohort(self, userid : str, cohortid : str) -> Any:
members = [ members = [
{ {
"cohorttype": {"type": "id", "value": cohortid}, "cohorttype": {"type": "id", "value": cohortid},
@ -224,21 +230,21 @@ class Moodle:
user = self.call("core_cohort_add_cohort_members", members=members) user = self.call("core_cohort_add_cohort_members", members=members)
return user return user
def delete_user_in_cohort(self, userid, cohortid): def delete_user_in_cohort(self, userid : str, cohortid : str) -> Any:
members = [{"cohortid": cohortid, "userid": userid}] members = [{"cohortid": cohortid, "userid": userid}]
user = self.call("core_cohort_delete_cohort_members", members=members) user = self.call("core_cohort_delete_cohort_members", members=members)
return user return user
def get_cohort_members(self, cohort_ids): def get_cohort_members(self, cohort_ids : str) -> Any:
members = self.call("core_cohort_get_cohort_members", cohortids=cohort_ids) members = self.call("core_cohort_get_cohort_members", cohortids=cohort_ids)
# [0]['userids'] # [0]['userids']
return members return members
def delete_cohorts(self, cohortids): def delete_cohorts(self, cohortids : Iterable[str]) -> Any:
deleted = self.call("core_cohort_delete_cohorts", cohortids=cohortids) deleted = self.call("core_cohort_delete_cohorts", cohortids=cohortids)
return deleted return deleted
def get_user_cohorts(self, user_id): def get_user_cohorts(self, user_id : str) -> Any:
user_cohorts = [] user_cohorts = []
cohorts = self.get_cohorts() cohorts = self.get_cohorts()
for cohort in cohorts: for cohort in cohorts:
@ -246,7 +252,7 @@ class Moodle:
user_cohorts.append(cohort) user_cohorts.append(cohort)
return user_cohorts return user_cohorts
def add_user_to_siteadmin(self, user_id): def add_user_to_siteadmin(self, user_id : str) -> Any:
q = """SELECT value FROM mdl_config WHERE name='siteadmins'""" q = """SELECT value FROM mdl_config WHERE name='siteadmins'"""
value = self.moodle_pg.select(q)[0][0] value = self.moodle_pg.select(q)[0][0]
if str(user_id) not in value: if str(user_id) not in value:

View File

@ -1,5 +1,6 @@
# #
# Copyright © 2021,2022 IsardVDI S.L. # Copyright © 2021,2022 IsardVDI S.L.
# Copyright © 2022 Evilham <contact@evilham.com>
# #
# This file is part of DD # This file is part of DD
# #
@ -18,32 +19,29 @@
# #
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
import json
import logging as log
import time
import traceback
from datetime import datetime, timedelta
import mysql.connector import mysql.connector
import yaml
# from admin import app from typing import List, Tuple
class Mysql: class Mysql:
def __init__(self, host, database, user, password): # TODO: Fix this whole class
cur : mysql.connector.MySQLCursor
conn : mysql.connector.MySQLConnection
def __init__(self, host : str, database : str, user : str, password : str) -> None:
self.conn = mysql.connector.connect( self.conn = mysql.connector.connect(
host=host, database=database, user=user, password=password host=host, database=database, user=user, password=password
) )
def select(self, sql): def select(self, sql : str) -> List[Tuple]:
self.cur = self.conn.cursor() self.cur = self.conn.cursor()
self.cur.execute(sql) self.cur.execute(sql)
data = self.cur.fetchall() data : List[Tuple] = self.cur.fetchall()
self.cur.close() self.cur.close()
return data return data
def update(self, sql): def update(self, sql : str) -> None:
# TODO: Fix this whole method
self.cur = self.conn.cursor() self.cur = self.conn.cursor()
self.cur.execute(sql) self.cur.execute(sql)
self.conn.commit() self.conn.commit()

View File

@ -1,5 +1,6 @@
# #
# Copyright © 2021,2022 IsardVDI S.L. # Copyright © 2021,2022 IsardVDI S.L.
# Copyright © 2022 Evilham <contact@evilham.com>
# #
# This file is part of DD # This file is part of DD
# #
@ -30,21 +31,31 @@ import urllib
import requests import requests
from psycopg2 import sql from psycopg2 import sql
# from ..lib.log import *
from admin import app
from .nextcloud_exc import * from .nextcloud_exc import *
from .postgres import Postgres from .postgres import Postgres
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
if TYPE_CHECKING:
from admin.flaskapp import AdminFlaskApp
DDUser = Dict[Any, Any]
class Nextcloud: class Nextcloud:
verify_cert : bool
apiurl : str
shareurl : str
davurl : str
auth : Tuple[str, str]
user : str
nextcloud_pg : Postgres
def __init__( def __init__(
self, self,
url="https://nextcloud." + app.config["DOMAIN"], app : "AdminFlaskApp",
username=os.environ["NEXTCLOUD_ADMIN_USER"], username : str=os.environ["NEXTCLOUD_ADMIN_USER"],
password=os.environ["NEXTCLOUD_ADMIN_PASSWORD"], password : str=os.environ["NEXTCLOUD_ADMIN_PASSWORD"],
verify=True, verify : bool=True,
): ) -> None:
url = "https://nextcloud." + app.config["DOMAIN"]
self.verify_cert = verify self.verify_cert = verify
self.apiurl = url + "/ocs/v1.php/cloud/" self.apiurl = url + "/ocs/v1.php/cloud/"
@ -61,9 +72,9 @@ class Nextcloud:
) )
def _request( def _request(
self, method, url, data={}, headers={"OCS-APIRequest": "true"}, auth=False self, method : str, url : str, data : Any={}, headers : Dict[str, str]={"OCS-APIRequest": "true"}, auth : Optional[Tuple[str, str]]=None
): ) -> str:
if auth == False: if auth is None:
auth = self.auth auth = self.auth
try: try:
response = requests.request( response = requests.request(
@ -96,7 +107,7 @@ class Nextcloud:
raise ProviderConnError raise ProviderConnError
raise ProviderError raise ProviderError
def check_connection(self): def check_connection(self) -> bool:
url = self.apiurl + "users/" + self.user + "?format=json" url = self.apiurl + "users/" + self.user + "?format=json"
try: try:
result = self._request("GET", url) result = self._request("GET", url)
@ -118,7 +129,7 @@ class Nextcloud:
raise ProviderConnError raise ProviderConnError
raise ProviderError raise ProviderError
def get_user(self, userid): def get_user(self, userid : str) -> Any:
url = self.apiurl + "users/" + userid + "?format=json" url = self.apiurl + "users/" + userid + "?format=json"
try: try:
result = json.loads(self._request("GET", url)) result = json.loads(self._request("GET", url))
@ -148,7 +159,7 @@ class Nextcloud:
# 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]
# 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] # 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] # list_dict_users = [dict(zip(fields, r)) for r in users_with_lists]
def get_users_list(self): def get_users_list(self) -> List[DDUser]:
# 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 # 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 # from oc_users as u
# left join oc_group_user as gu on gu.uid = u.uid # left join oc_group_user as gu on gu.uid = u.uid
@ -200,9 +211,10 @@ class Nextcloud:
# log.error(traceback.format_exc()) # log.error(traceback.format_exc())
# raise # raise
# TODO: Improve typing of these functions...
def add_user( def add_user(
self, userid, userpassword, quota=False, group=False, email="", displayname="" self, userid : str, userpassword : str, quota : Any=False, group : Any=False, email : str="", displayname : str=""
): ) -> bool:
data = { data = {
"userid": userid, "userid": userid,
"password": userpassword, "password": userpassword,
@ -247,7 +259,7 @@ class Nextcloud:
# 106 - no group specified (required for subadmins) # 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) # 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): def update_user(self, userid : str, key_values : Dict[str, Any]) -> bool:
# key_values={'quota':quota,'email':email,'displayname':displayname} # key_values={'quota':quota,'email':email,'displayname':displayname}
url = self.apiurl + "users/" + userid + "?format=json" url = self.apiurl + "users/" + userid + "?format=json"
@ -262,6 +274,8 @@ class Nextcloud:
result = json.loads( result = json.loads(
self._request("PUT", url, data=data, headers=headers) self._request("PUT", url, data=data, headers=headers)
) )
# TODO: It seems like this only sets the first item in key_values
# This function probably doesn't do what it is supposed to
if result["ocs"]["meta"]["statuscode"] == 100: if result["ocs"]["meta"]["statuscode"] == 100:
return True return True
if result["ocs"]["meta"]["statuscode"] == 102: if result["ocs"]["meta"]["statuscode"] == 102:
@ -273,8 +287,9 @@ class Nextcloud:
except: except:
# log.error(traceback.format_exc()) # log.error(traceback.format_exc())
raise raise
return False
def add_user_to_group(self, userid, group_id): def add_user_to_group(self, userid : str, group_id : str) -> bool:
data = {"groupid": group_id} data = {"groupid": group_id}
url = self.apiurl + "users/" + userid + "/groups?format=json" url = self.apiurl + "users/" + userid + "/groups?format=json"
@ -296,7 +311,7 @@ class Nextcloud:
# log.error(traceback.format_exc()) # log.error(traceback.format_exc())
raise raise
def remove_user_from_group(self, userid, group_id): def remove_user_from_group(self, userid : str, group_id : str) -> bool:
data = {"groupid": group_id} data = {"groupid": group_id}
url = self.apiurl + "users/" + userid + "/groups?format=json" url = self.apiurl + "users/" + userid + "/groups?format=json"
@ -321,9 +336,10 @@ class Nextcloud:
# log.error(traceback.format_exc()) # log.error(traceback.format_exc())
raise raise
# TODO: Improve typing of these functions...
def add_user_with_groups( def add_user_with_groups(
self, userid, userpassword, quota=False, groups=[], email="", displayname="" self, userid : str, userpassword : str, quota : Any=False, groups : Any=[], email : str="", displayname : str=""
): ) -> bool:
data = { data = {
"userid": userid, "userid": userid,
"password": userpassword, "password": userpassword,
@ -352,7 +368,7 @@ class Nextcloud:
raise ProviderItemExists raise ProviderItemExists
if result["ocs"]["meta"]["statuscode"] == 104: if result["ocs"]["meta"]["statuscode"] == 104:
# self.add_group(group) # self.add_group(group)
None pass
# raise ProviderGroupNotExists # raise ProviderGroupNotExists
log.error("Get Nextcloud provider user add error: " + str(result)) log.error("Get Nextcloud provider user add error: " + str(result))
raise ProviderOpError raise ProviderOpError
@ -368,7 +384,7 @@ class Nextcloud:
# 106 - no group specified (required for subadmins) # 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) # 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): def delete_user(self, userid : str) -> bool:
url = self.apiurl + "users/" + userid + "?format=json" url = self.apiurl + "users/" + userid + "?format=json"
try: try:
result = json.loads(self._request("DELETE", url)) result = json.loads(self._request("DELETE", url))
@ -384,13 +400,13 @@ class Nextcloud:
# 100 - successful # 100 - successful
# 101 - failure # 101 - failure
def enable_user(self, userid): def enable_user(self, userid : str) -> None:
None pass
def disable_user(self, userid): def disable_user(self, userid : str) -> None:
None pass
def exists_user_folder(self, userid, userpassword, folder="IsardVDI"): def exists_user_folder(self, userid : str, userpassword : str, folder : str="IsardVDI") -> bool:
auth = (userid, userpassword) auth = (userid, userpassword)
url = self.davurl + userid + "/" + folder + "?format=json" url = self.davurl + userid + "/" + folder + "?format=json"
headers = { headers = {
@ -407,7 +423,7 @@ class Nextcloud:
# log.error(traceback.format_exc()) # log.error(traceback.format_exc())
raise raise
def add_user_folder(self, userid, userpassword, folder="IsardVDI"): def add_user_folder(self, userid : str, userpassword : str, folder : str="IsardVDI") -> bool:
auth = (userid, userpassword) auth = (userid, userpassword)
url = self.davurl + userid + "/" + folder + "?format=json" url = self.davurl + userid + "/" + folder + "?format=json"
headers = { headers = {
@ -429,7 +445,7 @@ class Nextcloud:
# log.error(traceback.format_exc()) # log.error(traceback.format_exc())
raise raise
def exists_user_share_folder(self, userid, userpassword, folder="IsardVDI"): def exists_user_share_folder(self, userid : str, userpassword : str, folder : str="IsardVDI") -> Dict[str, str]:
auth = (userid, userpassword) auth = (userid, userpassword)
url = self.shareurl + "shares?format=json" url = self.shareurl + "shares?format=json"
headers = { headers = {
@ -449,7 +465,7 @@ class Nextcloud:
# log.error(traceback.format_exc()) # log.error(traceback.format_exc())
raise raise
def add_user_share_folder(self, userid, userpassword, folder="IsardVDI"): def add_user_share_folder(self, userid : str, userpassword : str, folder : str="IsardVDI") -> Dict[str, str]:
auth = (userid, userpassword) auth = (userid, userpassword)
data = {"path": "/" + folder, "shareType": 3} data = {"path": "/" + folder, "shareType": 3}
url = self.shareurl + "shares?format=json" url = self.shareurl + "shares?format=json"
@ -477,10 +493,10 @@ class Nextcloud:
# log.error(traceback.format_exc()) # log.error(traceback.format_exc())
raise raise
def get_group(self, userid): def get_group(self, userid : str) -> None:
None pass
def get_groups_list(self): def get_groups_list(self) -> List[Any]:
url = self.apiurl + "groups?format=json" url = self.apiurl + "groups?format=json"
try: try:
result = json.loads(self._request("GET", url)) result = json.loads(self._request("GET", url))
@ -491,7 +507,7 @@ class Nextcloud:
# log.error(traceback.format_exc()) # log.error(traceback.format_exc())
raise raise
def add_group(self, groupid): def add_group(self, groupid : str) -> bool:
data = {"groupid": groupid} data = {"groupid": groupid}
url = self.apiurl + "groups?format=json" url = self.apiurl + "groups?format=json"
headers = { headers = {
@ -515,7 +531,7 @@ class Nextcloud:
# 102 - group already exists # 102 - group already exists
# 103 - failed to add the group # 103 - failed to add the group
def delete_group(self, groupid): def delete_group(self, groupid : str) -> bool:
group = urllib.parse.quote(groupid, safe="") group = urllib.parse.quote(groupid, safe="")
url = self.apiurl + "groups/" + group + "?format=json" url = self.apiurl + "groups/" + group + "?format=json"
headers = { headers = {
@ -538,7 +554,7 @@ class Nextcloud:
# 102 - group already exists # 102 - group already exists
# 103 - failed to add the group # 103 - failed to add the group
def set_user_mail(self, data): def set_user_mail(self, data : DDUser) -> None:
query = """SELECT * FROM "oc_mail_accounts" WHERE "email" = '%s'""" query = """SELECT * FROM "oc_mail_accounts" WHERE "email" = '%s'"""
sql_query = sql.SQL(query.format(data["email"])) sql_query = sql.SQL(query.format(data["email"]))
if not len(self.nextcloud_pg.select(sql_query)): if not len(self.nextcloud_pg.select(sql_query)):

View File

@ -1,5 +1,6 @@
# #
# Copyright © 2021,2022 IsardVDI S.L. # Copyright © 2021,2022 IsardVDI S.L.
# Copyright © 2022 Evilham <contact@evilham.com>
# #
# This file is part of DD # This file is part of DD
# #
@ -18,54 +19,41 @@
# #
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
import json
import logging as log
import time
import traceback
from datetime import datetime, timedelta
import psycopg2 import psycopg2
import yaml import psycopg2.sql
from psycopg2.extensions import connection, cursor
# from admin import app from typing import Any, List, Tuple, Union
query = Union[str, psycopg2.sql.SQL]
class Postgres: class Postgres:
def __init__(self, host, database, user, password): # TODO: Fix this whole class
cur : cursor
conn : connection
def __init__(self, host : str, database : str, user : str, password : str) -> None:
self.conn = psycopg2.connect( self.conn = psycopg2.connect(
host=host, database=database, user=user, password=password host=host, database=database, user=user, password=password
) )
# def __del__(self): def select(self, sql: query) -> List[Tuple[Any, ...]]:
# self.cur.close()
# self.conn.close()
def select(self, sql):
self.cur = self.conn.cursor() self.cur = self.conn.cursor()
self.cur.execute(sql) self.cur.execute(sql)
data = self.cur.fetchall() data = self.cur.fetchall()
self.cur.close() self.cur.close() # type: ignore # psycopg2 type hint missing
return data return data
def update(self, sql): def update(self, sql : query) -> None:
self.cur = self.conn.cursor() self.cur = self.conn.cursor()
self.cur.execute(sql) self.cur.execute(sql)
self.conn.commit() self.conn.commit()
self.cur.close() self.cur.close() # type: ignore # psycopg2 type hint missing
# return self.cur.fetchall() # return self.cur.fetchall()
def select_with_headers(self, sql): def select_with_headers(self, sql : query) -> Tuple[List[Any], List[Tuple[Any, ...]]]:
self.cur = self.conn.cursor() self.cur = self.conn.cursor()
self.cur.execute(sql) self.cur.execute(sql)
data = self.cur.fetchall() data = self.cur.fetchall()
fields = [a.name for a in self.cur.description] fields = [a.name for a in self.cur.description]
self.cur.close() self.cur.close() # type: ignore # psycopg2 type hint missing
return (fields, data) 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

@ -1,5 +1,6 @@
# #
# Copyright © 2021,2022 IsardVDI S.L. # Copyright © 2021,2022 IsardVDI S.L.
# Copyright © 2022 Evilham <contact@evilham.com>
# #
# This file is part of DD # This file is part of DD
# #
@ -23,8 +24,6 @@ import logging as log
import os import os
import random import random
# from .keycloak import Keycloak
# from .moodle import Moodle
import string import string
import time import time
import traceback import traceback
@ -33,13 +32,16 @@ from datetime import datetime, timedelta
import psycopg2 import psycopg2
import yaml import yaml
from admin import app from typing import TYPE_CHECKING
if TYPE_CHECKING:
from admin.flaskapp import AdminFlaskApp
from .postgres import Postgres from .postgres import Postgres
class Postup: class Postup:
def __init__(self): def __init__(self, app: "AdminFlaskApp") -> None:
ready = False ready = False
while not ready: while not ready:
try: try:
@ -93,9 +95,9 @@ class Postup:
self.select_and_configure_theme() self.select_and_configure_theme()
self.configure_tipnc() self.configure_tipnc()
self.add_moodle_ws_token() self.add_moodle_ws_token(app)
def select_and_configure_theme(self, theme="cbe"): def select_and_configure_theme(self, theme : str="cbe") -> None:
try: try:
self.pg.update( self.pg.update(
"""UPDATE "mdl_config" SET value = '%s' WHERE "name" = 'theme';""" """UPDATE "mdl_config" SET value = '%s' WHERE "name" = 'theme';"""
@ -104,7 +106,6 @@ class Postup:
except: except:
log.error(traceback.format_exc()) log.error(traceback.format_exc())
exit(1) exit(1)
None
try: try:
self.pg.update( self.pg.update(
@ -127,9 +128,8 @@ class Postup:
except: except:
log.error(traceback.format_exc()) log.error(traceback.format_exc())
exit(1) exit(1)
None
def configure_tipnc(self): def configure_tipnc(self) -> None:
try: try:
self.pg.update( self.pg.update(
"""UPDATE "mdl_config_plugins" SET value = '%s' WHERE "plugin" = 'assignsubmission_tipnc' AND "name" = 'host';""" """UPDATE "mdl_config_plugins" SET value = '%s' WHERE "plugin" = 'assignsubmission_tipnc' AND "name" = 'host';"""
@ -155,9 +155,8 @@ class Postup:
except: except:
log.error(traceback.format_exc()) log.error(traceback.format_exc())
exit(1) exit(1)
None
def add_moodle_ws_token(self): def add_moodle_ws_token(self, app: "AdminFlaskApp") -> None:
try: try:
token = self.pg.select( token = self.pg.select(
"""SELECT * FROM "mdl_external_tokens" WHERE "externalserviceid" = 3""" """SELECT * FROM "mdl_external_tokens" WHERE "externalserviceid" = 3"""
@ -166,7 +165,7 @@ class Postup:
return return
except: except:
# log.error(traceback.format_exc()) # log.error(traceback.format_exc())
None pass
try: try:
self.pg.update( self.pg.update(
@ -225,4 +224,3 @@ class Postup:
except: except:
log.error(traceback.format_exc()) log.error(traceback.format_exc())
exit(1) exit(1)
None

View File

@ -1,5 +1,6 @@
# #
# Copyright © 2021,2022 IsardVDI S.L. # Copyright © 2021,2022 IsardVDI S.L.
# Copyright © 2022 Evilham <contact@evilham.com>
# #
# This file is part of DD # This file is part of DD
# #
@ -19,6 +20,7 @@
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
import json import json
import logging as log import logging as log
from operator import itemgetter
import os import os
import socket import socket
import sys import sys
@ -27,54 +29,57 @@ import traceback
from flask import request from flask import request
from admin import app from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional
if TYPE_CHECKING:
from admin.flaskapp import AdminFlaskApp
from ..lib.api_exceptions import Error from ..lib.api_exceptions import Error
from .decorators import has_token from .decorators import has_token, OptionalJsonResponse
## LISTS def setup_api_views(app : "AdminFlaskApp") -> None:
@app.route("/ddapi/users", methods=["GET"]) ## LISTS
@has_token @app.json_route("/ddapi/users", methods=["GET"])
def ddapi_users(): @has_token
def ddapi_users() -> OptionalJsonResponse:
if request.method == "GET": if request.method == "GET":
sorted_users = sorted(app.admin.get_mix_users(), key=lambda k: k["username"]) sorted_users = sorted(app.admin.get_mix_users(), key=itemgetter("username"))
users = [] users = []
for user in sorted_users: for user in sorted_users:
users.append(user_parser(user)) users.append(user_parser(user))
return json.dumps(users), 200, {"Content-Type": "application/json"} return json.dumps(users), 200, {"Content-Type": "application/json"}
return None
@app.json_route("/ddapi/users/filter", methods=["POST"])
@app.route("/ddapi/users/filter", methods=["POST"]) @has_token
@has_token def ddapi_users_search() -> OptionalJsonResponse:
def ddapi_users_search():
if request.method == "POST": if request.method == "POST":
data = request.get_json(force=True) data = request.get_json(force=True)
if not data.get("text"): if not data.get("text"):
raise Error("bad_request", "Incorrect data requested.") raise Error("bad_request", "Incorrect data requested.")
users = app.admin.get_mix_users() users = app.admin.get_mix_users()
result = [user_parser(user) for user in filter_users(users, data["text"])] result = [user_parser(user) for user in filter_users(users, data["text"])]
sorted_result = sorted(result, key=lambda k: k["id"]) sorted_result = sorted(result, key=itemgetter("id"))
return json.dumps(sorted_result), 200, {"Content-Type": "application/json"} return json.dumps(sorted_result), 200, {"Content-Type": "application/json"}
return None
@app.json_route("/ddapi/groups", methods=["GET"])
@app.route("/ddapi/groups", methods=["GET"]) @has_token
@has_token def ddapi_groups() -> OptionalJsonResponse:
def ddapi_groups():
if request.method == "GET": if request.method == "GET":
sorted_groups = sorted(app.admin.get_mix_groups(), key=lambda k: k["name"]) sorted_groups = sorted(app.admin.get_mix_groups(), key=itemgetter("name"))
groups = [] groups = []
for group in sorted_groups: for group in sorted_groups:
groups.append(group_parser(group)) groups.append(group_parser(group))
return json.dumps(groups), 200, {"Content-Type": "application/json"} return json.dumps(groups), 200, {"Content-Type": "application/json"}
return None
@app.json_route("/ddapi/group/users", methods=["POST"])
@app.route("/ddapi/group/users", methods=["POST"]) @has_token
@has_token def ddapi_group_users() -> OptionalJsonResponse:
def ddapi_group_users():
if request.method == "POST": if request.method == "POST":
data = request.get_json(force=True) data = request.get_json(force=True)
sorted_users = sorted(app.admin.get_mix_users(), key=lambda k: k["username"]) sorted_users = sorted(app.admin.get_mix_users(), key=itemgetter("username"))
if data.get("id"): if data.get("id"):
group_users = [ group_users = [
user_parser(user) user_parser(user)
@ -112,14 +117,14 @@ def ddapi_group_users():
else: else:
raise Error("bad_request", "Incorrect data requested.") raise Error("bad_request", "Incorrect data requested.")
return json.dumps(group_users), 200, {"Content-Type": "application/json"} return json.dumps(group_users), 200, {"Content-Type": "application/json"}
return None
@app.json_route("/ddapi/roles", methods=["GET"])
@app.route("/ddapi/roles", methods=["GET"]) @has_token
@has_token def ddapi_roles() -> OptionalJsonResponse:
def ddapi_roles():
if request.method == "GET": if request.method == "GET":
roles = [] roles = []
for role in sorted(app.admin.get_roles(), key=lambda k: k["name"]): for role in sorted(app.admin.get_roles(), key=itemgetter("name")):
log.error(role) log.error(role)
roles.append( roles.append(
{ {
@ -130,14 +135,14 @@ def ddapi_roles():
} }
) )
return json.dumps(roles), 200, {"Content-Type": "application/json"} return json.dumps(roles), 200, {"Content-Type": "application/json"}
return None
@app.json_route("/ddapi/role/users", methods=["POST"])
@app.route("/ddapi/role/users", methods=["POST"]) @has_token
@has_token def ddapi_role_users() -> OptionalJsonResponse:
def ddapi_role_users():
if request.method == "POST": if request.method == "POST":
data = request.get_json(force=True) data = request.get_json(force=True)
sorted_users = sorted(app.admin.get_mix_users(), key=lambda k: k["username"]) sorted_users = sorted(app.admin.get_mix_users(), key=itemgetter("username"))
if data.get("id", data.get("name")): if data.get("id", data.get("name")):
role_users = [ role_users = [
user_parser(user) user_parser(user)
@ -159,20 +164,21 @@ def ddapi_role_users():
else: else:
raise Error("bad_request", "Incorrect data requested.") raise Error("bad_request", "Incorrect data requested.")
return json.dumps(role_users), 200, {"Content-Type": "application/json"} return json.dumps(role_users), 200, {"Content-Type": "application/json"}
return None
## INDIVIDUAL ACTIONS
## INDIVIDUAL ACTIONS @app.json_route("/ddapi/user", methods=["POST"])
@app.route("/ddapi/user", methods=["POST"]) @app.json_route("/ddapi/user/<user_ddid>", methods=["PUT", "GET", "DELETE"])
@app.route("/ddapi/user/<user_ddid>", methods=["PUT", "GET", "DELETE"]) @has_token
@has_token def ddapi_user(user_ddid : Optional[str]=None) -> OptionalJsonResponse:
def ddapi_user(user_ddid=None): uid : str = user_ddid if user_ddid else ''
if request.method == "GET": if request.method == "GET":
user = app.admin.get_user_username(user_ddid) user = app.admin.get_user_username(uid)
if not user: if not user:
raise Error("not_found", "User id not found") raise Error("not_found", "User id not found")
return json.dumps(user_parser(user)), 200, {"Content-Type": "application/json"} return json.dumps(user_parser(user)), 200, {"Content-Type": "application/json"}
if request.method == "DELETE": if request.method == "DELETE":
user = app.admin.get_user_username(user_ddid) user = app.admin.get_user_username(uid)
if not user: if not user:
raise Error("not_found", "User id not found") raise Error("not_found", "User id not found")
app.admin.delete_user(user["id"]) app.admin.delete_user(user["id"])
@ -203,7 +209,7 @@ def ddapi_user(user_ddid=None):
) )
if request.method == "PUT": if request.method == "PUT":
user = app.admin.get_user_username(user_ddid) user = app.admin.get_user_username(uid)
if not user: if not user:
raise Error("not_found", "User id not found") raise Error("not_found", "User id not found")
data = request.get_json(force=True) data = request.get_json(force=True)
@ -216,7 +222,7 @@ def ddapi_user(user_ddid=None):
) )
data = {**user, **data} data = {**user, **data}
data = app.validators["user_update"].normalized(data) data = app.validators["user_update"].normalized(data)
data = {**data, **{"username": user_ddid}} data = {**data, **{"username": uid}}
data["roles"] = [data.pop("role")] data["roles"] = [data.pop("role")]
data["firstname"] = data.pop("first") data["firstname"] = data.pop("first")
data["lastname"] = data.pop("last") data["lastname"] = data.pop("last")
@ -226,25 +232,25 @@ def ddapi_user(user_ddid=None):
user["id"], data["password"], data["password_temporary"] user["id"], data["password"], data["password_temporary"]
) )
return json.dumps({}), 200, {"Content-Type": "application/json"} return json.dumps({}), 200, {"Content-Type": "application/json"}
return None
@app.json_route("/ddapi/username/<old_user_ddid>/<new_user_did>", methods=["PUT"])
@app.route("/ddapi/username/<old_user_ddid>/<new_user_did>", methods=["PUT"]) @has_token
@has_token def ddapi_username(old_user_ddid : str, new_user_did : str) -> OptionalJsonResponse:
def ddapi_username(old_user_ddid, new_user_did):
user = app.admin.get_user_username(user_ddid) user = app.admin.get_user_username(user_ddid)
if not user: if not user:
raise Error("not_found", "User id not found") raise Error("not_found", "User id not found")
# user = app.admin.update_user_username(old_user_ddid,new_user_did) # user = app.admin.update_user_username(old_user_ddid,new_user_did)
return json.dumps("Not implemented yet!"), 419, {"Content-Type": "application/json"} return json.dumps("Not implemented yet!"), 419, {"Content-Type": "application/json"}
@app.json_route("/ddapi/group", methods=["POST"])
@app.route("/ddapi/group", methods=["POST"]) @app.json_route("/ddapi/group/<group_id>", methods=["GET", "POST", "DELETE"])
@app.route("/ddapi/group/<id>", methods=["GET", "POST", "DELETE"]) # @app.json_route("/api/group/<group_id>", methods=["PUT", "GET", "DELETE"])
# @app.route("/api/group/<group_id>", methods=["PUT", "GET", "DELETE"]) @has_token
@has_token def ddapi_group(group_id : Optional[str]=None) -> OptionalJsonResponse:
def ddapi_group(id=None): uid : str = group_id if group_id else ''
if request.method == "GET": if request.method == "GET":
group = app.admin.get_group_by_name(id) group = app.admin.get_group_by_name(uid)
if not group: if not group:
Error("not found", "Group id not found") Error("not found", "Group id not found")
return ( return (
@ -264,7 +270,7 @@ def ddapi_group(id=None):
data = app.validators["group"].normalized(data) data = app.validators["group"].normalized(data)
data["parent"] = data["parent"] if data["parent"] != "" else None data["parent"] = data["parent"] if data["parent"] != "" else None
if app.admin.get_group_by_name(id): if app.admin.get_group_by_name(uid):
raise Error("conflict", "Group id already exists") raise Error("conflict", "Group id already exists")
path = app.admin.add_group(data) path = app.admin.add_group(data)
@ -277,17 +283,17 @@ def ddapi_group(id=None):
{"Content-Type": "application/json"}, {"Content-Type": "application/json"},
) )
if request.method == "DELETE": if request.method == "DELETE":
group = app.admin.get_group_by_name(id) group = app.admin.get_group_by_name(uid)
if not group: if not group:
raise Error("not_found", "Group id not found") raise Error("not_found", "Group id not found")
app.admin.delete_group_by_id(group["id"]) app.admin.delete_group_by_id(group["id"])
return json.dumps({}), 200, {"Content-Type": "application/json"} return json.dumps({}), 200, {"Content-Type": "application/json"}
return None
@app.json_route("/ddapi/user_mail", methods=["POST"])
@app.route("/ddapi/user_mail", methods=["POST"]) @app.json_route("/ddapi/user_mail/<id>", methods=["GET", "DELETE"])
@app.route("/ddapi/user_mail/<id>", methods=["GET", "DELETE"]) @has_token
@has_token def ddapi_user_mail(id : Optional[str]=None) -> OptionalJsonResponse:
def ddapi_user_mail(id=None):
if request.method == "GET": if request.method == "GET":
return ( return (
json.dumps("Not implemented yet"), json.dumps("Not implemented yet"),
@ -320,9 +326,10 @@ def ddapi_user_mail(id=None):
200, 200,
{"Content-Type": "application/json"}, {"Content-Type": "application/json"},
) )
return None
# TODO: After this line, this is all mostly duplicated from other places...
def user_parser(user): def user_parser(user : Dict[str, Any]) -> Dict[str, Any]:
return { return {
"keycloak_id": user["id"], "keycloak_id": user["id"],
"id": user["username"], "id": user["username"],
@ -338,7 +345,7 @@ def user_parser(user):
} }
def group_parser(group): def group_parser(group : Dict[str, str]) -> Dict[str, Any]:
return { return {
"keycloak_id": group["id"], "keycloak_id": group["id"],
"id": group["name"], "id": group["name"],
@ -348,7 +355,7 @@ def group_parser(group):
} }
def filter_users(users, text): def filter_users(users : Iterable[Dict[str, Any]], text : str) -> List[Dict[str, Any]]:
return [ return [
user user
for user in users for user in users

View File

@ -1,5 +1,6 @@
# #
# Copyright © 2021,2022 IsardVDI S.L. # Copyright © 2021,2022 IsardVDI S.L.
# Copyright © 2022 Evilham <contact@evilham.com>
# #
# This file is part of DD # This file is part of DD
# #
@ -33,10 +34,12 @@ from uuid import uuid4
from flask import Response, jsonify, redirect, render_template, request, url_for from flask import Response, jsonify, redirect, render_template, request, url_for
from flask_login import current_user, login_required from flask_login import current_user, login_required
from admin import app from typing import TYPE_CHECKING, cast, Any, Dict, Optional
if TYPE_CHECKING:
from admin.flaskapp import AdminFlaskApp
from ..lib.helpers import system_group from ..lib.helpers import system_group
from .decorators import login_or_token from .decorators import login_or_token, OptionalJsonResponse
threads = {"external": None} threads = {"external": None}
# q = Queue.Queue() # q = Queue.Queue()
@ -46,14 +49,15 @@ from keycloak.exceptions import KeycloakGetError
from ..lib.dashboard import Dashboard from ..lib.dashboard import Dashboard
from ..lib.exceptions import UserExists, UserNotFound from ..lib.exceptions import UserExists, UserNotFound
dashboard = Dashboard()
from ..lib.legal import get_legal, gen_legal_if_not_exists, new_legal from ..lib.legal import get_legal, gen_legal_if_not_exists, new_legal
@app.route("/sysadmin/api/resync") def setup_app_views(app : "AdminFlaskApp") -> None:
@app.route("/api/resync") dashboard = Dashboard(app)
@login_required @app.json_route("/sysadmin/api/resync")
def resync(): @app.json_route("/api/resync")
@login_required
def resync() -> OptionalJsonResponse:
return ( return (
json.dumps(app.admin.resync_data()), json.dumps(app.admin.resync_data()),
200, 200,
@ -61,10 +65,10 @@ def resync():
) )
@app.route("/api/users", methods=["GET", "PUT"]) @app.json_route("/api/users", methods=["GET", "PUT"])
@app.route("/api/users/<provider>", methods=["POST", "PUT", "GET", "DELETE"]) @app.json_route("/api/users/<provider>", methods=["POST", "PUT", "GET", "DELETE"])
@login_or_token @login_or_token
def users(provider=False): def users(provider : bool=False) -> OptionalJsonResponse:
if request.method == "DELETE": if request.method == "DELETE":
if current_user.role != "admin": if current_user.role != "admin":
return json.dumps({}), 301, {"Content-Type": "application/json"} return json.dumps({}), 301, {"Content-Type": "application/json"}
@ -82,7 +86,7 @@ def users(provider=False):
) )
if provider == "moodle": if provider == "moodle":
return ( return (
json.dumps(app.admin.delete_moodle_users()), json.dumps(app.admin.delete_moodle_users(app)),
200, 200,
{"Content-Type": "application/json"}, {"Content-Type": "application/json"},
) )
@ -141,9 +145,9 @@ def users(provider=False):
return json.dumps(users), 200, {"Content-Type": "application/json"} return json.dumps(users), 200, {"Content-Type": "application/json"}
@app.route("/api/users_bulk/<action>", methods=["PUT"]) @app.json_route("/api/users_bulk/<action>", methods=["PUT"])
@login_required @login_required
def users_bulk(action): def users_bulk(action : str) -> OptionalJsonResponse:
data = request.get_json(force=True) data = request.get_json(force=True)
if request.method == "PUT": if request.method == "PUT":
if action == "enable": if action == "enable":
@ -224,11 +228,11 @@ def users_bulk(action):
return json.dumps({}), 405, {"Content-Type": "application/json"} return json.dumps({}), 405, {"Content-Type": "application/json"}
# Update pwd # Update pwd
@app.route("/api/user_password", methods=["GET"]) @app.json_route("/api/user_password", methods=["GET"])
@app.route("/api/user_password/<userid>", methods=["PUT"]) @app.json_route("/api/user_password/<userid>", methods=["PUT"])
@login_required @login_required
def user_password(userid=False): def user_password(userid : Optional[str]=None) -> OptionalJsonResponse:
if request.method == "GET": if request.method == "GET":
return ( return (
json.dumps(app.admin.get_dice_pwd()), json.dumps(app.admin.get_dice_pwd()),
@ -239,8 +243,9 @@ def user_password(userid=False):
data = request.get_json(force=True) data = request.get_json(force=True)
password = data["password"] password = data["password"]
temporary = data.get("temporary", True) temporary = data.get("temporary", True)
uid = cast(str, userid)
try: try:
res = app.admin.user_update_password(userid, password, temporary) res = app.admin.user_update_password(uid, password, temporary)
return json.dumps({}), 200, {"Content-Type": "application/json"} return json.dumps({}), 200, {"Content-Type": "application/json"}
except KeycloakGetError as e: except KeycloakGetError as e:
log.error(e.error_message.decode("utf-8")) log.error(e.error_message.decode("utf-8"))
@ -253,13 +258,14 @@ def user_password(userid=False):
return json.dumps({}), 405, {"Content-Type": "application/json"} return json.dumps({}), 405, {"Content-Type": "application/json"}
# User # User
@app.route("/api/user", methods=["POST"]) @app.json_route("/api/user", methods=["POST"])
@app.route("/api/user/<userid>", methods=["PUT", "GET", "DELETE"]) @app.json_route("/api/user/<userid>", methods=["PUT", "GET", "DELETE"])
@login_required @login_required
def user(userid=None): def user(userid : Optional[str]=None) -> OptionalJsonResponse:
uid : str = userid if userid else ''
if request.method == "DELETE": if request.method == "DELETE":
app.admin.delete_user(userid) app.admin.delete_user(uid)
return json.dumps({}), 200, {"Content-Type": "application/json"} return json.dumps({}), 200, {"Content-Type": "application/json"}
if request.method == "POST": if request.method == "POST":
data = request.get_json(force=True) data = request.get_json(force=True)
@ -312,7 +318,7 @@ def user(userid=None):
if request.method == "DELETE": if request.method == "DELETE":
pass pass
if request.method == "GET": if request.method == "GET":
user = app.admin.get_user(userid) user = app.admin.get_user(uid)
if not user: if not user:
return ( return (
json.dumps({"msg": "User not found."}), json.dumps({"msg": "User not found."}),
@ -320,21 +326,21 @@ def user(userid=None):
{"Content-Type": "application/json"}, {"Content-Type": "application/json"},
) )
return json.dumps(user), 200, {"Content-Type": "application/json"} return json.dumps(user), 200, {"Content-Type": "application/json"}
return None
@app.json_route("/api/roles")
@app.route("/api/roles") @login_required
@login_required def roles() -> OptionalJsonResponse:
def roles():
sorted_roles = sorted(app.admin.get_roles(), key=lambda k: k["name"]) sorted_roles = sorted(app.admin.get_roles(), key=lambda k: k["name"])
if current_user.role != "admin": if current_user.role != "admin":
sorted_roles = [sr for sr in sorted_roles if sr["name"] != "admin"] sorted_roles = [sr for sr in sorted_roles if sr["name"] != "admin"]
return json.dumps(sorted_roles), 200, {"Content-Type": "application/json"} return json.dumps(sorted_roles), 200, {"Content-Type": "application/json"}
@app.route("/api/group", methods=["POST", "DELETE"]) @app.json_route("/api/group", methods=["POST", "DELETE"])
@app.route("/api/group/<group_id>", methods=["PUT", "GET", "DELETE"]) @app.json_route("/api/group/<group_id>", methods=["PUT", "GET", "DELETE"])
@login_required @login_required
def group(group_id=False): def group(group_id : Optional[str]=None) -> OptionalJsonResponse:
if request.method == "POST": if request.method == "POST":
data = request.get_json(force=True) data = request.get_json(force=True)
log.error(data) log.error(data)
@ -361,12 +367,12 @@ def group(group_id=False):
) )
res = app.admin.delete_group_by_id(group_id) res = app.admin.delete_group_by_id(group_id)
return json.dumps(res), 200, {"Content-Type": "application/json"} return json.dumps(res), 200, {"Content-Type": "application/json"}
return None
@app.json_route("/api/groups")
@app.route("/api/groups") @app.json_route("/api/groups/<provider>", methods=["POST", "PUT", "GET", "DELETE"])
@app.route("/api/groups/<provider>", methods=["POST", "PUT", "GET", "DELETE"]) @login_required
@login_required def groups(provider : Optional[str] = None) -> OptionalJsonResponse:
def groups(provider=False):
if request.method == "GET": if request.method == "GET":
sorted_groups = sorted(app.admin.get_mix_groups(), key=lambda k: str(k["name"])) sorted_groups = sorted(app.admin.get_mix_groups(), key=lambda k: str(k["name"]))
if current_user.role != "admin": if current_user.role != "admin":
@ -382,14 +388,14 @@ def groups(provider=False):
200, 200,
{"Content-Type": "application/json"}, {"Content-Type": "application/json"},
) )
return None
### SYSADM USERS ONLY
### SYSADM USERS ONLY @app.json_route("/api/external", methods=["POST", "PUT", "GET", "DELETE"])
@login_required
def external() -> OptionalJsonResponse:
@app.route("/api/external", methods=["POST", "PUT", "GET", "DELETE"])
@login_required
def external():
if "external" in threads.keys(): if "external" in threads.keys():
if threads["external"] is not None and threads["external"].is_alive(): if threads["external"] is not None and threads["external"].is_alive():
return json.dumps({}), 301, {"Content-Type": "application/json"} return json.dumps({}), 301, {"Content-Type": "application/json"}
@ -428,9 +434,9 @@ def external():
return json.dumps({}), 500, {"Content-Type": "application/json"} return json.dumps({}), 500, {"Content-Type": "application/json"}
@app.route("/api/external/users") @app.json_route("/api/external/users")
@login_required @login_required
def external_users_list(): def external_users_list() -> OptionalJsonResponse:
while threads["external"] is not None and threads["external"].is_alive(): while threads["external"] is not None and threads["external"].is_alive():
time.sleep(0.5) time.sleep(0.5)
return ( return (
@ -440,9 +446,9 @@ def external_users_list():
) )
@app.route("/api/external/groups") @app.json_route("/api/external/groups")
@login_required @login_required
def external_groups_list(): def external_groups_list() -> OptionalJsonResponse:
while threads["external"] is not None and threads["external"].is_alive(): while threads["external"] is not None and threads["external"].is_alive():
time.sleep(0.5) time.sleep(0.5)
return ( return (
@ -452,18 +458,19 @@ def external_groups_list():
) )
@app.route("/api/external/roles", methods=["PUT"]) @app.json_route("/api/external/roles", methods=["PUT"])
@login_required @login_required
def external_roles(): def external_roles() -> OptionalJsonResponse:
if request.method == "PUT": if request.method == "PUT":
return ( return (
json.dumps(app.admin.external_roleassign(request.get_json(force=True))), json.dumps(app.admin.external_roleassign(request.get_json(force=True))),
200, 200,
{"Content-Type": "application/json"}, {"Content-Type": "application/json"},
) )
return None
def check_upload_errors(data): def check_upload_errors(data : Dict[Any, Any]) -> Dict[Any, Any]:
email_regex = r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b" email_regex = r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b"
for u in data["data"]: for u in data["data"]:
try: try:
@ -501,9 +508,9 @@ def check_upload_errors(data):
return {"pass": True, "msg": ""} return {"pass": True, "msg": ""}
@app.route("/api/dashboard/<item>", methods=["PUT"]) @app.json_route("/api/dashboard/<item>", methods=["PUT"])
@login_required @login_required
def dashboard_put(item): def dashboard_put(item : str) -> OptionalJsonResponse:
if item == "colours": if item == "colours":
try: try:
data = request.get_json(force=True) data = request.get_json(force=True)
@ -536,27 +543,28 @@ def dashboard_put(item):
) )
@app.route("/api/legal/<item>", methods=["GET"]) @app.json_route("/api/legal/<item>", methods=["GET"])
# @login_required # @login_required
def legal_get(item): def legal_get(item : str) -> OptionalJsonResponse:
if request.method == "GET": if request.method == "GET":
if item == "legal": if item == "legal":
lang = request.args.get("lang") lang = request.args.get("lang")
if not lang or lang not in ["ca","es","en","fr"]: if not lang or lang not in ["ca","es","en","fr"]:
lang="ca" lang="ca"
gen_legal_if_not_exists(lang) gen_legal_if_not_exists(app, lang)
return ( return (
json.dumps({"html": get_legal(lang)}), json.dumps({"html": get_legal(app, lang)}),
200, 200,
{"Content-Type": "application/json"}, {"Content-Type": "application/json"},
) )
# if item == "privacy": # if item == "privacy":
# return json.dumps({ "html": "<b>Privacy policy</b><br>This works!"}), 200, {'Content-Type': 'application/json'} # return json.dumps({ "html": "<b>Privacy policy</b><br>This works!"}), 200, {'Content-Type': 'application/json'}
return None
@app.route("/api/legal/<item>", methods=["POST"]) @app.json_route("/api/legal/<item>", methods=["POST"])
@login_required @login_required
def legal_put(item): def legal_put(item : str) -> OptionalJsonResponse:
if request.method == "POST": if request.method == "POST":
if item == "legal": if item == "legal":
data = None data = None
@ -566,10 +574,11 @@ def legal_put(item):
lang = data["lang"] lang = data["lang"]
if not lang or lang not in ["ca","es","en","fr"]: if not lang or lang not in ["ca","es","en","fr"]:
lang="ca" lang="ca"
new_legal(lang,html) new_legal(app, lang, html)
except: except:
log.error(traceback.format_exc()) log.error(traceback.format_exc())
return json.dumps(data), 200, {"Content-Type": "application/json"} return json.dumps(data), 200, {"Content-Type": "application/json"}
return None
# if item == "privacy": # if item == "privacy":
# data = None # data = None
# try: # try:

View File

@ -1,5 +1,6 @@
# #
# Copyright © 2021,2022 IsardVDI S.L. # Copyright © 2021,2022 IsardVDI S.L.
# Copyright © 2022 Evilham <contact@evilham.com>
# #
# This file is part of DD # This file is part of DD
# #
@ -21,15 +22,19 @@ import os
from flask import flash, redirect, render_template, request, url_for from flask import flash, redirect, render_template, request, url_for
from flask_login import current_user, login_required, login_user, logout_user from flask_login import current_user, login_required, login_user, logout_user
from werkzeug.wrappers import Response
from admin import app from typing import TYPE_CHECKING
if TYPE_CHECKING:
from admin.flaskapp import AdminFlaskApp
from ..auth.authentication import * from ..auth.authentication import *
@app.route("/", methods=["GET", "POST"]) def setup_login_views(app : "AdminFlaskApp") -> None:
@app.route("/login", methods=["GET", "POST"]) @app.route("/", methods=["GET", "POST"])
def login(): @app.route("/login", methods=["GET", "POST"])
def login() -> Response:
if request.method == "POST": if request.method == "POST":
if request.form["user"] == "" or request.form["password"] == "": if request.form["user"] == "" or request.form["password"] == "":
flash("Can't leave it blank", "danger") flash("Can't leave it blank", "danger")
@ -39,23 +44,22 @@ def login():
ram_user = ram_users.get(request.form["user"]) ram_user = ram_users.get(request.form["user"])
if ram_user and request.form["password"] == ram_user["password"]: if ram_user and request.form["password"] == ram_user["password"]:
user = User( user = User(
{ id = ram_user["id"],
"id": ram_user["id"], password = ram_user["password"],
"password": ram_user["password"], role = ram_user["role"],
"role": ram_user["role"], active = True,
"active": True,
}
) )
login_user(user) login_user(user)
flash("Logged in successfully.", "success") flash("Logged in successfully.", "success")
return redirect(url_for("web_users")) return redirect(url_for("web_users"))
else: else:
flash("Username not found or incorrect password.", "warning") flash("Username not found or incorrect password.", "warning")
return render_template("login.html") o : Response = app.make_response(render_template("login.html"))
return o
@app.route("/logout", methods=["GET"]) @app.route("/logout", methods=["GET"])
@login_required @login_required
def logout(): def logout() -> Response:
logout_user() logout_user()
return redirect(url_for("login")) return redirect(url_for("login"))

View File

@ -1,39 +0,0 @@
#
# Copyright © 2021,2022 IsardVDI S.L.
#
# This file is part of DD
#
# DD is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or (at your
# option) any later version.
#
# DD is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License
# along with DD. If not, see <https://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: AGPL-3.0-or-later
# from flask_socketio import SocketIO, emit, join_room, leave_room, \
# close_room, rooms, disconnect, send
# from admin import app
# import json
# # socketio = SocketIO(app)
# # from ...start import socketio
# @socketio.on('connect', namespace='//sio')
# def socketio_connect():
# join_room('admin')
# socketio.emit('update',
# json.dumps('Joined'),
# namespace='//sio',
# room='admin')
# @socketio.on('disconnect', namespace='//sio')
# def socketio_domains_disconnect():
# None

View File

@ -1,5 +1,6 @@
# #
# Copyright © 2021,2022 IsardVDI S.L. # Copyright © 2021,2022 IsardVDI S.L.
# Copyright © 2022 Evilham <contact@evilham.com>
# #
# This file is part of DD # This file is part of DD
# #
@ -34,107 +35,110 @@ from flask import (
jsonify, jsonify,
redirect, redirect,
request, request,
Response,
send_file, send_file,
url_for, url_for,
) )
from flask import render_template as render_template_flask from flask import render_template as render_template_flask
from flask_login import login_required from flask_login import login_required
from admin import app from typing import TYPE_CHECKING
if TYPE_CHECKING:
from admin.flaskapp import AdminFlaskApp
from ..lib.avatars import Avatars
from .decorators import is_admin from .decorators import is_admin
avatars = Avatars()
from ..lib.legal import gen_legal_if_not_exists from ..lib.legal import gen_legal_if_not_exists
def render_template(*args, **kwargs): def render_template(*args : str, **kwargs : str) -> str:
kwargs["DOMAIN"] = os.environ["DOMAIN"] kwargs["DOMAIN"] = os.environ["DOMAIN"]
return render_template_flask(*args, **kwargs) return render_template_flask(*args, **kwargs)
@app.route("/users") def setup_web_views(app : "AdminFlaskApp") -> None:
@login_required @app.route("/users")
def web_users(): @login_required
def web_users() -> str:
return render_template("pages/users.html", title="Users", nav="Users") return render_template("pages/users.html", title="Users", nav="Users")
@app.route("/roles") @app.route("/roles")
@login_required @login_required
def web_roles(): def web_roles() -> str:
return render_template("pages/roles.html", title="Roles", nav="Roles") return render_template("pages/roles.html", title="Roles", nav="Roles")
@app.route("/groups") @app.route("/groups")
@login_required @login_required
def web_groups(provider=False): def web_groups(provider : bool=False) -> str:
return render_template("pages/groups.html", title="Groups", nav="Groups") return render_template("pages/groups.html", title="Groups", nav="Groups")
@app.route("/avatar/<userid>", methods=["GET"]) @app.route("/avatar/<userid>", methods=["GET"])
@login_required @login_required
def avatar(userid): def avatar(userid : str) -> Response:
if userid != "false": if userid != "false":
return send_file("../avatars/master-avatars/" + userid, mimetype="image/jpeg") return send_file("../avatars/master-avatars/" + userid, mimetype="image/jpeg")
return send_file("static/img/missing.jpg", mimetype="image/jpeg") return send_file("static/img/missing.jpg", mimetype="image/jpeg")
@app.route("/dashboard") @app.route("/dashboard")
@login_required @login_required
def dashboard(provider=False): def dashboard(provider : bool=False) -> str:
data = json.loads(requests.get("http://dd-sso-api/json").text) data = json.loads(requests.get("http://dd-sso-api/json").text)
return render_template( return render_template(
"pages/dashboard.html", title="Customization", nav="Customization", data=data "pages/dashboard.html", title="Customization", nav="Customization", data=data
) )
@app.route("/legal") @app.route("/legal")
@login_required @login_required
def legal(): def legal() -> str:
# data = json.loads(requests.get("http://dd-sso-api/json").text) # data = json.loads(requests.get("http://dd-sso-api/json").text)
return render_template("pages/legal.html", title="Legal", nav="Legal", data={}) return render_template("pages/legal.html", title="Legal", nav="Legal", data="")
@app.route("/legal_text") @app.route("/legal_text")
def legal_text(): def legal_text() -> str:
lang = request.args.get("lang") lang = request.args.get("lang")
if not lang or lang not in ["ca","es","en","fr"]: if not lang or lang not in ["ca","es","en","fr"]:
lang="ca" lang="ca"
gen_legal_if_not_exists(lang) gen_legal_if_not_exists(app, lang)
return render_template("pages/legal/"+lang) return render_template("pages/legal/"+lang)
### SYS ADMIN ### SYS ADMIN
@app.route("/sysadmin/users") @app.route("/sysadmin/users")
@login_required @login_required
@is_admin @is_admin
def web_sysadmin_users(): def web_sysadmin_users() -> Response:
return render_template( o : Response = app.make_response(render_template(
"pages/sysadmin/users.html", title="SysAdmin Users", nav="SysAdminUsers" "pages/sysadmin/users.html", title="SysAdmin Users", nav="SysAdminUsers"
) ))
return o
@app.route("/sysadmin/groups") @app.route("/sysadmin/groups")
@login_required @login_required
@is_admin @is_admin
def web_sysadmin_groups(): def web_sysadmin_groups() -> Response:
return render_template( o : Response = app.make_response(render_template(
"pages/sysadmin/groups.html", title="SysAdmin Groups", nav="SysAdminGroups" "pages/sysadmin/groups.html", title="SysAdmin Groups", nav="SysAdminGroups"
) ))
return o
@app.route("/sysadmin/external") @app.route("/sysadmin/external")
@login_required @login_required
## SysAdmin role ## SysAdmin role
def web_sysadmin_external(): def web_sysadmin_external() -> str:
return render_template( return render_template(
"pages/sysadmin/external.html", title="External", nav="External" "pages/sysadmin/external.html", title="External", nav="External"
) )
@app.route("/sockettest") @app.route("/sockettest")
def web_sockettest(): def web_sockettest() -> str:
return render_template( return render_template(
"pages/sockettest.html", title="Sockettest Users", nav="SysAdminUsers" "pages/sockettest.html", title="Sockettest Users", nav="SysAdminUsers"
) )

View File

@ -1,5 +1,6 @@
# #
# Copyright © 2021,2022 IsardVDI S.L. # Copyright © 2021,2022 IsardVDI S.L.
# Copyright © 2022 Evilham <contact@evilham.com>
# #
# This file is part of DD # This file is part of DD
# #
@ -20,6 +21,7 @@
import json import json
import logging as log import logging as log
from operator import itemgetter
import os import os
import socket import socket
import sys import sys
@ -28,17 +30,21 @@ import traceback
from flask import request from flask import request
from admin import app from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, List
if TYPE_CHECKING:
from admin.flaskapp import AdminFlaskApp
from .decorators import is_internal from admin.views.decorators import OptionalJsonResponse, is_internal
@app.route("/api/internal/users", methods=["GET"])
@is_internal def setup_wp_views(app : "AdminFlaskApp") -> None:
def internal_users(): @app.json_route("/api/internal/users", methods=["GET"])
@is_internal
def internal_users() -> OptionalJsonResponse:
log.error(socket.gethostbyname("dd-apps-wordpress")) log.error(socket.gethostbyname("dd-apps-wordpress"))
if request.method == "GET": if request.method == "GET":
sorted_users = sorted(app.admin.get_mix_users(), key=lambda k: k["username"]) sorted_users = sorted(app.admin.get_mix_users(), key=itemgetter("username"))
# group_users = [user for user in sorted_users if data['path'] in user['keycloak_groups']] # group_users = [user for user in sorted_users if data['path'] in user['keycloak_groups']]
users = [] users = []
for user in sorted_users: for user in sorted_users:
@ -46,24 +52,24 @@ def internal_users():
continue continue
users.append(user_parser(user)) users.append(user_parser(user))
return json.dumps(users), 200, {"Content-Type": "application/json"} return json.dumps(users), 200, {"Content-Type": "application/json"}
return None
@app.json_route("/api/internal/users/filter", methods=["POST"])
@app.route("/api/internal/users/filter", methods=["POST"]) @is_internal
@is_internal def internal_users_search() -> OptionalJsonResponse:
def internal_users_search():
if request.method == "POST": if request.method == "POST":
data = request.get_json(force=True) data = request.get_json(force=True)
users = app.admin.get_mix_users() users = app.admin.get_mix_users()
result = [user_parser(user) for user in filter_users(users, data["text"])] result = [user_parser(user) for user in filter_users(users, data["text"])]
sorted_result = sorted(result, key=lambda k: k["id"]) sorted_result = sorted(result, key=itemgetter("id"))
return json.dumps(sorted_result), 200, {"Content-Type": "application/json"} return json.dumps(sorted_result), 200, {"Content-Type": "application/json"}
return None
@app.json_route("/api/internal/groups", methods=["GET"])
@app.route("/api/internal/groups", methods=["GET"]) @is_internal
@is_internal def internal_groups() -> OptionalJsonResponse:
def internal_groups():
if request.method == "GET": if request.method == "GET":
sorted_groups = sorted(app.admin.get_mix_groups(), key=lambda k: k["name"]) sorted_groups = sorted(app.admin.get_mix_groups(), key=itemgetter("name"))
groups = [] groups = []
for group in sorted_groups: for group in sorted_groups:
if not group["path"].startswith("/"): if not group["path"].startswith("/"):
@ -76,14 +82,14 @@ def internal_groups():
} }
) )
return json.dumps(groups), 200, {"Content-Type": "application/json"} return json.dumps(groups), 200, {"Content-Type": "application/json"}
return None
@app.json_route("/api/internal/group/users", methods=["POST"])
@app.route("/api/internal/group/users", methods=["POST"]) @is_internal
@is_internal def internal_group_users() -> OptionalJsonResponse:
def internal_group_users():
if request.method == "POST": if request.method == "POST":
data = request.get_json(force=True) data = request.get_json(force=True)
sorted_users = sorted(app.admin.get_mix_users(), key=lambda k: k["username"]) sorted_users = sorted(app.admin.get_mix_users(), key=itemgetter("username"))
# group_users = [user for user in sorted_users if data['path'] in user['keycloak_groups']] # group_users = [user for user in sorted_users if data['path'] in user['keycloak_groups']]
users = [] users = []
for user in sorted_users: for user in sorted_users:
@ -95,14 +101,14 @@ def internal_group_users():
else: else:
result = [user_parser(user) for user in users] result = [user_parser(user) for user in users]
return json.dumps(result), 200, {"Content-Type": "application/json"} return json.dumps(result), 200, {"Content-Type": "application/json"}
return None
@app.json_route("/api/internal/roles", methods=["GET"])
@app.route("/api/internal/roles", methods=["GET"]) @is_internal
@is_internal def internal_roles() -> OptionalJsonResponse:
def internal_roles():
if request.method == "GET": if request.method == "GET":
roles = [] roles = []
for role in sorted(app.admin.get_roles(), key=lambda k: k["name"]): for role in sorted(app.admin.get_roles(), key=itemgetter("name")):
if role["name"] == "admin": if role["name"] == "admin":
continue continue
roles.append( roles.append(
@ -113,14 +119,14 @@ def internal_roles():
} }
) )
return json.dumps(roles), 200, {"Content-Type": "application/json"} return json.dumps(roles), 200, {"Content-Type": "application/json"}
return None
@app.json_route("/api/internal/role/users", methods=["POST"])
@app.route("/api/internal/role/users", methods=["POST"]) @is_internal
@is_internal def internal_role_users() -> OptionalJsonResponse:
def internal_role_users():
if request.method == "POST": if request.method == "POST":
data = request.get_json(force=True) data = request.get_json(force=True)
sorted_users = sorted(app.admin.get_mix_users(), key=lambda k: k["username"]) sorted_users = sorted(app.admin.get_mix_users(), key=itemgetter("username"))
# group_users = [user for user in sorted_users if data['path'] in user['keycloak_groups']] # group_users = [user for user in sorted_users if data['path'] in user['keycloak_groups']]
users = [] users = []
for user in sorted_users: for user in sorted_users:
@ -132,9 +138,9 @@ def internal_role_users():
else: else:
result = [user_parser(user) for user in users] result = [user_parser(user) for user in users]
return json.dumps(result), 200, {"Content-Type": "application/json"} return json.dumps(result), 200, {"Content-Type": "application/json"}
return None
def user_parser(user : Dict[str, Any]) -> Dict[str, Any]:
def user_parser(user):
return { return {
"id": user["username"], "id": user["username"],
"first": user["first"], "first": user["first"],
@ -145,7 +151,7 @@ def user_parser(user):
} }
def filter_users(users, text): def filter_users(users : Iterable[Dict[str, Any]], text : str) -> List[Dict[str, Any]]:
return [ return [
user user
for user in users for user in users

View File

@ -1,5 +1,6 @@
# #
# Copyright © 2021,2022 IsardVDI S.L. # Copyright © 2021,2022 IsardVDI S.L.
# Copyright © 2022 Evilham <contact@evilham.com>
# #
# This file is part of DD # This file is part of DD
# #
@ -25,25 +26,28 @@ import socket
from functools import wraps from functools import wraps
from flask import redirect, request, url_for from flask import redirect, request, url_for
from werkzeug.wrappers import Response
from flask_login import current_user, logout_user from flask_login import current_user, logout_user
from jose import jwt from jose import jwt
from ..auth.tokens import get_header_jwt_payload from ..auth.tokens import get_header_jwt_payload
from typing import Any, Callable, Dict, Optional, Tuple
JsonResponse = Tuple[str, int, Dict[str, str]]
OptionalJsonResponse = Optional[JsonResponse]
def is_admin(fn): def is_admin(fn : Callable[..., Response]) -> Callable[..., Response]:
@wraps(fn) @wraps(fn)
def decorated_view(*args, **kwargs): def decorated_view(*args : Any, **kwargs : Any) -> Response:
if current_user.role == "admin": if current_user.role == "admin":
return fn(*args, **kwargs) return fn(*args, **kwargs)
return redirect(url_for("login")) return redirect(url_for("login"))
return decorated_view return decorated_view
def is_internal(fn : Callable[..., OptionalJsonResponse]) -> Callable[..., OptionalJsonResponse]:
def is_internal(fn):
@wraps(fn) @wraps(fn)
def decorated_view(*args, **kwargs): def decorated_view(*args : Any, **kwargs : Any) -> OptionalJsonResponse:
remote_addr = ( remote_addr = (
request.headers["X-Forwarded-For"].split(",")[0] request.headers["X-Forwarded-For"].split(",")[0]
if "X-Forwarded-For" in request.headers if "X-Forwarded-For" in request.headers
@ -67,18 +71,18 @@ def is_internal(fn):
return decorated_view return decorated_view
def has_token(fn): def has_token(fn : Callable[..., Any]) -> Callable[..., Any]:
@wraps(fn) @wraps(fn)
def decorated(*args, **kwargs): def decorated(*args : Any, **kwargs : Any) -> Any:
payload = get_header_jwt_payload() payload = get_header_jwt_payload()
return fn(*args, **kwargs) return fn(*args, **kwargs)
return decorated return decorated
def is_internal_or_has_token(fn): def is_internal_or_has_token(fn : Callable[..., Any]) -> Callable[..., Any]:
@wraps(fn) @wraps(fn)
def decorated_view(*args, **kwargs): def decorated_view(*args : Any, **kwargs : Any) -> Any:
remote_addr = ( remote_addr = (
request.headers["X-Forwarded-For"].split(",")[0] request.headers["X-Forwarded-For"].split(",")[0]
if "X-Forwarded-For" in request.headers if "X-Forwarded-For" in request.headers
@ -94,9 +98,9 @@ def is_internal_or_has_token(fn):
return decorated_view return decorated_view
def login_or_token(fn): def login_or_token(fn : Callable[..., Any]) -> Callable[..., Any]:
@wraps(fn) @wraps(fn)
def decorated_view(*args, **kwargs): def decorated_view(*args : Any, **kwargs : Any) -> Any:
if current_user.is_authenticated: if current_user.is_authenticated:
return fn(*args, **kwargs) return fn(*args, **kwargs)
payload = get_header_jwt_payload() payload = get_header_jwt_payload()

View File

@ -1,5 +1,6 @@
# #
# Copyright © 2021,2022 IsardVDI S.L. # Copyright © 2021,2022 IsardVDI S.L.
# Copyright © 2022 Evilham <contact@evilham.com>
# #
# This file is part of DD # This file is part of DD
# #
@ -39,14 +40,17 @@ from flask_socketio import (
send, send,
) )
from admin import app from admin import get_app
# Set up the app
app = get_app()
app.setup()
app.socketio = SocketIO(app) app.socketio = SocketIO(app)
@app.socketio.on("connect", namespace="/sio") @app.socketio.on("connect", namespace="/sio")
@login_required @login_required
def socketio_connect(): def socketio_connect() -> None:
if current_user.id: if current_user.id:
join_room("admin") join_room("admin")
app.socketio.emit( app.socketio.emit(
@ -57,12 +61,12 @@ def socketio_connect():
@app.socketio.on("disconnect", namespace="/sio") @app.socketio.on("disconnect", namespace="/sio")
def socketio_disconnect(): def socketio_disconnect() -> None:
leave_room("admin") leave_room("admin")
@app.socketio.on("connect", namespace="/sio/events") @app.socketio.on("connect", namespace="/sio/events")
def socketio_connect(): def socketio_connect() -> None:
jwt = get_token_payload(request.args.get("jwt")) jwt = get_token_payload(request.args.get("jwt"))
join_room("events") join_room("events")
@ -75,7 +79,7 @@ def socketio_connect():
@app.socketio.on("disconnect", namespace="/sio/events") @app.socketio.on("disconnect", namespace="/sio/events")
def socketio_events_disconnect(): def socketio_events_disconnect() -> None:
leave_room("events") leave_room("events")

View File

@ -43,9 +43,11 @@ services:
- ${DATA_FOLDER}/moodle/saml2:/admin/moodledata/saml2:rw - ${DATA_FOLDER}/moodle/saml2:/admin/moodledata/saml2:rw
- ${DATA_FOLDER}/saml_certs:/admin/saml_certs:rw - ${DATA_FOLDER}/saml_certs:/admin/saml_certs:rw
- ${DATA_FOLDER}/legal:/admin/admin/static/templates/pages/legal:rw - ${DATA_FOLDER}/legal:/admin/admin/static/templates/pages/legal:rw
- ${DATA_FOLDER}/dd-admin:/data:rw
env_file: env_file:
- .env - .env
environment: environment:
- VERIFY="false" # In development do not verify certificates - VERIFY="false" # In development do not verify certificates
- DOMAIN=${DOMAIN} - DOMAIN=${DOMAIN}
- MANAGED_EMAIL_DOMAIN=${MANAGED_EMAIL_DOMAIN} - MANAGED_EMAIL_DOMAIN=${MANAGED_EMAIL_DOMAIN}
- SECRETS=/data/secret