mirror of
https://github.com/dkmstr/openuds.git
synced 2025-10-26 07:33:41 +03:00
Compare commits
320 Commits
dev/janier
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f79fe14d0 | ||
|
|
1e28b7fa28 | ||
|
|
0613f3d390 | ||
|
|
3ffe5fc4f3 | ||
|
|
3a0db31d81 | ||
|
|
d281582e18 | ||
|
|
d2d2308f43 | ||
|
|
866295daa4 | ||
|
|
e0238d2e07 | ||
|
|
487d841234 | ||
|
|
6ab3a94c20 | ||
|
|
0efcbfc3e6 | ||
|
|
97d105db7c | ||
|
|
ba7698f8ed | ||
|
|
0b721ce999 | ||
|
|
0480dcdff6 | ||
|
|
8caff9acf9 | ||
|
|
0a1b9b5033 | ||
|
|
2599c6d478 | ||
|
|
461917e652 | ||
|
|
d049215951 | ||
|
|
ba74a800d8 | ||
|
|
8c6b8bf1c4 | ||
|
|
a867ed7b6a | ||
|
|
3bf3cdeadc | ||
|
|
b9f16a65ca | ||
|
|
361ba8b60a | ||
|
|
f7f65b8ad5 | ||
|
|
a5aa14404b | ||
|
|
844060addb | ||
|
|
dcc491c5ad | ||
|
|
8a2364c85b | ||
|
|
a7f3632ac3 | ||
|
|
eb33d95280 | ||
|
|
cd60b398a9 | ||
|
|
4e7f3ca096 | ||
|
|
979f992b6d | ||
|
|
2a10f34a29 | ||
|
|
8c0f6deb96 | ||
|
|
a201741c67 | ||
|
|
48c146d0b1 | ||
|
|
4f9acc7dc4 | ||
|
|
2b5a62445f | ||
|
|
67bd5fc067 | ||
|
|
b628cc2459 | ||
|
|
cbdacb0311 | ||
|
|
9dc25e96d4 | ||
|
|
449d59e27e | ||
|
|
e38616f4d6 | ||
|
|
2c5ec79ea4 | ||
|
|
4528dbac9a | ||
|
|
83689dddaa | ||
|
|
fbf088d052 | ||
|
|
2fcefd50cc | ||
|
|
6fd6de8d31 | ||
|
|
b0cb36f93b | ||
|
|
2530fd3afc | ||
|
|
037b4abad1 | ||
|
|
839e4c6b1d | ||
|
|
611b54eee0 | ||
|
|
dbbe153564 | ||
|
|
2fd157e463 | ||
|
|
e9f795b83b | ||
|
|
7e51c1fd93 | ||
|
|
b6af59cc44 | ||
|
|
50072e948e | ||
|
|
18dab9bd2c | ||
|
|
e4c7e6e546 | ||
|
|
d5fc098193 | ||
|
|
3cc06433ba | ||
|
|
b01496728f | ||
|
|
f4a3fee375 | ||
|
|
34e05e450d | ||
|
|
5cdeae711a | ||
|
|
889570f4b9 | ||
|
|
d6229b7e31 | ||
|
|
98ae77b5d9 | ||
|
|
a57984b2aa | ||
|
|
9b6e578b79 | ||
|
|
b2aed256bd | ||
|
|
b0cbee2cd0 | ||
|
|
edd50bf92d | ||
|
|
00fb79244a | ||
|
|
4cedf057b3 | ||
|
|
c5e0d0721f | ||
|
|
afbd4c5355 | ||
|
|
85ade4b9fa | ||
|
|
bf97c6f2dc | ||
|
|
dd1068f18d | ||
|
|
51a0388ff2 | ||
|
|
455c19fa99 | ||
|
|
c550e70937 | ||
|
|
1b93fa8d51 | ||
|
|
8ec5170f28 | ||
|
|
7d092ca993 | ||
|
|
90890ef916 | ||
|
|
5d64e37c85 | ||
|
|
4498e2d90e | ||
|
|
5124a21096 | ||
|
|
c3b1e8cbe8 | ||
|
|
6e719eaf78 | ||
|
|
cf613fa0ab | ||
|
|
909991a963 | ||
|
|
f091e40a2a | ||
|
|
4322465040 | ||
|
|
674b29afb9 | ||
|
|
5ede89c56b | ||
|
|
192c6ffdac | ||
|
|
0dbc2d7c5b | ||
|
|
e08846b834 | ||
|
|
d3d97c4579 | ||
|
|
e5ac3cef9d | ||
|
|
48c18f885f | ||
|
|
5242013cf1 | ||
|
|
15dd9cdffb | ||
|
|
7a5f4883ee | ||
|
|
b5ce128f23 | ||
|
|
fc352d9b7d | ||
|
|
3e2c38d5b1 | ||
|
|
8410894b2d | ||
|
|
ec6896952c | ||
|
|
e0d1305e76 | ||
|
|
f987af05a1 | ||
|
|
54f254c6fb | ||
|
|
388e557ca5 | ||
|
|
6fddebd08a | ||
|
|
9b247daada | ||
|
|
152683c9f1 | ||
|
|
386ad0001d | ||
|
|
e437de3855 | ||
|
|
7ffc781fca | ||
|
|
40387b03cb | ||
|
|
4e7c990340 | ||
|
|
768d16b4b4 | ||
|
|
5f3c6fd868 | ||
|
|
9eb4c029df | ||
|
|
4d6f7c1b67 | ||
|
|
bc69075f6a | ||
|
|
355362956f | ||
|
|
2454af7ec1 | ||
|
|
4210fbe009 | ||
|
|
fffd3621ad | ||
|
|
df057662d5 | ||
|
|
7ee2be5336 | ||
|
|
04ae83ce21 | ||
|
|
2a0f4fb2ba | ||
|
|
1b3e6261fb | ||
|
|
2c17f72695 | ||
|
|
07676fa0f3 | ||
|
|
be3175be57 | ||
|
|
fa2e045275 | ||
|
|
42fb3d42d8 | ||
|
|
f4170ab861 | ||
|
|
95610d4bca | ||
|
|
07d03bacc8 | ||
|
|
15f6e889c7 | ||
|
|
c73ddb37c5 | ||
|
|
70954a18fe | ||
|
|
2c11d29974 | ||
|
|
01fab78ce0 | ||
|
|
bb81841340 | ||
|
|
4d4d6aee6c | ||
|
|
10d2929cf4 | ||
|
|
f8358837cd | ||
|
|
738081bf7d | ||
|
|
1e6cca1c37 | ||
|
|
7a55d284b7 | ||
|
|
5437cfe6de | ||
|
|
e4aff9d1e5 | ||
|
|
af3c309917 | ||
|
|
c0cdb26c48 | ||
|
|
fab66744ad | ||
|
|
968fca260f | ||
|
|
088ab0cdc1 | ||
|
|
10b0978fe7 | ||
|
|
e8de389830 | ||
|
|
fdcebc4821 | ||
|
|
26b963374d | ||
|
|
971e51ff27 | ||
|
|
df098ddbc1 | ||
|
|
8231855bd0 | ||
|
|
378fecab10 | ||
|
|
73fb7d831c | ||
|
|
549f2cc769 | ||
|
|
6950370c96 | ||
|
|
53f95c6733 | ||
|
|
a265dfb754 | ||
|
|
162ae349af | ||
|
|
e0efa0a79c | ||
|
|
20c923dad9 | ||
|
|
679fd6dd49 | ||
|
|
353288971d | ||
|
|
95b2b2eb4e | ||
|
|
ac231f80eb | ||
|
|
942c3a63fe | ||
|
|
ce9110b7ca | ||
|
|
19f7cda26c | ||
|
|
be6cfb0ec5 | ||
|
|
d74e6daed2 | ||
|
|
6922d28537 | ||
|
|
3d9bc55b1d | ||
|
|
e251c69599 | ||
|
|
21f156a930 | ||
|
|
355b3ca07a | ||
|
|
fc964ab96d | ||
|
|
6116a98bc6 | ||
|
|
5dd4b455f3 | ||
|
|
6df756e22f | ||
|
|
6fcc5efc3e | ||
|
|
2a3e90be57 | ||
|
|
92cb728715 | ||
|
|
68f16c7ad3 | ||
|
|
87d2c5a064 | ||
|
|
441fd7b7da | ||
|
|
f7b7aef9bb | ||
|
|
3f034bb51c | ||
|
|
fb7be05ea9 | ||
|
|
f78e8c74d6 | ||
|
|
474302174b | ||
|
|
a8ec91025c | ||
|
|
daf3646577 | ||
|
|
861fdc67a0 | ||
|
|
feaa780c54 | ||
|
|
43d7096e6c | ||
|
|
5a609d4df9 | ||
|
|
da79c38fc2 | ||
|
|
7037f4e362 | ||
|
|
35ee5346d1 | ||
|
|
55adeea6e4 | ||
|
|
bed22c2aed | ||
|
|
45e0d61491 | ||
|
|
7746ba0335 | ||
|
|
8e78884715 | ||
|
|
3a0922e41b | ||
|
|
f444b1222f | ||
|
|
c4732791ff | ||
|
|
452225b3a2 | ||
|
|
4701e89e64 | ||
|
|
911368ed43 | ||
|
|
d3757343bc | ||
|
|
2ab2e3219f | ||
|
|
3c2041d5bf | ||
|
|
c1fa4c17a5 | ||
|
|
5f22412a10 | ||
|
|
a8f17403df | ||
|
|
30825d5538 | ||
|
|
925ba00523 | ||
|
|
0da3a5cccc | ||
|
|
b724d526ac | ||
|
|
fc883eac8d | ||
|
|
13a1656b37 | ||
|
|
de91ffcfa2 | ||
|
|
f5254ca68a | ||
|
|
fa95ca4d23 | ||
|
|
e57de9520f | ||
|
|
b3b8b037cc | ||
|
|
4112ce053d | ||
|
|
f65156602a | ||
|
|
1c1f99febc | ||
|
|
0871a0b2cd | ||
|
|
411c9643fc | ||
|
|
7646cd1dd3 | ||
|
|
2b6759b7b7 | ||
|
|
8358025f54 | ||
|
|
3b26a25a2c | ||
|
|
5d1ea79049 | ||
|
|
68df062990 | ||
|
|
a7272d700d | ||
|
|
c12c9d616c | ||
|
|
a92fd0d150 | ||
|
|
bee801296a | ||
|
|
6b2b465c25 | ||
|
|
7b860bbd36 | ||
|
|
1cfae43c2c | ||
|
|
e71a131f1f | ||
|
|
d6518ee30c | ||
|
|
79aeaee67b | ||
|
|
475fefbbfd | ||
|
|
803c8ba7b2 | ||
|
|
10076bf46a | ||
|
|
94249decfb | ||
|
|
f9a0026d5d | ||
|
|
2d524fcdf2 | ||
|
|
d82e7dc838 | ||
|
|
8130afa2d5 | ||
|
|
458b4d3412 | ||
|
|
4c6a9237e8 | ||
|
|
0c4a00e163 | ||
|
|
6899cff246 | ||
|
|
4c59a25092 | ||
|
|
b41a1afd43 | ||
|
|
ee2262a779 | ||
|
|
c54111cf56 | ||
|
|
beccee144a | ||
|
|
b9f4e7f2ea | ||
|
|
84d565ec19 | ||
|
|
6424ca37cf | ||
|
|
c758819c6b | ||
|
|
6ab48c9d04 | ||
|
|
8a148be042 | ||
|
|
b20a051fd7 | ||
|
|
e738b5c447 | ||
|
|
cc03e5d6c3 | ||
|
|
6239d499af | ||
|
|
e37a65b8d1 | ||
|
|
80d015b410 | ||
|
|
6875e586cb | ||
|
|
10fda01ad3 | ||
|
|
482c537861 | ||
|
|
afea5e1eaa | ||
|
|
d6ea833674 | ||
|
|
8450938c75 | ||
|
|
541e29b27b | ||
|
|
e1992cdc3e | ||
|
|
c0faec45e6 | ||
|
|
3ec42a9f68 | ||
|
|
c111069e8c | ||
|
|
c9201c91a3 | ||
|
|
6230e80a30 | ||
|
|
2db0fe725b |
63
.github/workflows/test.yml
vendored
Normal file
63
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
name: Test OpenUDS
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: server
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libsasl2-dev \
|
||||
python3-dev \
|
||||
libldap2-dev \
|
||||
libssl-dev \
|
||||
libmemcached-dev \
|
||||
zlib1g-dev \
|
||||
gcc
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
|
||||
- name: Set PYTHONPATH
|
||||
run: echo "PYTHONPATH=$PWD/src" >> $GITHUB_ENV
|
||||
|
||||
- name: Copy Django settings
|
||||
run: cp src/server/settings.py.sample src/server/settings.py
|
||||
|
||||
- name: Generate RSA key and set as environment variable
|
||||
run: |
|
||||
openssl genrsa 2048 > private.pem
|
||||
RSA_KEY=$(awk 'NF {sub(/\r/, ""); printf "%s\\n",$0;}' private.pem)
|
||||
echo "RSA_KEY=$RSA_KEY" >> $GITHUB_ENV
|
||||
|
||||
- name: Patch settings.py with generated RSA key
|
||||
run: |
|
||||
sed -i "s|^RSA_KEY = .*|RSA_KEY = '''$RSA_KEY'''|" src/server/settings.py
|
||||
|
||||
- name: Create log directory
|
||||
run: mkdir -p src/log
|
||||
|
||||
- name: Run tests with pytest
|
||||
run: python3 -m pytest
|
||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
BSD 3-Clause License
|
||||
|
||||
Copyright (c) 2022-2024, Virtual Cable S.L.U.
|
||||
Copyright (c) 2022-2024, Virtualcable S.L.U.
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
|
||||
@@ -13,5 +13,7 @@ Please feel free to contribute to this project.
|
||||
|
||||
Notes
|
||||
=====
|
||||
* From 4.0 onwards (current master), OpenUDS has been splitted in several repositories and contains submodules. Remember to use "git clone --resursive ..." to fetch it ;-).
|
||||
* 4.0 version is tested on Python 3.11. It will probably work on 3.12 and 3.13 too (maybe 3.10, but not tested also)
|
||||
* Master version is always under heavy development and it is not recommended for use, it will probably have unfixed bugs. Please use the latest stable branch (`v4.0` right now).
|
||||
* From `v4.0` onwards (current master), OpenUDS has been splitted in several repositories and contains submodules. Remember to use "git clone --resursive ..." to fetch it ;-).
|
||||
* `v4.0` version needs Python 3.11 (may work fine on newer versions). It uses new features only available on 3.10 or later, and is tested against 3.11. It will probably work on 3.10 too.
|
||||
|
||||
|
||||
2
actor
2
actor
Submodule actor updated: 04ce3fc2d1...4638fd77a2
2
client
2
client
Submodule client updated: 5b044bca34...517f8935a2
28
server/conftest.py
Normal file
28
server/conftest.py
Normal file
@@ -0,0 +1,28 @@
|
||||
import pytest
|
||||
import gc
|
||||
from django.db import connections
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def close_all_db_connections():
|
||||
yield
|
||||
for conn in connections.all():
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def pytest_sessionfinish(session: pytest.Session, exitstatus: pytest.ExitCode) -> None:
|
||||
"""Al final de toda la suite, cerrar conexiones y forzar GC."""
|
||||
try:
|
||||
from django.db import connections
|
||||
|
||||
for conn in connections.all():
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
except ImportError:
|
||||
pass
|
||||
gc.collect()
|
||||
@@ -1,7 +1,7 @@
|
||||
[mypy]
|
||||
#plugins =
|
||||
# mypy_django_plugin.main
|
||||
python_version = 3.11
|
||||
python_version = 3.12
|
||||
|
||||
# Exclude all .*/transports/.*/scripts/.* directories and all tests
|
||||
exclude = (.*/transports/.*/scripts/.*|.*/tests/.*)
|
||||
@@ -17,4 +17,4 @@ django_settings_module = "server.settings"
|
||||
|
||||
# Disable some anoying reports, because pyright needs the redundant cast on some cases
|
||||
# [mypy-tests.*]
|
||||
# disable_error_code =
|
||||
# disable_error_code =
|
||||
|
||||
@@ -11,4 +11,11 @@ python_classes =
|
||||
filterwarnings =
|
||||
error
|
||||
ignore:The --rsyncdir command line argument and rsyncdirs config variable are deprecated.:DeprecationWarning
|
||||
ignore::matplotlib._api.deprecation.MatplotlibDeprecationWarning:pydev
|
||||
ignore::matplotlib._api.deprecation.MatplotlibDeprecationWarning:pydev
|
||||
ignore::pytest.PytestUnraisableExceptionWarning
|
||||
ignore::ResourceWarning:sqlite3
|
||||
|
||||
log_format = "%(asctime)s %(levelname)s %(message)s"
|
||||
log_date_format = "%Y-%m-%d %H:%M:%S"
|
||||
log_cli = true
|
||||
log_level = debug
|
||||
@@ -1,6 +1,13 @@
|
||||
# Broker (and common)
|
||||
# Latest versions should work fine with master branch
|
||||
Django>5.0
|
||||
Django>5.2
|
||||
pytest
|
||||
pytest-django
|
||||
lark
|
||||
ldap3
|
||||
aiosmtpd
|
||||
pillow
|
||||
cairosvg
|
||||
bitarray
|
||||
numpy
|
||||
html5lib
|
||||
@@ -29,7 +36,6 @@ XenAPI
|
||||
PyJWT
|
||||
pylibmc
|
||||
gunicorn
|
||||
python-dateutil
|
||||
pywinrm
|
||||
pywinrm[credssp]
|
||||
whitenoise
|
||||
@@ -37,7 +43,6 @@ setproctitle
|
||||
openpyxl
|
||||
boto3
|
||||
uvicorn[standard]
|
||||
numpy
|
||||
pandas
|
||||
xxhash
|
||||
psutil
|
||||
@@ -47,7 +52,6 @@ qrcode
|
||||
qrcode[pil]
|
||||
art
|
||||
# For tunnel
|
||||
dnspython
|
||||
aiohttp
|
||||
uvloop
|
||||
argon2-cffi
|
||||
|
||||
@@ -32,7 +32,6 @@
|
||||
Author: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
'''
|
||||
import typing
|
||||
import collections.abc
|
||||
import asyncio
|
||||
import aiohttp
|
||||
import enum
|
||||
@@ -151,8 +150,6 @@ async def main():
|
||||
if options.params is not None:
|
||||
options.params = json.loads(options.params)
|
||||
|
||||
REST_URL = options.url
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
# request_pools() # Not logged in, this will generate an error
|
||||
await login(session, options.auth, options.username, options.password)
|
||||
|
||||
@@ -14,6 +14,7 @@ BASE_DIR = '/'.join(
|
||||
) # If used 'relpath' instead of abspath, returns path of "enterprise" instead of "openuds"
|
||||
|
||||
DEBUG = True
|
||||
PROFILING = False
|
||||
|
||||
# USE_X_FORWARDED_HOST = True
|
||||
SECURE_PROXY_SSL_HEADER = (
|
||||
@@ -32,7 +33,7 @@ DATABASES = {
|
||||
},
|
||||
'NAME': 'dbuds', # Or path to database file if using sqlite3.
|
||||
'USER': 'dbuds', # Not used with sqlite3.
|
||||
'PASSWORD': 'PASSWOR', # Not used with sqlite3.
|
||||
'PASSWORD': 'PASSWORD', # Not used with sqlite3.
|
||||
'HOST': 'localhost', # Set to empty string for localhost. Not used with sqlite3.
|
||||
'PORT': '3306', # Set to empty string for default. Not used with sqlite3.
|
||||
# 'CONN_MAX_AGE': 600, # Enable DB Pooling, 10 minutes max connection duration
|
||||
@@ -59,7 +60,8 @@ DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
|
||||
# system time zone.
|
||||
|
||||
# TIME_SECTION_START
|
||||
TIME_ZONE = 'Europe/Madrid'
|
||||
USE_TZ = True
|
||||
TIME_ZONE = 'UTC'
|
||||
# TIME_SECTION_END
|
||||
|
||||
# Override for gettext so we can use the same syntax as in django
|
||||
@@ -97,6 +99,8 @@ USE_I18N = True
|
||||
# calendars according to the current locale
|
||||
USE_L10N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
# Absolute filesystem path to the directory that will hold user-uploaded files.
|
||||
# Example: "/home/media/media.lawrence.com/media/"
|
||||
MEDIA_ROOT = ''
|
||||
@@ -150,7 +154,7 @@ CACHES = {
|
||||
# }
|
||||
'memory': {
|
||||
'BACKEND': 'django.core.cache.backends.memcached.PyLibMCCache',
|
||||
'LOCATION': 'db.dkmon.com:11211',
|
||||
'LOCATION': '127.0.0.1:11211',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -194,7 +198,7 @@ SECURE_CIPHERS = (
|
||||
':ECDHE-ECDSA-CHACHA20-POLY1305'
|
||||
)
|
||||
# Min TLS version
|
||||
SECURE_MIN_TLS_VERSION = '1.2'
|
||||
# SECURE_MIN_TLS_VERSION = '1.2'
|
||||
|
||||
# LDAP CIFHER SUITE can be enforced here. Use GNU TLS cipher suite names in this case
|
||||
# Debian libldap uses gnutls, and it's my development environment. Continue reading for more info:
|
||||
|
||||
@@ -47,7 +47,7 @@ from uds.core.util.model import sql_stamp_seconds
|
||||
|
||||
from . import processors, log
|
||||
from .handlers import Handler
|
||||
from .model import DetailHandler
|
||||
from . import model as rest_model
|
||||
|
||||
# Not imported at runtime, just for type checking
|
||||
if typing.TYPE_CHECKING:
|
||||
@@ -58,81 +58,46 @@ logger = logging.getLogger(__name__)
|
||||
__all__ = ['Handler', 'Dispatcher']
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class HandlerNode:
|
||||
"""
|
||||
Represents a node on the handler tree
|
||||
"""
|
||||
|
||||
name: str
|
||||
handler: typing.Optional[type[Handler]]
|
||||
children: collections.abc.MutableMapping[str, 'HandlerNode']
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f'HandlerNode({self.name}, {self.handler}, {self.children})'
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return str(self)
|
||||
|
||||
def tree(self, level: int = 0) -> str:
|
||||
"""
|
||||
Returns a string representation of the tree
|
||||
"""
|
||||
ret = f'{" " * level}{self.name} ({self.handler.__name__ if self.handler else "None"})\n'
|
||||
for child in self.children.values():
|
||||
ret += child.tree(level + 1)
|
||||
return ret
|
||||
|
||||
|
||||
class Dispatcher(View):
|
||||
"""
|
||||
This class is responsible of dispatching REST requests
|
||||
"""
|
||||
|
||||
# This attribute will contain all paths--> handler relations, filled at Initialized method
|
||||
services: typing.ClassVar[HandlerNode] = HandlerNode('', None, {})
|
||||
root_node: typing.ClassVar[types.rest.HandlerNode] = types.rest.HandlerNode('', None, None, {})
|
||||
|
||||
@method_decorator(csrf_exempt)
|
||||
def dispatch(
|
||||
self, request: 'http.request.HttpRequest', *args: typing.Any, **kwargs: typing.Any
|
||||
) -> 'http.HttpResponse':
|
||||
def dispatch(self, request: 'http.request.HttpRequest', path: str) -> 'http.HttpResponse':
|
||||
"""
|
||||
Processes the REST request and routes it wherever it needs to be routed
|
||||
"""
|
||||
request = typing.cast('ExtendedHttpRequestWithUser', request) # Reconverting to typed request
|
||||
if not hasattr(request, 'user'):
|
||||
raise exceptions.rest.HandlerError('Request does not have a user, cannot process request')
|
||||
|
||||
# Remove session from request, so response middleware do nothing with this
|
||||
del request.session
|
||||
|
||||
# Now we extract method and possible variables from path
|
||||
path: list[str] = kwargs['arguments'].split('/')
|
||||
del kwargs['arguments']
|
||||
# path: list[str] = kwargs['arguments'].split('/')
|
||||
# path = kwargs['arguments']
|
||||
# del kwargs['arguments']
|
||||
|
||||
# Transverse service nodes, so we can locate class processing this path
|
||||
service = Dispatcher.services
|
||||
full_path_lst: list[str] = []
|
||||
# Guess content type from content type header (post) or ".xxx" to method
|
||||
# # Transverse service nodes, so we can locate class processing this path
|
||||
# service = Dispatcher.services
|
||||
# full_path_lst: list[str] = []
|
||||
# # Guess content type from content type header (post) or ".xxx" to method
|
||||
content_type: str = request.META.get('CONTENT_TYPE', 'application/json').split(';')[0]
|
||||
|
||||
while path:
|
||||
clean_path = path[0]
|
||||
# Skip empty path elements, so /x/y == /x////y for example (due to some bugs detected on some clients)
|
||||
if not clean_path:
|
||||
path = path[1:]
|
||||
continue
|
||||
handler_node = Dispatcher.root_node.find_path(path)
|
||||
if not handler_node:
|
||||
return http.HttpResponseNotFound('Service not found', content_type="text/plain")
|
||||
|
||||
if clean_path in service.children: # if we have a node for this path, walk down
|
||||
service = service.children[clean_path]
|
||||
full_path_lst.append(path[0]) # Add this path to full path
|
||||
path = path[1:] # Remove first part of path
|
||||
else:
|
||||
break # If we don't have a node for this path, we are done
|
||||
|
||||
full_path = '/'.join(full_path_lst)
|
||||
logger.debug("REST request: %s (%s)", full_path, content_type)
|
||||
logger.debug("REST request: %s (%s)", handler_node, handler_node.full_path())
|
||||
|
||||
# Now, service points to the class that will process the request
|
||||
# We get the '' node, that is the "current" node, and get the class from it
|
||||
cls: typing.Optional[type[Handler]] = service.handler
|
||||
cls: typing.Optional[type[Handler]] = handler_node.handler
|
||||
if not cls:
|
||||
return http.HttpResponseNotFound('Method not found', content_type="text/plain")
|
||||
|
||||
@@ -146,42 +111,50 @@ class Dispatcher(View):
|
||||
if http_method not in ('get', 'post', 'put', 'delete'):
|
||||
return http.HttpResponseNotAllowed(['GET', 'POST', 'PUT', 'DELETE'], content_type="text/plain")
|
||||
|
||||
node_full_path: typing.Final[str] = handler_node.full_path()
|
||||
|
||||
# Path here has "remaining" path, that is, method part has been removed
|
||||
args = tuple(path)
|
||||
args = path[len(node_full_path) :].split('/')[1:] # First element is always empty, so we skip it
|
||||
|
||||
handler: typing.Optional[Handler] = None
|
||||
|
||||
try:
|
||||
handler = cls(
|
||||
request,
|
||||
full_path,
|
||||
node_full_path,
|
||||
http_method,
|
||||
processor.process_parameters(),
|
||||
*args,
|
||||
**kwargs,
|
||||
)
|
||||
processor.set_odata(handler.odata)
|
||||
operation: collections.abc.Callable[[], typing.Any] = getattr(handler, http_method)
|
||||
except processors.ParametersException as e:
|
||||
logger.debug('Path: %s', full_path)
|
||||
logger.debug(
|
||||
'Path: %s',
|
||||
)
|
||||
logger.debug('Error: %s', e)
|
||||
|
||||
log.log_operation(handler, 400, types.log.LogLevel.ERROR)
|
||||
return http.HttpResponseBadRequest(
|
||||
f'Invalid parameters invoking {full_path}: {e}',
|
||||
f'Invalid parameters invoking {handler_node.full_path()}: {e}',
|
||||
content_type="text/plain",
|
||||
)
|
||||
except AttributeError:
|
||||
allowed_methods: list[str] = [n for n in ['get', 'post', 'put', 'delete'] if hasattr(handler, n)]
|
||||
log.log_operation(handler, 405, types.log.LogLevel.ERROR)
|
||||
return http.HttpResponseNotAllowed(allowed_methods, content_type="text/plain")
|
||||
return http.HttpResponseNotAllowed(
|
||||
allowed_methods, content=b'{"error": "Invalid method"}', content_type="application/json"
|
||||
)
|
||||
except exceptions.rest.AccessDenied:
|
||||
log.log_operation(handler, 403, types.log.LogLevel.ERROR)
|
||||
return http.HttpResponseForbidden('access denied', content_type="text/plain")
|
||||
return http.HttpResponseForbidden(b'{"error": "Access denied"}', content_type="application/json")
|
||||
except Exception:
|
||||
log.log_operation(handler, 500, types.log.LogLevel.ERROR)
|
||||
logger.exception('error accessing attribute')
|
||||
logger.debug('Getting attribute %s for %s', http_method, full_path)
|
||||
return http.HttpResponseServerError('Unexcepected error', content_type="text/plain")
|
||||
logger.debug('Getting attribute %s for %s', http_method, handler_node.full_path())
|
||||
return http.HttpResponseServerError(
|
||||
b'{"error": "Unexpected error"}', content_type="application/json"
|
||||
)
|
||||
|
||||
# Invokes the handler's operation, add headers to response and returns
|
||||
try:
|
||||
@@ -199,7 +172,7 @@ class Dispatcher(View):
|
||||
),
|
||||
)
|
||||
else:
|
||||
response = processor.get_response(response)
|
||||
response = processor.get_response(response)
|
||||
# Set response headers
|
||||
response['UDS-Version'] = f'{consts.system.VERSION};{consts.system.VERSION_STAMP}'
|
||||
response['Response-Stamp'] = sql_stamp_seconds()
|
||||
@@ -210,33 +183,35 @@ class Dispatcher(View):
|
||||
# Exceptiol will also be logged, but with ERROR level
|
||||
log.log_operation(handler, response.status_code, types.log.LogLevel.INFO)
|
||||
return response
|
||||
except exceptions.rest.RequestError as e:
|
||||
log.log_operation(handler, 400, types.log.LogLevel.ERROR)
|
||||
return http.HttpResponseBadRequest(str(e), content_type="text/plain")
|
||||
except exceptions.rest.ResponseError as e:
|
||||
log.log_operation(handler, 500, types.log.LogLevel.ERROR)
|
||||
return http.HttpResponseServerError(str(e), content_type="text/plain")
|
||||
# Note that the order of exceptions is important
|
||||
# because some exceptions are subclasses of others
|
||||
except exceptions.rest.NotSupportedError as e:
|
||||
log.log_operation(handler, 501, types.log.LogLevel.ERROR)
|
||||
return http.HttpResponseBadRequest(str(e), content_type="text/plain")
|
||||
return http.HttpResponseBadRequest(f'{{"error": "{e}"}}'.encode(), content_type="application/json")
|
||||
except exceptions.rest.AccessDenied as e:
|
||||
log.log_operation(handler, 403, types.log.LogLevel.ERROR)
|
||||
return http.HttpResponseForbidden(str(e), content_type="text/plain")
|
||||
return http.HttpResponseForbidden(f'{{"error": "{e}"}}'.encode(), content_type="application/json")
|
||||
except exceptions.rest.NotFound as e:
|
||||
log.log_operation(handler, 404, types.log.LogLevel.ERROR)
|
||||
return http.HttpResponseNotFound(str(e), content_type="text/plain")
|
||||
return http.HttpResponseNotFound(f'{{"error": "{e}"}}'.encode(), content_type="application/json")
|
||||
except exceptions.rest.RequestError as e:
|
||||
log.log_operation(handler, 400, types.log.LogLevel.ERROR)
|
||||
return http.HttpResponseBadRequest(f'{{"error": "{e}"}}'.encode(), content_type="application/json")
|
||||
except exceptions.rest.ResponseError as e:
|
||||
log.log_operation(handler, 500, types.log.LogLevel.ERROR)
|
||||
return http.HttpResponseServerError(f'{{"error": "{e}"}}'.encode(), content_type="application/json")
|
||||
except exceptions.rest.HandlerError as e:
|
||||
log.log_operation(handler, 500, types.log.LogLevel.ERROR)
|
||||
return http.HttpResponseBadRequest(str(e), content_type="text/plain")
|
||||
return http.HttpResponseBadRequest(f'{{"error": "{e}"}}'.encode(), content_type="application/json")
|
||||
except Exception as e:
|
||||
log.log_operation(handler, 500, types.log.LogLevel.ERROR)
|
||||
# Get ecxeption backtrace
|
||||
trace_back = traceback.format_exc()
|
||||
logger.error('Exception processing request: %s', full_path)
|
||||
logger.error('Exception processing request: %s', handler_node.full_path())
|
||||
for i in trace_back.splitlines():
|
||||
logger.error('* %s', i)
|
||||
|
||||
return http.HttpResponseServerError(str(e), content_type="text/plain")
|
||||
return http.HttpResponseServerError(f'{{"error": "{e}"}}'.encode(), content_type="application/json")
|
||||
|
||||
@staticmethod
|
||||
def register_handler(type_: type[Handler]) -> None:
|
||||
@@ -244,26 +219,26 @@ class Dispatcher(View):
|
||||
Method to register a class as a REST service
|
||||
param type_: Class to be registered
|
||||
"""
|
||||
if not type_.name:
|
||||
if not type_.NAME:
|
||||
name = sys.intern(type_.__name__.lower())
|
||||
else:
|
||||
name = type_.name
|
||||
name = type_.NAME
|
||||
|
||||
# Fill the service_node tree with the class
|
||||
service_node = Dispatcher.services # Root path
|
||||
service_node = Dispatcher.root_node # Root path
|
||||
# If path, ensure that the path exists on the tree
|
||||
if type_.path:
|
||||
logger.info('Path: /%s/%s', type_.path, name)
|
||||
for k in type_.path.split('/'):
|
||||
if type_.PATH:
|
||||
logger.info('Path: /%s/%s', type_.PATH, name)
|
||||
for k in type_.PATH.split('/'):
|
||||
intern_k = sys.intern(k)
|
||||
if intern_k not in service_node.children:
|
||||
service_node.children[intern_k] = HandlerNode(k, None, {})
|
||||
service_node.children[intern_k] = types.rest.HandlerNode(k, None, service_node, {})
|
||||
service_node = service_node.children[intern_k]
|
||||
else:
|
||||
logger.info('Path: /%s', name)
|
||||
|
||||
if name not in service_node.children:
|
||||
service_node.children[name] = HandlerNode(name, None, {})
|
||||
service_node.children[name] = types.rest.HandlerNode(name, None, service_node, {})
|
||||
|
||||
service_node.children[name] = dataclasses.replace(service_node.children[name], handler=type_)
|
||||
|
||||
@@ -279,11 +254,7 @@ class Dispatcher(View):
|
||||
module_name = __name__[: __name__.rfind('.')]
|
||||
|
||||
def checker(x: type[Handler]) -> bool:
|
||||
# only register if final class, no classes that have subclasses
|
||||
logger.debug(
|
||||
'Checking %s - %s - %s', x.__name__, issubclass(x, DetailHandler), x.__subclasses__() == []
|
||||
)
|
||||
return not issubclass(x, DetailHandler) and not x.__subclasses__()
|
||||
return not issubclass(x, rest_model.DetailHandler) and not x.__subclasses__()
|
||||
|
||||
# Register all subclasses of Handler
|
||||
modfinder.dynamically_load_and_register_packages(
|
||||
@@ -294,5 +265,7 @@ class Dispatcher(View):
|
||||
package_name='methods',
|
||||
)
|
||||
|
||||
logger.info('REST Handlers initialized')
|
||||
|
||||
|
||||
Dispatcher.initialize()
|
||||
|
||||
@@ -29,17 +29,21 @@
|
||||
"""
|
||||
Author: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
"""
|
||||
import abc
|
||||
import typing
|
||||
import logging
|
||||
import codecs
|
||||
import collections.abc
|
||||
|
||||
from django.contrib.sessions.backends.base import SessionBase
|
||||
from django.contrib.sessions.backends.db import SessionStore
|
||||
from django.db.models import QuerySet
|
||||
|
||||
from uds.core import consts, types
|
||||
from uds.core import consts, types, exceptions
|
||||
from uds.core.util.config import GlobalConfig
|
||||
from uds.core.auths.auth import root_user
|
||||
from uds.core.util import net
|
||||
from uds.core.util import net, query_db_filter, query_filter
|
||||
|
||||
from uds.models import Authenticator, User
|
||||
from uds.core.managers.crypto import CryptoManager
|
||||
|
||||
@@ -52,30 +56,23 @@ if typing.TYPE_CHECKING:
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
T = typing.TypeVar('T')
|
||||
|
||||
class Handler:
|
||||
class Handler(abc.ABC):
|
||||
"""
|
||||
REST requests handler base class
|
||||
"""
|
||||
|
||||
name: typing.ClassVar[typing.Optional[str]] = (
|
||||
NAME: typing.ClassVar[typing.Optional[str]] = (
|
||||
None # If name is not used, name will be the class name in lower case
|
||||
)
|
||||
path: typing.ClassVar[typing.Optional[str]] = (
|
||||
PATH: typing.ClassVar[typing.Optional[str]] = (
|
||||
None # Path for this method, so we can do /auth/login, /auth/logout, /auth/auths in a simple way
|
||||
)
|
||||
authenticated: typing.ClassVar[bool] = (
|
||||
True # By default, all handlers needs authentication. Will be overwriten if needs_admin or needs_staff,
|
||||
)
|
||||
needs_admin: typing.ClassVar[bool] = (
|
||||
False # By default, the methods will be accessible by anyone if nothing else indicated
|
||||
)
|
||||
needs_staff: typing.ClassVar[bool] = False # By default, staff
|
||||
|
||||
# For implementing help
|
||||
# A list of pairs of (path, help) for subpaths on this handler
|
||||
help_paths: typing.ClassVar[list[tuple[str, str]]] = []
|
||||
help_text: typing.ClassVar[str] = 'No help available'
|
||||
ROLE: typing.ClassVar[consts.UserRole] = consts.UserRole.USER # By default, only users can access
|
||||
|
||||
REST_API_INFO: typing.ClassVar[types.rest.api.RestApiInfo] = types.rest.api.RestApiInfo()
|
||||
|
||||
_request: 'ExtendedHttpRequestWithUser' # It's a modified HttpRequest
|
||||
_path: str
|
||||
@@ -85,11 +82,13 @@ class Handler:
|
||||
] # This is a deserliazied object from request. Can be anything as 'a' or {'a': 1} or ....
|
||||
# These are the "path" split by /, that is, the REST invocation arguments
|
||||
_args: list[str]
|
||||
_kwargs: dict[str, typing.Any] # This are the "path" split by /, that is, the REST invocation arguments
|
||||
_headers: dict[str, str] # Note: These are "output" headers, not input headers (input headers can be retrieved from request)
|
||||
_headers: dict[
|
||||
str, str
|
||||
] # Note: These are "output" headers, not input headers (input headers can be retrieved from request)
|
||||
_session: typing.Optional[SessionStore]
|
||||
_auth_token: typing.Optional[str]
|
||||
_user: 'User'
|
||||
_odata: 'types.rest.api.ODataParams' # OData parameters, if any
|
||||
|
||||
# The dispatcher proceses the request and calls the method with the same name as the operation
|
||||
# currently, only 'get', 'post, 'put' y 'delete' are supported
|
||||
@@ -102,25 +101,16 @@ class Handler:
|
||||
method: str,
|
||||
params: dict[str, typing.Any],
|
||||
*args: str,
|
||||
**kwargs: typing.Any,
|
||||
):
|
||||
logger.debug('Data: %s %s %s', self.__class__, self.needs_admin, self.authenticated)
|
||||
if (
|
||||
self.needs_admin or self.needs_staff
|
||||
) and not self.authenticated: # If needs_admin, must also be authenticated
|
||||
raise Exception(
|
||||
f'class {self.__class__} is not authenticated but has needs_admin or needs_staff set!!'
|
||||
)
|
||||
|
||||
self._request = request
|
||||
self._path = path
|
||||
self._operation = method
|
||||
self._params = params
|
||||
self._args = list(args) # copy of args
|
||||
self._kwargs = kwargs
|
||||
self._headers = {}
|
||||
self._auth_token = None
|
||||
if self.authenticated: # Only retrieve auth related data on authenticated handlers
|
||||
|
||||
if self.ROLE.needs_authentication:
|
||||
try:
|
||||
self._auth_token = self._request.headers.get(consts.auth.AUTH_TOKEN_HEADER, '')
|
||||
self._session = SessionStore(session_key=self._auth_token)
|
||||
@@ -133,16 +123,14 @@ class Handler:
|
||||
if self._auth_token is None:
|
||||
raise AccessDenied()
|
||||
|
||||
if self.needs_admin and not self.is_admin():
|
||||
raise AccessDenied()
|
||||
|
||||
if self.needs_staff and not self.is_staff_member():
|
||||
raise AccessDenied()
|
||||
try:
|
||||
self._user = self.get_user()
|
||||
except Exception as e:
|
||||
# Maybe the user was deleted, so access is denied
|
||||
raise AccessDenied() from e
|
||||
|
||||
if not self._user.can_access(self.ROLE):
|
||||
raise AccessDenied()
|
||||
else:
|
||||
self._user = User() # Empty user for non authenticated handlers
|
||||
self._user.state = types.states.State.ACTIVE # Ensure it's active
|
||||
@@ -150,6 +138,8 @@ class Handler:
|
||||
if self._user and self._user.state != types.states.State.ACTIVE:
|
||||
raise AccessDenied()
|
||||
|
||||
self._odata = types.rest.api.ODataParams.from_dict(self.query_params())
|
||||
|
||||
def headers(self) -> dict[str, str]:
|
||||
"""
|
||||
Returns the headers of the REST request (all)
|
||||
@@ -159,22 +149,34 @@ class Handler:
|
||||
def header(self, header_name: str) -> typing.Optional[str]:
|
||||
"""
|
||||
Get's an specific header name from REST request
|
||||
|
||||
|
||||
Args:
|
||||
header_name: Name of header to retrieve
|
||||
|
||||
|
||||
Returns:
|
||||
Value of header or None if not found
|
||||
"""
|
||||
return self._headers.get(header_name)
|
||||
|
||||
def add_header(self, header: str, value: str) -> None:
|
||||
def query_params(self) -> dict[str, str | list[str]]:
|
||||
"""
|
||||
Returns the query parameters from the request (GET parameters)
|
||||
|
||||
Note:
|
||||
Dispatcher has it own parameters processor that fills our "_params".
|
||||
The processor tries to get from POST body json (or whatever), and, if not available
|
||||
from GET. So maybe this returns same values as _params, but, this always are GET parameters.
|
||||
Useful for odata fields ($filter, $skip, $top, $orderby)
|
||||
"""
|
||||
return {k: v[0] if len(v) == 1 else v for k, v in self._request.GET.lists()}
|
||||
|
||||
def add_header(self, header: str, value: str | int) -> None:
|
||||
"""
|
||||
Inserts a new header inside the headers list
|
||||
:param header: name of header to insert
|
||||
:param value: value of header
|
||||
"""
|
||||
self._headers[header] = value
|
||||
self._headers[header] = str(value)
|
||||
|
||||
def delete_header(self, header: str) -> None:
|
||||
"""
|
||||
@@ -207,6 +209,10 @@ class Handler:
|
||||
"""
|
||||
return self._args
|
||||
|
||||
@property
|
||||
def odata(self) -> 'types.rest.api.ODataParams':
|
||||
return self._odata
|
||||
|
||||
@property
|
||||
def session(self) -> 'SessionStore':
|
||||
if self._session is None:
|
||||
@@ -228,8 +234,6 @@ class Handler:
|
||||
password: str,
|
||||
locale: str,
|
||||
platform: str,
|
||||
is_admin: bool,
|
||||
staff_member: bool,
|
||||
scrambler: str,
|
||||
) -> None:
|
||||
"""
|
||||
@@ -241,11 +245,10 @@ class Handler:
|
||||
:param is_admin: If user is considered admin or not
|
||||
:param staff_member: If is considered as staff member
|
||||
"""
|
||||
if is_admin:
|
||||
staff_member = True # Make admins also staff members :-)
|
||||
|
||||
# crypt password and convert to base64
|
||||
passwd = codecs.encode(CryptoManager().symmetric_encrypt(password, scrambler), 'base64').decode()
|
||||
passwd = codecs.encode(
|
||||
CryptoManager.manager().symmetric_encrypt(password, scrambler), 'base64'
|
||||
).decode()
|
||||
|
||||
session['REST'] = {
|
||||
'auth': id_auth,
|
||||
@@ -253,8 +256,6 @@ class Handler:
|
||||
'password': passwd,
|
||||
'locale': locale,
|
||||
'platform': platform,
|
||||
'is_admin': is_admin,
|
||||
'staff_member': staff_member,
|
||||
}
|
||||
|
||||
def gen_auth_token(
|
||||
@@ -264,8 +265,6 @@ class Handler:
|
||||
password: str,
|
||||
locale: str,
|
||||
platform: str,
|
||||
is_admin: bool,
|
||||
staf_member: bool,
|
||||
scrambler: str,
|
||||
) -> str:
|
||||
"""
|
||||
@@ -285,8 +284,6 @@ class Handler:
|
||||
password,
|
||||
locale,
|
||||
platform,
|
||||
is_admin,
|
||||
staf_member,
|
||||
scrambler,
|
||||
)
|
||||
session.save()
|
||||
@@ -393,3 +390,67 @@ class Handler:
|
||||
if name in self._params:
|
||||
return self._params[name]
|
||||
return ''
|
||||
|
||||
def filter_queryset(self, qs: QuerySet[typing.Any]) -> QuerySet[typing.Any]:
|
||||
"""
|
||||
Filters the queryset based on odata
|
||||
"""
|
||||
# OData filter
|
||||
if self.odata.filter:
|
||||
try:
|
||||
qs = query_db_filter.exec_query(self.odata.filter, qs)
|
||||
except ValueError as e:
|
||||
raise exceptions.rest.RequestError(f'Invalid odata filter: {e}') from e
|
||||
|
||||
for order in self.odata.orderby:
|
||||
qs = qs.order_by(order)
|
||||
|
||||
if self.odata.start is not None:
|
||||
qs = qs[self.odata.start :]
|
||||
if self.odata.limit is not None:
|
||||
qs = qs[: self.odata.limit]
|
||||
|
||||
# Get total items and set it on X-Total-Count
|
||||
try:
|
||||
total_items = qs.count()
|
||||
self.add_header('X-Total-Count', total_items)
|
||||
except Exception as e:
|
||||
raise exceptions.rest.RequestError(f'Invalid odata: {e}')
|
||||
|
||||
return qs
|
||||
|
||||
def filter_data(self, data: collections.abc.Iterable[T]) -> list[T]:
|
||||
"""
|
||||
Filters the dict base on the currnet odata
|
||||
"""
|
||||
if self.odata.filter:
|
||||
try:
|
||||
data = list(query_filter.exec_query(self.odata.filter, data))
|
||||
|
||||
except ValueError as e:
|
||||
raise exceptions.rest.RequestError(f'Invalid odata filter: {e}') from e
|
||||
else:
|
||||
data = list(data)
|
||||
|
||||
# Get total items and set it on X-Total-Count
|
||||
try:
|
||||
self.add_header('X-Total-Count', len(data))
|
||||
except Exception as e:
|
||||
raise exceptions.rest.RequestError(f'Invalid odata: {e}')
|
||||
|
||||
return data
|
||||
|
||||
|
||||
@classmethod
|
||||
def api_components(cls: type[typing.Self]) -> types.rest.api.Components:
|
||||
"""
|
||||
Returns the types that should be registered
|
||||
"""
|
||||
return types.rest.api.Components()
|
||||
|
||||
@classmethod
|
||||
def api_paths(cls: type[typing.Self], path: str, tags: list[str], security: str) -> dict[str, types.rest.api.PathItem]:
|
||||
"""
|
||||
Returns the API operations that should be registered
|
||||
"""
|
||||
return {}
|
||||
|
||||
@@ -30,68 +30,110 @@
|
||||
"""
|
||||
@Author: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
"""
|
||||
import dataclasses
|
||||
import datetime
|
||||
import logging
|
||||
import typing
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils import timezone
|
||||
|
||||
from uds.REST.model import ModelHandler
|
||||
from uds.core import types
|
||||
import uds.core.types.permissions
|
||||
from uds.core.util import permissions, ensure
|
||||
from uds.core.util import permissions, ensure, ui as ui_utils
|
||||
from uds.models import Account
|
||||
from .accountsusage import AccountsUsage
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from django.db.models import Model
|
||||
from django.db import models
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Enclosed methods under /item path
|
||||
|
||||
|
||||
class Accounts(ModelHandler):
|
||||
@dataclasses.dataclass
|
||||
class AccountItem(types.rest.BaseRestItem):
|
||||
id: str
|
||||
name: str
|
||||
tags: typing.List[str]
|
||||
comments: str
|
||||
time_mark: typing.Optional[datetime.datetime]
|
||||
permission: int
|
||||
|
||||
|
||||
class Accounts(ModelHandler[AccountItem]):
|
||||
"""
|
||||
Processes REST requests about accounts
|
||||
"""
|
||||
|
||||
model = Account
|
||||
detail = {'usage': AccountsUsage}
|
||||
MODEL = Account
|
||||
DETAIL = {'usage': AccountsUsage}
|
||||
|
||||
custom_methods = [('clear', True), ('timemark', True)]
|
||||
|
||||
save_fields = ['name', 'comments', 'tags']
|
||||
|
||||
table_title = _('Accounts')
|
||||
table_fields = [
|
||||
{'name': {'title': _('Name'), 'visible': True}},
|
||||
{'comments': {'title': _('Comments')}},
|
||||
{'time_mark': {'title': _('Time mark'), 'type': 'callback'}},
|
||||
{'tags': {'title': _('tags'), 'visible': False}},
|
||||
CUSTOM_METHODS = [
|
||||
types.rest.ModelCustomMethod('clear', True),
|
||||
types.rest.ModelCustomMethod('timemark', True),
|
||||
]
|
||||
|
||||
def item_as_dict(self, item: 'Model') -> types.rest.ItemDictType:
|
||||
item = ensure.is_instance(item, Account)
|
||||
return {
|
||||
'id': item.uuid,
|
||||
'name': item.name,
|
||||
'tags': [tag.tag for tag in item.tags.all()],
|
||||
'comments': item.comments,
|
||||
'time_mark': item.time_mark,
|
||||
'permission': permissions.effective_permissions(self._user, item),
|
||||
}
|
||||
FIELDS_TO_SAVE = ['name', 'comments', 'tags']
|
||||
|
||||
def get_gui(self, type_: str) -> list[typing.Any]:
|
||||
return self.add_default_fields([], ['name', 'comments', 'tags'])
|
||||
TABLE = (
|
||||
ui_utils.TableBuilder(_('Accounts'))
|
||||
.text_column(name='name', title=_('Name'))
|
||||
.text_column(name='comments', title=_('Comments'))
|
||||
.datetime_column(name='time_mark', title=_('Time mark'))
|
||||
.text_column(name='tags', title=_('tags'), visible=False)
|
||||
.build()
|
||||
)
|
||||
|
||||
def timemark(self, item: 'Model') -> typing.Any:
|
||||
# Rest api related information to complete the auto-generated API
|
||||
REST_API_INFO = types.rest.api.RestApiInfo(
|
||||
typed=types.rest.api.RestApiInfoGuiType.SINGLE_TYPE,
|
||||
)
|
||||
|
||||
def get_item(self, item: 'models.Model') -> AccountItem:
|
||||
item = ensure.is_instance(item, Account)
|
||||
item.time_mark = datetime.datetime.now()
|
||||
return AccountItem(
|
||||
id=item.uuid,
|
||||
name=item.name,
|
||||
tags=[tag.tag for tag in item.tags.all()],
|
||||
comments=item.comments,
|
||||
time_mark=item.time_mark,
|
||||
permission=permissions.effective_permissions(self._user, item),
|
||||
)
|
||||
|
||||
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
|
||||
return (
|
||||
ui_utils.GuiBuilder()
|
||||
.add_stock_field(types.rest.stock.StockField.NAME)
|
||||
.add_stock_field(types.rest.stock.StockField.COMMENTS)
|
||||
.add_stock_field(types.rest.stock.StockField.TAGS)
|
||||
).build()
|
||||
|
||||
def timemark(self, item: 'models.Model') -> typing.Any:
|
||||
"""
|
||||
API:
|
||||
Generates a time mark associated with the account.
|
||||
This is useful to easily identify when the account data was last updated.
|
||||
(For example, one user enters an service, we get the usage data and "timemark" it, later read again
|
||||
and we can identify that all data before this timemark has already been processed)
|
||||
|
||||
Arguments:
|
||||
item: Account to timemark
|
||||
|
||||
"""
|
||||
item = ensure.is_instance(item, Account)
|
||||
item.time_mark = timezone.localtime()
|
||||
item.save()
|
||||
return ''
|
||||
|
||||
def clear(self, item: 'Model') -> typing.Any:
|
||||
def clear(self, item: 'models.Model') -> typing.Any:
|
||||
"""
|
||||
Api documentation for the method. From here, will be used by the documentation generator
|
||||
Always starts with API:
|
||||
API:
|
||||
Clears all usage associated with the account
|
||||
"""
|
||||
item = ensure.is_instance(item, Account)
|
||||
self.ensure_has_access(item, uds.core.types.permissions.PermissionType.MANAGEMENT)
|
||||
self.check_access(item, uds.core.types.permissions.PermissionType.MANAGEMENT)
|
||||
return item.usages.filter(user_service=None).delete()
|
||||
|
||||
@@ -30,78 +30,95 @@
|
||||
"""
|
||||
Author: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
"""
|
||||
import dataclasses
|
||||
import datetime
|
||||
import logging
|
||||
import typing
|
||||
|
||||
from django.utils.translation import gettext as _
|
||||
from django.db.models import Model
|
||||
|
||||
from uds.core import exceptions, types
|
||||
from uds.core.util import ensure, permissions
|
||||
from uds.core.types.rest import TableInfo
|
||||
from uds.core.util import ensure, permissions, ui as ui_utils
|
||||
from uds.core.util.model import process_uuid
|
||||
from uds.models import Account, AccountUsage
|
||||
from uds.REST.model import DetailHandler
|
||||
|
||||
# Not imported at runtime, just for type checking
|
||||
if typing.TYPE_CHECKING:
|
||||
from django.db.models import Model
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AccountsUsage(DetailHandler): # pylint: disable=too-many-public-methods
|
||||
@dataclasses.dataclass
|
||||
class AccountItem(types.rest.BaseRestItem):
|
||||
uuid: str
|
||||
pool_uuid: str
|
||||
pool_name: str
|
||||
user_uuid: str
|
||||
user_name: str
|
||||
start: datetime.datetime
|
||||
end: datetime.datetime
|
||||
running: bool
|
||||
elapsed: str
|
||||
elapsed_timemark: str
|
||||
permission: int
|
||||
|
||||
|
||||
class AccountsUsage(DetailHandler[AccountItem]): # pylint: disable=too-many-public-methods
|
||||
"""
|
||||
Detail handler for Services, whose parent is a Provider
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def usage_to_dict(item: 'AccountUsage', perm: int) -> dict[str, typing.Any]:
|
||||
def usage_to_dict(item: 'AccountUsage', perm: int) -> AccountItem:
|
||||
"""
|
||||
Convert an account usage to a dictionary
|
||||
:param item: Account usage item (db)
|
||||
:param perm: permission
|
||||
"""
|
||||
return {
|
||||
'uuid': item.uuid,
|
||||
'pool_uuid': item.pool_uuid,
|
||||
'pool_name': item.pool_name,
|
||||
'user_uuid': item.user_uuid,
|
||||
'user_name': item.user_name,
|
||||
'start': item.start,
|
||||
'end': item.end,
|
||||
'running': item.user_service is not None,
|
||||
'elapsed': item.elapsed,
|
||||
'elapsed_timemark': item.elapsed_timemark,
|
||||
'permission': perm,
|
||||
}
|
||||
return AccountItem(
|
||||
uuid=item.uuid,
|
||||
pool_uuid=item.pool_uuid,
|
||||
pool_name=item.pool_name,
|
||||
user_uuid=item.user_uuid,
|
||||
user_name=item.user_name,
|
||||
start=item.start,
|
||||
end=item.end,
|
||||
running=item.user_service is not None,
|
||||
elapsed=item.elapsed,
|
||||
elapsed_timemark=item.elapsed_timemark,
|
||||
permission=perm,
|
||||
)
|
||||
|
||||
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
|
||||
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ItemsResult[AccountItem]:
|
||||
parent = ensure.is_instance(parent, Account)
|
||||
# Check what kind of access do we have to parent provider
|
||||
perm = permissions.effective_permissions(self._user, parent)
|
||||
try:
|
||||
if not item:
|
||||
return [AccountsUsage.usage_to_dict(k, perm) for k in parent.usages.all()]
|
||||
return [AccountsUsage.usage_to_dict(k, perm) for k in self.filter_queryset(parent.usages.all())]
|
||||
k = parent.usages.get(uuid=process_uuid(item))
|
||||
return AccountsUsage.usage_to_dict(k, perm)
|
||||
except Exception:
|
||||
logger.exception('itemId %s', item)
|
||||
raise self.invalid_item_response()
|
||||
raise exceptions.rest.NotFound(_('Account usage not found: {}').format(item)) from None
|
||||
|
||||
def get_fields(self, parent: 'Model') -> list[typing.Any]:
|
||||
return [
|
||||
{'pool_name': {'title': _('Pool name')}},
|
||||
{'user_name': {'title': _('User name')}},
|
||||
{'running': {'title': _('Running')}},
|
||||
{'start': {'title': _('Starts'), 'type': 'datetime'}},
|
||||
{'end': {'title': _('Ends'), 'type': 'datetime'}},
|
||||
{'elapsed': {'title': _('Elapsed')}},
|
||||
{'elapsed_timemark': {'title': _('Elapsed timemark')}},
|
||||
]
|
||||
def get_table(self, parent: 'Model') -> TableInfo:
|
||||
parent = ensure.is_instance(parent, Account)
|
||||
return (
|
||||
ui_utils.TableBuilder(_('Usages of {0}').format(parent.name))
|
||||
.text_column(name='pool_name', title=_('Pool name'))
|
||||
.text_column(name='user_name', title=_('User name'))
|
||||
.text_column(name='running', title=_('Running'))
|
||||
.datetime_column(name='start', title=_('Starts'))
|
||||
.datetime_column(name='end', title=_('Ends'))
|
||||
.text_column(name='elapsed', title=_('Elapsed'))
|
||||
.datetime_column(name='elapsed_timemark', title=_('Elapsed timemark'))
|
||||
.row_style(prefix='row-running-', field='running')
|
||||
.build()
|
||||
)
|
||||
|
||||
def get_row_style(self, parent: 'Model') -> types.ui.RowStyleInfo:
|
||||
return types.ui.RowStyleInfo(prefix='row-running-', field='running')
|
||||
|
||||
def save_item(self, parent: 'Model', item: typing.Optional[str]) -> None:
|
||||
def save_item(self, parent: 'Model', item: typing.Optional[str]) -> AccountItem:
|
||||
raise exceptions.rest.RequestError('Accounts usage cannot be edited')
|
||||
|
||||
def delete_item(self, parent: 'Model', item: str) -> None:
|
||||
@@ -111,12 +128,5 @@ class AccountsUsage(DetailHandler): # pylint: disable=too-many-public-methods
|
||||
usage = parent.usages.get(uuid=process_uuid(item))
|
||||
usage.delete()
|
||||
except Exception:
|
||||
logger.exception('Exception')
|
||||
raise self.invalid_item_response()
|
||||
|
||||
def get_title(self, parent: 'Model') -> str:
|
||||
parent = ensure.is_instance(parent, Account)
|
||||
try:
|
||||
return _('Usages of {0}').format(parent.name)
|
||||
except Exception:
|
||||
return _('Current usages')
|
||||
logger.error('Error deleting account usage %s from %s', item, parent)
|
||||
raise exceptions.rest.NotFound(_('Account usage not found: {}').format(item)) from None
|
||||
|
||||
@@ -30,68 +30,88 @@
|
||||
"""
|
||||
Author: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
"""
|
||||
import dataclasses
|
||||
import datetime
|
||||
import logging
|
||||
import typing
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.db import models
|
||||
|
||||
from uds.core import types, consts
|
||||
from uds.core.types import permissions
|
||||
from uds.core.util import ensure
|
||||
from uds.core.util import ensure, ui as ui_utils
|
||||
from uds.core.util.log import LogLevel
|
||||
from uds.models import Server
|
||||
from uds.core.exceptions.rest import NotFound, RequestError
|
||||
from uds.REST.model import ModelHandler
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from django.db.models import Model
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Enclosed methods under /osm path
|
||||
|
||||
@dataclasses.dataclass
|
||||
class ActorTokenItem(types.rest.BaseRestItem):
|
||||
id: str
|
||||
name: str
|
||||
stamp: datetime.datetime
|
||||
username: str
|
||||
ip: str
|
||||
host: str
|
||||
hostname: str
|
||||
version: str
|
||||
pre_command: str
|
||||
post_command: str
|
||||
run_once_command: str
|
||||
log_level: str
|
||||
os: str
|
||||
|
||||
class ActorTokens(ModelHandler):
|
||||
model = Server
|
||||
model_filter = {'type': types.servers.ServerType.ACTOR}
|
||||
|
||||
table_title = _('Actor tokens')
|
||||
table_fields = [
|
||||
# {'token': {'title': _('Token')}},
|
||||
{'stamp': {'title': _('Date'), 'type': 'datetime'}},
|
||||
{'username': {'title': _('Issued by')}},
|
||||
{'host': {'title': _('Origin')}},
|
||||
{'version': {'title': _('Version')}},
|
||||
{'hostname': {'title': _('Hostname')}},
|
||||
{'pre_command': {'title': _('Pre-connect')}},
|
||||
{'post_command': {'title': _('Post-Configure')}},
|
||||
{'run_once_command': {'title': _('Run Once')}},
|
||||
{'log_level': {'title': _('Log level')}},
|
||||
{'os': {'title': _('OS')}},
|
||||
]
|
||||
class ActorTokens(ModelHandler[ActorTokenItem]):
|
||||
|
||||
def item_as_dict(self, item: 'Model') -> dict[str, typing.Any]:
|
||||
MODEL = Server
|
||||
FILTER = {'type': types.servers.ServerType.ACTOR}
|
||||
|
||||
TABLE = (
|
||||
ui_utils.TableBuilder(_('Actor tokens'))
|
||||
.datetime_column('stamp', _('Date'))
|
||||
.text_column('username', _('Issued by'))
|
||||
.text_column('host', _('Origin'))
|
||||
.text_column('version', _('Version'))
|
||||
.text_column('hostname', _('Hostname'))
|
||||
.text_column('pre_command', _('Pre-connect'))
|
||||
.text_column('post_command', _('Post-Configure'))
|
||||
.text_column('run_once_command', _('Run Once'))
|
||||
.text_column('log_level', _('Log level'))
|
||||
.text_column('os', _('OS'))
|
||||
.build()
|
||||
)
|
||||
|
||||
def get_item(self, item: 'models.Model') -> ActorTokenItem:
|
||||
item = ensure.is_instance(item, Server)
|
||||
data: dict[str, typing.Any] = item.data or {}
|
||||
if item.log_level < 10000: # Old log level, from actor, etc..
|
||||
log_level = LogLevel.from_actor_level(item.log_level).name
|
||||
else:
|
||||
log_level = LogLevel(item.log_level).name
|
||||
return {
|
||||
'id': item.token,
|
||||
'name': str(_('Token isued by {} from {}')).format(item.register_username, item.hostname or item.ip),
|
||||
'stamp': item.stamp,
|
||||
'username': item.register_username,
|
||||
'ip': item.ip,
|
||||
'host': f'{item.ip} - {data.get("mac")}',
|
||||
'hostname': item.hostname,
|
||||
'version': item.version,
|
||||
'pre_command': data.get('pre_command', ''),
|
||||
'post_command': data.get('post_command', ''),
|
||||
'run_once_command': data.get('run_once_command', ''),
|
||||
'log_level': log_level,
|
||||
'os': item.os_type,
|
||||
}
|
||||
return ActorTokenItem(
|
||||
id=item.token,
|
||||
name=str(_('Token isued by {} from {}')).format(
|
||||
item.register_username, item.hostname or item.ip
|
||||
),
|
||||
stamp=item.stamp,
|
||||
username=item.register_username,
|
||||
ip=item.ip,
|
||||
host=f'{item.ip} - {data.get("mac")}',
|
||||
hostname=item.hostname,
|
||||
version=item.version,
|
||||
pre_command=data.get('pre_command', ''),
|
||||
post_command=data.get('post_command', ''),
|
||||
run_once_command=data.get('run_once_command', ''),
|
||||
log_level=log_level,
|
||||
os=item.os_type,
|
||||
)
|
||||
|
||||
def delete(self) -> str:
|
||||
"""
|
||||
@@ -100,13 +120,13 @@ class ActorTokens(ModelHandler):
|
||||
if len(self._args) != 1:
|
||||
raise RequestError('Delete need one and only one argument')
|
||||
|
||||
self.ensure_has_access(
|
||||
self.model(), permissions.PermissionType.ALL, root=True
|
||||
self.check_access(
|
||||
self.MODEL(), permissions.PermissionType.ALL, root=True
|
||||
) # Must have write permissions to delete
|
||||
|
||||
try:
|
||||
self.model.objects.get(token=self._args[0]).delete()
|
||||
except self.model.DoesNotExist:
|
||||
self.MODEL.objects.get(token=self._args[0]).delete()
|
||||
except self.MODEL.DoesNotExist:
|
||||
raise NotFound('Element do not exists') from None
|
||||
|
||||
return consts.OK
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
"""
|
||||
Author: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
"""
|
||||
import enum
|
||||
import functools
|
||||
import logging
|
||||
import time
|
||||
@@ -64,16 +63,6 @@ logger = logging.getLogger(__name__)
|
||||
cache = Cache('actorv3')
|
||||
|
||||
|
||||
class NotifyActionType(enum.StrEnum):
|
||||
LOGIN = 'login'
|
||||
LOGOUT = 'logout'
|
||||
DATA = 'data'
|
||||
|
||||
@staticmethod
|
||||
def valid_names() -> list[str]:
|
||||
return [e.value for e in NotifyActionType]
|
||||
|
||||
|
||||
# Helpers
|
||||
def get_list_of_ids(handler: 'Handler') -> list[str]:
|
||||
"""
|
||||
@@ -145,8 +134,9 @@ def clear_failed_ip_counter(request: 'ExtendedHttpRequest') -> None:
|
||||
|
||||
|
||||
class ActorV3Action(Handler):
|
||||
authenticated = False # Actor requests are not authenticated normally
|
||||
path = 'actor/v3'
|
||||
ROLE = consts.UserRole.ANONYMOUS
|
||||
PATH = 'actor/v3'
|
||||
NAME = 'actorv3'
|
||||
|
||||
@staticmethod
|
||||
def actor_result(result: typing.Any = None, **kwargs: typing.Any) -> dict[str, typing.Any]:
|
||||
@@ -154,7 +144,7 @@ class ActorV3Action(Handler):
|
||||
|
||||
@staticmethod
|
||||
def set_comms_endpoint(userservice: UserService, ip: str, port: int, secret: str) -> None:
|
||||
userservice.set_comms_endpoint(f'https://{ip}:{port}/actor/{secret}')
|
||||
userservice.set_comms_info(f'https://{ip}:{port}/actor/{secret}', secret)
|
||||
|
||||
@staticmethod
|
||||
def actor_cert_result(key: str, certificate: str, password: str) -> dict[str, typing.Any]:
|
||||
@@ -197,7 +187,7 @@ class ActorV3Action(Handler):
|
||||
raise exceptions.rest.AccessDenied('Access denied')
|
||||
|
||||
# Some helpers
|
||||
def notify_service(self, action: NotifyActionType) -> None:
|
||||
def notify_service(self, action: types.rest.actor.NotifyActionType) -> None:
|
||||
"""
|
||||
Notifies the Service (not userservice) that an action has been performed
|
||||
|
||||
@@ -227,17 +217,17 @@ class ActorV3Action(Handler):
|
||||
is_remote = self._params.get('session_type', '')[:4] in ('xrdp', 'RDP-')
|
||||
|
||||
# Must be valid
|
||||
if action in (NotifyActionType.LOGIN, NotifyActionType.LOGOUT):
|
||||
if action in (types.rest.actor.NotifyActionType.LOGIN, types.rest.actor.NotifyActionType.LOGOUT):
|
||||
if not service_id: # For login/logout, we need a valid id
|
||||
raise Exception()
|
||||
# Notify Service that someone logged in/out
|
||||
|
||||
if action == NotifyActionType.LOGIN:
|
||||
if action == types.rest.actor.NotifyActionType.LOGIN:
|
||||
# Try to guess if this is a remote session
|
||||
service.process_login(service_id, remote_login=is_remote)
|
||||
elif action == NotifyActionType.LOGOUT:
|
||||
elif action == types.rest.actor.NotifyActionType.LOGOUT:
|
||||
service.process_logout(service_id, remote_login=is_remote)
|
||||
elif action == NotifyActionType.DATA:
|
||||
elif action == types.rest.actor.NotifyActionType.DATA:
|
||||
service.notify_data(service_id, self._params['data'])
|
||||
else:
|
||||
raise Exception('Invalid action')
|
||||
@@ -254,7 +244,7 @@ class Test(ActorV3Action):
|
||||
Tests UDS Broker actor connectivity & key
|
||||
"""
|
||||
|
||||
name = 'test'
|
||||
NAME = 'test'
|
||||
|
||||
def action(self) -> dict[str, typing.Any]:
|
||||
# First, try to locate an user service providing this token.
|
||||
@@ -266,7 +256,8 @@ class Test(ActorV3Action):
|
||||
token=self._params['token'], type=types.servers.ServerType.ACTOR
|
||||
) # Not assigned, because only needs check
|
||||
clear_failed_ip_counter(self._request)
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.info('Test host request: %s, %s', self._params, e)
|
||||
# Increase failed attempts
|
||||
increase_failed_ip_count(self._request)
|
||||
# And return test failed
|
||||
@@ -291,10 +282,9 @@ class Register(ActorV3Action):
|
||||
|
||||
"""
|
||||
|
||||
authenticated = True
|
||||
needs_staff = True
|
||||
ROLE = consts.UserRole.STAFF
|
||||
|
||||
name = 'register'
|
||||
NAME = 'register'
|
||||
|
||||
def post(self) -> dict[str, typing.Any]:
|
||||
# If already exists a token for this MAC, return it instead of creating a new one, and update the information...
|
||||
@@ -316,12 +306,24 @@ class Register(ActorV3Action):
|
||||
|
||||
# Actors does not support any SERVER API version in fact, they has their own interfaces on UserServices
|
||||
# This means that we can invoke its API from user_service, but not from server (The actor token is transformed as soon as initialized to a user service token)
|
||||
data = {
|
||||
'pre_command': self._params['pre_command'],
|
||||
'post_command': self._params['post_command'],
|
||||
'run_once_command': self._params['run_once_command'],
|
||||
'custom': self._params.get('custom', ''),
|
||||
}
|
||||
|
||||
# New model has "commands" field in data, old one not
|
||||
if 'commands' in self._params:
|
||||
commands = self._params['commands']
|
||||
data = {
|
||||
'pre_command': commands.get('pre_command') or '',
|
||||
'post_command': commands.get('post_command') or '',
|
||||
'run_once_command': commands.get('run_once_command') or '',
|
||||
'custom': self._params.get('custom') or '',
|
||||
}
|
||||
else:
|
||||
data = {
|
||||
'pre_command': self._params['pre_command'],
|
||||
'post_command': self._params['post_command'],
|
||||
'run_once_command': self._params['run_once_command'],
|
||||
'custom': self._params.get('custom', ''),
|
||||
}
|
||||
|
||||
if actor_token:
|
||||
# Update parameters
|
||||
# type is already set
|
||||
@@ -368,7 +370,7 @@ class Initialize(ActorV3Action):
|
||||
Also returns the id used for the rest of the actions. (Only this one will use actor key)
|
||||
"""
|
||||
|
||||
name = 'initialize'
|
||||
NAME = 'initialize'
|
||||
|
||||
def action(self) -> dict[str, typing.Any]:
|
||||
"""
|
||||
@@ -390,12 +392,12 @@ class Initialize(ActorV3Action):
|
||||
]
|
||||
}
|
||||
Will return on field "result" a dictinary with:
|
||||
* own_token: Optional[str] -> Personal uuid for the service (That, on service, will be used from now onwards). If None, there is no own_token
|
||||
* token: Optional[str] -> Personal uuid for the service (That, on service, will be used from now onwards). If None, there is no own_token
|
||||
* unique_id: Optional[str] -> If not None, unique id for the service (normally, mac adress of recognized interface)
|
||||
* os: Optional[dict] -> Data returned by os manager for setting up this service.
|
||||
Example:
|
||||
{
|
||||
'own_token' 'asdfasdfasdffsadfasfd'
|
||||
'token' 'asdfasdfasdffsadfasfd'
|
||||
'unique_id': 'aa:bb:cc:dd:ee:ff'
|
||||
'os': {
|
||||
'action': 'rename',
|
||||
@@ -507,7 +509,7 @@ class BaseReadyChange(ActorV3Action):
|
||||
Records the IP change of actor
|
||||
"""
|
||||
|
||||
name = 'notused' # Not really important, this is not a "leaf" class and will not be directly available
|
||||
NAME = 'notused' # Not really important, this is not a "leaf" class and will not be directly available
|
||||
|
||||
def action(self) -> dict[str, typing.Any]:
|
||||
"""
|
||||
@@ -521,8 +523,10 @@ class BaseReadyChange(ActorV3Action):
|
||||
This method will also regenerater the public-private key pair for client, that will be needed for the new ip
|
||||
|
||||
Returns: {
|
||||
private_key: str -> Generated private key, PEM
|
||||
server_certificate: str -> Generated public key, PEM
|
||||
key: str -> Generated private key, PEM
|
||||
certificate: str -> Generated public key, PEM
|
||||
password: str -> Password for private key
|
||||
ciphers: str -> Ciphers that server supports (could be empty, so default of python requests will be used)
|
||||
}
|
||||
"""
|
||||
logger.debug('Args: %s, Params: %s', self._args, self._params)
|
||||
@@ -553,7 +557,9 @@ class BaseReadyChange(ActorV3Action):
|
||||
) # Currently, no data is received for os manager
|
||||
|
||||
# Generates a certificate and send it to client.
|
||||
private_key, cert, password = security.create_self_signed_cert(self._params['ip'])
|
||||
# Password will be removed on a release after 5.0 as it is useful
|
||||
# Currently we have to maintain it for compat with older actors
|
||||
private_key, cert, password = security.create_self_signed_cert(self._params['ip'], with_password=True)
|
||||
# Store certificate with userService
|
||||
userservice.properties['cert'] = cert
|
||||
userservice.properties['priv'] = private_key
|
||||
@@ -567,7 +573,7 @@ class IpChange(BaseReadyChange):
|
||||
Processses IP Change.
|
||||
"""
|
||||
|
||||
name = 'ipchange'
|
||||
NAME = 'ipchange'
|
||||
|
||||
|
||||
class Ready(BaseReadyChange):
|
||||
@@ -575,7 +581,7 @@ class Ready(BaseReadyChange):
|
||||
Notifies the user service is ready
|
||||
"""
|
||||
|
||||
name = 'ready'
|
||||
NAME = 'ready'
|
||||
|
||||
def action(self) -> dict[str, typing.Any]:
|
||||
"""
|
||||
@@ -587,15 +593,17 @@ class Ready(BaseReadyChange):
|
||||
* port: port of the listener (normally 43910)
|
||||
|
||||
Returns: {
|
||||
private_key: str -> Generated private key, PEM
|
||||
server_cert: str -> Generated public key, PEM
|
||||
key: str -> Generated private key, PEM
|
||||
certificate: str -> Generated public key, PEM
|
||||
password: str -> Password for private key
|
||||
ciphers: str -> Ciphers that server supports (could be empty, so default of python requests will be used)
|
||||
}
|
||||
"""
|
||||
result = super().action()
|
||||
|
||||
# Set as "inUse" to false because a ready can only ocurr if an user is not logged in
|
||||
# Note that an assigned dynamic user service that gets "restarted", will be marked as not in use
|
||||
# until it's logged ing again. So, id the system has
|
||||
# until it's logged ing again. So, id the system has
|
||||
userservice = self.get_userservice()
|
||||
userservice.set_in_use(False)
|
||||
|
||||
@@ -608,7 +616,7 @@ class Version(ActorV3Action):
|
||||
Used on possible "customized" actors.
|
||||
"""
|
||||
|
||||
name = 'version'
|
||||
NAME = 'version'
|
||||
|
||||
def action(self) -> dict[str, typing.Any]:
|
||||
logger.debug('Version Args: %s, Params: %s', self._args, self._params)
|
||||
@@ -624,7 +632,7 @@ class Login(ActorV3Action):
|
||||
Notifies user logged id
|
||||
"""
|
||||
|
||||
name = 'login'
|
||||
NAME = 'login'
|
||||
|
||||
# payload received
|
||||
# {
|
||||
@@ -673,7 +681,7 @@ class Login(ActorV3Action):
|
||||
): # If unamanaged host, lest do a bit more work looking for a service with the provided parameters...
|
||||
if is_managed:
|
||||
raise
|
||||
self.notify_service(action=NotifyActionType.LOGIN)
|
||||
self.notify_service(action=types.rest.actor.NotifyActionType.LOGIN)
|
||||
|
||||
return ActorV3Action.actor_result(
|
||||
{
|
||||
@@ -692,7 +700,7 @@ class Logout(ActorV3Action):
|
||||
Notifies user logged out
|
||||
"""
|
||||
|
||||
name = 'logout'
|
||||
NAME = 'logout'
|
||||
|
||||
@staticmethod
|
||||
def process_logout(userservice: UserService, username: str, session_id: str) -> None:
|
||||
@@ -726,7 +734,7 @@ class Logout(ActorV3Action):
|
||||
except Exception:
|
||||
if is_managed:
|
||||
raise
|
||||
self.notify_service(NotifyActionType.LOGOUT) # Logout notification
|
||||
self.notify_service(types.rest.actor.NotifyActionType.LOGOUT) # Logout notification
|
||||
# Result is that we have not processed the logout in fact, but notified the service
|
||||
return ActorV3Action.actor_result('notified')
|
||||
|
||||
@@ -738,7 +746,7 @@ class Log(ActorV3Action):
|
||||
Sends a log from the service
|
||||
"""
|
||||
|
||||
name = 'log'
|
||||
NAME = 'log'
|
||||
|
||||
def action(self) -> dict[str, typing.Any]:
|
||||
logger.debug('Args: %s, Params: %s', self._args, self._params)
|
||||
@@ -763,27 +771,49 @@ class Ticket(ActorV3Action):
|
||||
Gets an stored ticket
|
||||
"""
|
||||
|
||||
name = 'ticket'
|
||||
NAME = 'ticket'
|
||||
|
||||
def action(self) -> dict[str, typing.Any]:
|
||||
logger.debug('Args: %s, Params: %s', self._args, self._params)
|
||||
|
||||
try:
|
||||
# Simple check that token exists
|
||||
Server.objects.get(
|
||||
token=self._params['token'], type=types.servers.ServerType.ACTOR
|
||||
) # Not assigned, because only needs check
|
||||
except Server.DoesNotExist:
|
||||
raise exceptions.rest.BlockAccess() from None # If too many blocks...
|
||||
if len(self._args) > 1:
|
||||
raise exceptions.rest.RequestError('Invalid request')
|
||||
|
||||
kind = self._args[0] if len(self._args) == 1 else 'server'
|
||||
|
||||
try:
|
||||
return ActorV3Action.actor_result(TicketStore.get(self._params['ticket'], invalidate=True))
|
||||
match kind:
|
||||
case 'server':
|
||||
# Server tickets are simple applicaitons with parameters
|
||||
# Enough secure this way (no onwer)
|
||||
try:
|
||||
# Simple check that token exists
|
||||
Server.objects.get(
|
||||
token=self._params['token'], type=types.servers.ServerType.ACTOR
|
||||
) # Not assigned, because only needs check
|
||||
except Server.DoesNotExist:
|
||||
raise exceptions.rest.BlockAccess() from None # If too many blocks...
|
||||
|
||||
return ActorV3Action.actor_result(TicketStore.get(self._params['ticket'], invalidate=True))
|
||||
|
||||
case 'userservice':
|
||||
# Userservice also has owner, to increase security
|
||||
self.get_userservice() # We just want to check that is valid
|
||||
|
||||
return ActorV3Action.actor_result(
|
||||
TicketStore.get(
|
||||
uuid=self._params['ticket'], owner=self._params['token'], invalidate=True
|
||||
)
|
||||
)
|
||||
|
||||
case _:
|
||||
raise exceptions.rest.RequestError('Invalid request')
|
||||
except TicketStore.DoesNotExist:
|
||||
return ActorV3Action.actor_result(error='Invalid ticket')
|
||||
|
||||
|
||||
class Unmanaged(ActorV3Action):
|
||||
name = 'unmanaged'
|
||||
NAME = 'unmanaged'
|
||||
|
||||
def action(self) -> dict[str, typing.Any]:
|
||||
"""
|
||||
@@ -804,9 +834,9 @@ class Unmanaged(ActorV3Action):
|
||||
|
||||
try:
|
||||
token = self._params['token']
|
||||
if ServiceTokenAlias.objects.filter(alias=token).exists():
|
||||
if dbservice_alias := ServiceTokenAlias.objects.filter(alias=token).first():
|
||||
# Retrieve real service from token alias
|
||||
dbservice = ServiceTokenAlias.objects.get(alias=token).service
|
||||
dbservice = dbservice_alias.service
|
||||
else:
|
||||
dbservice = Service.objects.get(token=token)
|
||||
service: 'services.Service' = dbservice.get_instance()
|
||||
@@ -845,7 +875,9 @@ class Unmanaged(ActorV3Action):
|
||||
ip = self._params['id'][0]['ip'] # Get first IP if no valid ip found
|
||||
|
||||
# Generates a certificate and send it to client (actor).
|
||||
private_key, certificate, password = security.create_self_signed_cert(ip)
|
||||
# Password will be removed on a release after 5.0 as it is useful
|
||||
# Currently we have to maintain it for compat with older actors
|
||||
private_key, certificate, password = security.create_self_signed_cert(ip, with_password=True)
|
||||
|
||||
if valid_id:
|
||||
# If id is assigned to an user service, notify "logout" to it
|
||||
@@ -869,7 +901,7 @@ class Unmanaged(ActorV3Action):
|
||||
|
||||
|
||||
class Notify(ActorV3Action):
|
||||
name = 'notify'
|
||||
NAME = 'notify'
|
||||
|
||||
def post(self) -> dict[str, typing.Any]:
|
||||
# Raplaces original post (non existent here)
|
||||
@@ -878,7 +910,7 @@ class Notify(ActorV3Action):
|
||||
def get(self) -> collections.abc.MutableMapping[str, typing.Any]:
|
||||
logger.debug('Args: %s, Params: %s', self._args, self._params)
|
||||
try:
|
||||
action = NotifyActionType(self._params['action'])
|
||||
action = types.rest.actor.NotifyActionType(self._params['action'])
|
||||
_token = self._params['token'] # Just to check it exists
|
||||
except Exception as e:
|
||||
# Requested login, logout or whatever
|
||||
@@ -887,11 +919,11 @@ class Notify(ActorV3Action):
|
||||
try:
|
||||
# Check block manually
|
||||
check_ip_is_blocked(self._request) # pylint: disable=protected-access
|
||||
if action == NotifyActionType.LOGIN:
|
||||
if action == types.rest.actor.NotifyActionType.LOGIN:
|
||||
Login.action(typing.cast(Login, self))
|
||||
elif action == NotifyActionType.LOGOUT:
|
||||
elif action == types.rest.actor.NotifyActionType.LOGOUT:
|
||||
Logout.action(typing.cast(Logout, self))
|
||||
elif action == NotifyActionType.DATA:
|
||||
elif action == types.rest.actor.NotifyActionType.DATA:
|
||||
self.notify_service(action)
|
||||
|
||||
return ActorV3Action.actor_result('ok')
|
||||
|
||||
@@ -31,18 +31,19 @@
|
||||
Author: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
"""
|
||||
import collections.abc
|
||||
import dataclasses
|
||||
import itertools
|
||||
import logging
|
||||
import re
|
||||
import typing
|
||||
|
||||
from django.utils.translation import gettext
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.translation import gettext, gettext_lazy as _
|
||||
from django.db import models
|
||||
|
||||
from uds.core import auths, consts, exceptions, types
|
||||
|
||||
from uds.core import auths, consts, exceptions, types, ui
|
||||
from uds.core.environment import Environment
|
||||
from uds.core.ui import gui
|
||||
from uds.core.util import ensure, permissions
|
||||
from uds.core.util import ensure, permissions, ui as ui_utils
|
||||
from uds.core.util.model import process_uuid
|
||||
from uds.models import MFA, Authenticator, Network, Tag
|
||||
from uds.REST.model import ModelHandler
|
||||
@@ -50,45 +51,89 @@ from uds.REST.model import ModelHandler
|
||||
from .users_groups import Groups, Users
|
||||
|
||||
# Not imported at runtime, just for type checking
|
||||
if typing.TYPE_CHECKING:
|
||||
from django.db.models import Model
|
||||
|
||||
from uds.core.module import Module
|
||||
from uds.core.module import Module
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class AuthenticatorTypeInfo(types.rest.ExtraTypeInfo):
|
||||
search_users_supported: bool
|
||||
search_groups_supported: bool
|
||||
needs_password: bool
|
||||
label_username: str
|
||||
label_groupname: str
|
||||
label_password: str
|
||||
create_users_supported: bool
|
||||
is_external: bool
|
||||
mfa_data_enabled: bool
|
||||
mfa_supported: bool
|
||||
|
||||
def as_dict(self) -> dict[str, typing.Any]:
|
||||
return dataclasses.asdict(self)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class AuthenticatorItem(types.rest.ManagedObjectItem[Authenticator]):
|
||||
numeric_id: int
|
||||
id: str
|
||||
name: str
|
||||
priority: int
|
||||
|
||||
tags: list[str]
|
||||
comments: str
|
||||
net_filtering: str
|
||||
networks: list[str]
|
||||
state: str
|
||||
mfa_id: str
|
||||
small_name: str
|
||||
users_count: int
|
||||
permission: int
|
||||
|
||||
type_info: types.rest.TypeInfo | None
|
||||
|
||||
|
||||
# Enclosed methods under /auth path
|
||||
class Authenticators(ModelHandler):
|
||||
model = Authenticator
|
||||
class Authenticators(ModelHandler[AuthenticatorItem]):
|
||||
ITEM_TYPE = AuthenticatorItem
|
||||
|
||||
MODEL = Authenticator
|
||||
# Custom get method "search" that requires authenticator id
|
||||
custom_methods = [('search', True)]
|
||||
detail = {'users': Users, 'groups': Groups}
|
||||
save_fields = ['name', 'comments', 'tags', 'priority', 'small_name', 'mfa_id:_', 'state']
|
||||
CUSTOM_METHODS = [types.rest.ModelCustomMethod('search', True)]
|
||||
DETAIL = {'users': Users, 'groups': Groups}
|
||||
FIELDS_TO_SAVE = ['name', 'comments', 'tags', 'priority', 'small_name', 'mfa_id:_', 'state']
|
||||
|
||||
table_title = _('Authenticators')
|
||||
table_fields = [
|
||||
{'numeric_id': {'title': _('Id'), 'visible': True}},
|
||||
{'name': {'title': _('Name'), 'visible': True, 'type': 'iconType'}},
|
||||
{'type_name': {'title': _('Type')}},
|
||||
{'comments': {'title': _('Comments')}},
|
||||
{'priority': {'title': _('Priority'), 'type': 'numeric', 'width': '5rem'}},
|
||||
{'small_name': {'title': _('Label')}},
|
||||
{'users_count': {'title': _('Users'), 'type': 'numeric', 'width': '1rem'}},
|
||||
{
|
||||
'mfa_name': {
|
||||
'title': _('MFA'),
|
||||
}
|
||||
},
|
||||
{'tags': {'title': _('tags'), 'visible': False}},
|
||||
]
|
||||
TABLE = (
|
||||
ui_utils.TableBuilder(_('Authenticators'))
|
||||
.numeric_column(name='numeric_id', title=_('Id'), visible=True, width='1rem')
|
||||
.icon(name='name', title=_('Name'), visible=True)
|
||||
.text_column(name='type_name', title=_('Type'))
|
||||
.text_column(name='comments', title=_('Comments'))
|
||||
.numeric_column(name='priority', title=_('Priority'), width='5rem')
|
||||
.text_column(name='small_name', title=_('Label'))
|
||||
.numeric_column(name='users_count', title=_('Users'), width='1rem')
|
||||
.text_column(name='mfa_name', title=_('MFA'))
|
||||
.text_column(name='tags', title=_('tags'), visible=False)
|
||||
.row_style(prefix='row-state-', field='state')
|
||||
.build()
|
||||
)
|
||||
|
||||
def enum_types(self) -> collections.abc.Iterable[type[auths.Authenticator]]:
|
||||
# Rest api related information to complete the auto-generated API
|
||||
REST_API_INFO = types.rest.api.RestApiInfo(
|
||||
typed=types.rest.api.RestApiInfoGuiType.MULTIPLE_TYPES,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def possible_types(cls: type[typing.Self]) -> collections.abc.Iterable[type[auths.Authenticator]]:
|
||||
return auths.factory().providers().values()
|
||||
|
||||
def type_info(self, type_: type['Module']) -> typing.Optional[types.rest.AuthenticatorTypeInfo]:
|
||||
@classmethod
|
||||
def extra_type_info(
|
||||
cls: type[typing.Self], type_: type['Module']
|
||||
) -> typing.Optional[AuthenticatorTypeInfo]:
|
||||
if issubclass(type_, auths.Authenticator):
|
||||
return types.rest.AuthenticatorTypeInfo(
|
||||
return AuthenticatorTypeInfo(
|
||||
search_users_supported=type_.search_users != auths.Authenticator.search_users,
|
||||
search_groups_supported=type_.search_groups != auths.Authenticator.search_groups,
|
||||
needs_password=type_.needs_password,
|
||||
@@ -98,95 +143,82 @@ class Authenticators(ModelHandler):
|
||||
create_users_supported=type_.create_user != auths.Authenticator.create_user,
|
||||
is_external=type_.external_source,
|
||||
mfa_data_enabled=type_.mfa_data_enabled,
|
||||
mfa_supported=type_.provides_mfa(),
|
||||
mfa_supported=type_.provides_mfa_identifier(),
|
||||
)
|
||||
# Not of my type
|
||||
return None
|
||||
|
||||
def get_gui(self, type_: str) -> list[typing.Any]:
|
||||
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
|
||||
try:
|
||||
auth_type = auths.factory().lookup(type_)
|
||||
auth_type = auths.factory().lookup(for_type)
|
||||
if auth_type:
|
||||
# Create a new instance of the authenticator to access to its GUI
|
||||
with Environment.temporary_environment() as env:
|
||||
auth_instance = auth_type(env, None)
|
||||
field = self.add_default_fields(
|
||||
auth_instance.gui_description(),
|
||||
['name', 'comments', 'tags', 'priority', 'small_name', 'networks'],
|
||||
)
|
||||
self.add_field(
|
||||
field,
|
||||
{
|
||||
'name': 'state',
|
||||
'value': consts.auth.VISIBLE,
|
||||
'choices': [
|
||||
{'id': consts.auth.VISIBLE, 'text': _('Visible')},
|
||||
{'id': consts.auth.HIDDEN, 'text': _('Hidden')},
|
||||
{'id': consts.auth.DISABLED, 'text': _('Disabled')},
|
||||
],
|
||||
'label': gettext('Access'),
|
||||
'tooltip': gettext(
|
||||
'Access type for this transport. Disabled means not only hidden, but also not usable as login method.'
|
||||
),
|
||||
'type': types.ui.FieldType.CHOICE,
|
||||
'order': 107,
|
||||
'tab': gettext('Display'),
|
||||
},
|
||||
)
|
||||
# If supports mfa, add MFA provider selector field
|
||||
if auth_type.provides_mfa():
|
||||
self.add_field(
|
||||
field,
|
||||
{
|
||||
'name': 'mfa_id',
|
||||
'choices': [gui.choice_item('', str(_('None')))]
|
||||
+ gui.sorted_choices(
|
||||
[gui.choice_item(v.uuid, v.name) for v in MFA.objects.all()]
|
||||
),
|
||||
'label': gettext('MFA Provider'),
|
||||
'tooltip': gettext('MFA provider to use for this authenticator'),
|
||||
'type': types.ui.FieldType.CHOICE,
|
||||
'order': 108,
|
||||
'tab': types.ui.Tab.MFA,
|
||||
},
|
||||
auth_instance = auth_type(env, None)
|
||||
gui = (
|
||||
(
|
||||
ui_utils.GuiBuilder()
|
||||
.set_order(100)
|
||||
.add_stock_field(types.rest.stock.StockField.PRIORITY)
|
||||
.add_stock_field(types.rest.stock.StockField.NAME)
|
||||
.add_stock_field(types.rest.stock.StockField.LABEL)
|
||||
.add_stock_field(types.rest.stock.StockField.NETWORKS)
|
||||
.add_stock_field(types.rest.stock.StockField.COMMENTS)
|
||||
.add_stock_field(types.rest.stock.StockField.TAGS)
|
||||
)
|
||||
return field
|
||||
.add_fields(auth_instance.gui_description())
|
||||
.add_choice(
|
||||
name='state',
|
||||
default=consts.auth.VISIBLE,
|
||||
choices=[
|
||||
ui.gui.choice_item(consts.auth.VISIBLE, _('Visible')),
|
||||
ui.gui.choice_item(consts.auth.HIDDEN, _('Hidden')),
|
||||
ui.gui.choice_item(consts.auth.DISABLED, _('Disabled')),
|
||||
],
|
||||
label=gettext('Access'),
|
||||
)
|
||||
)
|
||||
|
||||
if auth_type.provides_mfa_identifier():
|
||||
gui.add_choice(
|
||||
name='mfa_id',
|
||||
label=gettext('MFA Provider'),
|
||||
choices=[ui.gui.choice_item('', str(_('None')))]
|
||||
+ ui.gui.sorted_choices(
|
||||
[ui.gui.choice_item(v.uuid, v.name) for v in MFA.objects.all()]
|
||||
),
|
||||
)
|
||||
|
||||
return gui.build()
|
||||
|
||||
raise Exception() # Not found
|
||||
except Exception as e:
|
||||
logger.info('Type not found: %s', e)
|
||||
raise exceptions.rest.NotFound('type not found') from e
|
||||
|
||||
def item_as_dict(self, item: 'Model') -> dict[str, typing.Any]:
|
||||
summary = 'summarize' in self._params
|
||||
logger.info('Authenticator type not found: %s', e)
|
||||
raise exceptions.rest.NotFound('Authenticator type not found') from e
|
||||
|
||||
def get_item(self, item: 'models.Model') -> AuthenticatorItem:
|
||||
item = ensure.is_instance(item, Authenticator)
|
||||
v: dict[str, typing.Any] = {
|
||||
'numeric_id': item.id,
|
||||
'id': item.uuid,
|
||||
'name': item.name,
|
||||
'priority': item.priority,
|
||||
}
|
||||
if not summary:
|
||||
type_ = item.get_type()
|
||||
v.update(
|
||||
{
|
||||
'tags': [tag.tag for tag in typing.cast(collections.abc.Iterable[Tag], item.tags.all())],
|
||||
'comments': item.comments,
|
||||
'net_filtering': item.net_filtering,
|
||||
'networks': [n.uuid for n in item.networks.all()],
|
||||
'state': item.state,
|
||||
'mfa_id': item.mfa.uuid if item.mfa else '',
|
||||
'small_name': item.small_name,
|
||||
'users_count': item.users.count(),
|
||||
'type': type_.mod_type(),
|
||||
'type_name': type_.mod_name(),
|
||||
'type_info': self.type_as_dict(type_),
|
||||
'permission': permissions.effective_permissions(self._user, item),
|
||||
}
|
||||
)
|
||||
return v
|
||||
|
||||
def post_save(self, item: 'Model') -> None:
|
||||
return AuthenticatorItem(
|
||||
numeric_id=item.id,
|
||||
id=item.uuid,
|
||||
name=item.name,
|
||||
priority=item.priority,
|
||||
tags=[tag.tag for tag in typing.cast(collections.abc.Iterable[Tag], item.tags.all())],
|
||||
comments=item.comments,
|
||||
net_filtering=item.net_filtering,
|
||||
networks=[n.uuid for n in item.networks.all()],
|
||||
state=item.state,
|
||||
mfa_id=item.mfa.uuid if item.mfa else '',
|
||||
small_name=item.small_name,
|
||||
users_count=item.users.count(),
|
||||
permission=permissions.effective_permissions(self._user, item),
|
||||
item=item,
|
||||
type_info=type(self).as_typeinfo(item.get_type()),
|
||||
)
|
||||
|
||||
def post_save(self, item: 'models.Model') -> None:
|
||||
item = ensure.is_instance(item, Authenticator)
|
||||
try:
|
||||
networks = self._params['networks']
|
||||
@@ -199,13 +231,17 @@ class Authenticators(ModelHandler):
|
||||
item.networks.set(Network.objects.filter(uuid__in=networks))
|
||||
|
||||
# Custom "search" method
|
||||
def search(self, item: 'Model') -> list[types.rest.ItemDictType]:
|
||||
def search(self, item: 'models.Model') -> list[types.auth.SearchResultItem.ItemDict]:
|
||||
"""
|
||||
API:
|
||||
Search for users or groups in this authenticator
|
||||
"""
|
||||
item = ensure.is_instance(item, Authenticator)
|
||||
self.ensure_has_access(item, types.permissions.PermissionType.READ)
|
||||
self.check_access(item, types.permissions.PermissionType.READ)
|
||||
try:
|
||||
type_ = self._params['type']
|
||||
if type_ not in ('user', 'group'):
|
||||
raise self.invalid_request_response()
|
||||
raise exceptions.rest.RequestError(_('Invalid type: {}').format(type_))
|
||||
|
||||
term = self._params['term']
|
||||
|
||||
@@ -227,7 +263,7 @@ class Authenticators(ModelHandler):
|
||||
)
|
||||
)
|
||||
if search_supported is False:
|
||||
raise self.not_supported_response()
|
||||
raise exceptions.rest.NotSupportedError(_('Search not supported'))
|
||||
|
||||
if type_ == 'user':
|
||||
iterable = auth.search_users(term)
|
||||
@@ -237,13 +273,15 @@ class Authenticators(ModelHandler):
|
||||
return [i.as_dict() for i in itertools.islice(iterable, limit)]
|
||||
except Exception as e:
|
||||
logger.exception('Too many results: %s', e)
|
||||
return [{'id': _('Too many results...'), 'name': _('Refine your query')}]
|
||||
return [
|
||||
types.auth.SearchResultItem(id=_('Too many results...'), name=_('Refine your query')).as_dict()
|
||||
]
|
||||
# self.invalidResponseException('{}'.format(e))
|
||||
|
||||
def test(self, type_: str) -> typing.Any:
|
||||
auth_type = auths.factory().lookup(type_)
|
||||
if not auth_type:
|
||||
raise self.invalid_request_response(f'Invalid type: {type_}')
|
||||
raise exceptions.rest.RequestError(_('Invalid type: {}').format(type_))
|
||||
|
||||
dct = self._params.copy()
|
||||
dct['_request'] = self._request
|
||||
@@ -270,11 +308,9 @@ class Authenticators(ModelHandler):
|
||||
fields['small_name'] = fields['small_name'].strip().replace(' ', '_')
|
||||
# And ensure small_name chars are valid [a-zA-Z0-9:-]+
|
||||
if fields['small_name'] and not re.match(r'^[a-zA-Z0-9:.-]+$', fields['small_name']):
|
||||
raise self.invalid_request_response(
|
||||
_('Label must contain only letters, numbers, or symbols: - : .')
|
||||
)
|
||||
raise exceptions.rest.RequestError(_('Label must contain only letters, numbers, or symbols: - : .'))
|
||||
|
||||
def delete_item(self, item: 'Model') -> None:
|
||||
def delete_item(self, item: 'models.Model') -> None:
|
||||
# For every user, remove assigned services (mark them for removal)
|
||||
item = ensure.is_instance(item, Authenticator)
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ import typing
|
||||
|
||||
from django.core.cache import caches
|
||||
|
||||
from uds.core import exceptions
|
||||
from uds.core import exceptions, consts
|
||||
from uds.core.util.cache import Cache as UCache
|
||||
from uds.REST import Handler
|
||||
|
||||
@@ -44,8 +44,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# Enclosed methods under /cache path
|
||||
class Cache(Handler):
|
||||
authenticated = True
|
||||
needs_admin = True
|
||||
ROLE = consts.UserRole.ADMIN
|
||||
|
||||
def get(self) -> typing.Any:
|
||||
"""
|
||||
|
||||
@@ -30,85 +30,98 @@
|
||||
"""
|
||||
Author: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
"""
|
||||
import dataclasses
|
||||
import datetime
|
||||
import logging
|
||||
import typing
|
||||
|
||||
from django.db import IntegrityError
|
||||
from django.db import IntegrityError, models
|
||||
from django.utils.translation import gettext as _
|
||||
from django.utils import timezone
|
||||
|
||||
from uds.core import exceptions
|
||||
from uds.core.util import ensure, permissions
|
||||
from uds.core import exceptions, types
|
||||
from uds.core.util import ensure, permissions, ui as ui_utils
|
||||
from uds.core.util.model import process_uuid, sql_now
|
||||
from uds.models.calendar import Calendar
|
||||
from uds.models.calendar_rule import CalendarRule, FrequencyInfo
|
||||
from uds.REST.model import DetailHandler
|
||||
|
||||
# Not imported at runtime, just for type checking
|
||||
if typing.TYPE_CHECKING:
|
||||
from django.db.models import Model
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CalendarRules(DetailHandler): # pylint: disable=too-many-public-methods
|
||||
@dataclasses.dataclass
|
||||
class CalendarRuleItem(types.rest.BaseRestItem):
|
||||
id: str
|
||||
name: str
|
||||
comments: str
|
||||
start: datetime.datetime
|
||||
end: datetime.datetime | None
|
||||
frequency: str
|
||||
interval: int
|
||||
duration: int
|
||||
duration_unit: str
|
||||
permission: int
|
||||
|
||||
|
||||
class CalendarRules(DetailHandler[CalendarRuleItem]): # pylint: disable=too-many-public-methods
|
||||
"""
|
||||
Detail handler for Services, whose parent is a Provider
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def rule_as_dict(item: CalendarRule, perm: int) -> dict[str, typing.Any]:
|
||||
def rule_as_dict(item: CalendarRule, perm: int) -> CalendarRuleItem:
|
||||
"""
|
||||
Convert a calrule db item to a dict for a rest response
|
||||
:param item: Rule item (db)
|
||||
:param perm: Permission of the object
|
||||
"""
|
||||
return {
|
||||
'id': item.uuid,
|
||||
'name': item.name,
|
||||
'comments': item.comments,
|
||||
'start': item.start,
|
||||
'end': datetime.datetime.combine(item.end, datetime.time.max) if item.end else None,
|
||||
'frequency': item.frequency,
|
||||
'interval': item.interval,
|
||||
'duration': item.duration,
|
||||
'duration_unit': item.duration_unit,
|
||||
'permission': perm,
|
||||
}
|
||||
return CalendarRuleItem(
|
||||
id=item.uuid,
|
||||
name=item.name,
|
||||
comments=item.comments,
|
||||
start=item.start,
|
||||
end=timezone.make_aware(datetime.datetime.combine(item.end, datetime.time.max)) if item.end else None,
|
||||
frequency=item.frequency,
|
||||
interval=item.interval,
|
||||
duration=item.duration,
|
||||
duration_unit=item.duration_unit,
|
||||
permission=perm,
|
||||
)
|
||||
|
||||
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> typing.Any:
|
||||
def get_items(
|
||||
self, parent: 'models.Model', item: typing.Optional[str]
|
||||
) -> types.rest.ItemsResult[CalendarRuleItem]:
|
||||
parent = ensure.is_instance(parent, Calendar)
|
||||
# Check what kind of access do we have to parent provider
|
||||
perm = permissions.effective_permissions(self._user, parent)
|
||||
try:
|
||||
if item is None:
|
||||
return [CalendarRules.rule_as_dict(k, perm) for k in parent.rules.all()]
|
||||
return [CalendarRules.rule_as_dict(k, perm) for k in self.filter_queryset(parent.rules.all())]
|
||||
k = parent.rules.get(uuid=process_uuid(item))
|
||||
return CalendarRules.rule_as_dict(k, perm)
|
||||
except CalendarRule.DoesNotExist:
|
||||
raise exceptions.rest.NotFound(_('Calendar rule not found: {}').format(item)) from None
|
||||
except Exception as e:
|
||||
logger.exception('itemId %s', item)
|
||||
raise self.invalid_item_response() from e
|
||||
raise exceptions.rest.RequestError(f'Error retrieving calendar rule: {e}') from e
|
||||
|
||||
def get_fields(self, parent: 'Model') -> list[typing.Any]:
|
||||
def get_table(self, parent: 'models.Model') -> types.rest.TableInfo:
|
||||
parent = ensure.is_instance(parent, Calendar)
|
||||
return (
|
||||
ui_utils.TableBuilder(_('Rules of {0}').format(parent.name))
|
||||
.text_column(name='name', title=_('Name'))
|
||||
.datetime_column(name='start', title=_('Start'))
|
||||
.date(name='end', title=_('End'))
|
||||
.dict_column(name='frequency', title=_('Frequency'), dct=FrequencyInfo.literals_dict())
|
||||
.numeric_column(name='interval', title=_('Interval'))
|
||||
.numeric_column(name='duration', title=_('Duration'))
|
||||
.text_column(name='comments', title=_('Comments'))
|
||||
.build()
|
||||
)
|
||||
|
||||
return [
|
||||
{'name': {'title': _('Rule name')}},
|
||||
{'start': {'title': _('Starts'), 'type': 'datetime'}},
|
||||
{'end': {'title': _('Ends'), 'type': 'date'}},
|
||||
{
|
||||
'frequency': {
|
||||
'title': _('Repeats'),
|
||||
'type': 'dict',
|
||||
'dict': dict((v.name, str(v.value.title)) for v in FrequencyInfo),
|
||||
}
|
||||
},
|
||||
{'interval': {'title': _('Every'), 'type': 'callback'}},
|
||||
{'duration': {'title': _('Duration'), 'type': 'callback'}},
|
||||
{'comments': {'title': _('Comments')}},
|
||||
]
|
||||
|
||||
def save_item(self, parent: 'Model', item: typing.Optional[str]) -> typing.Any:
|
||||
def save_item(self, parent: 'models.Model', item: typing.Optional[str]) -> typing.Any:
|
||||
parent = ensure.is_instance(parent, Calendar)
|
||||
|
||||
# Extract item db fields
|
||||
@@ -128,12 +141,12 @@ class CalendarRules(DetailHandler): # pylint: disable=too-many-public-methods
|
||||
)
|
||||
|
||||
if int(fields['interval']) < 1:
|
||||
raise self.invalid_item_response('Repeat must be greater than zero')
|
||||
raise exceptions.rest.RequestError('Repeat must be greater than zero')
|
||||
|
||||
# Convert timestamps to datetimes
|
||||
fields['start'] = datetime.datetime.fromtimestamp(fields['start'])
|
||||
fields['start'] = timezone.make_aware(datetime.datetime.fromtimestamp(fields['start']))
|
||||
if fields['end'] is not None:
|
||||
fields['end'] = datetime.datetime.fromtimestamp(fields['end'])
|
||||
fields['end'] = timezone.make_aware(datetime.datetime.fromtimestamp(fields['end']))
|
||||
|
||||
calendar_rule: CalendarRule
|
||||
try:
|
||||
@@ -145,14 +158,14 @@ class CalendarRules(DetailHandler): # pylint: disable=too-many-public-methods
|
||||
calendar_rule.save()
|
||||
return {'id': calendar_rule.uuid}
|
||||
except CalendarRule.DoesNotExist:
|
||||
raise self.invalid_item_response() from None
|
||||
raise exceptions.rest.NotFound(_('Calendar rule not found: {}').format(item)) from None
|
||||
except IntegrityError as e: # Duplicate key probably
|
||||
raise exceptions.rest.RequestError(_('Element already exists (duplicate key error)')) from e
|
||||
except Exception as e:
|
||||
logger.exception('Saving calendar')
|
||||
raise self.invalid_request_response(f'incorrect invocation to PUT: {e}') from e
|
||||
raise exceptions.rest.RequestError(f'incorrect invocation to PUT: {e}') from e
|
||||
|
||||
def delete_item(self, parent: 'Model', item: str) -> None:
|
||||
def delete_item(self, parent: 'models.Model', item: str) -> None:
|
||||
parent = ensure.is_instance(parent, Calendar)
|
||||
logger.debug('Deleting rule %s from %s', item, parent)
|
||||
try:
|
||||
@@ -160,13 +173,8 @@ class CalendarRules(DetailHandler): # pylint: disable=too-many-public-methods
|
||||
calendar_rule.calendar.modified = sql_now()
|
||||
calendar_rule.calendar.save()
|
||||
calendar_rule.delete()
|
||||
except CalendarRule.DoesNotExist:
|
||||
raise exceptions.rest.NotFound(_('Calendar rule not found: {}').format(item)) from None
|
||||
except Exception as e:
|
||||
logger.exception('Exception')
|
||||
raise self.invalid_item_response() from e
|
||||
|
||||
def get_title(self, parent: 'Model') -> str:
|
||||
parent = ensure.is_instance(parent, Calendar)
|
||||
try:
|
||||
return _('Rules of {0}').format(parent.name)
|
||||
except Exception:
|
||||
return _('Current rules')
|
||||
logger.error('Error deleting calendar rule %s from %s', item, parent)
|
||||
raise exceptions.rest.RequestError(f'Error deleting calendar rule: {e}') from e
|
||||
|
||||
@@ -30,66 +30,84 @@
|
||||
"""
|
||||
@Author: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
"""
|
||||
import dataclasses
|
||||
import datetime
|
||||
import logging
|
||||
import typing
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.db import models
|
||||
|
||||
from uds.core import types
|
||||
from uds.models import Calendar
|
||||
from uds.core.util import permissions, ensure
|
||||
from uds.core.util import permissions, ensure, ui as ui_utils
|
||||
|
||||
from uds.REST.model import ModelHandler
|
||||
from .calendarrules import CalendarRules
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from django.db.models import Model
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Enclosed methods under /item path
|
||||
|
||||
@dataclasses.dataclass
|
||||
class CalendarItem(types.rest.BaseRestItem):
|
||||
id: str
|
||||
name: str
|
||||
tags: list[str]
|
||||
comments: str
|
||||
modified: datetime.datetime
|
||||
number_rules: int
|
||||
number_access: int
|
||||
number_actions: int
|
||||
permission: types.permissions.PermissionType
|
||||
|
||||
|
||||
class Calendars(ModelHandler):
|
||||
class Calendars(ModelHandler[CalendarItem]):
|
||||
"""
|
||||
Processes REST requests about calendars
|
||||
"""
|
||||
|
||||
model = Calendar
|
||||
detail = {'rules': CalendarRules}
|
||||
MODEL = Calendar
|
||||
DETAIL = {'rules': CalendarRules}
|
||||
|
||||
save_fields = ['name', 'comments', 'tags']
|
||||
FIELDS_TO_SAVE = ['name', 'comments', 'tags']
|
||||
|
||||
table_title = _('Calendars')
|
||||
table_fields = [
|
||||
{
|
||||
'name': {
|
||||
'title': _('Name'),
|
||||
'visible': True,
|
||||
'type': 'icon',
|
||||
'icon': 'fa fa-calendar text-success',
|
||||
}
|
||||
},
|
||||
{'comments': {'title': _('Comments')}},
|
||||
{'modified': {'title': _('Modified'), 'type': 'datetime'}},
|
||||
{'number_rules': {'title': _('Rules')}},
|
||||
{'number_access': {'title': _('Pools with Accesses')}},
|
||||
{'number_actions': {'title': _('Pools with Actions')}},
|
||||
{'tags': {'title': _('tags'), 'visible': False}},
|
||||
]
|
||||
TABLE = (
|
||||
ui_utils.TableBuilder(_('Calendars'))
|
||||
.text_column(name='name', title=_('Name'), visible=True)
|
||||
.text_column(name='comments', title=_('Comments'))
|
||||
.datetime_column(name='modified', title=_('Modified'))
|
||||
.numeric_column(name='number_rules', title=_('Rules'), width='5rem')
|
||||
.numeric_column(name='number_access', title=_('Pools with Accesses'), width='5rem')
|
||||
.numeric_column(name='number_actions', title=_('Pools with Actions'), width='5rem')
|
||||
.text_column(name='tags', title=_('tags'), visible=False)
|
||||
.build()
|
||||
)
|
||||
|
||||
def item_as_dict(self, item: 'Model') -> dict[str, typing.Any]:
|
||||
# Rest api related information to complete the auto-generated API
|
||||
REST_API_INFO = types.rest.api.RestApiInfo(
|
||||
typed=types.rest.api.RestApiInfoGuiType.SINGLE_TYPE,
|
||||
)
|
||||
|
||||
def get_item(self, item: 'models.Model') -> CalendarItem:
|
||||
item = ensure.is_instance(item, Calendar)
|
||||
return {
|
||||
'id': item.uuid,
|
||||
'name': item.name,
|
||||
'tags': [tag.tag for tag in item.tags.all()],
|
||||
'comments': item.comments,
|
||||
'modified': item.modified,
|
||||
'number_rules': item.rules.count(),
|
||||
'number_access': item.calendaraccess_set.all().values('service_pool').distinct().count(),
|
||||
'number_actions': item.calendaraction_set.all().values('service_pool').distinct().count(),
|
||||
'permission': permissions.effective_permissions(self._user, item),
|
||||
}
|
||||
return CalendarItem(
|
||||
id=item.uuid,
|
||||
name=item.name,
|
||||
tags=[tag.tag for tag in item.tags.all()],
|
||||
comments=item.comments,
|
||||
modified=item.modified,
|
||||
number_rules=item.rules.count(),
|
||||
number_access=item.calendaraccess_set.all().values('service_pool').distinct().count(),
|
||||
number_actions=item.calendaraction_set.all().values('service_pool').distinct().count(),
|
||||
permission=permissions.effective_permissions(self._user, item),
|
||||
)
|
||||
|
||||
def get_gui(self, type_: str) -> list[typing.Any]:
|
||||
return self.add_default_fields([], ['name', 'comments', 'tags'])
|
||||
def get_gui(self, for_type: str) -> list[typing.Any]:
|
||||
return (
|
||||
ui_utils.GuiBuilder()
|
||||
.add_stock_field(types.rest.stock.StockField.NAME)
|
||||
.add_stock_field(types.rest.stock.StockField.COMMENTS)
|
||||
.add_stock_field(types.rest.stock.StockField.TAGS)
|
||||
.build()
|
||||
)
|
||||
|
||||
@@ -42,7 +42,7 @@ from uds.core.exceptions.services import ServiceNotReadyError
|
||||
from uds.core.types.log import LogLevel, LogSource
|
||||
from uds.core.util.config import GlobalConfig
|
||||
from uds.core.util.model import sql_stamp_seconds
|
||||
from uds.core.util.rest.tools import match
|
||||
from uds.core.util.rest.tools import match_args
|
||||
from uds.models import TicketStore, User
|
||||
from uds.REST import Handler
|
||||
|
||||
@@ -58,7 +58,7 @@ class Client(Handler):
|
||||
Processes Client requests
|
||||
"""
|
||||
|
||||
authenticated = False # Client requests are not authenticated
|
||||
ROLE = consts.UserRole.ANONYMOUS
|
||||
|
||||
@staticmethod
|
||||
def result(
|
||||
@@ -130,7 +130,7 @@ class Client(Handler):
|
||||
|
||||
try:
|
||||
data: dict[str, typing.Any] = TicketStore.get(ticket)
|
||||
except TicketStore.InvalidTicket:
|
||||
except TicketStore.DoesNotExist:
|
||||
return Client.result(error=types.errors.Error.ACCESS_DENIED)
|
||||
|
||||
self._request.user = User.objects.get(uuid=data['user'])
|
||||
@@ -224,7 +224,7 @@ class Client(Handler):
|
||||
ticket, command = self._args[:2]
|
||||
try:
|
||||
data: dict[str, typing.Any] = TicketStore.get(ticket)
|
||||
except TicketStore.InvalidTicket:
|
||||
except TicketStore.DoesNotExist:
|
||||
return Client.result(error=types.errors.Error.ACCESS_DENIED)
|
||||
|
||||
self._request.user = User.objects.get(uuid=data['user'])
|
||||
@@ -282,7 +282,7 @@ class Client(Handler):
|
||||
}
|
||||
)
|
||||
|
||||
return match(
|
||||
return match_args(
|
||||
self._args,
|
||||
_error, # In case of error, raises RequestError
|
||||
((), _noargs), # No args, return version
|
||||
|
||||
@@ -33,6 +33,7 @@ Author: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
import typing
|
||||
import logging
|
||||
|
||||
from uds.core import consts
|
||||
from uds.core.util.config import Config as CfgConfig
|
||||
from uds.REST import Handler
|
||||
|
||||
@@ -42,10 +43,15 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# Enclosed methods under /config path
|
||||
class Config(Handler):
|
||||
needs_admin = True # By default, staff is lower level needed
|
||||
"""
|
||||
API:
|
||||
Get or update UDS configuration
|
||||
"""
|
||||
|
||||
ROLE = consts.UserRole.ADMIN
|
||||
|
||||
def get(self) -> typing.Any:
|
||||
return CfgConfig.get_config_values(self.is_admin())
|
||||
return self.filter_data(CfgConfig.get_config_values(self.is_admin()))
|
||||
|
||||
def put(self) -> typing.Any:
|
||||
for section, section_dict in typing.cast(dict[str, dict[str, dict[str, str]]], self._params).items():
|
||||
@@ -60,5 +66,11 @@ class Config(Handler):
|
||||
self._user.name,
|
||||
)
|
||||
else:
|
||||
logger.error('Non existing config value %s.%s to %s by %s', section, key, vals['value'], self._user.name)
|
||||
logger.error(
|
||||
'Non existing config value %s.%s to %s by %s',
|
||||
section,
|
||||
key,
|
||||
vals['value'],
|
||||
self._user.name,
|
||||
)
|
||||
return 'done'
|
||||
|
||||
@@ -30,15 +30,16 @@
|
||||
"""
|
||||
Author: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
"""
|
||||
import datetime
|
||||
import logging
|
||||
import typing
|
||||
|
||||
from uds.core import exceptions, types
|
||||
from django.utils import timezone
|
||||
|
||||
from uds.core import exceptions, types, consts
|
||||
from uds.core.managers.crypto import CryptoManager
|
||||
from uds.core.managers.userservice import UserServiceManager
|
||||
from uds.core.exceptions.services import ServiceNotReadyError
|
||||
from uds.core.util.rest.tools import match
|
||||
from uds.core.util.rest.tools import match_args
|
||||
from uds.REST import Handler
|
||||
from uds.web.util import services
|
||||
|
||||
@@ -51,9 +52,7 @@ class Connection(Handler):
|
||||
Processes actor requests
|
||||
"""
|
||||
|
||||
authenticated = True # Actor requests are not authenticated
|
||||
needs_admin = False
|
||||
needs_staff = False
|
||||
ROLE = consts.UserRole.USER
|
||||
|
||||
@staticmethod
|
||||
def result(
|
||||
@@ -69,7 +68,7 @@ class Connection(Handler):
|
||||
:return: A dictionary, suitable for response to Caller
|
||||
"""
|
||||
result = result if result is not None else ''
|
||||
res = {'result': result, 'date': datetime.datetime.now()}
|
||||
res = {'result': result, 'date': timezone.localtime()}
|
||||
if error:
|
||||
if isinstance(error, int):
|
||||
error = types.errors.Error.from_int(error).message
|
||||
@@ -87,7 +86,7 @@ class Connection(Handler):
|
||||
# Ensure user is present on request, used by web views methods
|
||||
self._request.user = self._user
|
||||
|
||||
return Connection.result(result=services.get_services_info_dict(self._request))
|
||||
return Connection.result(result=self.filter_data(services.get_services_info_dict(self._request)))
|
||||
|
||||
def connection(self, id_service: str, id_transport: str, skip: str = '') -> dict[str, typing.Any]:
|
||||
skip_check = skip in ('doNotCheck', 'do_not_check', 'no_check', 'nocheck', 'skip_check')
|
||||
@@ -179,7 +178,7 @@ class Connection(Handler):
|
||||
def error() -> dict[str, typing.Any]:
|
||||
raise exceptions.rest.RequestError('Invalid Request')
|
||||
|
||||
return match(
|
||||
return match_args(
|
||||
self._args,
|
||||
error,
|
||||
((), self.service_list),
|
||||
|
||||
@@ -32,7 +32,7 @@ Author: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
"""
|
||||
import logging
|
||||
|
||||
from uds.core import exceptions, types
|
||||
from uds.core import exceptions, types, consts
|
||||
from uds.core.ui import gui
|
||||
from uds.REST import Handler
|
||||
|
||||
@@ -42,9 +42,13 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Callback(Handler):
|
||||
path = 'gui'
|
||||
authenticated = True
|
||||
needs_staff = True
|
||||
"""
|
||||
API:
|
||||
Executes a callback from the GUI. Internal use, not intended to be called from outside.
|
||||
"""
|
||||
PATH = 'gui'
|
||||
|
||||
ROLE = consts.UserRole.STAFF
|
||||
|
||||
def get(self) -> types.ui.CallbackResultType:
|
||||
if len(self._args) != 1:
|
||||
|
||||
@@ -30,87 +30,87 @@
|
||||
"""
|
||||
@Author: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
"""
|
||||
import dataclasses
|
||||
import logging
|
||||
import typing
|
||||
|
||||
from django.utils.translation import gettext_lazy as _, gettext
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.db import models
|
||||
|
||||
from uds.models import Image
|
||||
from uds.core import types
|
||||
from uds.core.util import ensure
|
||||
from uds.core.util import ensure, ui as ui_utils
|
||||
|
||||
from uds.REST.model import ModelHandler
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from django.db.models import Model
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Enclosed methods under /item path
|
||||
|
||||
|
||||
class Images(ModelHandler):
|
||||
@dataclasses.dataclass
|
||||
class ImageItem(types.rest.BaseRestItem):
|
||||
id: str
|
||||
name: str
|
||||
data: str = ''
|
||||
size: str = ''
|
||||
thumb: str = ''
|
||||
|
||||
|
||||
class Images(ModelHandler[ImageItem]):
|
||||
"""
|
||||
Handles the gallery REST interface
|
||||
"""
|
||||
|
||||
path = 'gallery'
|
||||
model = Image
|
||||
save_fields = ['name', 'data']
|
||||
PATH = 'gallery'
|
||||
MODEL = Image
|
||||
FIELDS_TO_SAVE = ['name', 'data']
|
||||
|
||||
table_title = _('Image Gallery')
|
||||
table_fields = [
|
||||
{
|
||||
'thumb': {
|
||||
'title': _('Image'),
|
||||
'visible': True,
|
||||
'type': 'image',
|
||||
'width': '96px',
|
||||
}
|
||||
},
|
||||
{'name': {'title': _('Name')}},
|
||||
{'size': {'title': _('Size')}},
|
||||
]
|
||||
TABLE = (
|
||||
ui_utils.TableBuilder(_('Image Gallery'))
|
||||
.image('thumb', _('Image'), width='96px')
|
||||
.text_column('name', _('Name'))
|
||||
.text_column('size', _('Size'))
|
||||
.build()
|
||||
)
|
||||
|
||||
def pre_save(self, fields: dict[str, typing.Any]) -> None:
|
||||
fields['image'] = fields['data']
|
||||
del fields['data']
|
||||
#fields['data'] = Image.prepareForDb(Image.decode64(fields['data']))[2]
|
||||
# fields['data'] = Image.prepareForDb(Image.decode64(fields['data']))[2]
|
||||
|
||||
def post_save(self, item: 'Model') -> None:
|
||||
def post_save(self, item: 'models.Model') -> None:
|
||||
item = ensure.is_instance(item, Image)
|
||||
# Updates the thumbnail and re-saves it
|
||||
logger.debug('After save: item = %s', item)
|
||||
#item.updateThumbnail()
|
||||
#item.save()
|
||||
# item.updateThumbnail()
|
||||
# item.save()
|
||||
|
||||
def get_gui(self, type_: str) -> list[typing.Any]:
|
||||
return self.add_field(
|
||||
self.add_default_fields([], ['name']),
|
||||
{
|
||||
'name': 'data',
|
||||
'value': '',
|
||||
'label': gettext('Image'),
|
||||
'tooltip': gettext('Image object'),
|
||||
'type': types.ui.FieldType.IMAGECHOICE,
|
||||
'order': 100, # At end
|
||||
},
|
||||
# Note:
|
||||
# This has no get_gui because its treated on the admin or client.
|
||||
# We expect an Image List
|
||||
|
||||
# Rest api related information to complete the auto-generated API
|
||||
REST_API_INFO = types.rest.api.RestApiInfo(
|
||||
typed=types.rest.api.RestApiInfoGuiType.UNTYPED,
|
||||
)
|
||||
|
||||
def get_item(self, item: 'models.Model') -> ImageItem:
|
||||
item = ensure.is_instance(item, Image)
|
||||
return ImageItem(
|
||||
id=item.uuid,
|
||||
name=item.name,
|
||||
data=item.data64,
|
||||
)
|
||||
|
||||
def item_as_dict(self, item: 'Model') -> dict[str, typing.Any]:
|
||||
def get_item_summary(self, item: 'models.Model') -> ImageItem:
|
||||
item = ensure.is_instance(item, Image)
|
||||
return {
|
||||
'id': item.uuid,
|
||||
'name': item.name,
|
||||
'data': item.data64,
|
||||
}
|
||||
|
||||
def item_as_dict_overview(self, item: 'Model') -> dict[str, typing.Any]:
|
||||
item = ensure.is_instance(item, Image)
|
||||
return {
|
||||
'id': item.uuid,
|
||||
'size': '{}x{}, {} bytes (thumb {} bytes)'.format(
|
||||
return ImageItem(
|
||||
id=item.uuid,
|
||||
size='{}x{}, {} bytes (thumb {} bytes)'.format(
|
||||
item.width, item.height, len(item.data), len(item.thumb)
|
||||
),
|
||||
'name': item.name,
|
||||
'thumb': item.thumb64,
|
||||
}
|
||||
name=item.name,
|
||||
thumb=item.thumb64,
|
||||
)
|
||||
|
||||
@@ -55,8 +55,8 @@ class Login(Handler):
|
||||
Responsible of user authentication
|
||||
"""
|
||||
|
||||
path = 'auth'
|
||||
authenticated = False # Public method
|
||||
PATH = 'auth'
|
||||
ROLE = consts.UserRole.ANONYMOUS
|
||||
|
||||
@staticmethod
|
||||
def result(
|
||||
@@ -156,7 +156,7 @@ class Login(Handler):
|
||||
if GlobalConfig.SUPER_USER_LOGIN.get(True) == username and CryptoManager.manager().check_hash(
|
||||
password, GlobalConfig.SUPER_USER_PASS.get(True)
|
||||
):
|
||||
self.gen_auth_token(-1, username, password, locale, platform, True, True, scrambler)
|
||||
self.gen_auth_token(-1, username, password, locale, platform, scrambler)
|
||||
return Login.result(result='ok', token=self.get_auth_token())
|
||||
return Login.result(error='Invalid credentials')
|
||||
|
||||
@@ -188,8 +188,6 @@ class Login(Handler):
|
||||
password,
|
||||
locale,
|
||||
platform,
|
||||
auth_result.user.is_admin,
|
||||
auth_result.user.staff_member,
|
||||
scrambler,
|
||||
),
|
||||
scrambler=scrambler,
|
||||
@@ -207,8 +205,8 @@ class Logout(Handler):
|
||||
Responsible of user de-authentication
|
||||
"""
|
||||
|
||||
path = 'auth'
|
||||
authenticated = True # By default, all handlers needs authentication
|
||||
PATH = 'auth'
|
||||
ROLE = consts.UserRole.USER # Must be logged in to logout :)
|
||||
|
||||
def get(self) -> typing.Any:
|
||||
# Remove auth token
|
||||
@@ -220,8 +218,8 @@ class Logout(Handler):
|
||||
|
||||
|
||||
class Auths(Handler):
|
||||
path = 'auth'
|
||||
authenticated = False # By default, all handlers needs authentication
|
||||
PATH = 'auth'
|
||||
ROLE = consts.UserRole.ANONYMOUS
|
||||
|
||||
def auths(self) -> collections.abc.Iterable[dict[str, typing.Any]]:
|
||||
all_param: bool = self._params.get('all', 'false').lower() == 'true'
|
||||
@@ -231,11 +229,14 @@ class Auths(Handler):
|
||||
if all_param or (auth_type.is_custom() is False and auth_type.type_type not in ('IP',)):
|
||||
yield {
|
||||
'authId': auth.uuid, # Deprecated, use 'auth_id'
|
||||
'auth_id': auth.uuid,
|
||||
'auth_id': auth.uuid, # Deprecated, use 'id'
|
||||
'id': auth.uuid,
|
||||
'authSmallName': str(auth.small_name), # Deprecated
|
||||
'authLabel': str(auth.small_name), # Deprecated, use 'auth_label'
|
||||
'auth_label': str(auth.small_name),
|
||||
'auth': auth.name,
|
||||
'auth_label': str(auth.small_name), # Deprecated, use 'label'
|
||||
'label': str(auth.small_name),
|
||||
'auth': auth.name, # Deprecated, use 'name'
|
||||
'name': auth.name,
|
||||
'type': auth_type.type_type,
|
||||
'priority': auth.priority,
|
||||
'isCustom': auth_type.is_custom(), # Deprecated, use 'custom'
|
||||
|
||||
@@ -30,16 +30,17 @@
|
||||
"""
|
||||
Author: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
"""
|
||||
import dataclasses
|
||||
import logging
|
||||
import typing
|
||||
|
||||
from django.utils.translation import gettext
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.translation import gettext, gettext_lazy as _
|
||||
from django.db import models
|
||||
|
||||
from uds.core import types, exceptions
|
||||
from uds.core.consts.images import DEFAULT_THUMB_BASE64
|
||||
from uds.core.ui import gui
|
||||
from uds.core.util import ensure, permissions
|
||||
from uds.core import ui
|
||||
from uds.core.util import ensure, permissions, ui as ui_utils
|
||||
from uds.core.util.model import process_uuid
|
||||
from uds.core.types.states import State
|
||||
from uds.models import Image, MetaPool, ServicePoolGroup
|
||||
@@ -49,26 +50,47 @@ from uds.REST.model import ModelHandler
|
||||
from .meta_service_pools import MetaAssignedService, MetaServicesPool
|
||||
from .user_services import Groups
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from django.db.models import Model
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MetaPools(ModelHandler):
|
||||
@dataclasses.dataclass
|
||||
class MetaPoolItem(types.rest.BaseRestItem):
|
||||
id: str
|
||||
name: str
|
||||
short_name: str
|
||||
tags: list[str]
|
||||
comments: str
|
||||
thumb: str
|
||||
image_id: str | None
|
||||
servicesPoolGroup_id: str | None
|
||||
pool_group_name: str | None
|
||||
pool_group_thumb: str | None
|
||||
user_services_count: int
|
||||
user_services_in_preparation: int
|
||||
visible: bool
|
||||
policy: str
|
||||
fallbackAccess: str
|
||||
permission: int
|
||||
calendar_message: str
|
||||
transport_grouping: int
|
||||
ha_policy: str
|
||||
|
||||
|
||||
class MetaPools(ModelHandler[MetaPoolItem]):
|
||||
"""
|
||||
Handles Services Pools REST requests
|
||||
"""
|
||||
|
||||
model = MetaPool
|
||||
detail = {
|
||||
MODEL = MetaPool
|
||||
DETAIL = {
|
||||
'pools': MetaServicesPool,
|
||||
'services': MetaAssignedService,
|
||||
'groups': Groups,
|
||||
'access': AccessCalendars,
|
||||
}
|
||||
|
||||
save_fields = [
|
||||
FIELDS_TO_SAVE = [
|
||||
'name',
|
||||
'short_name',
|
||||
'comments',
|
||||
@@ -82,35 +104,40 @@ class MetaPools(ModelHandler):
|
||||
'transport_grouping',
|
||||
]
|
||||
|
||||
table_title = _('Meta Pools')
|
||||
table_fields = [
|
||||
{'name': {'title': _('Name')}},
|
||||
{'comments': {'title': _('Comments')}},
|
||||
{
|
||||
'policy': {
|
||||
'title': _('Policy'),
|
||||
'type': 'dict',
|
||||
'dict': dict(types.pools.LoadBalancingPolicy.enumerate()),
|
||||
}
|
||||
},
|
||||
{
|
||||
'ha_policy': {
|
||||
'title': _('HA Policy'),
|
||||
'type': 'dict',
|
||||
'dict': dict(types.pools.HighAvailabilityPolicy.enumerate()),
|
||||
}
|
||||
},
|
||||
{'user_services_count': {'title': _('User services'), 'type': 'number'}},
|
||||
{'user_services_in_preparation': {'title': _('In Preparation')}},
|
||||
{'visible': {'title': _('Visible'), 'type': 'callback'}},
|
||||
{'pool_group_name': {'title': _('Pool Group')}},
|
||||
{'label': {'title': _('Label')}},
|
||||
{'tags': {'title': _('tags'), 'visible': False}},
|
||||
TABLE = (
|
||||
ui_utils.TableBuilder(_('Meta Pools'))
|
||||
.text_column(name='name', title=_('Name'))
|
||||
.text_column(name='comments', title=_('Comments'))
|
||||
.dict_column(
|
||||
name='policy',
|
||||
title=_('Policy'),
|
||||
dct=dict(types.pools.LoadBalancingPolicy.enumerate()),
|
||||
)
|
||||
.dict_column(
|
||||
name='ha_policy',
|
||||
title=_('HA Policy'),
|
||||
dct=dict(types.pools.HighAvailabilityPolicy.enumerate()),
|
||||
)
|
||||
.numeric_column(name='user_services_count', title=_('User services'))
|
||||
.numeric_column(name='user_services_in_preparation', title=_('In Preparation'))
|
||||
.boolean(name='visible', title=_('Visible'))
|
||||
.text_column(name='pool_group_name', title=_('Pool Group'), width='16em')
|
||||
.text_column(name='short_name', title=_('Label'))
|
||||
.text_column(name='tags', title=_('tags'), visible=False)
|
||||
.build()
|
||||
)
|
||||
|
||||
CUSTOM_METHODS = [
|
||||
types.rest.ModelCustomMethod('set_fallback_access', True),
|
||||
types.rest.ModelCustomMethod('get_fallback_access', True),
|
||||
]
|
||||
|
||||
custom_methods = [('setFallbackAccess', True), ('getFallbackAccess', True)]
|
||||
# Rest api related information to complete the auto-generated API
|
||||
REST_API_INFO = types.rest.api.RestApiInfo(
|
||||
typed=types.rest.api.RestApiInfoGuiType.SINGLE_TYPE,
|
||||
)
|
||||
|
||||
def item_as_dict(self, item: 'Model') -> dict[str, typing.Any]:
|
||||
def get_item(self, item: 'models.Model') -> MetaPoolItem:
|
||||
item = ensure.is_instance(item, MetaPool)
|
||||
# if item does not have an associated service, hide it (the case, for example, for a removed service)
|
||||
# Access from dict will raise an exception, and item will be skipped
|
||||
@@ -131,126 +158,93 @@ class MetaPools(ModelHandler):
|
||||
(i.pool.userServices.filter(state=State.PREPARING).count()) for i in all_pools
|
||||
)
|
||||
|
||||
val = {
|
||||
'id': item.uuid,
|
||||
'name': item.name,
|
||||
'short_name': item.short_name,
|
||||
'tags': [tag.tag for tag in item.tags.all()],
|
||||
'comments': item.comments,
|
||||
'thumb': item.image.thumb64 if item.image is not None else DEFAULT_THUMB_BASE64,
|
||||
'image_id': item.image.uuid if item.image is not None else None,
|
||||
'servicesPoolGroup_id': pool_group_id,
|
||||
'pool_group_name': pool_group_name,
|
||||
'pool_group_thumb': pool_group_thumb,
|
||||
'user_services_count': userservices_total,
|
||||
'user_services_in_preparation': userservices_in_preparation,
|
||||
'visible': item.visible,
|
||||
'policy': str(item.policy),
|
||||
'fallbackAccess': item.fallbackAccess,
|
||||
'permission': permissions.effective_permissions(self._user, item),
|
||||
'calendar_message': item.calendar_message,
|
||||
'transport_grouping': item.transport_grouping,
|
||||
'ha_policy': str(item.ha_policy),
|
||||
}
|
||||
|
||||
return val
|
||||
return MetaPoolItem(
|
||||
id=item.uuid,
|
||||
name=item.name,
|
||||
short_name=item.short_name,
|
||||
tags=[tag.tag for tag in item.tags.all()],
|
||||
comments=item.comments,
|
||||
thumb=item.image.thumb64 if item.image is not None else DEFAULT_THUMB_BASE64,
|
||||
image_id=item.image.uuid if item.image is not None else None,
|
||||
servicesPoolGroup_id=pool_group_id,
|
||||
pool_group_name=pool_group_name,
|
||||
pool_group_thumb=pool_group_thumb,
|
||||
user_services_count=userservices_total,
|
||||
user_services_in_preparation=userservices_in_preparation,
|
||||
visible=item.visible,
|
||||
policy=str(item.policy),
|
||||
fallbackAccess=item.fallbackAccess,
|
||||
permission=permissions.effective_permissions(self._user, item),
|
||||
calendar_message=item.calendar_message,
|
||||
transport_grouping=item.transport_grouping,
|
||||
ha_policy=str(item.ha_policy),
|
||||
)
|
||||
|
||||
# Gui related
|
||||
def get_gui(self, type_: str) -> list[typing.Any]:
|
||||
local_gui = self.add_default_fields([], ['name', 'comments', 'tags'])
|
||||
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
|
||||
|
||||
for field in [
|
||||
{
|
||||
'name': 'short_name',
|
||||
'type': 'text',
|
||||
'label': _('Short name'),
|
||||
'tooltip': _('Short name for user service visualization'),
|
||||
'required': False,
|
||||
'length': 32,
|
||||
'order': 0 - 95,
|
||||
},
|
||||
{
|
||||
'name': 'policy',
|
||||
'choices': [gui.choice_item(k, str(v)) for k, v in types.pools.LoadBalancingPolicy.enumerate()],
|
||||
'label': gettext('Load balancing policy'),
|
||||
'tooltip': gettext('Service pool load balancing policy'),
|
||||
'type': types.ui.FieldType.CHOICE,
|
||||
'order': 100,
|
||||
},
|
||||
{
|
||||
'name': 'ha_policy',
|
||||
'choices': [
|
||||
gui.choice_item(k, str(v)) for k, v in types.pools.HighAvailabilityPolicy.enumerate()
|
||||
return (
|
||||
ui_utils.GuiBuilder()
|
||||
.add_stock_field(types.rest.stock.StockField.TAGS)
|
||||
.add_text(
|
||||
name='short_name',
|
||||
label=gettext('Short name'),
|
||||
tooltip=gettext('Short name for user service visualization'),
|
||||
length=32,
|
||||
)
|
||||
.add_stock_field(types.rest.stock.StockField.NAME)
|
||||
.add_stock_field(types.rest.stock.StockField.COMMENTS)
|
||||
.set_order(100)
|
||||
.add_multichoice(
|
||||
name='policy',
|
||||
label=gettext('Load balancing policy'),
|
||||
choices=[ui.gui.choice_item(k, str(v)) for k, v in types.pools.LoadBalancingPolicy.enumerate()],
|
||||
tooltip=gettext('Service pool load balancing policy'),
|
||||
)
|
||||
.add_choice(
|
||||
name='ha_policy',
|
||||
label=gettext('HA Policy'),
|
||||
choices=[
|
||||
ui.gui.choice_item(k, str(v)) for k, v in types.pools.HighAvailabilityPolicy.enumerate()
|
||||
],
|
||||
'label': gettext('HA Policy'),
|
||||
'tooltip': gettext(
|
||||
'Service pool High Availability policy. If enabled and a pool fails, it will be restarted in another pool. Enable with care!.'
|
||||
tooltip=gettext(
|
||||
'Service pool High Availability policy. If enabled and a pool fails, it will be restarted in another pool. Enable with care!'
|
||||
),
|
||||
'type': types.ui.FieldType.CHOICE,
|
||||
'order': 101,
|
||||
},
|
||||
{
|
||||
'name': 'image_id',
|
||||
'choices': [gui.choice_image(-1, '--------', DEFAULT_THUMB_BASE64)]
|
||||
+ gui.sorted_choices(
|
||||
[gui.choice_image(v.uuid, v.name, v.thumb64) for v in Image.objects.all()]
|
||||
),
|
||||
'label': gettext('Associated Image'),
|
||||
'tooltip': gettext('Image assocciated with this service'),
|
||||
'type': types.ui.FieldType.IMAGECHOICE,
|
||||
'order': 120,
|
||||
'tab': types.ui.Tab.DISPLAY,
|
||||
},
|
||||
{
|
||||
'name': 'servicesPoolGroup_id',
|
||||
'choices': [gui.choice_image(-1, _('Default'), DEFAULT_THUMB_BASE64)]
|
||||
+ gui.sorted_choices(
|
||||
[
|
||||
gui.choice_image(v.uuid, v.name, v.thumb64)
|
||||
for v in ServicePoolGroup.objects.all()
|
||||
]
|
||||
),
|
||||
'label': gettext('Pool group'),
|
||||
'tooltip': gettext('Pool group for this pool (for pool classify on display)'),
|
||||
'type': types.ui.FieldType.IMAGECHOICE,
|
||||
'order': 121,
|
||||
'tab': types.ui.Tab.DISPLAY,
|
||||
},
|
||||
{
|
||||
'name': 'visible',
|
||||
'value': True,
|
||||
'label': gettext('Visible'),
|
||||
'tooltip': gettext('If active, metapool will be visible for users'),
|
||||
'type': types.ui.FieldType.CHECKBOX,
|
||||
'order': 123,
|
||||
'tab': types.ui.Tab.DISPLAY,
|
||||
},
|
||||
{
|
||||
'name': 'calendar_message',
|
||||
'value': '',
|
||||
'label': gettext('Calendar access denied text'),
|
||||
'tooltip': gettext(
|
||||
'Custom message to be shown to users if access is limited by calendar rules.'
|
||||
),
|
||||
'type': types.ui.FieldType.TEXT,
|
||||
'order': 124,
|
||||
'tab': types.ui.Tab.DISPLAY,
|
||||
},
|
||||
{
|
||||
'name': 'transport_grouping',
|
||||
'choices': [
|
||||
gui.choice_item(k, str(v)) for k, v in types.pools.TransportSelectionPolicy.enumerate()
|
||||
)
|
||||
.new_tab(types.ui.Tab.DISPLAY)
|
||||
.add_image_choice()
|
||||
.add_image_choice(
|
||||
name='servicesPoolGroup_id',
|
||||
label=gettext('Pool group'),
|
||||
choices=[
|
||||
ui.gui.choice_image(
|
||||
x.uuid, x.name, x.image.thumb64 if x.image is not None else DEFAULT_THUMB_BASE64
|
||||
)
|
||||
for x in ServicePoolGroup.objects.all()
|
||||
],
|
||||
'label': gettext('Transport Selection'),
|
||||
'tooltip': gettext('Transport selection policy'),
|
||||
'type': types.ui.FieldType.CHOICE,
|
||||
'order': 125,
|
||||
'tab': types.ui.Tab.DISPLAY,
|
||||
},
|
||||
]:
|
||||
self.add_field(local_gui, field)
|
||||
|
||||
return local_gui
|
||||
tooltip=gettext('Pool group for this pool (for pool classify on display)'),
|
||||
)
|
||||
.add_checkbox(
|
||||
name='visible',
|
||||
label=gettext('Visible'),
|
||||
tooltip=gettext('If active, metapool will be visible for users'),
|
||||
default=True,
|
||||
)
|
||||
.add_text(
|
||||
name='calendar_message',
|
||||
label=gettext('Calendar access denied text'),
|
||||
tooltip=gettext('Custom message to be shown to users if access is limited by calendar rules.'),
|
||||
)
|
||||
.add_choice(
|
||||
name='transport_grouping', # Transport Selection
|
||||
label=gettext('Transport Selection'),
|
||||
choices=[
|
||||
ui.gui.choice_item(k, str(v)) for k, v in types.pools.TransportSelectionPolicy.enumerate()
|
||||
],
|
||||
tooltip=gettext('Transport selection policy'),
|
||||
)
|
||||
.build()
|
||||
)
|
||||
|
||||
def pre_save(self, fields: dict[str, typing.Any]) -> None:
|
||||
# logger.debug(self._params)
|
||||
@@ -284,13 +278,17 @@ class MetaPools(ModelHandler):
|
||||
|
||||
logger.debug('Fields: %s', fields)
|
||||
|
||||
def delete_item(self, item: 'Model') -> None:
|
||||
def delete_item(self, item: 'models.Model') -> None:
|
||||
item = ensure.is_instance(item, MetaPool)
|
||||
item.delete()
|
||||
|
||||
# Set fallback status
|
||||
def set_fallback_access(self, item: MetaPool) -> typing.Any:
|
||||
self.ensure_has_access(item, types.permissions.PermissionType.MANAGEMENT)
|
||||
"""
|
||||
API:
|
||||
Sets the fallback access for a metapool
|
||||
"""
|
||||
self.check_access(item, types.permissions.PermissionType.MANAGEMENT)
|
||||
|
||||
fallback = self._params.get('fallbackAccess', 'ALLOW')
|
||||
logger.debug('Setting fallback of %s to %s', item.name, fallback)
|
||||
|
||||
@@ -29,69 +29,92 @@
|
||||
"""
|
||||
Author: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
"""
|
||||
import dataclasses
|
||||
import logging
|
||||
import typing
|
||||
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
|
||||
from uds import models
|
||||
from uds.core import types
|
||||
from uds.core import exceptions, types
|
||||
|
||||
# from uds.models.meta_pool import MetaPool, MetaPoolMember
|
||||
# from uds.models.service_pool import ServicePool
|
||||
# from uds.models.user_service import UserService
|
||||
# from uds.models.user import User
|
||||
|
||||
from uds.core.types.rest import TableInfo
|
||||
from uds.core.types.states import State
|
||||
from uds.core.util.model import process_uuid
|
||||
from uds.core.util import log, ensure
|
||||
from uds.core.util import log, ensure, ui as ui_utils
|
||||
from uds.REST.model import DetailHandler
|
||||
from .user_services import AssignedService
|
||||
from .user_services import AssignedUserService, UserServiceItem
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from django.db.models import Model
|
||||
from django.db.models import Model
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MetaServicesPool(DetailHandler):
|
||||
@dataclasses.dataclass
|
||||
class MetaItem(types.rest.BaseRestItem):
|
||||
"""
|
||||
Item type for a Meta Pool Member
|
||||
"""
|
||||
|
||||
id: str
|
||||
pool_id: str
|
||||
name: str
|
||||
comments: str
|
||||
priority: int
|
||||
enabled: bool
|
||||
user_services_count: int
|
||||
user_services_in_preparation: int
|
||||
|
||||
pool_name: str = '' # Optional
|
||||
|
||||
|
||||
class MetaServicesPool(DetailHandler[MetaItem]):
|
||||
"""
|
||||
Processes the transports detail requests of a Service Pool
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def as_dict(item: models.MetaPoolMember) -> dict[str, typing.Any]:
|
||||
return {
|
||||
'id': item.uuid,
|
||||
'pool_id': item.pool.uuid,
|
||||
'name': item.pool.name,
|
||||
'comments': item.pool.comments,
|
||||
'priority': item.priority,
|
||||
'enabled': item.enabled,
|
||||
'user_services_count': item.pool.userServices.exclude(state__in=State.INFO_STATES).count(),
|
||||
'user_services_in_preparation': item.pool.userServices.filter(state=State.PREPARING).count(),
|
||||
}
|
||||
def as_dict(item: models.MetaPoolMember) -> 'MetaItem':
|
||||
return MetaItem(
|
||||
id=item.uuid,
|
||||
pool_id=item.pool.uuid,
|
||||
name=item.pool.name,
|
||||
comments=item.pool.comments,
|
||||
priority=item.priority,
|
||||
enabled=item.enabled,
|
||||
user_services_count=item.pool.userServices.exclude(state__in=State.INFO_STATES).count(),
|
||||
user_services_in_preparation=item.pool.userServices.filter(state=State.PREPARING).count(),
|
||||
)
|
||||
|
||||
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
|
||||
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ItemsResult['MetaItem']:
|
||||
parent = ensure.is_instance(parent, models.MetaPool)
|
||||
try:
|
||||
if not item:
|
||||
return [MetaServicesPool.as_dict(i) for i in parent.members.all()]
|
||||
return [MetaServicesPool.as_dict(i) for i in self.filter_queryset(parent.members.all())]
|
||||
i = parent.members.get(uuid=process_uuid(item))
|
||||
return MetaServicesPool.as_dict(i)
|
||||
except Exception:
|
||||
except models.MetaPoolMember.DoesNotExist:
|
||||
raise exceptions.rest.NotFound(_('Meta pool member not found: {}').format(item)) from None
|
||||
except Exception as e:
|
||||
logger.exception('err: %s', item)
|
||||
raise self.invalid_item_response()
|
||||
raise exceptions.rest.RequestError(f'Error retrieving meta pool member: {e}') from e
|
||||
|
||||
def get_title(self, parent: 'Model') -> str:
|
||||
return _('Service pools')
|
||||
|
||||
def get_fields(self, parent: 'Model') -> list[typing.Any]:
|
||||
return [
|
||||
{'priority': {'title': _('Priority'), 'type': 'numeric', 'width': '6em'}},
|
||||
{'name': {'title': _('Service Pool name')}},
|
||||
{'enabled': {'title': _('Enabled')}},
|
||||
]
|
||||
def get_table(self, parent: 'Model') -> types.rest.TableInfo:
|
||||
parent = ensure.is_instance(parent, models.MetaPool)
|
||||
return (
|
||||
ui_utils.TableBuilder(_('Members of {0}').format(parent.name))
|
||||
.text_column(name='name', title=_('Name'))
|
||||
.text_column(name='comments', title=_('Comments'))
|
||||
.numeric_column(name='priority', title=_('Priority'))
|
||||
.text_column(name='enabled', title=_('Enabled'))
|
||||
.build()
|
||||
)
|
||||
|
||||
def save_item(self, parent: 'Model', item: typing.Optional[str]) -> typing.Any:
|
||||
parent = ensure.is_instance(parent, models.MetaPool)
|
||||
@@ -105,13 +128,13 @@ class MetaServicesPool(DetailHandler):
|
||||
|
||||
if uuid is not None:
|
||||
member = parent.members.get(uuid=uuid)
|
||||
member.pool = pool
|
||||
member.pool = pool
|
||||
member.enabled = enabled
|
||||
member.priority = priority
|
||||
member.save()
|
||||
else:
|
||||
member = parent.members.create(pool=pool, priority=priority, enabled=enabled)
|
||||
|
||||
|
||||
log.log(
|
||||
parent,
|
||||
types.log.LogLevel.INFO,
|
||||
@@ -122,7 +145,6 @@ class MetaServicesPool(DetailHandler):
|
||||
|
||||
return {'id': member.uuid}
|
||||
|
||||
|
||||
def delete_item(self, parent: 'Model', item: str) -> None:
|
||||
parent = ensure.is_instance(parent, models.MetaPool)
|
||||
member = parent.members.get(uuid=process_uuid(self._args[0]))
|
||||
@@ -133,7 +155,7 @@ class MetaServicesPool(DetailHandler):
|
||||
log.log(parent, types.log.LogLevel.INFO, log_str, types.log.LogSource.ADMIN)
|
||||
|
||||
|
||||
class MetaAssignedService(DetailHandler):
|
||||
class MetaAssignedService(DetailHandler[UserServiceItem]):
|
||||
"""
|
||||
Rest handler for Assigned Services, wich parent is Service
|
||||
"""
|
||||
@@ -143,10 +165,10 @@ class MetaAssignedService(DetailHandler):
|
||||
meta_pool: 'models.MetaPool',
|
||||
item: 'models.UserService',
|
||||
props: typing.Optional[dict[str, typing.Any]],
|
||||
) -> dict[str, typing.Any]:
|
||||
element = AssignedService.item_as_dict(item, props, False)
|
||||
element['pool_id'] = item.deployed_service.uuid
|
||||
element['pool_name'] = item.deployed_service.name
|
||||
) -> 'UserServiceItem':
|
||||
element = AssignedUserService.userservice_item(item, props, False)
|
||||
element.pool_id = item.deployed_service.uuid
|
||||
element.pool_name = item.deployed_service.name
|
||||
return element
|
||||
|
||||
def _get_assigned_userservice(self, metapool: models.MetaPool, userservice_id: str) -> models.UserService:
|
||||
@@ -160,17 +182,21 @@ class MetaAssignedService(DetailHandler):
|
||||
cache_level=0,
|
||||
deployed_service__in=[i.pool for i in metapool.members.all()],
|
||||
)[0]
|
||||
except IndexError:
|
||||
raise exceptions.rest.NotFound(_('User service not found: {}').format(userservice_id)) from None
|
||||
except Exception:
|
||||
raise self.invalid_item_response()
|
||||
logger.error('Error getting assigned userservice %s for metapool %s', userservice_id, metapool.uuid)
|
||||
raise exceptions.rest.RequestError(
|
||||
_('Error retrieving assigned service: {}').format(userservice_id)
|
||||
) from None
|
||||
|
||||
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
|
||||
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ItemsResult[UserServiceItem]:
|
||||
parent = ensure.is_instance(parent, models.MetaPool)
|
||||
|
||||
def _assigned_userservices_for_pools() -> (
|
||||
typing.Generator[
|
||||
tuple[models.UserService, typing.Optional[dict[str, typing.Any]]], None, None
|
||||
]
|
||||
typing.Generator[tuple[models.UserService, typing.Optional[dict[str, typing.Any]]], None, None]
|
||||
):
|
||||
for m in parent.members.filter(enabled=True):
|
||||
for m in self.filter_queryset(parent.members.filter(enabled=True)):
|
||||
properties: dict[str, typing.Any] = {
|
||||
k: v
|
||||
for k, v in models.Properties.objects.filter(
|
||||
@@ -203,47 +229,40 @@ class MetaAssignedService(DetailHandler):
|
||||
).values_list('key', 'value')
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.exception('get_items')
|
||||
raise self.invalid_item_response()
|
||||
raise exceptions.rest.RequestError(f'Error retrieving meta pool member: {e}') from e
|
||||
|
||||
def get_title(self, parent: 'Model') -> str:
|
||||
def get_table(self, parent: 'Model') -> TableInfo:
|
||||
parent = ensure.is_instance(parent, models.MetaPool)
|
||||
return _('Assigned services')
|
||||
return (
|
||||
ui_utils.TableBuilder(_('Assigned services to {0}').format(parent.name))
|
||||
.datetime_column(name='creation_date', title=_('Creation date'))
|
||||
.text_column(name='pool_name', title=_('Pool'))
|
||||
.text_column(name='unique_id', title='Unique ID')
|
||||
.text_column(name='ip', title=_('IP'))
|
||||
.text_column(name='friendly_name', title=_('Friendly name'))
|
||||
.dict_column(name='state', title=_('status'), dct=State.literals_dict())
|
||||
.text_column(name='in_use', title=_('In Use'))
|
||||
.text_column(name='source_host', title=_('Src Host'))
|
||||
.text_column(name='source_ip', title=_('Src Ip'))
|
||||
.text_column(name='owner', title=_('Owner'))
|
||||
.text_column(name='actor_version', title=_('Actor version'))
|
||||
.row_style(prefix='row-state-', field='state')
|
||||
.build()
|
||||
)
|
||||
|
||||
def get_fields(self, parent: 'Model') -> list[typing.Any]:
|
||||
parent = ensure.is_instance(parent, models.MetaPool)
|
||||
return [
|
||||
{'creation_date': {'title': _('Creation date'), 'type': 'datetime'}},
|
||||
{'pool_name': {'title': _('Pool')}},
|
||||
{'unique_id': {'title': 'Unique ID'}},
|
||||
{'ip': {'title': _('IP')}},
|
||||
{'friendly_name': {'title': _('Friendly name')}},
|
||||
{
|
||||
'state': {
|
||||
'title': _('status'),
|
||||
'type': 'dict',
|
||||
'dict': State.literals_dict(),
|
||||
}
|
||||
},
|
||||
{'in_use': {'title': _('In Use')}},
|
||||
{'source_host': {'title': _('Src Host')}},
|
||||
{'source_ip': {'title': _('Src Ip')}},
|
||||
{'owner': {'title': _('Owner')}},
|
||||
{'actor_version': {'title': _('Actor version')}},
|
||||
]
|
||||
|
||||
def get_row_style(self, parent: 'Model') -> types.ui.RowStyleInfo:
|
||||
return types.ui.RowStyleInfo(prefix='row-state-', field='state')
|
||||
|
||||
def get_logs(self, parent: 'Model', item: str) -> list[typing.Any]:
|
||||
parent = ensure.is_instance(parent, models.MetaPool)
|
||||
try:
|
||||
asigned_userservice = self._get_assigned_userservice(parent, item)
|
||||
logger.debug('Getting logs for %s', asigned_userservice)
|
||||
return log.get_logs(asigned_userservice)
|
||||
except Exception:
|
||||
raise self.invalid_item_response()
|
||||
assigned_userservice = self._get_assigned_userservice(parent, item)
|
||||
logger.debug('Getting logs for %s', assigned_userservice)
|
||||
return log.get_logs(assigned_userservice)
|
||||
except exceptions.rest.HandlerError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error('Error getting logs for %s', e)
|
||||
raise exceptions.rest.RequestError(f'Error retrieving logs for assigned service: {e}') from e
|
||||
|
||||
def delete_item(self, parent: 'Model', item: str) -> None:
|
||||
parent = ensure.is_instance(parent, models.MetaPool)
|
||||
@@ -256,16 +275,18 @@ class MetaAssignedService(DetailHandler):
|
||||
self._user.pretty_name,
|
||||
)
|
||||
else:
|
||||
log_str = 'Deleted cached service {} by {}'.format(userservice.friendly_name, self._user.pretty_name)
|
||||
log_str = 'Deleted cached service {} by {}'.format(
|
||||
userservice.friendly_name, self._user.pretty_name
|
||||
)
|
||||
|
||||
if userservice.state in (State.USABLE, State.REMOVING):
|
||||
userservice.release()
|
||||
elif userservice.state == State.PREPARING:
|
||||
userservice.cancel()
|
||||
elif userservice.state == State.REMOVABLE:
|
||||
raise self.invalid_item_response(_('Item already being removed'))
|
||||
raise exceptions.rest.RequestError(_('Item already being removed'))
|
||||
else:
|
||||
raise self.invalid_item_response(_('Item is not removable'))
|
||||
raise exceptions.rest.RequestError(_('Item is not removable'))
|
||||
|
||||
log.log(parent, types.log.LogLevel.INFO, log_str, types.log.LogSource.ADMIN)
|
||||
|
||||
@@ -273,14 +294,16 @@ class MetaAssignedService(DetailHandler):
|
||||
def save_item(self, parent: 'Model', item: typing.Optional[str]) -> typing.Any:
|
||||
parent = ensure.is_instance(parent, models.MetaPool)
|
||||
if item is None:
|
||||
raise self.invalid_item_response()
|
||||
raise exceptions.rest.RequestError(_('Invalid item specified'))
|
||||
|
||||
fields = self.fields_from_params(['auth_id', 'user_id'])
|
||||
userservice = self._get_assigned_userservice(parent, item)
|
||||
user = models.User.objects.get(uuid=process_uuid(fields['user_id']))
|
||||
|
||||
log_str = 'Changing ownership of service from {} to {} by {}'.format(
|
||||
userservice.user.pretty_name if userservice.user else 'unknown', user.pretty_name, self._user.pretty_name
|
||||
userservice.user.pretty_name if userservice.user else 'unknown',
|
||||
user.pretty_name,
|
||||
self._user.pretty_name,
|
||||
)
|
||||
|
||||
# If there is another service that has this same owner, raise an exception
|
||||
@@ -291,7 +314,7 @@ class MetaAssignedService(DetailHandler):
|
||||
.count()
|
||||
> 0
|
||||
):
|
||||
raise self.invalid_response_response(
|
||||
raise exceptions.rest.RequestError(
|
||||
'There is already another user service assigned to {}'.format(user.pretty_name)
|
||||
)
|
||||
|
||||
@@ -300,5 +323,5 @@ class MetaAssignedService(DetailHandler):
|
||||
|
||||
# Log change
|
||||
log.log(parent, types.log.LogLevel.INFO, log_str, types.log.LogSource.ADMIN)
|
||||
|
||||
|
||||
return {'id': userservice.uuid}
|
||||
|
||||
@@ -30,91 +30,109 @@
|
||||
'''
|
||||
@Author: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
'''
|
||||
import collections.abc
|
||||
import dataclasses
|
||||
import logging
|
||||
import typing
|
||||
import collections.abc
|
||||
|
||||
from django.db.models import Model
|
||||
from django.utils.translation import gettext
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from uds import models
|
||||
from uds.core import mfas, types
|
||||
from uds.core import exceptions, mfas, types
|
||||
from uds.core.environment import Environment
|
||||
from uds.core.util import ensure, permissions
|
||||
from uds.core.util import ui as ui_utils
|
||||
from uds.REST.model import ModelHandler
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from django.db.models import Model
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Enclosed methods under /item path
|
||||
|
||||
|
||||
class MFA(ModelHandler):
|
||||
model = models.MFA
|
||||
save_fields = ['name', 'comments', 'tags', 'remember_device', 'validity']
|
||||
@dataclasses.dataclass
|
||||
class MFAItem(types.rest.BaseRestItem):
|
||||
id: str
|
||||
name: str
|
||||
remember_device: int
|
||||
validity: int
|
||||
tags: list[str]
|
||||
comments: str
|
||||
type: str
|
||||
type_name: str
|
||||
permission: int
|
||||
|
||||
table_title = _('Multi Factor Authentication')
|
||||
table_fields = [
|
||||
{'name': {'title': _('Name'), 'visible': True, 'type': 'iconType'}},
|
||||
{'type_name': {'title': _('Type')}},
|
||||
{'comments': {'title': _('Comments')}},
|
||||
{'tags': {'title': _('tags'), 'visible': False}},
|
||||
]
|
||||
|
||||
def enum_types(self) -> collections.abc.Iterable[type[mfas.MFA]]:
|
||||
class MFA(ModelHandler[MFAItem]):
|
||||
|
||||
MODEL = models.MFA
|
||||
FIELDS_TO_SAVE = ['name', 'comments', 'tags', 'remember_device', 'validity']
|
||||
|
||||
TABLE = (
|
||||
ui_utils.TableBuilder(_('Multi Factor Authentication'))
|
||||
.icon(name='name', title=_('Name'), visible=True)
|
||||
.text_column(name='type_name', title=_('Type'))
|
||||
.text_column(name='comments', title=_('Comments'))
|
||||
.text_column(name='tags', title=_('tags'), visible=False)
|
||||
.build()
|
||||
)
|
||||
|
||||
# Rest api related information to complete the auto-generated API
|
||||
REST_API_INFO = types.rest.api.RestApiInfo(
|
||||
typed=types.rest.api.RestApiInfoGuiType.MULTIPLE_TYPES,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def possible_types(cls: type[typing.Self]) -> collections.abc.Iterable[type[mfas.MFA]]:
|
||||
return mfas.factory().providers().values()
|
||||
|
||||
def get_gui(self, type_: str) -> list[typing.Any]:
|
||||
mfa_type = mfas.factory().lookup(type_)
|
||||
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
|
||||
mfa_type = mfas.factory().lookup(for_type)
|
||||
|
||||
if not mfa_type:
|
||||
raise self.invalid_item_response()
|
||||
raise exceptions.rest.NotFound(_('MFA type not found: {}').format(for_type))
|
||||
|
||||
# Create a temporal instance to get the gui
|
||||
with Environment.temporary_environment() as env:
|
||||
mfa = mfa_type(env, None)
|
||||
|
||||
local_gui = self.add_default_fields(mfa.gui_description(), ['name', 'comments', 'tags'])
|
||||
self.add_field(
|
||||
local_gui,
|
||||
{
|
||||
'name': 'remember_device',
|
||||
'value': '0',
|
||||
'min_value': '0',
|
||||
'label': gettext('Device Caching'),
|
||||
'tooltip': gettext('Time in hours to cache device so MFA is not required again. User based.'),
|
||||
'type': types.ui.FieldType.NUMERIC,
|
||||
'order': 111,
|
||||
},
|
||||
)
|
||||
self.add_field(
|
||||
local_gui,
|
||||
{
|
||||
'name': 'validity',
|
||||
'value': '5',
|
||||
'min_value': '0',
|
||||
'label': gettext('MFA code validity'),
|
||||
'tooltip': gettext('Time in minutes to allow MFA code to be used.'),
|
||||
'type': types.ui.FieldType.NUMERIC,
|
||||
'order': 112,
|
||||
},
|
||||
return (
|
||||
ui_utils.GuiBuilder(100)
|
||||
.add_stock_field(types.rest.stock.StockField.NAME)
|
||||
.add_stock_field(types.rest.stock.StockField.COMMENTS)
|
||||
.add_stock_field(
|
||||
types.rest.stock.StockField.TAGS,
|
||||
)
|
||||
.add_fields(mfa.gui_description())
|
||||
.add_numeric(
|
||||
name='remember_device',
|
||||
default=0,
|
||||
min_value=0,
|
||||
label=gettext('Device Caching'),
|
||||
tooltip=gettext('Time in hours to cache device so MFA is not required again. User based.'),
|
||||
)
|
||||
.add_numeric(
|
||||
name='validity',
|
||||
default=5,
|
||||
min_value=0,
|
||||
label=gettext('MFA code validity'),
|
||||
tooltip=gettext('Time in minutes to allow MFA code to be used.'),
|
||||
)
|
||||
.build()
|
||||
)
|
||||
|
||||
return local_gui
|
||||
|
||||
def item_as_dict(self, item: 'Model') -> dict[str, typing.Any]:
|
||||
def get_item(self, item: 'Model') -> MFAItem:
|
||||
item = ensure.is_instance(item, models.MFA)
|
||||
type_ = item.get_type()
|
||||
return {
|
||||
'id': item.uuid,
|
||||
'name': item.name,
|
||||
'remember_device': item.remember_device,
|
||||
'validity': item.validity,
|
||||
'tags': [tag.tag for tag in item.tags.all()],
|
||||
'comments': item.comments,
|
||||
'type': type_.mod_type(),
|
||||
'type_name': type_.mod_name(),
|
||||
'permission': permissions.effective_permissions(self._user, item),
|
||||
}
|
||||
return MFAItem(
|
||||
id=item.uuid,
|
||||
name=item.name,
|
||||
remember_device=item.remember_device,
|
||||
validity=item.validity,
|
||||
tags=[tag.tag for tag in item.tags.all()],
|
||||
comments=item.comments,
|
||||
type=type_.mod_type(),
|
||||
type_name=type_.mod_name(),
|
||||
permission=permissions.effective_permissions(self._user, item),
|
||||
)
|
||||
|
||||
@@ -30,85 +30,83 @@
|
||||
"""
|
||||
@Author: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
"""
|
||||
import dataclasses
|
||||
import logging
|
||||
import typing
|
||||
|
||||
from django.utils.translation import gettext_lazy as _, gettext
|
||||
from django.db.models import Model
|
||||
|
||||
from uds.models import Network
|
||||
from uds.core import types
|
||||
from uds.core.util import permissions, ensure
|
||||
from uds.core.util import permissions, ensure, ui as ui_utils
|
||||
|
||||
from ..model import ModelHandler
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from django.db.models import Model
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Enclosed methods under /item path
|
||||
|
||||
|
||||
class Networks(ModelHandler):
|
||||
@dataclasses.dataclass
|
||||
class NetworkItem(types.rest.BaseRestItem):
|
||||
id: str
|
||||
name: str
|
||||
tags: list[str]
|
||||
net_string: str
|
||||
transports_count: int
|
||||
authenticators_count: int
|
||||
permission: types.permissions.PermissionType
|
||||
|
||||
|
||||
class Networks(ModelHandler[NetworkItem]):
|
||||
"""
|
||||
Processes REST requests about networks
|
||||
Implements specific handling for network related requests using GUI
|
||||
"""
|
||||
|
||||
model = Network
|
||||
save_fields = ['name', 'net_string', 'tags']
|
||||
MODEL = Network
|
||||
FIELDS_TO_SAVE = ['name', 'net_string', 'tags']
|
||||
|
||||
table_title = _('Networks')
|
||||
table_fields = [
|
||||
{
|
||||
'name': {
|
||||
'title': _('Name'),
|
||||
'visible': True,
|
||||
'type': 'icon',
|
||||
'icon': 'fa fa-globe text-success',
|
||||
}
|
||||
},
|
||||
{'net_string': {'title': _('Range')}},
|
||||
{
|
||||
'transports_count': {
|
||||
'title': _('Transports'),
|
||||
'type': 'numeric',
|
||||
'width': '8em',
|
||||
}
|
||||
},
|
||||
{
|
||||
'authenticators_count': {
|
||||
'title': _('Authenticators'),
|
||||
'type': 'numeric',
|
||||
'width': '8em',
|
||||
}
|
||||
},
|
||||
{'tags': {'title': _('tags'), 'visible': False}},
|
||||
]
|
||||
TABLE = (
|
||||
ui_utils.TableBuilder(_('Networks'))
|
||||
.text_column('name', _('Name'))
|
||||
.text_column('net_string', _('Range'))
|
||||
.numeric_column('transports_count', _('Transports'), width='8em')
|
||||
.numeric_column('authenticators_count', _('Authenticators'), width='8em')
|
||||
.text_column('tags', _('Tags'), visible=False)
|
||||
.build()
|
||||
)
|
||||
|
||||
def get_gui(self, type_: str) -> list[typing.Any]:
|
||||
return self.add_field(
|
||||
self.add_default_fields([], ['name', 'tags']),
|
||||
{
|
||||
'name': 'net_string',
|
||||
'value': '',
|
||||
'label': gettext('Network range'),
|
||||
'tooltip': gettext(
|
||||
'Network range. Accepts most network definitions formats (range, subnet, host, etc...'
|
||||
# Rest api related information to complete the auto-generated API
|
||||
REST_API_INFO = types.rest.api.RestApiInfo(
|
||||
typed=types.rest.api.RestApiInfoGuiType.SINGLE_TYPE,
|
||||
)
|
||||
|
||||
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
|
||||
return (
|
||||
ui_utils.GuiBuilder()
|
||||
.add_stock_field(types.rest.stock.StockField.NAME)
|
||||
.add_stock_field(types.rest.stock.StockField.COMMENTS)
|
||||
.add_stock_field(types.rest.stock.StockField.TAGS)
|
||||
.add_text(
|
||||
name='net_string',
|
||||
label=gettext('Network range'),
|
||||
tooltip=gettext(
|
||||
'Network range. Accepts most network definitions formats (range, subnet, host, etc...)'
|
||||
),
|
||||
'type': types.ui.FieldType.TEXT,
|
||||
'order': 100, # At end
|
||||
},
|
||||
)
|
||||
.build()
|
||||
)
|
||||
|
||||
def item_as_dict(self, item: 'Model') -> dict[str, typing.Any]:
|
||||
def get_item(self, item: 'Model') -> NetworkItem:
|
||||
item = ensure.is_instance(item, Network)
|
||||
return {
|
||||
'id': item.uuid,
|
||||
'name': item.name,
|
||||
'tags': [tag.tag for tag in item.tags.all()],
|
||||
'net_string': item.net_string,
|
||||
'transports_count': item.transports.count(),
|
||||
'authenticators_count': item.authenticators.count(),
|
||||
'permission': permissions.effective_permissions(self._user, item),
|
||||
}
|
||||
return NetworkItem(
|
||||
id=item.uuid,
|
||||
name=item.name,
|
||||
tags=[tag.tag for tag in item.tags.all()],
|
||||
net_string=item.net_string,
|
||||
transports_count=item.transports.count(),
|
||||
authenticators_count=item.authenticators.count(),
|
||||
permission=permissions.effective_permissions(self._user, item),
|
||||
)
|
||||
|
||||
@@ -30,33 +30,46 @@
|
||||
'''
|
||||
@Author: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
'''
|
||||
import collections.abc
|
||||
import dataclasses
|
||||
import logging
|
||||
import typing
|
||||
import collections.abc
|
||||
|
||||
from django.db.models import Model
|
||||
from django.utils.translation import gettext
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from uds.core import messaging, types
|
||||
from uds.core import exceptions, messaging, types
|
||||
from uds.core.environment import Environment
|
||||
from uds.core.ui import gui
|
||||
from uds.core.util import ensure, permissions
|
||||
from uds.core.util import ui as ui_utils
|
||||
from uds.models import LogLevel, Notifier
|
||||
from uds.REST.model import ModelHandler
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from django.db.models import Model
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Enclosed methods under /item path
|
||||
|
||||
|
||||
class Notifiers(ModelHandler):
|
||||
path = 'messaging'
|
||||
model = Notifier
|
||||
save_fields = [
|
||||
@dataclasses.dataclass
|
||||
class NotifierItem(types.rest.BaseRestItem):
|
||||
id: str
|
||||
name: str
|
||||
level: str
|
||||
enabled: bool
|
||||
tags: list[str]
|
||||
comments: str
|
||||
type: str
|
||||
type_name: str
|
||||
permission: types.permissions.PermissionType
|
||||
|
||||
|
||||
class Notifiers(ModelHandler[NotifierItem]):
|
||||
|
||||
PATH = 'messaging'
|
||||
MODEL = Notifier
|
||||
FIELDS_TO_SAVE = [
|
||||
'name',
|
||||
'comments',
|
||||
'level',
|
||||
@@ -64,66 +77,69 @@ class Notifiers(ModelHandler):
|
||||
'enabled',
|
||||
]
|
||||
|
||||
table_title = _('Notifiers')
|
||||
table_fields = [
|
||||
{'name': {'title': _('Name'), 'visible': True, 'type': 'iconType'}},
|
||||
{'type_name': {'title': _('Type')}},
|
||||
{'level': {'title': _('Level')}},
|
||||
{'enabled': {'title': _('Enabled')}},
|
||||
{'comments': {'title': _('Comments')}},
|
||||
{'tags': {'title': _('tags'), 'visible': False}},
|
||||
]
|
||||
TABLE = (
|
||||
ui_utils.TableBuilder(_('Notifiers'))
|
||||
.icon(name='name', title=_('Name'))
|
||||
.text_column(name='type_name', title=_('Type'))
|
||||
.text_column(name='level', title=_('Level'))
|
||||
.boolean(name='enabled', title=_('Enabled'))
|
||||
.text_column(name='comments', title=_('Comments'))
|
||||
.text_column(name='tags', title=_('Tags'), visible=False)
|
||||
).build()
|
||||
|
||||
def enum_types(self) -> collections.abc.Iterable[type[messaging.Notifier]]:
|
||||
# Rest api related information to complete the auto-generated API
|
||||
REST_API_INFO = types.rest.api.RestApiInfo(
|
||||
typed=types.rest.api.RestApiInfoGuiType.MULTIPLE_TYPES,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def possible_types(cls: type[typing.Self]) -> collections.abc.Iterable[type[messaging.Notifier]]:
|
||||
return messaging.factory().providers().values()
|
||||
|
||||
def get_gui(self, type_: str) -> list[typing.Any]:
|
||||
notifier_type = messaging.factory().lookup(type_)
|
||||
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
|
||||
notifier_type = messaging.factory().lookup(for_type)
|
||||
|
||||
if not notifier_type:
|
||||
raise self.invalid_item_response()
|
||||
raise exceptions.rest.NotFound(_('Notifier type not found: {}').format(for_type))
|
||||
|
||||
with Environment.temporary_environment() as env:
|
||||
notifier = notifier_type(env, None)
|
||||
|
||||
local_gui = self.add_default_fields(
|
||||
notifier.gui_description(), ['name', 'comments', 'tags']
|
||||
return (
|
||||
(
|
||||
ui_utils.GuiBuilder(100)
|
||||
.add_stock_field(types.rest.stock.StockField.NAME)
|
||||
.add_stock_field(types.rest.stock.StockField.COMMENTS)
|
||||
.add_stock_field(types.rest.stock.StockField.TAGS)
|
||||
)
|
||||
.add_fields(notifier.gui_description())
|
||||
.add_choice(
|
||||
name='level',
|
||||
choices=[gui.choice_item(i[0], i[1]) for i in LogLevel.interesting()],
|
||||
label=gettext('Level'),
|
||||
tooltip=gettext('Level of notifications'),
|
||||
default=str(LogLevel.ERROR.value),
|
||||
)
|
||||
.add_checkbox(
|
||||
name='enabled',
|
||||
label=gettext('Enabled'),
|
||||
tooltip=gettext('If checked, this notifier will be used'),
|
||||
default=True,
|
||||
)
|
||||
.build()
|
||||
)
|
||||
|
||||
for field in [
|
||||
{
|
||||
'name': 'level',
|
||||
'choices': [gui.choice_item(i[0], i[1]) for i in LogLevel.interesting()],
|
||||
'label': gettext('Level'),
|
||||
'tooltip': gettext('Level of notifications'),
|
||||
'type': types.ui.FieldType.CHOICE,
|
||||
'order': 102,
|
||||
'default': str(LogLevel.ERROR.value),
|
||||
},
|
||||
{
|
||||
'name': 'enabled',
|
||||
'label': gettext('Enabled'),
|
||||
'tooltip': gettext('If checked, this notifier will be used'),
|
||||
'type': types.ui.FieldType.CHECKBOX,
|
||||
'order': 103,
|
||||
'default': True,
|
||||
}
|
||||
]:
|
||||
self.add_field(local_gui, field)
|
||||
|
||||
return local_gui
|
||||
|
||||
def item_as_dict(self, item: 'Model') -> dict[str, typing.Any]:
|
||||
def get_item(self, item: 'Model') -> NotifierItem:
|
||||
item = ensure.is_instance(item, Notifier)
|
||||
type_ = item.get_type()
|
||||
return {
|
||||
'id': item.uuid,
|
||||
'name': item.name,
|
||||
'level': str(item.level),
|
||||
'enabled': item.enabled,
|
||||
'tags': [tag.tag for tag in item.tags.all()],
|
||||
'comments': item.comments,
|
||||
'type': type_.mod_type(),
|
||||
'type_name': type_.mod_name(),
|
||||
'permission': permissions.effective_permissions(self._user, item),
|
||||
}
|
||||
return NotifierItem(
|
||||
id=item.uuid,
|
||||
name=item.name,
|
||||
level=str(item.level),
|
||||
enabled=item.enabled,
|
||||
tags=[tag.tag for tag in item.tags.all()],
|
||||
comments=item.comments,
|
||||
type=type_.mod_type(),
|
||||
type_name=type_.mod_name(),
|
||||
permission=permissions.effective_permissions(self._user, item),
|
||||
)
|
||||
|
||||
@@ -30,21 +30,23 @@
|
||||
"""
|
||||
Author: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
"""
|
||||
import dataclasses
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import typing
|
||||
|
||||
from django.utils.translation import gettext as _
|
||||
from django.db.models import Model
|
||||
|
||||
from uds.core import types, consts
|
||||
from uds.core.util import log, ensure
|
||||
from uds.core import exceptions, types, consts
|
||||
from uds.core.types.rest import TableInfo
|
||||
from uds.core.util import log, ensure, ui as ui_utils
|
||||
from uds.core.util.model import process_uuid
|
||||
from uds import models
|
||||
from uds.REST.model import DetailHandler
|
||||
|
||||
# Not imported at runtime, just for type checking
|
||||
if typing.TYPE_CHECKING:
|
||||
from django.db.models import Model
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -52,38 +54,50 @@ ALLOW = 'ALLOW'
|
||||
DENY = 'DENY'
|
||||
|
||||
|
||||
class AccessCalendars(DetailHandler):
|
||||
@staticmethod
|
||||
def as_dict(item: 'models.CalendarAccess|models.CalendarAccessMeta') -> types.rest.ItemDictType:
|
||||
return {
|
||||
'id': item.uuid,
|
||||
'calendar_id': item.calendar.uuid,
|
||||
'calendar': item.calendar.name,
|
||||
'access': item.access,
|
||||
'priority': item.priority,
|
||||
}
|
||||
@dataclasses.dataclass
|
||||
class AccessCalendarItem(types.rest.BaseRestItem):
|
||||
id: str
|
||||
calendar_id: str
|
||||
calendar: str
|
||||
access: str
|
||||
priority: int
|
||||
|
||||
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
|
||||
|
||||
class AccessCalendars(DetailHandler[AccessCalendarItem]):
|
||||
@staticmethod
|
||||
def as_item(item: 'models.CalendarAccess|models.CalendarAccessMeta') -> AccessCalendarItem:
|
||||
return AccessCalendarItem(
|
||||
id=item.uuid,
|
||||
calendar_id=item.calendar.uuid,
|
||||
calendar=item.calendar.name,
|
||||
access=item.access,
|
||||
priority=item.priority,
|
||||
)
|
||||
|
||||
def get_items(
|
||||
self, parent: 'Model', item: typing.Optional[str]
|
||||
) -> types.rest.ItemsResult[AccessCalendarItem]:
|
||||
# parent can be a ServicePool or a metaPool
|
||||
parent = typing.cast(typing.Union['models.ServicePool', 'models.MetaPool'], parent)
|
||||
|
||||
try:
|
||||
if not item:
|
||||
return [AccessCalendars.as_dict(i) for i in parent.calendarAccess.all()]
|
||||
return AccessCalendars.as_dict(parent.calendarAccess.get(uuid=process_uuid(item)))
|
||||
return [AccessCalendars.as_item(i) for i in self.filter_queryset(parent.calendarAccess.all())]
|
||||
return AccessCalendars.as_item(parent.calendarAccess.get(uuid=process_uuid(item)))
|
||||
except models.CalendarAccess.DoesNotExist:
|
||||
raise exceptions.rest.NotFound(_('Access calendar not found: {}').format(item)) from None
|
||||
except Exception as e:
|
||||
logger.exception('err: %s', item)
|
||||
raise self.invalid_item_response() from e
|
||||
raise exceptions.rest.RequestError(f'Error retrieving access calendar: {e}') from e
|
||||
|
||||
def get_title(self, parent: 'Model') -> str:
|
||||
return _('Access restrictions by calendar')
|
||||
|
||||
def get_fields(self, parent: 'Model') -> list[typing.Any]:
|
||||
return [
|
||||
{'priority': {'title': _('Priority'), 'type': 'numeric', 'width': '6em'}},
|
||||
{'calendar': {'title': _('Calendar')}},
|
||||
{'access': {'title': _('Access')}},
|
||||
]
|
||||
def get_table(self, parent: 'Model') -> types.rest.TableInfo:
|
||||
return (
|
||||
ui_utils.TableBuilder(_('Access calendars'))
|
||||
.numeric_column('priority', _('Priority'))
|
||||
.text_column('calendar', _('Calendar'))
|
||||
.text_column('access', _('Access'))
|
||||
.build()
|
||||
)
|
||||
|
||||
def save_item(self, parent: 'Model', item: typing.Optional[str]) -> typing.Any:
|
||||
parent = typing.cast(typing.Union['models.ServicePool', 'models.MetaPool'], parent)
|
||||
@@ -91,12 +105,20 @@ class AccessCalendars(DetailHandler):
|
||||
uuid = process_uuid(item) if item is not None else None
|
||||
|
||||
try:
|
||||
calendar: models.Calendar = models.Calendar.objects.get(uuid=process_uuid(self._params['calendar_id']))
|
||||
calendar: models.Calendar = models.Calendar.objects.get(
|
||||
uuid=process_uuid(self._params['calendar_id'])
|
||||
)
|
||||
access: str = self._params['access'].upper()
|
||||
if access not in (ALLOW, DENY):
|
||||
raise Exception()
|
||||
except models.Calendar.DoesNotExist:
|
||||
raise exceptions.rest.NotFound(
|
||||
_('Calendar not found: {}').format(self._params['calendar_id'])
|
||||
) from None
|
||||
except Exception as e:
|
||||
raise self.invalid_request_response(_('Invalid parameters on request')) from e
|
||||
logger.error('Error saving calendar access: %s', e)
|
||||
raise exceptions.rest.RequestError(_('Invalid parameters on request')) from e
|
||||
|
||||
priority = int(self._params['priority'])
|
||||
|
||||
if uuid is not None:
|
||||
@@ -114,7 +136,7 @@ class AccessCalendars(DetailHandler):
|
||||
f'{"Added" if uuid is None else "Updated"} access calendar {calendar.name}/{access} by {self._user.pretty_name}',
|
||||
types.log.LogSource.ADMIN,
|
||||
)
|
||||
|
||||
|
||||
return {'id': calendar_access.uuid}
|
||||
|
||||
def delete_item(self, parent: 'Model', item: str) -> None:
|
||||
@@ -126,64 +148,76 @@ class AccessCalendars(DetailHandler):
|
||||
log.log(parent, types.log.LogLevel.INFO, log_str, types.log.LogSource.ADMIN)
|
||||
|
||||
|
||||
class ActionsCalendars(DetailHandler):
|
||||
@dataclasses.dataclass
|
||||
class ActionCalendarItem(types.rest.BaseRestItem):
|
||||
id: str
|
||||
calendar_id: str
|
||||
calendar: str
|
||||
action: str
|
||||
description: str
|
||||
at_start: bool
|
||||
events_offset: int
|
||||
params: dict[str, typing.Any]
|
||||
pretty_params: str
|
||||
next_execution: typing.Optional[datetime.datetime]
|
||||
last_execution: typing.Optional[datetime.datetime]
|
||||
|
||||
|
||||
class ActionsCalendars(DetailHandler[ActionCalendarItem]):
|
||||
"""
|
||||
Processes the transports detail requests of a Service Pool
|
||||
"""
|
||||
|
||||
custom_methods = [
|
||||
CUSTOM_METHODS = [
|
||||
'execute',
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def as_dict(item: 'models.CalendarAction') -> dict[str, typing.Any]:
|
||||
def as_dict(item: 'models.CalendarAction') -> ActionCalendarItem:
|
||||
action = consts.calendar.CALENDAR_ACTION_DICT.get(item.action)
|
||||
descrption = action.get('description') if action is not None else ''
|
||||
params = json.loads(item.params)
|
||||
return {
|
||||
'id': item.uuid,
|
||||
'calendar_id': item.calendar.uuid,
|
||||
'calendar': item.calendar.name,
|
||||
'action': item.action,
|
||||
'description': descrption,
|
||||
'at_start': item.at_start,
|
||||
'events_offset': item.events_offset,
|
||||
'params': params,
|
||||
'pretty_params': item.pretty_params,
|
||||
'next_execution': item.next_execution,
|
||||
'last_execution': item.last_execution,
|
||||
}
|
||||
return ActionCalendarItem(
|
||||
id=item.uuid,
|
||||
calendar_id=item.calendar.uuid,
|
||||
calendar=item.calendar.name,
|
||||
action=item.action,
|
||||
description=descrption,
|
||||
at_start=item.at_start,
|
||||
events_offset=item.events_offset,
|
||||
params=params,
|
||||
pretty_params=item.pretty_params,
|
||||
next_execution=item.next_execution,
|
||||
last_execution=item.last_execution,
|
||||
)
|
||||
|
||||
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
|
||||
def get_items(
|
||||
self, parent: 'Model', item: typing.Optional[str]
|
||||
) -> types.rest.ItemsResult[ActionCalendarItem]:
|
||||
parent = ensure.is_instance(parent, models.ServicePool)
|
||||
try:
|
||||
if item is None:
|
||||
return [ActionsCalendars.as_dict(i) for i in parent.calendaraction_set.all()]
|
||||
return [ActionsCalendars.as_dict(i) for i in self.filter_queryset(parent.calendaraction_set.all())]
|
||||
i = parent.calendaraction_set.get(uuid=process_uuid(item))
|
||||
return ActionsCalendars.as_dict(i)
|
||||
except models.CalendarAction.DoesNotExist:
|
||||
raise exceptions.rest.NotFound(_('Scheduled action not found: {}').format(item)) from None
|
||||
except Exception as e:
|
||||
raise self.invalid_item_response() from e
|
||||
logger.error('Error retrieving scheduled action %s: %s', item, e)
|
||||
raise exceptions.rest.RequestError(f'Error retrieving scheduled action: {e}') from e
|
||||
|
||||
def get_title(self, parent: 'Model') -> str:
|
||||
return _('Scheduled actions')
|
||||
|
||||
def get_fields(self, parent: 'Model') -> list[typing.Any]:
|
||||
return [
|
||||
{'calendar': {'title': _('Calendar')}},
|
||||
{'description': {'title': _('Action')}},
|
||||
{'pretty_params': {'title': _('Parameters')}},
|
||||
{
|
||||
'at_start': {
|
||||
'title': _('Relative to'),
|
||||
'type': 'dict',
|
||||
'dict': {True: _('Start'), False: _('End')},
|
||||
}
|
||||
},
|
||||
# {'at_start': {'title': _('At start')}},
|
||||
{'events_offset': {'title': _('Time offset')}},
|
||||
{'next_execution': {'title': _('Next execution'), 'type': 'datetime'}},
|
||||
{'last_execution': {'title': _('Last execution'), 'type': 'datetime'}},
|
||||
]
|
||||
def get_table(self, parent: 'Model') -> TableInfo:
|
||||
return (
|
||||
ui_utils.TableBuilder(_('Scheduled actions'))
|
||||
.text_column('calendar', _('Calendar'))
|
||||
.text_column('description', _('Action'))
|
||||
.text_column('pretty_params', _('Parameters'))
|
||||
.dict_column('at_start', _('Relative to'), dct={True: _('Start'), False: _('End')})
|
||||
.text_column('events_offset', _('Time offset'))
|
||||
.datetime_column('next_execution', _('Next execution'))
|
||||
.datetime_column('last_execution', _('Last execution'))
|
||||
.build()
|
||||
)
|
||||
|
||||
def save_item(self, parent: 'Model', item: typing.Optional[str]) -> typing.Any:
|
||||
parent = ensure.is_instance(parent, models.ServicePool)
|
||||
@@ -193,7 +227,7 @@ class ActionsCalendars(DetailHandler):
|
||||
calendar = models.Calendar.objects.get(uuid=process_uuid(self._params['calendar_id']))
|
||||
action = self._params['action'].upper()
|
||||
if action not in consts.calendar.CALENDAR_ACTION_DICT:
|
||||
raise self.invalid_request_response()
|
||||
raise exceptions.rest.RequestError(_('Invalid action: {}').format(action))
|
||||
events_offset = int(self._params['events_offset'])
|
||||
at_start = self._params['at_start'] not in ('false', False, '0', 0)
|
||||
params = json.dumps(self._params['params'])
|
||||
@@ -225,7 +259,7 @@ class ActionsCalendars(DetailHandler):
|
||||
)
|
||||
|
||||
log.log(parent, types.log.LogLevel.INFO, log_string, types.log.LogSource.ADMIN)
|
||||
|
||||
|
||||
return {'id': calendar_action.uuid}
|
||||
|
||||
def delete_item(self, parent: 'Model', item: str) -> None:
|
||||
@@ -247,7 +281,7 @@ class ActionsCalendars(DetailHandler):
|
||||
logger.debug('Launching action')
|
||||
uuid = process_uuid(item)
|
||||
calendar_action: models.CalendarAction = models.CalendarAction.objects.get(uuid=uuid)
|
||||
self.ensure_has_access(calendar_action, types.permissions.PermissionType.MANAGEMENT)
|
||||
self.check_access(calendar_action, types.permissions.PermissionType.MANAGEMENT)
|
||||
|
||||
log_str = (
|
||||
f'Launched scheduled action "{calendar_action.calendar.name},'
|
||||
|
||||
@@ -31,55 +31,75 @@
|
||||
Author: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
"""
|
||||
import collections.abc
|
||||
import dataclasses
|
||||
import logging
|
||||
import typing
|
||||
|
||||
from django.utils.translation import gettext, gettext_lazy as _
|
||||
from django.db.models import Model
|
||||
from django.utils.translation import gettext
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from uds.core import exceptions, osmanagers, types
|
||||
from uds.core.environment import Environment
|
||||
from uds.core.util import ensure, permissions
|
||||
from uds.core.util import ui as ui_utils
|
||||
from uds.models import OSManager
|
||||
from uds.REST.model import ModelHandler
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from django.db.models import Model
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Enclosed methods under /osm path
|
||||
|
||||
|
||||
class OsManagers(ModelHandler):
|
||||
model = OSManager
|
||||
save_fields = ['name', 'comments', 'tags']
|
||||
@dataclasses.dataclass
|
||||
class OsManagerItem(types.rest.ManagedObjectItem[OSManager]):
|
||||
id: str
|
||||
name: str
|
||||
tags: list[str]
|
||||
deployed_count: int
|
||||
servicesTypes: list[str]
|
||||
comments: str
|
||||
permission: types.permissions.PermissionType
|
||||
|
||||
table_title = _('OS Managers')
|
||||
table_fields = [
|
||||
{'name': {'title': _('Name'), 'visible': True, 'type': 'iconType'}},
|
||||
{'type_name': {'title': _('Type')}},
|
||||
{'comments': {'title': _('Comments')}},
|
||||
{'deployed_count': {'title': _('Used by'), 'type': 'numeric', 'width': '8em'}},
|
||||
{'tags': {'title': _('tags'), 'visible': False}},
|
||||
]
|
||||
|
||||
def os_manager_as_dict(self, osm: OSManager) -> dict[str, typing.Any]:
|
||||
type_ = osm.get_type()
|
||||
return {
|
||||
'id': osm.uuid,
|
||||
'name': osm.name,
|
||||
'tags': [tag.tag for tag in osm.tags.all()],
|
||||
'deployed_count': osm.deployedServices.count(),
|
||||
'type': type_.mod_type(),
|
||||
'type_name': type_.mod_name(),
|
||||
'servicesTypes': [
|
||||
class OsManagers(ModelHandler[OsManagerItem]):
|
||||
|
||||
MODEL = OSManager
|
||||
FIELDS_TO_SAVE = ['name', 'comments', 'tags']
|
||||
|
||||
TABLE = (
|
||||
ui_utils.TableBuilder(_('OS Managers'))
|
||||
.icon(name='name', title=_('Name'))
|
||||
.text_column(name='type_name', title=_('Type'))
|
||||
.text_column(name='comments', title=_('Comments'))
|
||||
.numeric_column(name='deployed_count', title=_('Used by'), width='8em')
|
||||
.text_column(name='tags', title=_('Tags'), visible=False)
|
||||
.build()
|
||||
)
|
||||
|
||||
# Rest api related information to complete the auto-generated API
|
||||
REST_API_INFO = types.rest.api.RestApiInfo(
|
||||
typed=types.rest.api.RestApiInfoGuiType.MULTIPLE_TYPES,
|
||||
)
|
||||
|
||||
def os_manager_as_dict(self, item: OSManager) -> OsManagerItem:
|
||||
type_ = item.get_type()
|
||||
ret_value = OsManagerItem(
|
||||
id=item.uuid,
|
||||
name=item.name,
|
||||
tags=[tag.tag for tag in item.tags.all()],
|
||||
deployed_count=item.deployedServices.count(),
|
||||
servicesTypes=[
|
||||
type_.services_types
|
||||
], # A list for backward compatibility. TODO: To be removed when admin interface is changed
|
||||
'comments': osm.comments,
|
||||
'permission': permissions.effective_permissions(self._user, osm),
|
||||
}
|
||||
comments=item.comments,
|
||||
permission=permissions.effective_permissions(self._user, item),
|
||||
item=item,
|
||||
)
|
||||
# Fill type and type_name
|
||||
return ret_value
|
||||
|
||||
def item_as_dict(self, item: 'Model') -> types.rest.ItemDictType:
|
||||
def get_item(self, item: 'Model') -> OsManagerItem:
|
||||
item = ensure.is_instance(item, OSManager)
|
||||
return self.os_manager_as_dict(item)
|
||||
|
||||
@@ -92,22 +112,26 @@ class OsManagers(ModelHandler):
|
||||
)
|
||||
|
||||
# Types related
|
||||
def enum_types(self) -> collections.abc.Iterable[type[osmanagers.OSManager]]:
|
||||
@classmethod
|
||||
def possible_types(cls: type[typing.Self]) -> collections.abc.Iterable[type[osmanagers.OSManager]]:
|
||||
return osmanagers.factory().providers().values()
|
||||
|
||||
# Gui related
|
||||
def get_gui(self, type_: str) -> list[typing.Any]:
|
||||
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
|
||||
try:
|
||||
osmanager_type = osmanagers.factory().lookup(type_)
|
||||
osmanager_type = osmanagers.factory().lookup(for_type)
|
||||
|
||||
if not osmanager_type:
|
||||
raise exceptions.rest.NotFound('OS Manager type not found')
|
||||
with Environment.temporary_environment() as env:
|
||||
osmanager = osmanager_type(env, None)
|
||||
|
||||
return self.add_default_fields(
|
||||
osmanager.gui_description(),
|
||||
['name', 'comments', 'tags'],
|
||||
return (
|
||||
ui_utils.GuiBuilder()
|
||||
.add_stock_field(types.rest.stock.StockField.NAME)
|
||||
.add_stock_field(types.rest.stock.StockField.TAGS)
|
||||
.add_stock_field(types.rest.stock.StockField.COMMENTS)
|
||||
.add_fields(osmanager.gui_description())
|
||||
.build()
|
||||
)
|
||||
except:
|
||||
raise exceptions.rest.NotFound('type not found')
|
||||
raise exceptions.rest.NotFound(_('OS Manager type not found: {}').format(for_type))
|
||||
|
||||
@@ -34,16 +34,16 @@ import collections.abc
|
||||
import logging
|
||||
import typing
|
||||
|
||||
from django.db.models import Model
|
||||
|
||||
import uds.core.types.permissions
|
||||
from uds import models
|
||||
from uds.core import exceptions
|
||||
from uds.core import consts, exceptions
|
||||
from uds.core.util import permissions
|
||||
from uds.core.util.rest.tools import match
|
||||
from uds.core.util.rest.tools import match_args
|
||||
from uds.REST import Handler
|
||||
|
||||
# Not imported at runtime, just for type checking
|
||||
if typing.TYPE_CHECKING:
|
||||
from django.db.models import Model
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -54,7 +54,7 @@ class Permissions(Handler):
|
||||
Processes permissions requests
|
||||
"""
|
||||
|
||||
needs_admin = True
|
||||
ROLE = consts.UserRole.ADMIN
|
||||
|
||||
@staticmethod
|
||||
def get_class(class_name: str) -> type['Model']:
|
||||
@@ -72,7 +72,6 @@ class Permissions(Handler):
|
||||
'mfa': models.MFA,
|
||||
'servers-groups': models.ServerGroup,
|
||||
'tunnels-tunnels': models.ServerGroup, # Same as servers-groups, but different items
|
||||
|
||||
}.get(class_name, None)
|
||||
|
||||
if cls is None:
|
||||
@@ -95,21 +94,19 @@ class Permissions(Handler):
|
||||
entity = perm.user
|
||||
|
||||
# If entity is None, it means that the permission is not valid anymore (user or group deleted on db manually?)
|
||||
if not entity:
|
||||
continue
|
||||
|
||||
res.append(
|
||||
{
|
||||
'id': perm.uuid,
|
||||
'type': kind,
|
||||
'auth': entity.manager.uuid,
|
||||
'auth_name': entity.manager.name,
|
||||
'entity_id': entity.uuid,
|
||||
'entity_name': entity.name,
|
||||
'perm': perm.permission,
|
||||
'perm_name': perm.as_str,
|
||||
}
|
||||
)
|
||||
if entity:
|
||||
res.append(
|
||||
{
|
||||
'id': perm.uuid,
|
||||
'type': kind,
|
||||
'auth': entity.manager.uuid,
|
||||
'auth_name': entity.manager.name,
|
||||
'entity_id': entity.uuid,
|
||||
'entity_name': entity.name,
|
||||
'perm': perm.permission,
|
||||
'perm_name': perm.as_str,
|
||||
}
|
||||
)
|
||||
|
||||
return sorted(res, key=lambda v: v['auth_name'] + v['entity_name'])
|
||||
|
||||
@@ -118,10 +115,10 @@ class Permissions(Handler):
|
||||
Processes get requests
|
||||
"""
|
||||
logger.debug('Permissions args for GET: %s', self._args)
|
||||
|
||||
|
||||
# Update some XXX/YYYY to XXX-YYYY (as server/groups, that is a valid class name)
|
||||
if len(self._args) == 3:
|
||||
self._args = [self._args[0]+ '-' + self._args[1], self._args[2]]
|
||||
self._args = [self._args[0] + '-' + self._args[1], self._args[2]]
|
||||
|
||||
if len(self._args) != 2:
|
||||
raise exceptions.rest.RequestError('Invalid request')
|
||||
@@ -136,11 +133,17 @@ class Permissions(Handler):
|
||||
Processes put requests
|
||||
"""
|
||||
logger.debug('Put args: %s', self._args)
|
||||
|
||||
|
||||
# Update some XXX/YYYY to XXX-YYYY (as server/groups, that is a valid class name)
|
||||
if len(self._args) == 6:
|
||||
self._args = [self._args[0]+ '-' + self._args[1], self._args[2], self._args[3], self._args[4], self._args[5]]
|
||||
|
||||
self._args = [
|
||||
self._args[0] + '-' + self._args[1],
|
||||
self._args[2],
|
||||
self._args[3],
|
||||
self._args[4],
|
||||
self._args[5],
|
||||
]
|
||||
|
||||
if len(self._args) != 5 and len(self._args) != 1:
|
||||
raise exceptions.rest.RequestError('Invalid request')
|
||||
|
||||
@@ -169,33 +172,10 @@ class Permissions(Handler):
|
||||
raise exceptions.rest.RequestError('Invalid request')
|
||||
|
||||
# match is a helper function that will match the args with the given patterns
|
||||
return match(self._args,
|
||||
return match_args(
|
||||
self._args,
|
||||
no_match,
|
||||
(('<cls>', '<obj>', 'users', 'add', '<user>'), add_user_permission),
|
||||
(('<cls>', '<obj>', 'groups', 'add', '<group>'), add_group_permission),
|
||||
(('revoke', ), revoke)
|
||||
(('revoke',), revoke),
|
||||
)
|
||||
|
||||
# Old code: (Replaced by code above :) )
|
||||
# if la == 5 and self._args[3] == 'add':
|
||||
#
|
||||
# cls = Permissions.getClass(self._args[0])
|
||||
#
|
||||
# obj = cls.objects.get(uuid=self._args[1])
|
||||
#
|
||||
# if self._args[2] == 'users':
|
||||
# user = models.User.objects.get(uuid=self._args[4])
|
||||
# permissions.add_user_permission(user, obj, perm)
|
||||
# elif self._args[2] == 'groups':
|
||||
# group = models.Group.objects.get(uuid=self._args[4])
|
||||
# permissions.add_group_permission(group, obj, perm)
|
||||
# else:
|
||||
# raise exceptions.rest.RequestError('Ivalid request')
|
||||
# return Permissions.permsToDict(permissions.getPermissions(obj))
|
||||
#
|
||||
# if la == 1 and self._args[0] == 'revoke':
|
||||
# for permId in self._params.get('items', []):
|
||||
# permissions.revoke_permission_by_id(permId)
|
||||
# return []
|
||||
#
|
||||
# raise exceptions.rest.RequestError('Invalid request')
|
||||
|
||||
@@ -31,16 +31,17 @@
|
||||
Author: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
"""
|
||||
import collections.abc
|
||||
import dataclasses
|
||||
import logging
|
||||
import typing
|
||||
|
||||
from django.utils.translation import gettext
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.translation import gettext, gettext_lazy as _
|
||||
from django.db.models import Model
|
||||
|
||||
import uds.core.types.permissions
|
||||
from uds.core import exceptions, services, types
|
||||
from uds.core.environment import Environment
|
||||
from uds.core.util import ensure, permissions
|
||||
from uds.core.util import ensure, permissions, ui as ui_utils
|
||||
from uds.core.types.states import State
|
||||
from uds.models import Provider, Service, UserService
|
||||
from uds.REST.model import ModelHandler
|
||||
@@ -50,67 +51,87 @@ from .services_usage import ServicesUsage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from django.db.models import Model
|
||||
|
||||
# Helper class for Provider offers
|
||||
@dataclasses.dataclass
|
||||
class OfferItem(types.rest.BaseRestItem):
|
||||
name: str
|
||||
type: str
|
||||
description: str
|
||||
icon: str
|
||||
|
||||
|
||||
class Providers(ModelHandler):
|
||||
"""
|
||||
Providers REST handler
|
||||
"""
|
||||
@dataclasses.dataclass
|
||||
class ProviderItem(types.rest.ManagedObjectItem[Provider]):
|
||||
id: str
|
||||
name: str
|
||||
tags: list[str]
|
||||
services_count: int
|
||||
user_services_count: int
|
||||
maintenance_mode: bool
|
||||
offers: list[OfferItem]
|
||||
comments: str
|
||||
permission: types.permissions.PermissionType
|
||||
|
||||
model = Provider
|
||||
detail = {'services': DetailServices, 'usage': ServicesUsage}
|
||||
|
||||
custom_methods = [('allservices', False), ('service', False), ('maintenance', True)]
|
||||
class Providers(ModelHandler[ProviderItem]):
|
||||
|
||||
save_fields = ['name', 'comments', 'tags']
|
||||
MODEL = Provider
|
||||
DETAIL = {'services': DetailServices, 'usage': ServicesUsage}
|
||||
|
||||
table_title = _('Service providers')
|
||||
|
||||
# Table info fields
|
||||
table_fields = [
|
||||
{'name': {'title': _('Name'), 'type': 'iconType'}},
|
||||
{'type_name': {'title': _('Type')}},
|
||||
{'comments': {'title': _('Comments')}},
|
||||
{'maintenance_state': {'title': _('Status')}},
|
||||
{'services_count': {'title': _('Services'), 'type': 'numeric'}},
|
||||
{'user_services_count': {'title': _('User Services'), 'type': 'numeric'}}, # , 'width': '132px'
|
||||
{'tags': {'title': _('tags'), 'visible': False}},
|
||||
CUSTOM_METHODS = [
|
||||
types.rest.ModelCustomMethod('allservices', False),
|
||||
types.rest.ModelCustomMethod('service', False),
|
||||
types.rest.ModelCustomMethod('maintenance', True),
|
||||
]
|
||||
# Field from where to get "class" and prefix for that class, so this will generate "row-state-A, row-state-X, ....
|
||||
table_row_style = types.ui.RowStyleInfo(prefix='row-maintenance-', field='maintenance_mode')
|
||||
|
||||
def item_as_dict(self, item: 'Model') -> types.rest.ItemDictType:
|
||||
FIELDS_TO_SAVE = ['name', 'comments', 'tags']
|
||||
|
||||
TABLE = (
|
||||
ui_utils.TableBuilder(_('Service providers'))
|
||||
.icon(name='name', title=_('Name'))
|
||||
.text_column(name='type_name', title=_('Type'))
|
||||
.text_column(name='comments', title=_('Comments'))
|
||||
.numeric_column(name='services_count', title=_('Services'))
|
||||
.numeric_column(name='user_services_count', title=_('User Services'))
|
||||
.text_column(name='tags', title=_('Tags'), visible=False)
|
||||
.row_style(prefix='row-maintenance-', field='maintenance_mode')
|
||||
).build()
|
||||
|
||||
# Rest api related information to complete the auto-generated API
|
||||
REST_API_INFO = types.rest.api.RestApiInfo(
|
||||
typed=types.rest.api.RestApiInfoGuiType.MULTIPLE_TYPES,
|
||||
)
|
||||
|
||||
def get_item(self, item: 'Model') -> ProviderItem:
|
||||
item = ensure.is_instance(item, Provider)
|
||||
type_ = item.get_type()
|
||||
|
||||
# Icon can have a lot of data (1-2 Kbytes), but it's not expected to have a lot of services providers, and even so, this will work fine
|
||||
offers = [
|
||||
{
|
||||
'name': gettext(t.mod_name()),
|
||||
'type': t.mod_type(),
|
||||
'description': gettext(t.description()),
|
||||
'icon': t.icon64().replace('\n', ''),
|
||||
}
|
||||
offers: list[OfferItem] = [
|
||||
OfferItem(
|
||||
name=gettext(t.mod_name()),
|
||||
type=t.mod_type(),
|
||||
description=gettext(t.description()),
|
||||
icon=t.icon64().replace('\n', ''),
|
||||
)
|
||||
for t in type_.get_provided_services()
|
||||
]
|
||||
|
||||
return {
|
||||
'id': item.uuid,
|
||||
'name': item.name,
|
||||
'tags': [tag.vtag for tag in item.tags.all()],
|
||||
'services_count': item.services.count(),
|
||||
'user_services_count': UserService.objects.filter(deployed_service__service__provider=item)
|
||||
return ProviderItem(
|
||||
id=item.uuid,
|
||||
name=item.name,
|
||||
tags=[tag.vtag for tag in item.tags.all()],
|
||||
services_count=item.services.count(),
|
||||
user_services_count=UserService.objects.filter(deployed_service__service__provider=item)
|
||||
.exclude(state__in=(State.REMOVED, State.ERROR))
|
||||
.count(),
|
||||
'maintenance_mode': item.maintenance_mode,
|
||||
'offers': offers,
|
||||
'type': type_.mod_type(),
|
||||
'type_name': type_.mod_name(),
|
||||
'comments': item.comments,
|
||||
'permission': permissions.effective_permissions(self._user, item),
|
||||
}
|
||||
maintenance_mode=item.maintenance_mode,
|
||||
offers=offers,
|
||||
comments=item.comments,
|
||||
permission=permissions.effective_permissions(self._user, item),
|
||||
item=item,
|
||||
)
|
||||
|
||||
def validate_delete(self, item: 'Model') -> None:
|
||||
item = ensure.is_instance(item, Provider)
|
||||
@@ -118,19 +139,27 @@ class Providers(ModelHandler):
|
||||
raise exceptions.rest.RequestError(gettext('Can\'t delete providers with services'))
|
||||
|
||||
# Types related
|
||||
def enum_types(self) -> collections.abc.Iterable[type[services.ServiceProvider]]:
|
||||
@classmethod
|
||||
def possible_types(cls: type[typing.Self]) -> collections.abc.Iterable[type[services.ServiceProvider]]:
|
||||
return services.factory().providers().values()
|
||||
|
||||
# Gui related
|
||||
def get_gui(self, type_: str) -> list[typing.Any]:
|
||||
provider_type = services.factory().lookup(type_)
|
||||
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
|
||||
provider_type = services.factory().lookup(for_type)
|
||||
if provider_type:
|
||||
with Environment.temporary_environment() as env:
|
||||
provider = provider_type(env, None)
|
||||
return self.add_default_fields(provider.gui_description(), ['name', 'comments', 'tags'])
|
||||
return (
|
||||
ui_utils.GuiBuilder()
|
||||
.add_stock_field(types.rest.stock.StockField.NAME)
|
||||
.add_stock_field(types.rest.stock.StockField.TAGS)
|
||||
.add_stock_field(types.rest.stock.StockField.COMMENTS)
|
||||
.add_fields(provider.gui_description(), parent='instance')
|
||||
).build()
|
||||
|
||||
raise exceptions.rest.NotFound('Type not found!')
|
||||
|
||||
def allservices(self) -> typing.Generator[types.rest.ItemDictType, None, None]:
|
||||
def allservices(self) -> typing.Generator[types.rest.BaseRestItem, None, None]:
|
||||
"""
|
||||
Custom method that returns "all existing services", no mater who's his daddy :)
|
||||
"""
|
||||
@@ -138,33 +167,33 @@ class Providers(ModelHandler):
|
||||
try:
|
||||
perm = permissions.effective_permissions(self._user, s)
|
||||
if perm >= uds.core.types.permissions.PermissionType.READ:
|
||||
yield DetailServices.service_to_dict(s, perm, True)
|
||||
yield DetailServices.service_item(s, perm, True)
|
||||
except Exception:
|
||||
logger.exception('Passed service cause type is unknown')
|
||||
|
||||
def service(self) -> types.rest.ItemDictType:
|
||||
def service(self) -> types.rest.BaseRestItem:
|
||||
"""
|
||||
Custom method that returns a service by its uuid, no matter who's his daddy
|
||||
"""
|
||||
try:
|
||||
service = Service.objects.get(uuid=self._args[1])
|
||||
self.ensure_has_access(service.provider, uds.core.types.permissions.PermissionType.READ)
|
||||
self.check_access(service.provider, uds.core.types.permissions.PermissionType.READ)
|
||||
perm = self.get_permissions(service.provider)
|
||||
return DetailServices.service_to_dict(service, perm, True)
|
||||
return DetailServices.service_item(service, perm, True)
|
||||
except Exception:
|
||||
# logger.exception('Exception')
|
||||
return {}
|
||||
return types.rest.BaseRestItem()
|
||||
|
||||
def maintenance(self, item: 'Model') -> types.rest.ItemDictType:
|
||||
def maintenance(self, item: 'Model') -> types.rest.BaseRestItem:
|
||||
"""
|
||||
Custom method that swaps maintenance mode state for a provider
|
||||
:param item:
|
||||
"""
|
||||
item = ensure.is_instance(item, Provider)
|
||||
self.ensure_has_access(item, uds.core.types.permissions.PermissionType.MANAGEMENT)
|
||||
self.check_access(item, uds.core.types.permissions.PermissionType.MANAGEMENT)
|
||||
item.maintenance_mode = not item.maintenance_mode
|
||||
item.save()
|
||||
return self.item_as_dict(item)
|
||||
return self.get_item(item)
|
||||
|
||||
def test(self, type_: str) -> str:
|
||||
from uds.core.environment import Environment
|
||||
@@ -178,7 +207,8 @@ class Providers(ModelHandler):
|
||||
with Environment.temporary_environment() as temp_environment:
|
||||
logger.debug('spType: %s', provider_type)
|
||||
|
||||
dct = self._params.copy()
|
||||
# On 5.0 onwards, instance comes inside "instance" key
|
||||
dct = self._params.copy()['instance']
|
||||
dct['_request'] = self._request
|
||||
test_result = provider_type.test(temp_environment, dct)
|
||||
return 'ok' if test_result.success else test_result.error
|
||||
|
||||
@@ -30,13 +30,15 @@
|
||||
"""
|
||||
Author: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
"""
|
||||
import dataclasses
|
||||
import logging
|
||||
import typing
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from uds.core import types, consts
|
||||
from uds.core.util.rest.tools import match
|
||||
from uds.core import exceptions, types, consts
|
||||
from uds.core.util.rest.tools import match_args
|
||||
from uds.core.util import ui as ui_utils
|
||||
from uds.REST import model
|
||||
from uds import reports
|
||||
|
||||
@@ -58,25 +60,42 @@ VALID_PARAMS = (
|
||||
)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class ReportItem(types.rest.BaseRestItem):
|
||||
id: str
|
||||
mime_type: str
|
||||
encoded: bool
|
||||
group: str
|
||||
name: str
|
||||
description: str
|
||||
|
||||
|
||||
# Enclosed methods under /actor path
|
||||
class Reports(model.BaseModelHandler):
|
||||
class Reports(model.BaseModelHandler[ReportItem]):
|
||||
"""
|
||||
Processes reports requests
|
||||
"""
|
||||
|
||||
needs_admin = True # By default, staff is lower level needed
|
||||
ROLE = consts.UserRole.ADMIN
|
||||
|
||||
table_title = _('Available reports')
|
||||
table_fields = [
|
||||
{'group': {'title': _('Group')}},
|
||||
{'name': {'title': _('Name')}},
|
||||
{'description': {'title': _('Description')}},
|
||||
{'mime_type': {'title': _('Generates')}},
|
||||
]
|
||||
# Field from where to get "class" and prefix for that class, so this will generate "row-state-A, row-state-X, ....
|
||||
table_row_style = types.ui.RowStyleInfo(prefix='row-state-', field='state')
|
||||
TABLE = (
|
||||
ui_utils.TableBuilder(_('Available reports'))
|
||||
.text_column(name='group', title=_('Group'), visible=True)
|
||||
.text_column(name='name', title=_('Name'), visible=True)
|
||||
.text_column(name='description', title=_('Description'), visible=True)
|
||||
.text_column(name='mime_type', title=_('Generates'), visible=True)
|
||||
.row_style(prefix='row-state-', field='state')
|
||||
.build()
|
||||
)
|
||||
|
||||
def _locate_report(self, uuid: str, values: typing.Optional[typing.Dict[str, typing.Any]] = None) -> 'Report':
|
||||
# Rest api related information to complete the auto-generated API
|
||||
REST_API_INFO = types.rest.api.RestApiInfo(
|
||||
typed=types.rest.api.RestApiInfoGuiType.MULTIPLE_TYPES,
|
||||
)
|
||||
|
||||
def _locate_report(
|
||||
self, uuid: str, values: typing.Optional[typing.Dict[str, typing.Any]] = None
|
||||
) -> 'Report':
|
||||
found = None
|
||||
logger.debug('Looking for report %s', uuid)
|
||||
for i in reports.available_reports:
|
||||
@@ -85,7 +104,7 @@ class Reports(model.BaseModelHandler):
|
||||
break
|
||||
|
||||
if not found:
|
||||
raise self.invalid_request_response('Invalid report uuid!')
|
||||
raise exceptions.rest.NotFound(f'Report not found: {uuid}') from None
|
||||
|
||||
return found
|
||||
|
||||
@@ -93,21 +112,19 @@ class Reports(model.BaseModelHandler):
|
||||
logger.debug('method GET for %s, %s', self.__class__.__name__, self._args)
|
||||
|
||||
def error() -> typing.NoReturn:
|
||||
raise self.invalid_request_response()
|
||||
raise exceptions.rest.RequestError('Invalid report uuid!')
|
||||
|
||||
def report_gui(report_id: str) -> typing.Any:
|
||||
return self.get_gui(report_id)
|
||||
|
||||
return match(
|
||||
return match_args(
|
||||
self._args,
|
||||
error,
|
||||
((), lambda: list(self.get_items())),
|
||||
((), lambda: list(self.filter_data(self.get_items()))),
|
||||
((consts.rest.OVERVIEW,), lambda: list(self.get_items())),
|
||||
(
|
||||
(consts.rest.TABLEINFO,),
|
||||
lambda: self.process_table_fields(
|
||||
str(self.table_title), self.table_fields, self.table_row_style
|
||||
),
|
||||
lambda: self.TABLE.as_dict(),
|
||||
),
|
||||
((consts.rest.GUI, '<report>'), report_gui),
|
||||
)
|
||||
@@ -124,7 +141,7 @@ class Reports(model.BaseModelHandler):
|
||||
)
|
||||
|
||||
if len(self._args) != 1:
|
||||
raise self.invalid_request_response()
|
||||
raise exceptions.rest.RequestError('Invalid report uuid!')
|
||||
|
||||
report = self._locate_report(self._args[0], self._params)
|
||||
|
||||
@@ -142,23 +159,21 @@ class Reports(model.BaseModelHandler):
|
||||
return data
|
||||
except Exception as e:
|
||||
logger.exception('Generating report')
|
||||
raise self.invalid_request_response(str(e))
|
||||
raise exceptions.rest.RequestError(str(e)) from e
|
||||
|
||||
# Gui related
|
||||
def get_gui(self, type_: str) -> list[typing.Any]:
|
||||
report = self._locate_report(type_)
|
||||
return sorted(report.gui_description(), key=lambda f: f['gui']['order'])
|
||||
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
|
||||
report = self._locate_report(for_type)
|
||||
return sorted(report.gui_description(), key=lambda f: f.gui.order)
|
||||
|
||||
# Returns the list of
|
||||
def get_items(
|
||||
self, *args: typing.Any, **kwargs: typing.Any
|
||||
) -> typing.Generator[types.rest.ItemDictType, None, None]:
|
||||
def get_items(self, *args: typing.Any, **kwargs: typing.Any) -> typing.Generator[ReportItem, None, None]:
|
||||
for i in reports.available_reports:
|
||||
yield {
|
||||
'id': i.get_uuid(),
|
||||
'mime_type': i.mime_type,
|
||||
'encoded': i.encoded,
|
||||
'group': i.translated_group(),
|
||||
'name': i.translated_name(),
|
||||
'description': i.translated_description(),
|
||||
}
|
||||
yield ReportItem(
|
||||
id=i.get_uuid(),
|
||||
mime_type=i.mime_type,
|
||||
encoded=i.encoded,
|
||||
group=i.translated_group(),
|
||||
name=i.translated_name(),
|
||||
description=i.translated_description(),
|
||||
)
|
||||
|
||||
@@ -57,7 +57,7 @@ class ServerRegisterBase(Handler):
|
||||
ip = ip.split('%')[0]
|
||||
port = self._params.get('port', consts.net.SERVER_DEFAULT_LISTEN_PORT)
|
||||
|
||||
mac = self._params.get('mac', consts.MAC_UNKNOWN)
|
||||
mac = self._params.get('mac', consts.NULL_MAC)
|
||||
data = self._params.get('data', None)
|
||||
subtype = self._params.get('subtype', '')
|
||||
os = self._params.get('os', types.os.KnownOS.UNKNOWN.os_name()).lower()
|
||||
@@ -138,17 +138,34 @@ class ServerRegisterBase(Handler):
|
||||
|
||||
|
||||
class ServerRegister(ServerRegisterBase):
|
||||
needs_staff = True
|
||||
path = 'servers'
|
||||
name = 'register'
|
||||
ROLE = consts.UserRole.STAFF
|
||||
|
||||
PATH = 'servers'
|
||||
NAME = 'register'
|
||||
|
||||
|
||||
@classmethod
|
||||
def api_components(cls: type[typing.Self]) -> types.rest.api.Components:
|
||||
return types.rest.api.Components(schemas={
|
||||
'ServerRegisterItem': types.rest.api.Schema(
|
||||
type='object',
|
||||
description='A server object',
|
||||
properties={
|
||||
'id': types.rest.api.SchemaProperty(type='string'),
|
||||
'name': types.rest.api.SchemaProperty(type='string'),
|
||||
'ip': types.rest.api.SchemaProperty(type='string'),
|
||||
'port': types.rest.api.SchemaProperty(type='integer'),
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
# REST handlers for server actions
|
||||
class ServerTest(Handler):
|
||||
authenticated = False # Test is not authenticated, the auth is the token to test itself
|
||||
ROLE = consts.UserRole.ANONYMOUS
|
||||
|
||||
path = 'servers'
|
||||
name = 'test'
|
||||
PATH = 'servers'
|
||||
NAME = 'test'
|
||||
|
||||
@decorators.blocker()
|
||||
def post(self) -> collections.abc.MutableMapping[str, typing.Any]:
|
||||
@@ -172,9 +189,9 @@ class ServerEvent(Handler):
|
||||
* log
|
||||
"""
|
||||
|
||||
authenticated = False # Actor requests are not authenticated normally
|
||||
path = 'servers'
|
||||
name = 'event'
|
||||
ROLE = consts.UserRole.ANONYMOUS
|
||||
PATH = 'servers'
|
||||
NAME = 'event'
|
||||
|
||||
def get_user_service(self) -> models.UserService:
|
||||
'''
|
||||
|
||||
@@ -29,65 +29,83 @@
|
||||
"""
|
||||
Author: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
"""
|
||||
import dataclasses
|
||||
import datetime
|
||||
import logging
|
||||
import typing
|
||||
|
||||
from django.utils.translation import gettext
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.db import transaction
|
||||
from django.utils.translation import gettext, gettext_lazy as _
|
||||
from django.db.models import Model
|
||||
|
||||
from uds import models
|
||||
from uds.core import consts, types, ui
|
||||
from uds.core.util import net, permissions, ensure
|
||||
from uds.core import consts, exceptions, types
|
||||
from uds.core.types.rest import TableInfo
|
||||
from uds.core.util import net, permissions, ensure, ui as ui_utils
|
||||
from uds.core.util.model import sql_now, process_uuid
|
||||
from uds.core.exceptions.rest import NotFound, RequestError
|
||||
from uds.REST.model import DetailHandler, ModelHandler
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from django.db.models import Model
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class TokenItem(types.rest.BaseRestItem):
|
||||
id: str
|
||||
name: str
|
||||
stamp: datetime.datetime
|
||||
username: str
|
||||
ip: str
|
||||
hostname: str
|
||||
listen_port: int
|
||||
mac: str
|
||||
token: str
|
||||
type: str
|
||||
os: str
|
||||
|
||||
|
||||
# REST API for Server Tokens management (for admin interface)
|
||||
class ServersTokens(ModelHandler):
|
||||
class ServersTokens(ModelHandler[TokenItem]):
|
||||
|
||||
# servers/groups/[id]/servers
|
||||
model = models.Server
|
||||
model_exclude = {
|
||||
MODEL = models.Server
|
||||
EXCLUDE = {
|
||||
'type__in': [
|
||||
types.servers.ServerType.ACTOR,
|
||||
types.servers.ServerType.UNMANAGED,
|
||||
]
|
||||
}
|
||||
path = 'servers'
|
||||
name = 'tokens'
|
||||
PATH = 'servers'
|
||||
NAME = 'tokens'
|
||||
|
||||
table_title = _('Registered Servers')
|
||||
table_fields = [
|
||||
{'hostname': {'title': _('Hostname')}},
|
||||
{'ip': {'title': _('IP')}},
|
||||
{'type': {'title': _('Type'), 'type': 'dict', 'dict': dict(types.servers.ServerType.enumerate())}},
|
||||
{'os': {'title': _('OS')}},
|
||||
{'username': {'title': _('Issued by')}},
|
||||
{'stamp': {'title': _('Date'), 'type': 'datetime'}},
|
||||
{'mac': {'title': _('MAC Address')}},
|
||||
]
|
||||
TABLE = (
|
||||
ui_utils.TableBuilder(_('Registered Servers'))
|
||||
.text_column(name='hostname', title=_('Hostname'), visible=True)
|
||||
.text_column(name='ip', title=_('IP'), visible=True)
|
||||
.text_column(name='mac', title=_('MAC'), visible=True)
|
||||
.text_column(name='type', title=_('Type'), visible=False)
|
||||
.text_column(name='os', title=_('OS'), visible=True)
|
||||
.text_column(name='username', title=_('Issued by'), visible=True)
|
||||
.datetime_column(name='stamp', title=_('Date'), visible=True)
|
||||
.text_column(name='mac', title=_('MAC Address'), visible=False)
|
||||
.build()
|
||||
)
|
||||
|
||||
def item_as_dict(self, item: 'Model') -> dict[str, typing.Any]:
|
||||
def get_item(self, item: 'Model') -> TokenItem:
|
||||
item = typing.cast('models.Server', item) # We will receive for sure
|
||||
return {
|
||||
'id': item.uuid,
|
||||
'name': str(_('Token isued by {} from {}')).format(item.register_username, item.ip),
|
||||
'stamp': item.stamp,
|
||||
'username': item.register_username,
|
||||
'ip': item.ip,
|
||||
'hostname': item.hostname,
|
||||
'listen_port': item.listen_port,
|
||||
'mac': item.mac,
|
||||
'token': item.token,
|
||||
'type': types.servers.ServerType(item.type).as_str(),
|
||||
'os': item.os_type,
|
||||
}
|
||||
return TokenItem(
|
||||
id=item.uuid,
|
||||
name=str(_('Token isued by {} from {}')).format(item.register_username, item.ip),
|
||||
stamp=item.stamp,
|
||||
username=item.register_username,
|
||||
ip=item.ip,
|
||||
hostname=item.hostname,
|
||||
listen_port=item.listen_port,
|
||||
mac=item.mac,
|
||||
token=item.token,
|
||||
type=types.servers.ServerType(item.type).as_str(),
|
||||
os=item.os_type,
|
||||
)
|
||||
|
||||
def delete(self) -> str:
|
||||
"""
|
||||
@@ -96,157 +114,134 @@ class ServersTokens(ModelHandler):
|
||||
if len(self._args) != 1:
|
||||
raise RequestError('Delete need one and only one argument')
|
||||
|
||||
self.ensure_has_access(
|
||||
self.model(), types.permissions.PermissionType.ALL, root=True
|
||||
self.check_access(
|
||||
self.MODEL(), types.permissions.PermissionType.ALL, root=True
|
||||
) # Must have write permissions to delete
|
||||
|
||||
try:
|
||||
self.model.objects.get(uuid=process_uuid(self._args[0])).delete()
|
||||
except self.model.DoesNotExist:
|
||||
self.MODEL.objects.get(uuid=process_uuid(self._args[0])).delete()
|
||||
except self.MODEL.DoesNotExist:
|
||||
raise NotFound('Element do not exists') from None
|
||||
|
||||
return consts.OK
|
||||
|
||||
|
||||
# REST API For servers (except tunnel servers nor actors)
|
||||
class ServersServers(DetailHandler):
|
||||
custom_methods = ['maintenance', 'importcsv']
|
||||
@dataclasses.dataclass
|
||||
class ServerItem(types.rest.BaseRestItem):
|
||||
id: str
|
||||
hostname: str
|
||||
ip: str
|
||||
listen_port: int
|
||||
mac: str
|
||||
maintenance_mode: bool
|
||||
register_username: str
|
||||
stamp: datetime.datetime
|
||||
|
||||
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
|
||||
|
||||
# REST API For servers (except tunnel servers nor actors)
|
||||
class ServersServers(DetailHandler[ServerItem]):
|
||||
|
||||
CUSTOM_METHODS = ['maintenance', 'importcsv']
|
||||
|
||||
# Rest api related information to complete the auto-generated API
|
||||
REST_API_INFO = types.rest.api.RestApiInfo(
|
||||
typed=types.rest.api.RestApiInfoGuiType.SINGLE_TYPE,
|
||||
)
|
||||
|
||||
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ItemsResult[ServerItem]:
|
||||
parent = typing.cast('models.ServerGroup', parent) # We will receive for sure
|
||||
try:
|
||||
if item is None:
|
||||
q = parent.servers.all()
|
||||
q = self.filter_queryset(parent.servers.all())
|
||||
else:
|
||||
q = parent.servers.filter(uuid=process_uuid(item))
|
||||
res: types.rest.ItemListType = []
|
||||
res: list[ServerItem] = []
|
||||
i = None
|
||||
for i in q:
|
||||
val = {
|
||||
'id': i.uuid,
|
||||
'hostname': i.hostname,
|
||||
'ip': i.ip,
|
||||
'listen_port': i.listen_port,
|
||||
'mac': i.mac if i.mac != consts.MAC_UNKNOWN else '',
|
||||
'maintenance_mode': i.maintenance_mode,
|
||||
'register_username': i.register_username,
|
||||
'stamp': i.stamp,
|
||||
}
|
||||
res.append(val)
|
||||
res.append(
|
||||
ServerItem(
|
||||
id=i.uuid,
|
||||
hostname=i.hostname,
|
||||
ip=i.ip,
|
||||
listen_port=i.listen_port,
|
||||
mac=i.mac if i.mac != consts.NULL_MAC else '',
|
||||
maintenance_mode=i.maintenance_mode,
|
||||
register_username=i.register_username,
|
||||
stamp=i.stamp,
|
||||
)
|
||||
)
|
||||
if item is None:
|
||||
return res
|
||||
if not i:
|
||||
raise Exception('Item not found')
|
||||
raise exceptions.rest.NotFound(f'Server not found: {item}')
|
||||
return res[0]
|
||||
except Exception as e:
|
||||
logger.exception('REST servers')
|
||||
raise self.invalid_item_response() from e
|
||||
|
||||
def get_title(self, parent: 'Model') -> str:
|
||||
parent = ensure.is_instance(parent, models.ServerGroup)
|
||||
try:
|
||||
return (_('Servers of {0}')).format(parent.name)
|
||||
except exceptions.rest.HandlerError:
|
||||
raise
|
||||
except Exception:
|
||||
return str(_('Servers'))
|
||||
logger.exception('Error getting server')
|
||||
raise exceptions.rest.ResponseError(_('Error getting server')) from None
|
||||
|
||||
def get_fields(self, parent: 'Model') -> list[typing.Any]:
|
||||
def get_table(self, parent: 'Model') -> TableInfo:
|
||||
parent = ensure.is_instance(parent, models.ServerGroup)
|
||||
table_info = (
|
||||
ui_utils.TableBuilder(_('Servers of {0}').format(parent.name))
|
||||
.text_column(name='hostname', title=_('Hostname'))
|
||||
.text_column(name='ip', title=_('Ip'))
|
||||
.text_column(name='mac', title=_('Mac'))
|
||||
)
|
||||
if parent.is_managed():
|
||||
table_info.text_column(name='listen_port', title=_('Port'))
|
||||
|
||||
return (
|
||||
[
|
||||
{
|
||||
'hostname': {
|
||||
'title': _('Hostname'),
|
||||
}
|
||||
},
|
||||
{'ip': {'title': _('Ip')}},
|
||||
] # If not managed, we can show mac, else listen port (related to UDS Server)
|
||||
+ (
|
||||
[
|
||||
{'mac': {'title': _('Mac')}},
|
||||
]
|
||||
if not parent.is_managed()
|
||||
else [
|
||||
{'mac': {'title': _('Mac')}},
|
||||
{'listen_port': {'title': _('Port')}},
|
||||
]
|
||||
table_info.dict_column(
|
||||
name='maintenance_mode',
|
||||
title=_('State'),
|
||||
dct={True: _('Maintenance'), False: _('Normal')},
|
||||
)
|
||||
+ [
|
||||
{
|
||||
'maintenance_mode': {
|
||||
'title': _('State'),
|
||||
'type': 'dict',
|
||||
'dict': {True: _('Maintenance'), False: _('Normal')},
|
||||
}
|
||||
},
|
||||
]
|
||||
.row_style(prefix='row-maintenance-', field='maintenance_mode')
|
||||
.build()
|
||||
)
|
||||
|
||||
def get_row_style(self, parent: 'Model') -> types.ui.RowStyleInfo:
|
||||
return types.ui.RowStyleInfo(prefix='row-maintenance-', field='maintenance_mode')
|
||||
|
||||
def get_gui(self, parent: 'Model', for_type: str = '') -> list[typing.Any]:
|
||||
def get_gui(self, parent: 'Model', for_type: str) -> list[types.ui.GuiElement]:
|
||||
parent = ensure.is_instance(parent, models.ServerGroup)
|
||||
kind, subkind = parent.server_type, parent.subtype
|
||||
title = _('of type') + f' {subkind.upper()} {kind.name.capitalize()}'
|
||||
gui_builder = ui_utils.GuiBuilder(order=100)
|
||||
if kind == types.servers.ServerType.UNMANAGED:
|
||||
return self.add_field(
|
||||
[],
|
||||
[
|
||||
{
|
||||
'name': 'hostname',
|
||||
'value': '',
|
||||
'label': gettext('Hostname'),
|
||||
'tooltip': gettext('Hostname of the server. It must be resolvable by UDS'),
|
||||
'type': types.ui.FieldType.TEXT,
|
||||
'order': 100, # At end
|
||||
},
|
||||
{
|
||||
'name': 'ip',
|
||||
'value': '',
|
||||
'label': gettext('IP'),
|
||||
'tooltip': gettext('IP of the server. Used if hostname is not resolvable by UDS'),
|
||||
'type': types.ui.FieldType.TEXT,
|
||||
'order': 101, # At end
|
||||
},
|
||||
{
|
||||
'name': 'mac',
|
||||
'value': '',
|
||||
'label': gettext('Server MAC'),
|
||||
'tooltip': gettext('Optional MAC address of the server'),
|
||||
'type': types.ui.FieldType.TEXT,
|
||||
'order': 102, # At end
|
||||
},
|
||||
{
|
||||
'name': 'title',
|
||||
'value': title,
|
||||
'type': types.ui.FieldType.INFO,
|
||||
},
|
||||
],
|
||||
return (
|
||||
gui_builder.add_text(
|
||||
name='hostname',
|
||||
label=gettext('Hostname'),
|
||||
tooltip=gettext('Hostname of the server. It must be resolvable by UDS'),
|
||||
default='',
|
||||
)
|
||||
.add_text(
|
||||
name='ip',
|
||||
label=gettext('IP'),
|
||||
)
|
||||
.add_text(
|
||||
name='mac',
|
||||
label=gettext('Server MAC'),
|
||||
tooltip=gettext('Optional MAC address of the server'),
|
||||
default='',
|
||||
)
|
||||
.add_info(
|
||||
name='title',
|
||||
default=title,
|
||||
)
|
||||
.build()
|
||||
)
|
||||
else:
|
||||
return self.add_field(
|
||||
[],
|
||||
[
|
||||
{
|
||||
'name': 'server',
|
||||
'value': '',
|
||||
'label': gettext('Server'),
|
||||
'tooltip': gettext('Server to include on group'),
|
||||
'type': types.ui.FieldType.CHOICE,
|
||||
'choices': [
|
||||
ui.gui.choice_item(item.uuid, item.hostname)
|
||||
for item in models.Server.objects.filter(type=parent.type, subtype=parent.subtype)
|
||||
if item.groups.count() == 0
|
||||
],
|
||||
'order': 100, # At end
|
||||
},
|
||||
{
|
||||
'name': 'title',
|
||||
'value': title,
|
||||
'type': types.ui.FieldType.INFO,
|
||||
},
|
||||
],
|
||||
|
||||
return (
|
||||
gui_builder.add_text(
|
||||
name='server',
|
||||
label=gettext('Server'),
|
||||
tooltip=gettext('Server to include on group'),
|
||||
default='',
|
||||
)
|
||||
.add_info(name='title', default=title)
|
||||
.build()
|
||||
)
|
||||
|
||||
def save_item(self, parent: 'Model', item: typing.Optional[str]) -> typing.Any:
|
||||
parent = ensure.is_instance(parent, models.ServerGroup)
|
||||
@@ -256,10 +251,10 @@ class ServersServers(DetailHandler):
|
||||
if item is None:
|
||||
# Create new, depending on server type
|
||||
if parent.type == types.servers.ServerType.UNMANAGED:
|
||||
# Ensure mac is emty or valid
|
||||
# Ensure mac is empty or valid
|
||||
mac = self._params['mac'].strip().upper()
|
||||
if mac and not net.is_valid_mac(mac):
|
||||
raise self.invalid_request_response('Invalid MAC address')
|
||||
raise exceptions.rest.RequestError(_('Invalid MAC address'))
|
||||
# Create a new one, and add it to group
|
||||
server = models.Server.objects.create(
|
||||
register_username=self._user.pretty_name,
|
||||
@@ -282,16 +277,20 @@ class ServersServers(DetailHandler):
|
||||
# Check server type is also SERVER
|
||||
if server and server.type != types.servers.ServerType.SERVER:
|
||||
logger.error('Server type for %s is not SERVER', server.host)
|
||||
raise self.invalid_request_response() from None
|
||||
raise exceptions.rest.RequestError('Invalid server type') from None
|
||||
parent.servers.add(server)
|
||||
except Exception:
|
||||
raise self.invalid_item_response() from None
|
||||
except models.Server.DoesNotExist:
|
||||
raise exceptions.rest.NotFound(f'Server not found: {self._params["server"]}') from None
|
||||
except Exception as e:
|
||||
logger.error('Error getting server: %s', e)
|
||||
raise exceptions.rest.ResponseError('Error getting server') from None
|
||||
|
||||
return {'id': server.uuid}
|
||||
else:
|
||||
if parent.type == types.servers.ServerType.UNMANAGED:
|
||||
mac = self._params['mac'].strip().upper()
|
||||
if mac and not net.is_valid_mac(mac):
|
||||
raise self.invalid_request_response('Invalid MAC address')
|
||||
raise exceptions.rest.RequestError('Invalid MAC address')
|
||||
try:
|
||||
models.Server.objects.filter(uuid=process_uuid(item)).update(
|
||||
# Update register info also on update
|
||||
@@ -302,20 +301,20 @@ class ServersServers(DetailHandler):
|
||||
mac=mac,
|
||||
stamp=sql_now(), # Modified now
|
||||
)
|
||||
except Exception:
|
||||
raise self.invalid_item_response() from None
|
||||
except models.Server.DoesNotExist:
|
||||
raise exceptions.rest.NotFound(f'Server not found: {item}') from None
|
||||
except Exception as e:
|
||||
logger.error('Error updating server: %s', e)
|
||||
raise exceptions.rest.ResponseError('Error updating server') from None
|
||||
|
||||
else:
|
||||
# Remove current server and add the new one in a single transaction
|
||||
try:
|
||||
with transaction.atomic():
|
||||
current_server = models.Server.objects.get(uuid=process_uuid(item))
|
||||
new_server = models.Server.objects.get(uuid=process_uuid(self._params['server']))
|
||||
parent.servers.remove(current_server)
|
||||
parent.servers.add(new_server)
|
||||
item = new_server.uuid
|
||||
except Exception:
|
||||
raise self.invalid_item_response() from None
|
||||
server = models.Server.objects.get(uuid=process_uuid(item))
|
||||
parent.servers.add(server)
|
||||
except models.Server.DoesNotExist:
|
||||
raise exceptions.rest.NotFound(f'Server not found: {item}') from None
|
||||
|
||||
return {'id': item}
|
||||
|
||||
def delete_item(self, parent: 'Model', item: str) -> None:
|
||||
@@ -327,8 +326,11 @@ class ServersServers(DetailHandler):
|
||||
server.delete() # and delete server
|
||||
else:
|
||||
parent.servers.remove(server) # Just remove reference
|
||||
except Exception:
|
||||
raise self.invalid_item_response() from None
|
||||
except models.Server.DoesNotExist:
|
||||
raise exceptions.rest.NotFound(f'Server not found: {item}') from None
|
||||
except Exception as e:
|
||||
logger.error('Error deleting server %s from %s: %s', item, parent, e)
|
||||
raise exceptions.rest.ResponseError('Error deleting server') from None
|
||||
|
||||
# Custom methods
|
||||
def maintenance(self, parent: 'Model', id: str) -> typing.Any:
|
||||
@@ -338,7 +340,7 @@ class ServersServers(DetailHandler):
|
||||
:param item:
|
||||
"""
|
||||
item = models.Server.objects.get(uuid=process_uuid(id))
|
||||
self.ensure_has_access(item, types.permissions.PermissionType.MANAGEMENT)
|
||||
self.check_access(item, types.permissions.PermissionType.MANAGEMENT)
|
||||
item.maintenance_mode = not item.maintenance_mode
|
||||
item.save()
|
||||
return 'ok'
|
||||
@@ -363,11 +365,11 @@ class ServersServers(DetailHandler):
|
||||
continue
|
||||
hostname = row[0].strip()
|
||||
ip = ''
|
||||
mac = consts.MAC_UNKNOWN
|
||||
mac = consts.NULL_MAC
|
||||
if len(row) > 1:
|
||||
ip = row[1].strip()
|
||||
if len(row) > 2:
|
||||
mac = row[2].strip().upper().strip() or consts.MAC_UNKNOWN
|
||||
mac = row[2].strip().upper().strip() or consts.NULL_MAC
|
||||
if mac and not net.is_valid_mac(mac):
|
||||
import_errors.append(f'Line {line_number}: MAC {mac} is invalid, skipping')
|
||||
continue # skip invalid macs
|
||||
@@ -415,71 +417,88 @@ class ServersServers(DetailHandler):
|
||||
return import_errors
|
||||
|
||||
|
||||
class ServersGroups(ModelHandler):
|
||||
custom_methods = [('stats', True)]
|
||||
model = models.ServerGroup
|
||||
model_filter = {
|
||||
@dataclasses.dataclass
|
||||
class GroupItem(types.rest.BaseRestItem):
|
||||
id: str
|
||||
name: str
|
||||
comments: str
|
||||
type: str
|
||||
subtype: str
|
||||
type_name: str
|
||||
tags: list[str]
|
||||
servers_count: int
|
||||
permission: types.permissions.PermissionType
|
||||
|
||||
|
||||
class ServersGroups(ModelHandler[GroupItem]):
|
||||
|
||||
CUSTOM_METHODS = [
|
||||
types.rest.ModelCustomMethod('stats', True),
|
||||
]
|
||||
MODEL = models.ServerGroup
|
||||
FILTER = {
|
||||
'type__in': [
|
||||
types.servers.ServerType.SERVER,
|
||||
types.servers.ServerType.UNMANAGED,
|
||||
]
|
||||
}
|
||||
detail = {'servers': ServersServers}
|
||||
DETAIL = {'servers': ServersServers}
|
||||
|
||||
path = 'servers'
|
||||
name = 'groups'
|
||||
PATH = 'servers'
|
||||
NAME = 'groups'
|
||||
|
||||
save_fields = ['name', 'comments', 'type', 'tags'] # Subtype is appended on pre_save
|
||||
table_title = _('Servers Groups')
|
||||
table_fields = [
|
||||
{'name': {'title': _('Name')}},
|
||||
{'comments': {'title': _('Comments')}},
|
||||
{'type_name': {'title': _('Type')}},
|
||||
{'type': {'title': '', 'visible': False}},
|
||||
{'subtype': {'title': _('Subtype')}},
|
||||
{'servers_count': {'title': _('Servers')}},
|
||||
{'tags': {'title': _('tags'), 'visible': False}},
|
||||
]
|
||||
FIELDS_TO_SAVE = ['name', 'comments', 'type', 'tags'] # Subtype is appended on pre_save
|
||||
|
||||
def get_types(
|
||||
TABLE = (
|
||||
ui_utils.TableBuilder(_('Servers Groups'))
|
||||
.text_column(name='name', title=_('Name'), visible=True)
|
||||
.text_column(name='comments', title=_('Comments'))
|
||||
.text_column(name='type_name', title=_('Type'), visible=True)
|
||||
.text_column(name='type', title='', visible=False)
|
||||
.text_column(name='subtype', title=_('Subtype'), visible=True)
|
||||
.numeric_column(name='servers_count', title=_('Servers'), width='5rem')
|
||||
.text_column(name='tags', title=_('tags'), visible=False)
|
||||
.build()
|
||||
)
|
||||
|
||||
# Rest api related information to complete the auto-generated API
|
||||
REST_API_INFO = types.rest.api.RestApiInfo(
|
||||
typed=types.rest.api.RestApiInfoGuiType.MULTIPLE_TYPES,
|
||||
)
|
||||
|
||||
def enum_types(
|
||||
self, *args: typing.Any, **kwargs: typing.Any
|
||||
) -> typing.Generator[types.rest.TypeInfoDict, None, None]:
|
||||
) -> typing.Generator[types.rest.TypeInfo, None, None]:
|
||||
for i in types.servers.ServerSubtype.manager().enum():
|
||||
v = types.rest.TypeInfo(
|
||||
yield types.rest.TypeInfo(
|
||||
name=i.description,
|
||||
type=f'{i.type.name}@{i.subtype}',
|
||||
description='',
|
||||
icon=i.icon,
|
||||
group=gettext('Managed') if i.managed else gettext('Unmanaged'),
|
||||
).as_dict()
|
||||
yield v
|
||||
)
|
||||
|
||||
def get_gui(self, type_: str) -> list[typing.Any]:
|
||||
if '@' not in type_: # If no subtype, use default
|
||||
type_ += '@default'
|
||||
kind, subkind = type_.split('@')[:2]
|
||||
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
|
||||
if '@' not in for_type: # If no subtype, use default
|
||||
for_type += '@default'
|
||||
kind, subkind = for_type.split('@')[:2]
|
||||
if kind == types.servers.ServerType.SERVER.name:
|
||||
kind = _('Standard')
|
||||
elif kind == types.servers.ServerType.UNMANAGED.name:
|
||||
kind = _('Unmanaged')
|
||||
title = _('of type') + f' {subkind.upper()} {kind}'
|
||||
return self.add_field(
|
||||
self.add_default_fields(
|
||||
[],
|
||||
['name', 'comments', 'tags'],
|
||||
),
|
||||
[
|
||||
{
|
||||
'name': 'type',
|
||||
'value': type_,
|
||||
'type': types.ui.FieldType.HIDDEN,
|
||||
},
|
||||
{
|
||||
'name': 'title',
|
||||
'value': title,
|
||||
'type': types.ui.FieldType.INFO,
|
||||
},
|
||||
],
|
||||
|
||||
return (
|
||||
ui_utils.GuiBuilder()
|
||||
.add_stock_field(types.rest.stock.StockField.NAME)
|
||||
.add_stock_field(types.rest.stock.StockField.TAGS)
|
||||
.add_stock_field(types.rest.stock.StockField.COMMENTS)
|
||||
.add_hidden(name='type', default=for_type)
|
||||
.add_info(
|
||||
name='title',
|
||||
default=title,
|
||||
)
|
||||
.build()
|
||||
)
|
||||
|
||||
def pre_save(self, fields: dict[str, typing.Any]) -> None:
|
||||
@@ -489,27 +508,27 @@ class ServersGroups(ModelHandler):
|
||||
fields['subtype'] = subtype
|
||||
return super().pre_save(fields)
|
||||
|
||||
def item_as_dict(self, item: 'Model') -> dict[str, typing.Any]:
|
||||
def get_item(self, item: 'Model') -> GroupItem:
|
||||
item = ensure.is_instance(item, models.ServerGroup)
|
||||
return {
|
||||
'id': item.uuid,
|
||||
'name': item.name,
|
||||
'comments': item.comments,
|
||||
'type': f'{types.servers.ServerType(item.type).name}@{item.subtype}',
|
||||
'subtype': item.subtype.capitalize(),
|
||||
'type_name': types.servers.ServerType(item.type).name.capitalize(),
|
||||
'tags': [tag.tag for tag in item.tags.all()],
|
||||
'servers_count': item.servers.count(),
|
||||
'permission': permissions.effective_permissions(self._user, item),
|
||||
}
|
||||
return GroupItem(
|
||||
id=item.uuid,
|
||||
name=item.name,
|
||||
comments=item.comments,
|
||||
type=f'{types.servers.ServerType(item.type).name}@{item.subtype}',
|
||||
subtype=item.subtype.capitalize(),
|
||||
type_name=types.servers.ServerType(item.type).name.capitalize(),
|
||||
tags=[tag.tag for tag in item.tags.all()],
|
||||
servers_count=item.servers.count(),
|
||||
permission=permissions.effective_permissions(self._user, item),
|
||||
)
|
||||
|
||||
def delete_item(self, item: 'Model') -> None:
|
||||
item = ensure.is_instance(item, models.ServerGroup)
|
||||
"""
|
||||
Processes a DELETE request
|
||||
"""
|
||||
self.ensure_has_access(
|
||||
self.model(), permissions.PermissionType.ALL, root=True
|
||||
self.check_access(
|
||||
self.MODEL(), permissions.PermissionType.ALL, root=True
|
||||
) # Must have write permissions to delete
|
||||
|
||||
try:
|
||||
@@ -518,7 +537,7 @@ class ServersGroups(ModelHandler):
|
||||
for server in item.servers.all():
|
||||
server.delete()
|
||||
item.delete()
|
||||
except self.model.DoesNotExist:
|
||||
except self.MODEL.DoesNotExist:
|
||||
raise NotFound('Element do not exists') from None
|
||||
|
||||
def stats(self, item: 'Model') -> typing.Any:
|
||||
@@ -533,7 +552,7 @@ class ServersGroups(ModelHandler):
|
||||
'server': {
|
||||
'id': s[1].uuid,
|
||||
'hostname': s[1].hostname,
|
||||
'mac': s[1].mac if s[1].mac != consts.MAC_UNKNOWN else '',
|
||||
'mac': s[1].mac if s[1].mac != consts.NULL_MAC else '',
|
||||
'ip': s[1].ip,
|
||||
'load': s[0].load(weights=item.weights) if s[0] else 0,
|
||||
'weights': item.weights.as_dict(),
|
||||
|
||||
@@ -30,106 +30,148 @@
|
||||
"""
|
||||
Author: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
"""
|
||||
import dataclasses
|
||||
import logging
|
||||
import typing
|
||||
import collections.abc
|
||||
|
||||
from django.db import IntegrityError
|
||||
from django.utils.translation import gettext as _
|
||||
from django.db.models import Model
|
||||
|
||||
from uds import models
|
||||
|
||||
from uds.core import exceptions, types
|
||||
from uds.core import exceptions, types, module, services
|
||||
import uds.core.types.permissions
|
||||
from uds.core.util import log, permissions, ensure
|
||||
from uds.core.types.rest import TableInfo
|
||||
from uds.core.util import log, permissions, ensure, ui as ui_utils
|
||||
from uds.core.util.model import process_uuid
|
||||
from uds.core.environment import Environment
|
||||
from uds.core.consts.images import DEFAULT_THUMB_BASE64
|
||||
from uds.core.ui import gui
|
||||
from uds.core import ui
|
||||
from uds.core.types.states import State
|
||||
|
||||
|
||||
from uds.REST.model import DetailHandler
|
||||
|
||||
# Not imported at runtime, just for type checking
|
||||
if typing.TYPE_CHECKING:
|
||||
from django.db.models import Model
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Services(DetailHandler): # pylint: disable=too-many-public-methods
|
||||
@dataclasses.dataclass
|
||||
class ServiceItem(types.rest.ManagedObjectItem['models.Service']):
|
||||
id: str
|
||||
name: str
|
||||
tags: list[str]
|
||||
comments: str
|
||||
deployed_services_count: int
|
||||
user_services_count: int
|
||||
max_services_count_type: str
|
||||
maintenance_mode: bool
|
||||
permission: int
|
||||
info: 'ServiceInfo|types.rest.NotRequired' = types.rest.NotRequired.field()
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class ServiceInfo(types.rest.BaseRestItem):
|
||||
icon: str
|
||||
needs_publication: bool
|
||||
max_deployed: int
|
||||
uses_cache: bool
|
||||
uses_cache_l2: bool
|
||||
cache_tooltip: str
|
||||
cache_tooltip_l2: str
|
||||
needs_osmanager: bool
|
||||
allowed_protocols: list[str]
|
||||
services_type_provided: str
|
||||
can_reset: bool
|
||||
can_list_assignables: bool
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class ServicePoolResumeItem(types.rest.BaseRestItem):
|
||||
id: str
|
||||
name: str
|
||||
thumb: str
|
||||
user_services_count: int
|
||||
state: str
|
||||
|
||||
|
||||
class Services(DetailHandler[ServiceItem]): # pylint: disable=too-many-public-methods
|
||||
"""
|
||||
Detail handler for Services, whose parent is a Provider
|
||||
"""
|
||||
|
||||
custom_methods = ['servicepools']
|
||||
CUSTOM_METHODS = ['servicepools']
|
||||
|
||||
# Rest api related information to complete the auto-generated API
|
||||
REST_API_INFO = types.rest.api.RestApiInfo(
|
||||
typed=types.rest.api.RestApiInfoGuiType.MULTIPLE_TYPES,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def service_info(item: models.Service) -> dict[str, typing.Any]:
|
||||
def service_info(item: models.Service) -> ServiceInfo:
|
||||
info = item.get_type()
|
||||
overrided_fields = info.overrided_pools_fields or {}
|
||||
|
||||
return {
|
||||
'icon': info.icon64().replace('\n', ''),
|
||||
'needs_publication': info.publication_type is not None,
|
||||
'max_deployed': info.userservices_limit,
|
||||
'uses_cache': info.uses_cache and overrided_fields.get('uses_cache', True),
|
||||
'uses_cache_l2': info.uses_cache_l2,
|
||||
'cache_tooltip': _(info.cache_tooltip),
|
||||
'cache_tooltip_l2': _(info.cache_tooltip_l2),
|
||||
'needs_osmanager': info.needs_osmanager,
|
||||
'allowed_protocols': info.allowed_protocols,
|
||||
'services_type_provided': info.services_type_provided,
|
||||
'can_reset': info.can_reset,
|
||||
'can_list_assignables': info.can_assign(),
|
||||
}
|
||||
return ServiceInfo(
|
||||
icon=info.icon64().replace('\n', ''),
|
||||
needs_publication=info.publication_type is not None,
|
||||
max_deployed=info.userservices_limit,
|
||||
uses_cache=info.uses_cache and overrided_fields.get('uses_cache', True),
|
||||
uses_cache_l2=info.uses_cache_l2,
|
||||
cache_tooltip=_(info.cache_tooltip),
|
||||
cache_tooltip_l2=_(info.cache_tooltip_l2),
|
||||
needs_osmanager=info.needs_osmanager,
|
||||
allowed_protocols=[str(i) for i in info.allowed_protocols],
|
||||
services_type_provided=info.services_type_provided,
|
||||
can_reset=info.can_reset,
|
||||
can_list_assignables=info.can_assign(),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def service_to_dict(item: models.Service, perm: int, full: bool = False) -> types.rest.ItemDictType:
|
||||
def service_item(item: models.Service, perm: int, full: bool = False) -> ServiceItem:
|
||||
"""
|
||||
Convert a service db item to a dict for a rest response
|
||||
:param item: Service item (db)
|
||||
:param full: If full is requested, add "extra" fields to complete information
|
||||
"""
|
||||
item_type = item.get_type()
|
||||
ret_value: dict[str, typing.Any] = {
|
||||
'id': item.uuid,
|
||||
'name': item.name,
|
||||
'tags': [tag.tag for tag in item.tags.all()],
|
||||
'comments': item.comments,
|
||||
'type': item.data_type, # Compat with old code
|
||||
'data_type': item.data_type,
|
||||
'type_name': _(item_type.mod_name()),
|
||||
'deployed_services_count': item.deployedServices.count(),
|
||||
'user_services_count': models.UserService.objects.filter(deployed_service__service=item)
|
||||
ret_value = ServiceItem(
|
||||
id=item.uuid,
|
||||
name=item.name,
|
||||
tags=[tag.tag for tag in item.tags.all()],
|
||||
comments=item.comments,
|
||||
deployed_services_count=item.deployedServices.count(),
|
||||
user_services_count=models.UserService.objects.filter(deployed_service__service=item)
|
||||
.exclude(state__in=State.INFO_STATES)
|
||||
.count(),
|
||||
'max_services_count_type': str(item.max_services_count_type),
|
||||
'maintenance_mode': item.provider.maintenance_mode,
|
||||
'permission': perm,
|
||||
}
|
||||
max_services_count_type=str(item.max_services_count_type),
|
||||
maintenance_mode=item.provider.maintenance_mode,
|
||||
permission=perm,
|
||||
item=item,
|
||||
)
|
||||
|
||||
if full:
|
||||
ret_value['info'] = Services.service_info(item)
|
||||
ret_value.info = Services.service_info(item)
|
||||
|
||||
return ret_value
|
||||
|
||||
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
|
||||
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ItemsResult[ServiceItem]:
|
||||
parent = ensure.is_instance(parent, models.Provider)
|
||||
# Check what kind of access do we have to parent provider
|
||||
perm = permissions.effective_permissions(self._user, parent)
|
||||
try:
|
||||
if item is None:
|
||||
return [Services.service_to_dict(k, perm) for k in parent.services.all()]
|
||||
return [Services.service_item(k, perm) for k in self.filter_queryset(parent.services.all())]
|
||||
k = parent.services.get(uuid=process_uuid(item))
|
||||
val = Services.service_to_dict(k, perm, full=True)
|
||||
return self.fill_instance_fields(k, val)
|
||||
val = Services.service_item(k, perm, full=True)
|
||||
# On detail, ne wee to fill the instance fields by hand
|
||||
return val
|
||||
except models.Service.DoesNotExist:
|
||||
raise exceptions.rest.NotFound(_('Service not found')) from None
|
||||
except Exception as e:
|
||||
logger.error('Error getting services for %s: %s', parent, e)
|
||||
raise self.invalid_item_response(repr(e)) from e
|
||||
|
||||
def get_row_style(self, parent: 'Model') -> types.ui.RowStyleInfo:
|
||||
return types.ui.RowStyleInfo(prefix='row-maintenance-', field='maintenance_mode')
|
||||
raise exceptions.rest.ResponseError(_('Error getting services')) from None
|
||||
|
||||
def _delete_incomplete_service(self, service: models.Service) -> None:
|
||||
"""
|
||||
@@ -141,7 +183,7 @@ class Services(DetailHandler): # pylint: disable=too-many-public-methods
|
||||
except Exception: # nosec: This is a delete, we don't care about exceptions
|
||||
pass
|
||||
|
||||
def save_item(self, parent: 'Model', item: typing.Optional[str]) -> typing.Any:
|
||||
def save_item(self, parent: 'Model', item: typing.Optional[str]) -> ServiceItem:
|
||||
parent = ensure.is_instance(parent, models.Provider)
|
||||
# Extract item db fields
|
||||
# We need this fields for all
|
||||
@@ -188,22 +230,25 @@ class Services(DetailHandler): # pylint: disable=too-many-public-methods
|
||||
service.data = service_instance.serialize()
|
||||
|
||||
service.save()
|
||||
return {'id': service.uuid}
|
||||
return Services.service_item(
|
||||
service, permissions.effective_permissions(self._user, service), full=True
|
||||
)
|
||||
|
||||
except models.Service.DoesNotExist:
|
||||
raise self.invalid_item_response() from None
|
||||
raise exceptions.rest.NotFound('Service not found') from None
|
||||
except IntegrityError as e: # Duplicate key probably
|
||||
if service and service.token and not item:
|
||||
service.delete()
|
||||
raise exceptions.rest.RequestError(
|
||||
_('Service token seems to be in use by other service. Please, select a new one.')
|
||||
'Service token seems to be in use by other service. Please, select a new one.'
|
||||
) from e
|
||||
raise exceptions.rest.RequestError(_('Element already exists (duplicate key error)')) from e
|
||||
raise exceptions.rest.RequestError('Element already exists (duplicate key error)') from e
|
||||
except exceptions.ui.ValidationError as e:
|
||||
if (
|
||||
not item and service
|
||||
): # Only remove partially saved element if creating new (if editing, ignore this)
|
||||
self._delete_incomplete_service(service)
|
||||
raise exceptions.rest.RequestError(_('Input error: {0}'.format(e))) from e
|
||||
raise exceptions.rest.ValidationError('Input error: {0}'.format(e)) from e
|
||||
except Exception as e:
|
||||
if not item and service:
|
||||
self._delete_incomplete_service(service)
|
||||
@@ -217,110 +262,99 @@ class Services(DetailHandler): # pylint: disable=too-many-public-methods
|
||||
if service.deployedServices.count() == 0:
|
||||
service.delete()
|
||||
return
|
||||
except Exception:
|
||||
logger.exception('Deleting service')
|
||||
raise self.invalid_item_response() from None
|
||||
except models.Service.DoesNotExist:
|
||||
raise exceptions.rest.NotFound(_('Service not found')) from None
|
||||
except Exception as e:
|
||||
logger.error('Error deleting service %s from %s: %s', item, parent, e)
|
||||
raise exceptions.rest.ResponseError(_('Error deleting service')) from None
|
||||
|
||||
raise exceptions.rest.RequestError('Item has associated deployed services')
|
||||
|
||||
def get_title(self, parent: 'Model') -> str:
|
||||
def get_table(self, parent: 'Model') -> TableInfo:
|
||||
parent = ensure.is_instance(parent, models.Provider)
|
||||
try:
|
||||
return _('Services of {}').format(parent.name)
|
||||
except Exception:
|
||||
return _('Current services')
|
||||
|
||||
def get_fields(self, parent: 'Model') -> list[typing.Any]:
|
||||
return [
|
||||
{'name': {'title': _('Service name'), 'visible': True, 'type': 'iconType'}},
|
||||
{'comments': {'title': _('Comments')}},
|
||||
{'type_name': {'title': _('Type')}},
|
||||
{
|
||||
'deployed_services_count': {
|
||||
'title': _('Services Pools'),
|
||||
'type': 'numeric',
|
||||
}
|
||||
},
|
||||
{'user_services_count': {'title': _('User services'), 'type': 'numeric'}},
|
||||
{
|
||||
'max_services_count_type': {
|
||||
'title': _('Max services count type'),
|
||||
'type': 'dict',
|
||||
'dict': {'0': _('Standard'), '1': _('Conservative')},
|
||||
return (
|
||||
ui_utils.TableBuilder(_('Services of {0}').format(parent.name))
|
||||
.icon(name='name', title=_('Name'))
|
||||
.text_column(name='type_name', title=_('Type'))
|
||||
.text_column(name='comments', title=_('Comments'))
|
||||
.numeric_column(name='deployed_services_count', title=_('Services Pools'), width='12em')
|
||||
.numeric_column(name='user_services_count', title=_('User Services'), width='12em')
|
||||
.dict_column(
|
||||
name='max_services_count_type',
|
||||
title=_('Counting method'),
|
||||
dct={
|
||||
types.services.ServicesCountingType.STANDARD: _('Standard'),
|
||||
types.services.ServicesCountingType.CONSERVATIVE: _('Conservative'),
|
||||
},
|
||||
},
|
||||
{'tags': {'title': _('tags'), 'visible': False}},
|
||||
]
|
||||
|
||||
def get_types(
|
||||
self, parent: 'Model', for_type: typing.Optional[str]
|
||||
) -> collections.abc.Iterable[types.rest.TypeInfoDict]:
|
||||
)
|
||||
.text_column(name='tags', title=_('Tags'), visible=False)
|
||||
.row_style(prefix='row-maintenance-', field='maintenance_mode')
|
||||
.build()
|
||||
)
|
||||
|
||||
def enum_types(self, parent: 'Model', for_type: typing.Optional[str]) -> list[types.rest.TypeInfo]:
|
||||
parent = ensure.is_instance(parent, models.Provider)
|
||||
logger.debug('get_types parameters: %s, %s', parent, for_type)
|
||||
offers: list[types.rest.TypeInfoDict] = []
|
||||
offers: list[types.rest.TypeInfo] = []
|
||||
if for_type is None:
|
||||
offers = [
|
||||
{
|
||||
'name': _(t.mod_name()),
|
||||
'type': t.mod_type(),
|
||||
'description': _(t.description()),
|
||||
'icon': t.icon64().replace('\n', ''),
|
||||
}
|
||||
for t in parent.get_type().get_provided_services()
|
||||
]
|
||||
offers = [type(self).as_typeinfo(t) for t in parent.get_type().get_provided_services()]
|
||||
else:
|
||||
for t in parent.get_type().get_provided_services():
|
||||
if for_type == t.mod_type():
|
||||
offers = [
|
||||
{
|
||||
'name': _(t.mod_name()),
|
||||
'type': t.mod_type(),
|
||||
'description': _(t.description()),
|
||||
'icon': t.icon64().replace('\n', ''),
|
||||
}
|
||||
]
|
||||
offers = [type(self).as_typeinfo(t)]
|
||||
break
|
||||
if not offers:
|
||||
raise exceptions.rest.NotFound('type not found')
|
||||
|
||||
return offers # Default is that details do not have types
|
||||
return offers
|
||||
|
||||
def get_gui(self, parent: 'Model', for_type: str) -> collections.abc.Iterable[typing.Any]:
|
||||
@classmethod
|
||||
def possible_types(cls: type[typing.Self]) -> collections.abc.Iterable[type[module.Module]]:
|
||||
"""
|
||||
If the detail has any possible types, provide them overriding this method
|
||||
:param cls:
|
||||
"""
|
||||
for parent_type in services.factory().providers().values():
|
||||
for service in parent_type.get_provided_services():
|
||||
yield service
|
||||
|
||||
def get_gui(self, parent: 'Model', for_type: str) -> list[types.ui.GuiElement]:
|
||||
parent = ensure.is_instance(parent, models.Provider)
|
||||
try:
|
||||
logger.debug('getGui parameters: %s, %s', parent, for_type)
|
||||
parent_instance = parent.get_instance()
|
||||
service_type = parent_instance.get_service_by_type(for_type)
|
||||
if not service_type:
|
||||
raise self.invalid_item_response(f'Gui for {for_type} not found')
|
||||
raise exceptions.rest.RequestError(f'Gui for type "{for_type}" not found')
|
||||
with Environment.temporary_environment() as env:
|
||||
service = service_type(
|
||||
env, parent_instance
|
||||
) # Instantiate it so it has the opportunity to alter gui description based on parent
|
||||
local_gui = self.add_default_fields(service.gui_description(), ['name', 'comments', 'tags'])
|
||||
self.add_field(
|
||||
local_gui,
|
||||
{
|
||||
'name': 'max_services_count_type',
|
||||
'choices': [
|
||||
gui.choice_item('0', _('Standard')),
|
||||
gui.choice_item('1', _('Conservative')),
|
||||
overrided_fields = service.overrided_fields or {}
|
||||
|
||||
gui = (
|
||||
ui_utils.GuiBuilder()
|
||||
.add_stock_field(types.rest.stock.StockField.TAGS)
|
||||
.add_stock_field(types.rest.stock.StockField.NAME)
|
||||
.add_stock_field(types.rest.stock.StockField.COMMENTS)
|
||||
.add_choice(
|
||||
name='max_services_count_type',
|
||||
choices=[
|
||||
ui.gui.choice_item(
|
||||
str(types.services.ServicesCountingType.STANDARD.value), _('Standard')
|
||||
),
|
||||
ui.gui.choice_item(
|
||||
str(types.services.ServicesCountingType.CONSERVATIVE.value), _('Conservative')
|
||||
),
|
||||
],
|
||||
'label': _('Service counting method'),
|
||||
'tooltip': _('Kind of service counting for calculating if MAX is reached'),
|
||||
'type': types.ui.FieldType.CHOICE,
|
||||
'readonly': False,
|
||||
'order': 110,
|
||||
'tab': types.ui.Tab.ADVANCED,
|
||||
},
|
||||
label=_('Service counting method'),
|
||||
tooltip=_('Kind of service counting for calculating if MAX is reached'),
|
||||
tab=types.ui.Tab.ADVANCED,
|
||||
)
|
||||
.add_fields(service.gui_description())
|
||||
)
|
||||
|
||||
# Remove all overrided fields from editables
|
||||
overrided_fields = service.overrided_fields or {}
|
||||
local_gui = [field_gui for field_gui in local_gui if field_gui['name'] not in overrided_fields]
|
||||
|
||||
return local_gui
|
||||
return [field_gui for field_gui in gui.build() if field_gui.name not in overrided_fields]
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('get_gui')
|
||||
@@ -332,29 +366,32 @@ class Services(DetailHandler): # pylint: disable=too-many-public-methods
|
||||
service = parent.services.get(uuid=process_uuid(item))
|
||||
logger.debug('Getting logs for %s', item)
|
||||
return log.get_logs(service)
|
||||
except Exception:
|
||||
raise self.invalid_item_response() from None
|
||||
except models.Service.DoesNotExist:
|
||||
raise exceptions.rest.NotFound(_('Service not found')) from None
|
||||
except Exception as e:
|
||||
logger.error('Error getting logs for %s: %s', item, e)
|
||||
raise exceptions.rest.ResponseError(_('Error getting logs')) from None
|
||||
|
||||
def servicepools(self, parent: 'Model', item: str) -> types.rest.ManyItemsDictType:
|
||||
def servicepools(self, parent: 'Model', item: str) -> list[ServicePoolResumeItem]:
|
||||
parent = ensure.is_instance(parent, models.Provider)
|
||||
service = parent.services.get(uuid=process_uuid(item))
|
||||
logger.debug('Got parameters for servicepools: %s, %s', parent, item)
|
||||
res: types.rest.ItemListType = []
|
||||
res: list[ServicePoolResumeItem] = []
|
||||
for i in service.deployedServices.all():
|
||||
try:
|
||||
self.ensure_has_access(
|
||||
self.check_access(
|
||||
i, uds.core.types.permissions.PermissionType.READ
|
||||
) # Ensures access before listing...
|
||||
res.append(
|
||||
{
|
||||
'id': i.uuid,
|
||||
'name': i.name,
|
||||
'thumb': i.image.thumb64 if i.image is not None else DEFAULT_THUMB_BASE64,
|
||||
'user_services_count': i.userServices.exclude(
|
||||
ServicePoolResumeItem(
|
||||
id=i.uuid,
|
||||
name=i.name,
|
||||
thumb=i.image.thumb64 if i.image is not None else DEFAULT_THUMB_BASE64,
|
||||
user_services_count=i.userServices.exclude(
|
||||
state__in=(State.REMOVED, State.ERROR)
|
||||
).count(),
|
||||
'state': _('With errors') if i.is_restrained() else _('Ok'),
|
||||
}
|
||||
state=_('With errors') if i.is_restrained() else _('Ok'),
|
||||
)
|
||||
)
|
||||
except exceptions.rest.AccessDenied:
|
||||
pass
|
||||
|
||||
@@ -30,54 +30,54 @@
|
||||
"""
|
||||
@Author: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
"""
|
||||
import dataclasses
|
||||
import logging
|
||||
import typing
|
||||
|
||||
from django.utils.translation import gettext
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.db.models import Model
|
||||
|
||||
from uds.core import types
|
||||
from uds.core.consts.images import DEFAULT_THUMB_BASE64
|
||||
from uds.core.ui import gui
|
||||
from uds.core.util import ensure
|
||||
from uds.core.util import ensure, ui as ui_utils
|
||||
from uds.core.util.model import process_uuid
|
||||
from uds.models import Image, ServicePoolGroup
|
||||
from uds.REST.model import ModelHandler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from django.db.models import Model
|
||||
from uds.core.ui import gui
|
||||
|
||||
# Enclosed methods under /item path
|
||||
|
||||
|
||||
class ServicesPoolGroups(ModelHandler):
|
||||
"""
|
||||
Handles the gallery REST interface
|
||||
"""
|
||||
@dataclasses.dataclass
|
||||
class ServicePoolGroupItem(types.rest.BaseRestItem):
|
||||
id: str
|
||||
name: str
|
||||
comments: str
|
||||
priority: int
|
||||
image_id: str | None | types.rest.NotRequired = types.rest.NotRequired.field()
|
||||
thumb: str | types.rest.NotRequired = types.rest.NotRequired.field()
|
||||
|
||||
# needs_admin = True
|
||||
|
||||
path = 'gallery'
|
||||
model = ServicePoolGroup
|
||||
save_fields = ['name', 'comments', 'image_id', 'priority']
|
||||
class ServicesPoolGroups(ModelHandler[ServicePoolGroupItem]):
|
||||
|
||||
table_title = _('Services Pool Groups')
|
||||
table_fields = [
|
||||
{'priority': {'title': _('Priority'), 'type': 'numeric', 'width': '6em'}},
|
||||
{
|
||||
'thumb': {
|
||||
'title': _('Image'),
|
||||
'visible': True,
|
||||
'type': 'image',
|
||||
'width': '96px',
|
||||
}
|
||||
},
|
||||
{'name': {'title': _('Name')}},
|
||||
{'comments': {'title': _('Comments')}},
|
||||
]
|
||||
PATH = 'gallery'
|
||||
MODEL = ServicePoolGroup
|
||||
FIELDS_TO_SAVE = ['name', 'comments', 'image_id', 'priority']
|
||||
|
||||
TABLE = (
|
||||
ui_utils.TableBuilder(_('Services Pool Groups'))
|
||||
.numeric_column(name='priority', title=_('Priority'), width='6em')
|
||||
.image(name='thumb', title=_('Image'), width='96px')
|
||||
.text_column(name='name', title=_('Name'))
|
||||
.text_column(name='comments', title=_('Comments'))
|
||||
.build()
|
||||
)
|
||||
|
||||
# Rest api related information to complete the auto-generated API
|
||||
REST_API_INFO = types.rest.api.RestApiInfo(
|
||||
typed=types.rest.api.RestApiInfoGuiType.SINGLE_TYPE,
|
||||
)
|
||||
|
||||
def pre_save(self, fields: dict[str, typing.Any]) -> None:
|
||||
img_id = fields['image_id']
|
||||
@@ -91,47 +91,33 @@ class ServicesPoolGroups(ModelHandler):
|
||||
logger.exception('At image recovering')
|
||||
|
||||
# Gui related
|
||||
def get_gui(self, type_: str) -> list[typing.Any]:
|
||||
local_gui = self.add_default_fields([], ['name', 'comments', 'priority'])
|
||||
def get_gui(self, for_type: str) -> list[typing.Any]:
|
||||
return (
|
||||
ui_utils.GuiBuilder()
|
||||
.add_stock_field(types.rest.stock.StockField.NAME)
|
||||
.add_stock_field(types.rest.stock.StockField.COMMENTS)
|
||||
.add_stock_field(types.rest.stock.StockField.PRIORITY)
|
||||
.new_tab(types.ui.Tab.DISPLAY)
|
||||
.add_image_choice()
|
||||
.build()
|
||||
)
|
||||
|
||||
for field in [
|
||||
{
|
||||
'name': 'image_id',
|
||||
'choices': [gui.choice_image(-1, '--------', DEFAULT_THUMB_BASE64)]
|
||||
+ gui.sorted_choices(
|
||||
[
|
||||
gui.choice_image(v.uuid, v.name, v.thumb64)
|
||||
for v in Image.objects.all()
|
||||
]
|
||||
),
|
||||
'label': gettext('Associated Image'),
|
||||
'tooltip': gettext('Image assocciated with this service'),
|
||||
'type': types.ui.FieldType.IMAGECHOICE,
|
||||
'order': 102,
|
||||
}
|
||||
]:
|
||||
self.add_field(local_gui, field)
|
||||
|
||||
return local_gui
|
||||
|
||||
def item_as_dict(self, item: 'Model') -> dict[str, typing.Any]:
|
||||
def get_item(self, item: 'Model') -> ServicePoolGroupItem:
|
||||
item = ensure.is_instance(item, ServicePoolGroup)
|
||||
return {
|
||||
'id': item.uuid,
|
||||
'priority': item.priority,
|
||||
'name': item.name,
|
||||
'comments': item.comments,
|
||||
'image_id': item.image.uuid if item.image else None,
|
||||
}
|
||||
return ServicePoolGroupItem(
|
||||
id=item.uuid,
|
||||
name=item.name,
|
||||
comments=item.comments,
|
||||
priority=item.priority,
|
||||
image_id=item.image.uuid if item.image else None,
|
||||
)
|
||||
|
||||
def item_as_dict_overview(
|
||||
self, item: 'Model'
|
||||
) -> dict[str, typing.Any]:
|
||||
def get_item_summary(self, item: 'Model') -> ServicePoolGroupItem:
|
||||
item = ensure.is_instance(item, ServicePoolGroup)
|
||||
return {
|
||||
'id': item.uuid,
|
||||
'priority': item.priority,
|
||||
'name': item.name,
|
||||
'comments': item.comments,
|
||||
'thumb': item.thumb64,
|
||||
}
|
||||
return ServicePoolGroupItem(
|
||||
id=item.uuid,
|
||||
priority=item.priority,
|
||||
name=item.name,
|
||||
comments=item.comments,
|
||||
thumb=item.thumb64,
|
||||
)
|
||||
|
||||
@@ -30,19 +30,19 @@
|
||||
"""
|
||||
Author: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
"""
|
||||
import dataclasses
|
||||
import datetime
|
||||
import logging
|
||||
import typing
|
||||
|
||||
from django.db.models import Count, Q
|
||||
from django.utils.translation import gettext
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.translation import gettext, gettext_lazy as _
|
||||
from django.db.models import Model, Count, Q
|
||||
|
||||
from uds.core import types, exceptions, consts
|
||||
from uds.core.managers.userservice import UserServiceManager
|
||||
from uds.core.ui import gui
|
||||
from uds.core import ui
|
||||
from uds.core.consts.images import DEFAULT_THUMB_BASE64
|
||||
from uds.core.util import log, permissions, ensure
|
||||
from uds.core.util import log, permissions, ensure, ui as ui_utils
|
||||
from uds.core.util.config import GlobalConfig
|
||||
from uds.core.util.model import sql_now, process_uuid
|
||||
from uds.core.types.states import State
|
||||
@@ -50,23 +50,64 @@ from uds.models import Account, Image, OSManager, Service, ServicePool, ServiceP
|
||||
from uds.REST.model import ModelHandler
|
||||
|
||||
from .op_calendars import AccessCalendars, ActionsCalendars
|
||||
from .services import Services
|
||||
from .user_services import AssignedService, CachedService, Changelog, Groups, Publications, Transports
|
||||
from .services import Services, ServiceInfo
|
||||
from .user_services import AssignedUserService, CachedService, Changelog, Groups, Publications, Transports
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from django.db.models import Model
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ServicesPools(ModelHandler):
|
||||
@dataclasses.dataclass
|
||||
class ServicePoolItem(types.rest.BaseRestItem):
|
||||
id: str
|
||||
name: str
|
||||
short_name: str
|
||||
tags: typing.List[str]
|
||||
parent: str
|
||||
parent_type: str
|
||||
comments: str
|
||||
state: str
|
||||
thumb: str
|
||||
account: str
|
||||
account_id: str | None
|
||||
service_id: str
|
||||
provider_id: str
|
||||
image_id: str | None
|
||||
initial_srvs: int
|
||||
cache_l1_srvs: int
|
||||
cache_l2_srvs: int
|
||||
max_srvs: int
|
||||
show_transports: bool
|
||||
visible: bool
|
||||
allow_users_remove: bool
|
||||
allow_users_reset: bool
|
||||
ignores_unused: bool
|
||||
fallbackAccess: str
|
||||
meta_member: list[dict[str, str]]
|
||||
calendar_message: str
|
||||
custom_message: str
|
||||
display_custom_message: bool
|
||||
osmanager_id: str | None
|
||||
|
||||
user_services_count: int | types.rest.NotRequired = types.rest.NotRequired.field()
|
||||
user_services_in_preparation: int | types.rest.NotRequired = types.rest.NotRequired.field()
|
||||
restrained: bool | types.rest.NotRequired = types.rest.NotRequired.field()
|
||||
permission: types.permissions.PermissionType | types.rest.NotRequired = types.rest.NotRequired.field()
|
||||
info: ServiceInfo | types.rest.NotRequired = types.rest.NotRequired.field()
|
||||
pool_group_id: str | None | types.rest.NotRequired = types.rest.NotRequired.field()
|
||||
pool_group_name: str | types.rest.NotRequired = types.rest.NotRequired.field()
|
||||
pool_group_thumb: str | types.rest.NotRequired = types.rest.NotRequired.field()
|
||||
usage: str | types.rest.NotRequired = types.rest.NotRequired.field()
|
||||
|
||||
|
||||
class ServicesPools(ModelHandler[ServicePoolItem]):
|
||||
"""
|
||||
Handles Services Pools REST requests
|
||||
"""
|
||||
|
||||
model = ServicePool
|
||||
detail = {
|
||||
'services': AssignedService,
|
||||
MODEL = ServicePool
|
||||
DETAIL = {
|
||||
'services': AssignedUserService,
|
||||
'cache': CachedService,
|
||||
'servers': CachedService, # Alias for cache, but will change in a future release
|
||||
'groups': Groups,
|
||||
@@ -77,7 +118,7 @@ class ServicesPools(ModelHandler):
|
||||
'actions': ActionsCalendars,
|
||||
}
|
||||
|
||||
save_fields = [
|
||||
FIELDS_TO_SAVE = [
|
||||
'name',
|
||||
'short_name',
|
||||
'comments',
|
||||
@@ -102,36 +143,41 @@ class ServicesPools(ModelHandler):
|
||||
'state:_', # Optional field, defaults to Nothing (to apply default or existing value)
|
||||
]
|
||||
|
||||
remove_fields = ['osmanager_id', 'service_id']
|
||||
EXCLUDED_FIELDS = ['osmanager_id', 'service_id']
|
||||
|
||||
table_title = _('Service Pools')
|
||||
table_fields = [
|
||||
{'name': {'title': _('Name')}},
|
||||
{'state': {'title': _('Status'), 'type': 'dict', 'dict': State.literals_dict()}},
|
||||
{'user_services_count': {'title': _('User services'), 'type': 'number'}},
|
||||
{'user_services_in_preparation': {'title': _('In Preparation')}},
|
||||
{'usage': {'title': _('Usage')}},
|
||||
{'visible': {'title': _('Visible'), 'type': 'callback'}},
|
||||
{'show_transports': {'title': _('Shows transports'), 'type': 'callback'}},
|
||||
{'pool_group_name': {'title': _('Pool group')}},
|
||||
{'parent': {'title': _('Parent service')}},
|
||||
{'tags': {'title': _('tags'), 'visible': False}},
|
||||
]
|
||||
# Field from where to get "class" and prefix for that class, so this will generate "row-state-A, row-state-X, ....
|
||||
table_row_style = types.ui.RowStyleInfo(prefix='row-state-', field='state')
|
||||
TABLE = (
|
||||
ui_utils.TableBuilder(_('Service Pools'))
|
||||
.text_column(name='name', title=_('Name'))
|
||||
.dict_column(name='state', title=_('Status'), dct=State.literals_dict())
|
||||
.numeric_column(name='user_services_count', title=_('User services'))
|
||||
.numeric_column(name='user_services_in_preparation', title=_('In Preparation'))
|
||||
.text_column(name='usage', title=_('Usage'))
|
||||
.boolean(name='visible', title=_('Visible'))
|
||||
.boolean(name='show_transports', title=_('Shows transports'))
|
||||
.text_column(name='pool_group_name', title=_('Pool group'))
|
||||
.text_column(name='parent', title=_('Parent service'))
|
||||
.text_column(name='tags', title=_('tags'), visible=False)
|
||||
.row_style(prefix='row-state-', field='state')
|
||||
.build()
|
||||
)
|
||||
|
||||
custom_methods = [
|
||||
('set_fallback_access', True),
|
||||
('get_fallback_access', True),
|
||||
('actions_list', True),
|
||||
('list_assignables', True),
|
||||
('create_from_assignable', True),
|
||||
('add_log', True),
|
||||
CUSTOM_METHODS = [
|
||||
types.rest.ModelCustomMethod('set_fallback_access', True),
|
||||
types.rest.ModelCustomMethod('get_fallback_access', True),
|
||||
types.rest.ModelCustomMethod('actions_list', True),
|
||||
types.rest.ModelCustomMethod('list_assignables', True),
|
||||
types.rest.ModelCustomMethod('create_from_assignable', True),
|
||||
types.rest.ModelCustomMethod('add_log', True),
|
||||
]
|
||||
|
||||
# Rest api related information to complete the auto-generated API
|
||||
REST_API_INFO = types.rest.api.RestApiInfo(
|
||||
typed=types.rest.api.RestApiInfoGuiType.SINGLE_TYPE,
|
||||
)
|
||||
|
||||
def get_items(
|
||||
self, *args: typing.Any, **kwargs: typing.Any
|
||||
) -> typing.Generator[types.rest.ItemDictType, None, None]:
|
||||
) -> typing.Generator[ServicePoolItem, None, None]:
|
||||
# Optimized query, due that there is a lot of info needed for theee
|
||||
d = sql_now() - datetime.timedelta(seconds=GlobalConfig.RESTRAINT_TIME.as_int())
|
||||
return super().get_items(
|
||||
@@ -178,7 +224,7 @@ class ServicesPools(ModelHandler):
|
||||
# return super().get_items(overview=kwargs.get('overview', True), prefetch=['service', 'service__provider', 'servicesPoolGroup', 'image', 'tags'])
|
||||
# return super(ServicesPools, self).get_items(*args, **kwargs)
|
||||
|
||||
def item_as_dict(self, item: 'Model') -> dict[str, typing.Any]:
|
||||
def get_item(self, item: 'Model') -> ServicePoolItem:
|
||||
item = ensure.is_instance(item, ServicePool)
|
||||
summary = 'summarize' in self._params
|
||||
# if item does not have an associated service, hide it (the case, for example, for a removed service)
|
||||
@@ -199,78 +245,76 @@ class ServicesPools(ModelHandler):
|
||||
# This needs a lot of queries, and really does not apport anything important to the report
|
||||
# elif UserServiceManager.manager().canInitiateServiceFromDeployedService(item) is False:
|
||||
# state = State.SLOWED_DOWN
|
||||
val: dict[str, typing.Any] = {
|
||||
'id': item.uuid,
|
||||
'name': item.name,
|
||||
'short_name': item.short_name,
|
||||
'tags': [tag.tag for tag in item.tags.all()],
|
||||
'parent': item.service.name,
|
||||
'parent_type': item.service.data_type,
|
||||
'comments': item.comments,
|
||||
'state': state,
|
||||
'thumb': item.image.thumb64 if item.image is not None else DEFAULT_THUMB_BASE64,
|
||||
'account': item.account.name if item.account is not None else '',
|
||||
'account_id': item.account.uuid if item.account is not None else None,
|
||||
'service_id': item.service.uuid,
|
||||
'provider_id': item.service.provider.uuid,
|
||||
'image_id': item.image.uuid if item.image is not None else None,
|
||||
'initial_srvs': item.initial_srvs,
|
||||
'cache_l1_srvs': item.cache_l1_srvs,
|
||||
'cache_l2_srvs': item.cache_l2_srvs,
|
||||
'max_srvs': item.max_srvs,
|
||||
'show_transports': item.show_transports,
|
||||
'visible': item.visible,
|
||||
'allow_users_remove': item.allow_users_remove,
|
||||
'allow_users_reset': item.allow_users_reset,
|
||||
'ignores_unused': item.ignores_unused,
|
||||
'fallbackAccess': item.fallbackAccess,
|
||||
'meta_member': [
|
||||
{'id': i.meta_pool.uuid, 'name': i.meta_pool.name} for i in item.memberOfMeta.all()
|
||||
],
|
||||
'calendar_message': item.calendar_message,
|
||||
'custom_message': item.custom_message,
|
||||
'display_custom_message': item.display_custom_message,
|
||||
}
|
||||
val: ServicePoolItem = ServicePoolItem(
|
||||
id=item.uuid,
|
||||
name=item.name,
|
||||
short_name=item.short_name,
|
||||
tags=[tag.tag for tag in item.tags.all()],
|
||||
parent=item.service.name,
|
||||
parent_type=item.service.data_type,
|
||||
comments=item.comments,
|
||||
state=state,
|
||||
thumb=item.image.thumb64 if item.image is not None else DEFAULT_THUMB_BASE64,
|
||||
account=item.account.name if item.account is not None else '',
|
||||
account_id=item.account.uuid if item.account is not None else None,
|
||||
service_id=item.service.uuid,
|
||||
provider_id=item.service.provider.uuid,
|
||||
image_id=item.image.uuid if item.image is not None else None,
|
||||
initial_srvs=item.initial_srvs,
|
||||
cache_l1_srvs=item.cache_l1_srvs,
|
||||
cache_l2_srvs=item.cache_l2_srvs,
|
||||
max_srvs=item.max_srvs,
|
||||
show_transports=item.show_transports,
|
||||
visible=item.visible,
|
||||
allow_users_remove=item.allow_users_remove,
|
||||
allow_users_reset=item.allow_users_reset,
|
||||
ignores_unused=item.ignores_unused,
|
||||
fallbackAccess=item.fallbackAccess,
|
||||
meta_member=[{'id': i.meta_pool.uuid, 'name': i.meta_pool.name} for i in item.memberOfMeta.all()],
|
||||
calendar_message=item.calendar_message,
|
||||
custom_message=item.custom_message,
|
||||
display_custom_message=item.display_custom_message,
|
||||
osmanager_id=item.osmanager.uuid if item.osmanager else None,
|
||||
)
|
||||
if summary:
|
||||
return val
|
||||
|
||||
# Extended info
|
||||
if not summary:
|
||||
if hasattr(item, 'valid_count'):
|
||||
valid_count = getattr(item, 'valid_count')
|
||||
preparing_count = getattr(item, 'preparing_count')
|
||||
restrained = getattr(item, 'error_count') >= GlobalConfig.RESTRAINT_COUNT.as_int()
|
||||
usage_count = getattr(item, 'usage_count')
|
||||
else:
|
||||
valid_count = item.userServices.exclude(state__in=State.INFO_STATES).count()
|
||||
preparing_count = item.userServices.filter(state=State.PREPARING).count()
|
||||
restrained = item.is_restrained()
|
||||
usage_count = -1
|
||||
if hasattr(item, 'valid_count'):
|
||||
valid_count = getattr(item, 'valid_count')
|
||||
preparing_count = getattr(item, 'preparing_count')
|
||||
restrained = getattr(item, 'error_count') >= GlobalConfig.RESTRAINT_COUNT.as_int()
|
||||
usage_count = getattr(item, 'usage_count')
|
||||
else:
|
||||
valid_count = item.userServices.exclude(state__in=State.INFO_STATES).count()
|
||||
preparing_count = item.userServices.filter(state=State.PREPARING).count()
|
||||
restrained = item.is_restrained()
|
||||
usage_count = -1
|
||||
|
||||
poolgroup_id = None
|
||||
poolgroup_name = _('Default')
|
||||
poolgroup_thumb = DEFAULT_THUMB_BASE64
|
||||
if item.servicesPoolGroup is not None:
|
||||
poolgroup_id = item.servicesPoolGroup.uuid
|
||||
poolgroup_name = item.servicesPoolGroup.name
|
||||
if item.servicesPoolGroup.image is not None:
|
||||
poolgroup_thumb = item.servicesPoolGroup.image.thumb64
|
||||
poolgroup_id = None
|
||||
poolgroup_name = _('Default')
|
||||
poolgroup_thumb = DEFAULT_THUMB_BASE64
|
||||
if item.servicesPoolGroup is not None:
|
||||
poolgroup_id = item.servicesPoolGroup.uuid
|
||||
poolgroup_name = item.servicesPoolGroup.name
|
||||
if item.servicesPoolGroup.image is not None:
|
||||
poolgroup_thumb = item.servicesPoolGroup.image.thumb64
|
||||
|
||||
val['user_services_count'] = valid_count
|
||||
val['user_services_in_preparation'] = preparing_count
|
||||
val['restrained'] = restrained
|
||||
val['permission'] = permissions.effective_permissions(self._user, item)
|
||||
val['info'] = Services.service_info(item.service)
|
||||
val['pool_group_id'] = poolgroup_id
|
||||
val['pool_group_name'] = poolgroup_name
|
||||
val['pool_group_thumb'] = poolgroup_thumb
|
||||
val['usage'] = str(item.usage(usage_count).percent) + '%'
|
||||
|
||||
if item.osmanager:
|
||||
val['osmanager_id'] = item.osmanager.uuid
|
||||
val.thumb = item.image.thumb64 if item.image is not None else DEFAULT_THUMB_BASE64
|
||||
val.user_services_count = valid_count
|
||||
val.user_services_in_preparation = preparing_count
|
||||
val.tags = [tag.tag for tag in item.tags.all()]
|
||||
val.restrained = restrained
|
||||
val.permission = permissions.effective_permissions(self._user, item)
|
||||
val.info = Services.service_info(item.service)
|
||||
val.pool_group_id = poolgroup_id
|
||||
val.pool_group_name = poolgroup_name
|
||||
val.pool_group_thumb = poolgroup_thumb
|
||||
val.usage = str(item.usage(usage_count).percent) + '%'
|
||||
|
||||
return val
|
||||
|
||||
# Gui related
|
||||
def get_gui(self, type_: str) -> list[typing.Any]:
|
||||
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
|
||||
# if OSManager.objects.count() < 1: # No os managers, can't create db
|
||||
# raise exceptions.rest.ResponseError(gettext('Create at least one OS Manager before creating a new service pool'))
|
||||
if Service.objects.count() < 1:
|
||||
@@ -278,202 +322,148 @@ class ServicesPools(ModelHandler):
|
||||
gettext('Create at least a service before creating a new service pool')
|
||||
)
|
||||
|
||||
g = self.add_default_fields([], ['name', 'comments', 'tags'])
|
||||
|
||||
for f in [
|
||||
{
|
||||
'name': 'short_name',
|
||||
'type': 'text',
|
||||
'label': _('Short name'),
|
||||
'tooltip': _('Short name for user service visualization'),
|
||||
'required': False,
|
||||
'length': 64,
|
||||
'order': 0 - 95,
|
||||
},
|
||||
{
|
||||
'name': 'service_id',
|
||||
'choices': [gui.choice_item('', '')]
|
||||
+ gui.sorted_choices(
|
||||
[gui.choice_item(v.uuid, v.provider.name + '\\' + v.name) for v in Service.objects.all()]
|
||||
gui = (
|
||||
(
|
||||
ui_utils.GuiBuilder()
|
||||
.add_stock_field(types.rest.stock.StockField.NAME)
|
||||
.add_stock_field(types.rest.stock.StockField.COMMENTS)
|
||||
.add_stock_field(types.rest.stock.StockField.TAGS)
|
||||
)
|
||||
.set_order(-95)
|
||||
.add_text(
|
||||
name='short_name',
|
||||
label=gettext('Short name'),
|
||||
tooltip=gettext('Short name for user service visualization'),
|
||||
length=32,
|
||||
)
|
||||
.set_order(100)
|
||||
.add_choice(
|
||||
name='service_id',
|
||||
choices=[ui.gui.choice_item('', '')]
|
||||
+ ui.gui.sorted_choices(
|
||||
[ui.gui.choice_item(v.uuid, v.provider.name + '\\' + v.name) for v in Service.objects.all()]
|
||||
),
|
||||
'label': gettext('Base service'),
|
||||
'tooltip': gettext('Service used as base of this service pool'),
|
||||
'type': types.ui.FieldType.CHOICE,
|
||||
'readonly': True,
|
||||
'order': 100, # Ensures is At end
|
||||
},
|
||||
{
|
||||
'name': 'osmanager_id',
|
||||
'choices': [gui.choice_item(-1, '')]
|
||||
+ gui.sorted_choices([gui.choice_item(v.uuid, v.name) for v in OSManager.objects.all()]),
|
||||
'label': gettext('OS Manager'),
|
||||
'tooltip': gettext('OS Manager used as base of this service pool'),
|
||||
'type': types.ui.FieldType.CHOICE,
|
||||
'readonly': True,
|
||||
'order': 101,
|
||||
},
|
||||
{
|
||||
'name': 'allow_users_remove',
|
||||
'value': False,
|
||||
'label': gettext('Allow removal by users'),
|
||||
'tooltip': gettext(
|
||||
'If active, the user will be allowed to remove the service "manually". Be careful with this, because the user will have the "power" to delete it\'s own service'
|
||||
),
|
||||
'type': types.ui.FieldType.CHECKBOX,
|
||||
'order': 111,
|
||||
'tab': gettext('Advanced'),
|
||||
},
|
||||
{
|
||||
'name': 'allow_users_reset',
|
||||
'value': False,
|
||||
'label': gettext('Allow reset by users'),
|
||||
'tooltip': gettext('If active, the user will be allowed to reset the service'),
|
||||
'type': types.ui.FieldType.CHECKBOX,
|
||||
'order': 112,
|
||||
'tab': gettext('Advanced'),
|
||||
},
|
||||
{
|
||||
'name': 'ignores_unused',
|
||||
'value': False,
|
||||
'label': gettext('Ignores unused'),
|
||||
'tooltip': gettext(
|
||||
'If the option is enabled, UDS will not attempt to detect and remove the user services assigned but not in use.'
|
||||
),
|
||||
'type': types.ui.FieldType.CHECKBOX,
|
||||
'order': 113,
|
||||
'tab': gettext('Advanced'),
|
||||
},
|
||||
{
|
||||
'name': 'visible',
|
||||
'value': True,
|
||||
'label': gettext('Visible'),
|
||||
'tooltip': gettext('If active, transport will be visible for users'),
|
||||
'type': types.ui.FieldType.CHECKBOX,
|
||||
'order': 107,
|
||||
'tab': gettext('Display'),
|
||||
},
|
||||
{
|
||||
'name': 'image_id',
|
||||
'choices': [gui.choice_image(-1, '--------', DEFAULT_THUMB_BASE64)]
|
||||
+ gui.sorted_choices(
|
||||
[gui.choice_image(v.uuid, v.name, v.thumb64) for v in Image.objects.all()]
|
||||
),
|
||||
'label': gettext('Associated Image'),
|
||||
'tooltip': gettext('Image assocciated with this service'),
|
||||
'type': types.ui.FieldType.IMAGECHOICE,
|
||||
'order': 120,
|
||||
'tab': gettext('Display'),
|
||||
},
|
||||
{
|
||||
'name': 'pool_group_id',
|
||||
'choices': [gui.choice_image(-1, _('Default'), DEFAULT_THUMB_BASE64)]
|
||||
+ gui.sorted_choices(
|
||||
[gui.choice_image(v.uuid, v.name, v.thumb64) for v in ServicePoolGroup.objects.all()]
|
||||
),
|
||||
'label': gettext('Pool group'),
|
||||
'tooltip': gettext('Pool group for this pool (for pool classify on display)'),
|
||||
'type': types.ui.FieldType.IMAGECHOICE,
|
||||
'order': 121,
|
||||
'tab': gettext('Display'),
|
||||
},
|
||||
{
|
||||
'name': 'calendar_message',
|
||||
'value': '',
|
||||
'label': gettext('Calendar access denied text'),
|
||||
'tooltip': gettext(
|
||||
'Custom message to be shown to users if access is limited by calendar rules.'
|
||||
),
|
||||
'type': types.ui.FieldType.TEXT,
|
||||
'order': 122,
|
||||
'tab': gettext('Display'),
|
||||
},
|
||||
{
|
||||
'name': 'custom_message',
|
||||
'value': '',
|
||||
'label': gettext('Custom launch message text'),
|
||||
'tooltip': gettext(
|
||||
label=gettext('Base service'),
|
||||
tooltip=gettext('Service used as base of this service pool'),
|
||||
readonly=True,
|
||||
)
|
||||
.add_choice(
|
||||
name='osmanager_id',
|
||||
choices=[ui.gui.choice_item(-1, '')]
|
||||
+ ui.gui.sorted_choices([ui.gui.choice_item(v.uuid, v.name) for v in OSManager.objects.all()]),
|
||||
label=gettext('OS Manager'),
|
||||
tooltip=gettext('OS Manager used as base of this service pool'),
|
||||
readonly=True,
|
||||
)
|
||||
.add_checkbox(
|
||||
name='publish_on_save',
|
||||
default=True,
|
||||
label=gettext('Publish on save'),
|
||||
tooltip=gettext('If active, the service will be published when saved'),
|
||||
)
|
||||
.new_tab(types.ui.Tab.DISPLAY)
|
||||
.add_checkbox(
|
||||
name='visible',
|
||||
default=True,
|
||||
label=gettext('Visible'),
|
||||
tooltip=gettext('If active, transport will be visible for users'),
|
||||
)
|
||||
.add_image_choice()
|
||||
.add_image_choice(
|
||||
name='pool_group_id',
|
||||
choices=[
|
||||
ui.gui.choice_image(v.uuid, v.name, v.thumb64) for v in ServicePoolGroup.objects.all()
|
||||
],
|
||||
label=gettext('Pool group'),
|
||||
tooltip=gettext('Pool group for this pool (for pool classify on display)'),
|
||||
)
|
||||
.add_text(
|
||||
name='calendar_message',
|
||||
label=gettext('Calendar access denied text'),
|
||||
tooltip=gettext('Custom message to be shown to users if access is limited by calendar rules.'),
|
||||
)
|
||||
.add_text(
|
||||
name='custom_message',
|
||||
label=gettext('Custom launch message text'),
|
||||
tooltip=gettext(
|
||||
'Custom message to be shown to users, if active, when trying to start a service from this pool.'
|
||||
),
|
||||
'type': types.ui.FieldType.TEXT,
|
||||
'order': 123,
|
||||
'tab': gettext('Display'),
|
||||
},
|
||||
{
|
||||
'name': 'display_custom_message',
|
||||
'value': False,
|
||||
'label': gettext('Enable custom launch message'),
|
||||
'tooltip': gettext('If active, the custom launch message will be shown to users'),
|
||||
'type': types.ui.FieldType.CHECKBOX,
|
||||
'order': 124,
|
||||
'tab': gettext('Display'),
|
||||
},
|
||||
{
|
||||
'name': 'initial_srvs',
|
||||
'value': '0',
|
||||
'min_value': '0',
|
||||
'label': gettext('Initial available services'),
|
||||
'tooltip': gettext('Services created initially for this service pool'),
|
||||
'type': types.ui.FieldType.NUMERIC,
|
||||
'order': 130,
|
||||
'tab': gettext('Availability'),
|
||||
},
|
||||
{
|
||||
'name': 'cache_l1_srvs',
|
||||
'value': '0',
|
||||
'min_value': '0',
|
||||
'label': gettext('Services to keep in cache'),
|
||||
'tooltip': gettext('Services kept in cache for improved user service assignation'),
|
||||
'type': types.ui.FieldType.NUMERIC,
|
||||
'order': 131,
|
||||
'tab': gettext('Availability'),
|
||||
},
|
||||
{
|
||||
'name': 'cache_l2_srvs',
|
||||
'value': '0',
|
||||
'min_value': '0',
|
||||
'label': gettext('Services to keep in L2 cache'),
|
||||
'tooltip': gettext('Services kept in cache of level2 for improved service generation'),
|
||||
'type': types.ui.FieldType.NUMERIC,
|
||||
'order': 132,
|
||||
'tab': gettext('Availability'),
|
||||
},
|
||||
{
|
||||
'name': 'max_srvs',
|
||||
'value': '0',
|
||||
'min_value': '0',
|
||||
'label': gettext('Maximum number of services to provide'),
|
||||
'tooltip': gettext(
|
||||
'Maximum number of service (assigned and L1 cache) that can be created for this service'
|
||||
)
|
||||
.add_checkbox(
|
||||
name='display_custom_message',
|
||||
default=False,
|
||||
label=gettext('Enable custom launch message'),
|
||||
tooltip=gettext('If active, the custom launch message will be shown to users'),
|
||||
)
|
||||
.new_tab(gettext('Availability'))
|
||||
.add_numeric(
|
||||
name='initial_srvs',
|
||||
default=0,
|
||||
min_value=0,
|
||||
label=gettext('Initial available services'),
|
||||
tooltip=gettext('Services created initially for this service pool'),
|
||||
)
|
||||
.add_numeric(
|
||||
name='cache_l1_srvs',
|
||||
default=0,
|
||||
min_value=0,
|
||||
label=gettext('Services to keep in cache'),
|
||||
tooltip=gettext('Services kept in cache for improved user service assignation'),
|
||||
)
|
||||
.add_numeric(
|
||||
name='cache_l2_srvs',
|
||||
default=0,
|
||||
min_value=0,
|
||||
label=gettext('Services to keep in L2 cache'),
|
||||
tooltip=gettext('Services kept in cache of level2 for improved service assignation'),
|
||||
)
|
||||
.add_numeric(
|
||||
name='max_srvs',
|
||||
default=0,
|
||||
min_value=0,
|
||||
label=gettext('Max services per user'),
|
||||
tooltip=gettext('Maximum number of services that can be assigned to a user from this pool'),
|
||||
)
|
||||
.add_checkbox(
|
||||
name='show_transports',
|
||||
default=False,
|
||||
label=gettext('Show transports'),
|
||||
tooltip=gettext('If active, transports will be shown to users'),
|
||||
)
|
||||
.new_tab(types.ui.Tab.ADVANCED)
|
||||
.add_checkbox(
|
||||
name='allow_users_remove',
|
||||
default=False,
|
||||
label=gettext('Allow removal by users'),
|
||||
tooltip=gettext(
|
||||
'If active, the user will be allowed to remove the service "manually". Be careful with this, because the user will have the "power" to delete its own service'
|
||||
),
|
||||
'type': types.ui.FieldType.NUMERIC,
|
||||
'order': 133,
|
||||
'tab': gettext('Availability'),
|
||||
},
|
||||
{
|
||||
'name': 'show_transports',
|
||||
'value': True,
|
||||
'label': gettext('Show transports'),
|
||||
'tooltip': gettext('If active, alternative transports for user will be shown'),
|
||||
'type': types.ui.FieldType.CHECKBOX,
|
||||
'tab': gettext('Advanced'),
|
||||
'order': 130,
|
||||
},
|
||||
{
|
||||
'name': 'account_id',
|
||||
'choices': [gui.choice_item(-1, '')]
|
||||
+ gui.sorted_choices([gui.choice_item(v.uuid, v.name) for v in Account.objects.all()]),
|
||||
'label': gettext('Accounting'),
|
||||
'tooltip': gettext('Account associated to this service pool'),
|
||||
'type': types.ui.FieldType.CHOICE,
|
||||
'tab': gettext('Advanced'),
|
||||
'order': 131,
|
||||
},
|
||||
]:
|
||||
self.add_field(g, f)
|
||||
)
|
||||
.add_checkbox(
|
||||
name='allow_users_reset',
|
||||
default=False,
|
||||
label=gettext('Allow reset by users'),
|
||||
tooltip=gettext('If active, the user will be allowed to reset the service'),
|
||||
)
|
||||
.add_checkbox(
|
||||
name='ignores_unused',
|
||||
default=False,
|
||||
label=gettext('Ignores unused'),
|
||||
tooltip=gettext(
|
||||
'If the option is enabled, UDS will not attempt to detect and remove the user services assigned but not in use.'
|
||||
),
|
||||
)
|
||||
.add_choice(
|
||||
name='account_id',
|
||||
choices=[ui.gui.choice_item('', '')]
|
||||
+ ui.gui.sorted_choices([ui.gui.choice_item(v.uuid, v.name) for v in Account.objects.all()]),
|
||||
label=gettext('Account'),
|
||||
tooltip=gettext('Account used for this service pool'),
|
||||
readonly=True,
|
||||
)
|
||||
)
|
||||
return gui.build()
|
||||
|
||||
return g
|
||||
|
||||
# pylint: disable=too-many-statements
|
||||
def pre_save(self, fields: dict[str, typing.Any]) -> None:
|
||||
# logger.debug(self._params)
|
||||
|
||||
@@ -505,7 +495,9 @@ class ServicesPools(ModelHandler):
|
||||
fields['osmanager_id'] = osmanager.id
|
||||
except Exception:
|
||||
if fields.get('state') != State.LOCKED:
|
||||
raise exceptions.rest.RequestError(gettext('This service requires an OS Manager')) from None
|
||||
raise exceptions.rest.RequestError(
|
||||
gettext('This service requires an OS Manager')
|
||||
) from None
|
||||
del fields['osmanager_id']
|
||||
else:
|
||||
del fields['osmanager_id']
|
||||
@@ -536,7 +528,7 @@ class ServicesPools(ModelHandler):
|
||||
# fields['initial_srvs'] = min(fields['initial_srvs'], service_type.userservices_limit)
|
||||
# fields['cache_l1_srvs'] = min(fields['cache_l1_srvs'], service_type.userservices_limit)
|
||||
except Exception as e:
|
||||
raise exceptions.rest.RequestError(gettext('This parameters provided are not valid')) from e
|
||||
raise exceptions.rest.RequestError(gettext('This service requires an OS Manager')) from e
|
||||
|
||||
# If max < initial or cache_1 or cache_l2
|
||||
fields['max_srvs'] = max(
|
||||
@@ -550,36 +542,36 @@ class ServicesPools(ModelHandler):
|
||||
# *** ACCOUNT ***
|
||||
account_id = fields['account_id']
|
||||
fields['account_id'] = None
|
||||
logger.debug('Account id: %s', account_id)
|
||||
|
||||
if account_id and account_id != '-1':
|
||||
logger.debug('Account id: %s', account_id)
|
||||
if account_id != '-1':
|
||||
try:
|
||||
fields['account_id'] = Account.objects.get(uuid=process_uuid(account_id)).id
|
||||
except Exception:
|
||||
logger.warning('Getting account ID: %s %s', account_id)
|
||||
logger.exception('Getting account ID')
|
||||
|
||||
# **** IMAGE ***
|
||||
image_id = fields['image_id']
|
||||
fields['image_id'] = None
|
||||
if image_id and image_id != '-1':
|
||||
logger.debug('Image id: %s', image_id)
|
||||
try:
|
||||
logger.debug('Image id: %s', image_id)
|
||||
try:
|
||||
if image_id != '-1':
|
||||
image = Image.objects.get(uuid=process_uuid(image_id))
|
||||
fields['image_id'] = image.id
|
||||
except Exception:
|
||||
logger.warning('At image recovering: %s', image_id)
|
||||
except Exception:
|
||||
logger.exception('At image recovering')
|
||||
|
||||
# Servicepool Group
|
||||
pool_group_id = fields['pool_group_id']
|
||||
del fields['pool_group_id']
|
||||
fields['servicesPoolGroup_id'] = None
|
||||
if pool_group_id and pool_group_id != '-1':
|
||||
logger.debug('pool_group_id: %s', pool_group_id)
|
||||
try:
|
||||
logger.debug('pool_group_id: %s', pool_group_id)
|
||||
try:
|
||||
if pool_group_id != '-1':
|
||||
spgrp = ServicePoolGroup.objects.get(uuid=process_uuid(pool_group_id))
|
||||
fields['servicesPoolGroup_id'] = spgrp.id
|
||||
except Exception:
|
||||
logger.warning('At service pool group recovering: %s', pool_group_id)
|
||||
except Exception:
|
||||
logger.exception('At service pool group recovering')
|
||||
|
||||
except (exceptions.rest.RequestError, exceptions.rest.ResponseError):
|
||||
raise
|
||||
@@ -614,7 +606,7 @@ class ServicesPools(ModelHandler):
|
||||
# Set fallback status
|
||||
def set_fallback_access(self, item: 'Model') -> typing.Any:
|
||||
item = ensure.is_instance(item, ServicePool)
|
||||
self.ensure_has_access(item, types.permissions.PermissionType.MANAGEMENT)
|
||||
self.check_access(item, types.permissions.PermissionType.MANAGEMENT)
|
||||
|
||||
fallback = self._params.get('fallbackAccess', self.params.get('fallback', None))
|
||||
if fallback:
|
||||
@@ -683,7 +675,7 @@ class ServicesPools(ModelHandler):
|
||||
def create_from_assignable(self, item: 'Model') -> typing.Any:
|
||||
item = ensure.is_instance(item, ServicePool)
|
||||
if 'user_id' not in self._params or 'assignable_id' not in self._params:
|
||||
return self.invalid_request_response('Invalid parameters')
|
||||
raise exceptions.rest.RequestError('Invalid parameters')
|
||||
|
||||
logger.debug('Creating from assignable: %s', self._params)
|
||||
UserServiceManager.manager().create_from_assignable(
|
||||
@@ -697,10 +689,10 @@ class ServicesPools(ModelHandler):
|
||||
def add_log(self, item: 'Model') -> typing.Any:
|
||||
item = ensure.is_instance(item, ServicePool)
|
||||
if 'message' not in self._params:
|
||||
return self.invalid_request_response('Invalid parameters')
|
||||
raise exceptions.rest.RequestError('Invalid parameters')
|
||||
if 'level' not in self._params:
|
||||
return self.invalid_request_response('Invalid parameters')
|
||||
|
||||
raise exceptions.rest.RequestError('Invalid parameters')
|
||||
|
||||
log.log(
|
||||
item,
|
||||
level=types.log.LogLevel.from_str(self._params['level']),
|
||||
@@ -708,4 +700,3 @@ class ServicesPools(ModelHandler):
|
||||
source=types.log.LogSource.REST,
|
||||
log_name=self._params.get('log_name', None),
|
||||
)
|
||||
|
||||
@@ -31,32 +31,53 @@
|
||||
Author: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
"""
|
||||
|
||||
import dataclasses
|
||||
import logging
|
||||
import typing
|
||||
import datetime
|
||||
|
||||
from django.utils.translation import gettext as _
|
||||
from uds.core import types
|
||||
from django.db.models import Model
|
||||
|
||||
from uds.core import exceptions, types
|
||||
|
||||
from uds.models import UserService, Provider
|
||||
from uds.core.types.states import State
|
||||
from uds.core.util.model import process_uuid
|
||||
from uds.REST.model import DetailHandler
|
||||
from uds.core.util import ensure
|
||||
from uds.core.util import ensure, ui as ui_utils
|
||||
|
||||
# Not imported at runtime, just for type checking
|
||||
if typing.TYPE_CHECKING:
|
||||
from django.db.models import Model
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ServicesUsage(DetailHandler):
|
||||
@dataclasses.dataclass
|
||||
class ServicesUsageItem(types.rest.BaseRestItem):
|
||||
id: str
|
||||
state_date: datetime.datetime
|
||||
creation_date: datetime.datetime
|
||||
unique_id: str
|
||||
friendly_name: str
|
||||
owner: str
|
||||
owner_info: dict[str, str]
|
||||
service: str
|
||||
service_id: str
|
||||
pool: str
|
||||
pool_id: str
|
||||
ip: str
|
||||
source_host: str
|
||||
source_ip: str
|
||||
in_use: bool
|
||||
|
||||
|
||||
class ServicesUsage(DetailHandler[ServicesUsageItem]):
|
||||
"""
|
||||
Rest handler for Assigned Services, which parent is Service
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def item_as_dict(item: UserService) -> dict[str, typing.Any]:
|
||||
def item_as_dict(item: UserService) -> ServicesUsageItem:
|
||||
"""
|
||||
Converts an assigned/cached service db item to a dictionary for REST response
|
||||
:param item: item to convert
|
||||
@@ -72,30 +93,32 @@ class ServicesUsage(DetailHandler):
|
||||
owner = item.user.pretty_name
|
||||
owner_info = {'auth_id': item.user.manager.uuid, 'user_id': item.user.uuid}
|
||||
|
||||
return {
|
||||
'id': item.uuid,
|
||||
'state_date': item.state_date,
|
||||
'creation_date': item.creation_date,
|
||||
'unique_id': item.unique_id,
|
||||
'friendly_name': item.friendly_name,
|
||||
'owner': owner,
|
||||
'owner_info': owner_info,
|
||||
'service': item.deployed_service.service.name,
|
||||
'service_id': item.deployed_service.service.uuid,
|
||||
'pool': item.deployed_service.name,
|
||||
'pool_id': item.deployed_service.uuid,
|
||||
'ip': props.get('ip', _('unknown')),
|
||||
'source_host': item.src_hostname,
|
||||
'source_ip': item.src_ip,
|
||||
'in_use': item.in_use,
|
||||
}
|
||||
return ServicesUsageItem(
|
||||
id=item.uuid,
|
||||
state_date=item.state_date,
|
||||
creation_date=item.creation_date,
|
||||
unique_id=item.unique_id,
|
||||
friendly_name=item.friendly_name,
|
||||
owner=owner,
|
||||
owner_info=owner_info,
|
||||
service=item.deployed_service.service.name,
|
||||
service_id=item.deployed_service.service.uuid,
|
||||
pool=item.deployed_service.name,
|
||||
pool_id=item.deployed_service.uuid,
|
||||
ip=props.get('ip', _('unknown')),
|
||||
source_host=item.src_hostname,
|
||||
source_ip=item.src_ip,
|
||||
in_use=item.in_use,
|
||||
)
|
||||
|
||||
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
|
||||
def get_items(
|
||||
self, parent: 'Model', item: typing.Optional[str]
|
||||
) -> types.rest.ItemsResult[ServicesUsageItem]:
|
||||
parent = ensure.is_instance(parent, Provider)
|
||||
try:
|
||||
if item is None:
|
||||
userservices_query = UserService.objects.filter(
|
||||
deployed_service__service__provider=parent
|
||||
userservices_query = self.filter_queryset(
|
||||
UserService.objects.filter(deployed_service__service__provider=parent)
|
||||
)
|
||||
else:
|
||||
userservices_query = UserService.objects.filter(
|
||||
@@ -109,29 +132,26 @@ class ServicesUsage(DetailHandler):
|
||||
.prefetch_related('deployed_service', 'deployed_service__service', 'user', 'user__manager')
|
||||
]
|
||||
|
||||
except Exception:
|
||||
logger.exception('get_items')
|
||||
raise self.invalid_item_response()
|
||||
except Exception as e:
|
||||
logger.error('Error getting services usage for %s: %s', parent.uuid, e)
|
||||
raise exceptions.rest.ResponseError(_('Error getting services usage')) from None
|
||||
|
||||
def get_title(self, parent: 'Model') -> str:
|
||||
return _('Services Usage')
|
||||
|
||||
def get_fields(self, parent: 'Model') -> list[typing.Any]:
|
||||
return [
|
||||
# {'creation_date': {'title': _('Creation date'), 'type': 'datetime'}},
|
||||
{'state_date': {'title': _('Access'), 'type': 'datetime'}},
|
||||
{'owner': {'title': _('Owner')}},
|
||||
{'service': {'title': _('Service')}},
|
||||
{'pool': {'title': _('Pool')}},
|
||||
{'unique_id': {'title': 'Unique ID'}},
|
||||
{'ip': {'title': _('IP')}},
|
||||
{'friendly_name': {'title': _('Friendly name')}},
|
||||
{'source_ip': {'title': _('Src Ip')}},
|
||||
{'source_host': {'title': _('Src Host')}},
|
||||
]
|
||||
|
||||
def get_row_style(self, parent: 'Model') -> types.ui.RowStyleInfo:
|
||||
return types.ui.RowStyleInfo(prefix='row-state-', field='state')
|
||||
def get_table(self, parent: 'Model') -> types.rest.TableInfo:
|
||||
parent = ensure.is_instance(parent, Provider)
|
||||
return (
|
||||
ui_utils.TableBuilder(_('Services Usage'))
|
||||
.datetime_column(name='state_date', title=_('Access'))
|
||||
.text_column(name='owner', title=_('Owner'))
|
||||
.text_column(name='service', title=_('Service'))
|
||||
.text_column(name='pool', title=_('Pool'))
|
||||
.text_column(name='unique_id', title='Unique ID')
|
||||
.text_column(name='ip', title=_('IP'))
|
||||
.text_column(name='friendly_name', title=_('Friendly name'))
|
||||
.text_column(name='source_ip', title=_('Src Ip'))
|
||||
.text_column(name='source_host', title=_('Src Host'))
|
||||
.row_style(prefix='row-state-', field='state')
|
||||
.build()
|
||||
)
|
||||
|
||||
def delete_item(self, parent: 'Model', item: str) -> None:
|
||||
parent = ensure.is_instance(parent, Provider)
|
||||
@@ -140,8 +160,11 @@ class ServicesUsage(DetailHandler):
|
||||
userservice = UserService.objects.get(
|
||||
uuid=process_uuid(item), deployed_service__service__provider=parent
|
||||
)
|
||||
except Exception:
|
||||
raise self.invalid_item_response()
|
||||
except UserService.DoesNotExist:
|
||||
raise exceptions.rest.NotFound(_('User service not found')) from None
|
||||
except Exception as e:
|
||||
logger.error('Error getting user service %s from %s: %s', item, parent, e)
|
||||
raise exceptions.rest.ResponseError(_('Error getting user service')) from None
|
||||
|
||||
logger.debug('Deleting user service')
|
||||
if userservice.state in (State.USABLE, State.REMOVING):
|
||||
@@ -149,6 +172,6 @@ class ServicesUsage(DetailHandler):
|
||||
elif userservice.state == State.PREPARING:
|
||||
userservice.cancel()
|
||||
elif userservice.state == State.REMOVABLE:
|
||||
raise self.invalid_item_response(_('Item already being removed'))
|
||||
raise exceptions.rest.ResponseError(_('Item already being removed')) from None
|
||||
else:
|
||||
raise self.invalid_item_response(_('Item is not removable'))
|
||||
raise exceptions.rest.ResponseError(_('Item is not removable')) from None
|
||||
|
||||
@@ -34,7 +34,9 @@ import logging
|
||||
import datetime
|
||||
import typing
|
||||
|
||||
from uds.core import types
|
||||
from django.utils import timezone
|
||||
|
||||
from uds.core import types, consts
|
||||
from uds.REST import Handler
|
||||
from uds import models
|
||||
from uds.core.util.stats import counters
|
||||
@@ -44,13 +46,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# Enclosed methods under /cache path
|
||||
class Stats(Handler):
|
||||
authenticated = True
|
||||
needs_admin = True
|
||||
|
||||
help_paths = [
|
||||
('', 'Returns the last day usage statistics for all authenticators'),
|
||||
]
|
||||
help_text = 'Provides access to usage statistics'
|
||||
ROLE = consts.UserRole.ADMIN
|
||||
|
||||
def _usage_stats(self, since: datetime.datetime) -> dict[str, list[dict[str, typing.Any]]]:
|
||||
"""
|
||||
@@ -138,4 +134,4 @@ class Stats(Handler):
|
||||
Processes get method. Basically, clears & purges the cache, no matter what params
|
||||
"""
|
||||
# Default returns usage stats for last day
|
||||
return self._usage_stats(datetime.datetime.now() - datetime.timedelta(days=1))
|
||||
return self._usage_stats(timezone.localtime() - datetime.timedelta(days=1))
|
||||
@@ -37,8 +37,10 @@ import pickle # nosec: pickle is used to cache data, not to load it
|
||||
import pickletools
|
||||
import typing
|
||||
|
||||
from django.db.models import Model
|
||||
|
||||
from uds import models
|
||||
from uds.core import exceptions, types
|
||||
from uds.core import exceptions, types, consts
|
||||
from uds.core.util import permissions
|
||||
from uds.core.util.cache import Cache
|
||||
from uds.core.util.model import process_uuid, sql_now
|
||||
@@ -48,8 +50,6 @@ from uds.REST import Handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from django.db.models import Model
|
||||
|
||||
cache = Cache('StatsDispatcher')
|
||||
|
||||
@@ -66,9 +66,7 @@ def get_servicepools_counters(
|
||||
) -> list[dict[str, typing.Any]]:
|
||||
val: list[dict[str, typing.Any]] = []
|
||||
try:
|
||||
cache_key = (
|
||||
(servicepool and str(servicepool.id) or 'all') + str(counter_type) + str(since_days)
|
||||
)
|
||||
cache_key = (servicepool and str(servicepool.id) or 'all') + str(counter_type) + str(since_days)
|
||||
# Get now but with 0 minutes and 0 seconds
|
||||
to = sql_now().replace(minute=0, second=0, microsecond=0)
|
||||
since: datetime.datetime = to - datetime.timedelta(days=since_days)
|
||||
@@ -87,7 +85,7 @@ def get_servicepools_counters(
|
||||
owner_type=types.stats.CounterOwnerType.SERVICEPOOL,
|
||||
owner_id=servicepool.id if servicepool.id != -1 else None,
|
||||
since=since,
|
||||
points=since_days*24, # One point per hour
|
||||
points=since_days * 24, # One point per hour
|
||||
)
|
||||
val = [
|
||||
{
|
||||
@@ -107,8 +105,7 @@ def get_servicepools_counters(
|
||||
else:
|
||||
# Generate as much points as needed with 0 value
|
||||
val = [
|
||||
{'stamp': since + datetime.timedelta(hours=i), 'value': 0}
|
||||
for i in range(since_days * 24)
|
||||
{'stamp': since + datetime.timedelta(hours=i), 'value': 0} for i in range(since_days * 24)
|
||||
]
|
||||
else:
|
||||
val = pickle.loads(
|
||||
@@ -143,21 +140,7 @@ class System(Handler):
|
||||
}
|
||||
"""
|
||||
|
||||
needs_admin = False
|
||||
needs_staff = True
|
||||
|
||||
help_paths = [
|
||||
('', ''),
|
||||
('stats/assigned', ''),
|
||||
('stats/inuse', ''),
|
||||
('stats/cached', ''),
|
||||
('stats/complete', ''),
|
||||
('stats/assigned/<servicePoolId>', ''),
|
||||
('stats/inuse/<servicePoolId>', ''),
|
||||
('stats/cached/<servicePoolId>', ''),
|
||||
('stats/complete/<servicePoolId>', ''),
|
||||
]
|
||||
help_text = 'Provides system information. Must be admin to access this'
|
||||
ROLE = consts.UserRole.STAFF
|
||||
|
||||
def get(self) -> typing.Any:
|
||||
logger.debug('args: %s', self._args)
|
||||
@@ -166,14 +149,16 @@ class System(Handler):
|
||||
if self._args[0] == 'overview': # System overview
|
||||
if not self._user.is_admin:
|
||||
raise exceptions.rest.AccessDenied()
|
||||
|
||||
fltr_user = models.User.objects.filter(userServices__state__in=types.states.State.VALID_STATES).order_by()
|
||||
|
||||
fltr_user = models.User.objects.filter(
|
||||
userServices__state__in=types.states.State.VALID_STATES
|
||||
).order_by()
|
||||
users = models.User.objects.all().count()
|
||||
users_with_services = (
|
||||
fltr_user.values('id').distinct().count()
|
||||
) # Use "values" to simplify query (only id)
|
||||
number_assigned_user_services = fltr_user.values('id').count()
|
||||
|
||||
|
||||
groups: int = models.Group.objects.count()
|
||||
services: int = models.Service.objects.count()
|
||||
service_pools: int = models.ServicePool.objects.count()
|
||||
@@ -188,7 +173,7 @@ class System(Handler):
|
||||
calendars: int = models.Calendar.objects.count()
|
||||
tunnels: int = models.Server.objects.filter(type=types.servers.ServerType.TUNNEL).count()
|
||||
auths: int = models.Authenticator.objects.count()
|
||||
|
||||
|
||||
return {
|
||||
'users': users,
|
||||
'users_with_services': users_with_services,
|
||||
|
||||
@@ -30,17 +30,17 @@
|
||||
"""
|
||||
Author: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
"""
|
||||
import datetime
|
||||
import logging
|
||||
import typing
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from uds.REST import Handler
|
||||
from uds import models
|
||||
from uds.core.managers.crypto import CryptoManager
|
||||
from uds.core.util.model import process_uuid
|
||||
from uds.core.util import ensure
|
||||
from uds.core import exceptions
|
||||
from uds.core import consts, exceptions
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -89,14 +89,14 @@ class Tickets(Handler):
|
||||
- servicePool has these groups in it's allowed list
|
||||
"""
|
||||
|
||||
needs_admin = True # By default, staff is lower level needed
|
||||
ROLE = consts.UserRole.ADMIN
|
||||
|
||||
@staticmethod
|
||||
def result(result: str = '', error: typing.Optional[str] = None) -> dict[str, typing.Any]:
|
||||
"""
|
||||
Returns a result for a Ticket request
|
||||
"""
|
||||
res = {'result': result, 'date': datetime.datetime.now()}
|
||||
res = {'result': result, 'date': timezone.localtime()}
|
||||
if error is not None:
|
||||
res['error'] = error
|
||||
return res
|
||||
|
||||
@@ -30,31 +30,49 @@
|
||||
'''
|
||||
@Author: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
'''
|
||||
import dataclasses
|
||||
import logging
|
||||
import re
|
||||
import typing
|
||||
import collections.abc
|
||||
|
||||
from django.utils.translation import gettext
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.translation import gettext, gettext_lazy as _
|
||||
from django.db.models import Model
|
||||
|
||||
from uds.core import consts, transports, types, ui
|
||||
from uds.core import consts, exceptions, transports, types, ui
|
||||
from uds.core.environment import Environment
|
||||
from uds.core.util import ensure, permissions
|
||||
from uds.core.util import ensure, permissions, ui as ui_utils
|
||||
from uds.models import Network, ServicePool, Transport
|
||||
from uds.REST.model import ModelHandler
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from django.db.models import Model
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Enclosed methods under /item path
|
||||
|
||||
|
||||
class Transports(ModelHandler):
|
||||
model = Transport
|
||||
save_fields = [
|
||||
@dataclasses.dataclass
|
||||
class TransportItem(types.rest.ManagedObjectItem[Transport]):
|
||||
id: str
|
||||
name: str
|
||||
tags: list[str]
|
||||
comments: str
|
||||
priority: int
|
||||
label: str
|
||||
net_filtering: str
|
||||
networks: list[str]
|
||||
allowed_oss: list[str]
|
||||
pools: list[str]
|
||||
pools_count: int
|
||||
deployed_count: int
|
||||
protocol: str
|
||||
permission: int
|
||||
|
||||
|
||||
class Transports(ModelHandler[TransportItem]):
|
||||
|
||||
MODEL = Transport
|
||||
FIELDS_TO_SAVE = [
|
||||
'name',
|
||||
'comments',
|
||||
'tags',
|
||||
@@ -64,112 +82,97 @@ class Transports(ModelHandler):
|
||||
'label',
|
||||
]
|
||||
|
||||
table_title = _('Transports')
|
||||
table_fields = [
|
||||
{'priority': {'title': _('Priority'), 'type': 'numeric', 'width': '6em'}},
|
||||
{'name': {'title': _('Name'), 'visible': True, 'type': 'iconType'}},
|
||||
{'type_name': {'title': _('Type')}},
|
||||
{'comments': {'title': _('Comments')}},
|
||||
{
|
||||
'pools_count': {
|
||||
'title': _('Service Pools'),
|
||||
'type': 'numeric',
|
||||
'width': '6em',
|
||||
}
|
||||
},
|
||||
{'allowed_oss': {'title': _('Devices'), 'width': '8em'}},
|
||||
{'tags': {'title': _('tags'), 'visible': False}},
|
||||
]
|
||||
TABLE = (
|
||||
ui_utils.TableBuilder(_('Transports'))
|
||||
.numeric_column(name='priority', title=_('Priority'), width='6em')
|
||||
.icon(name='name', title=_('Name'))
|
||||
.text_column(name='type_name', title=_('Type'))
|
||||
.text_column(name='comments', title=_('Comments'))
|
||||
.numeric_column(name='pools_count', title=_('Service Pools'), width='6em')
|
||||
.text_column(name='allowed_oss', title=_('Devices'), width='8em')
|
||||
.text_column(name='tags', title=_('tags'), visible=False)
|
||||
).build()
|
||||
|
||||
def enum_types(self) -> collections.abc.Iterable[type[transports.Transport]]:
|
||||
# Rest api related information to complete the auto-generated API
|
||||
REST_API_INFO = types.rest.api.RestApiInfo(
|
||||
typed=types.rest.api.RestApiInfoGuiType.MULTIPLE_TYPES,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def possible_types(cls: type[typing.Self]) -> collections.abc.Iterable[type[transports.Transport]]:
|
||||
return transports.factory().providers().values()
|
||||
|
||||
def get_gui(self, type_: str) -> list[typing.Any]:
|
||||
transport_type = transports.factory().lookup(type_)
|
||||
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
|
||||
transport_type = transports.factory().lookup(for_type)
|
||||
|
||||
if not transport_type:
|
||||
raise self.invalid_item_response()
|
||||
raise exceptions.rest.NotFound(_('Transport type not found: {}').format(for_type))
|
||||
|
||||
with Environment.temporary_environment() as env:
|
||||
transport = transport_type(env, None)
|
||||
|
||||
field = self.add_default_fields(
|
||||
transport.gui_description(), ['name', 'comments', 'tags', 'priority', 'networks']
|
||||
)
|
||||
field = self.add_field(
|
||||
field,
|
||||
{
|
||||
'name': 'allowed_oss',
|
||||
'value': [],
|
||||
'choices': sorted(
|
||||
[ui.gui.choice_item(x.db_value(), x.os_name().title()) for x in consts.os.KNOWN_OS_LIST],
|
||||
key=lambda x: x['text'].lower(),
|
||||
),
|
||||
'label': gettext('Allowed Devices'),
|
||||
'tooltip': gettext(
|
||||
'If empty, any kind of device compatible with this transport will be allowed. Else, only devices compatible with selected values will be allowed'
|
||||
),
|
||||
'type': types.ui.FieldType.MULTICHOICE,
|
||||
'tab': types.ui.Tab.ADVANCED,
|
||||
'order': 102,
|
||||
},
|
||||
)
|
||||
field = self.add_field(
|
||||
field,
|
||||
{
|
||||
'name': 'pools',
|
||||
'value': [],
|
||||
'choices': [
|
||||
return (
|
||||
ui_utils.GuiBuilder()
|
||||
.add_stock_field(types.rest.stock.StockField.NAME)
|
||||
.add_stock_field(types.rest.stock.StockField.COMMENTS)
|
||||
.add_stock_field(types.rest.stock.StockField.TAGS)
|
||||
.add_stock_field(types.rest.stock.StockField.PRIORITY)
|
||||
.add_stock_field(types.rest.stock.StockField.NETWORKS)
|
||||
.add_fields(transport.gui_description())
|
||||
.add_multichoice(
|
||||
name='pools',
|
||||
label=gettext('Service Pools'),
|
||||
choices=[
|
||||
ui.gui.choice_item(x.uuid, x.name)
|
||||
for x in ServicePool.objects.filter(service__isnull=False)
|
||||
.order_by('name')
|
||||
.prefetch_related('service')
|
||||
if transport_type.protocol in x.service.get_type().allowed_protocols
|
||||
if transport_type.PROTOCOL in x.service.get_type().allowed_protocols
|
||||
],
|
||||
'label': gettext('Service Pools'),
|
||||
'tooltip': gettext('Currently assigned services pools'),
|
||||
'type': types.ui.FieldType.MULTICHOICE,
|
||||
'order': 103,
|
||||
},
|
||||
)
|
||||
field = self.add_field(
|
||||
field,
|
||||
{
|
||||
'name': 'label',
|
||||
'length': 32,
|
||||
'value': '',
|
||||
'label': gettext('Label'),
|
||||
'tooltip': gettext('Metapool transport label (only used on metapool transports grouping)'),
|
||||
'type': types.ui.FieldType.TEXT,
|
||||
'order': 201,
|
||||
'tab': types.ui.Tab.ADVANCED,
|
||||
},
|
||||
tooltip=gettext(
|
||||
'Currently assigned services pools. If empty, no service pool is assigned to this transport'
|
||||
),
|
||||
)
|
||||
.new_tab(types.ui.Tab.ADVANCED)
|
||||
.add_multichoice(
|
||||
name='allowed_oss',
|
||||
label=gettext('Allowed Devices'),
|
||||
choices=[
|
||||
ui.gui.choice_item(x.db_value(), x.os_name().title()) for x in consts.os.KNOWN_OS_LIST
|
||||
],
|
||||
tooltip=gettext(
|
||||
'If empty, any kind of device compatible with this transport will be allowed. Else, only devices compatible with selected values will be allowed'
|
||||
),
|
||||
)
|
||||
.add_text(
|
||||
name='label',
|
||||
label=gettext('Label'),
|
||||
tooltip=gettext('Metapool transport label (only used on metapool transports grouping)'),
|
||||
)
|
||||
.build()
|
||||
)
|
||||
|
||||
return field
|
||||
|
||||
def item_as_dict(self, item: 'Model') -> dict[str, typing.Any]:
|
||||
def get_item(self, item: 'Model') -> TransportItem:
|
||||
item = ensure.is_instance(item, Transport)
|
||||
type_ = item.get_type()
|
||||
pools = list(item.deployedServices.all().values_list('uuid', flat=True))
|
||||
return {
|
||||
'id': item.uuid,
|
||||
'name': item.name,
|
||||
'tags': [tag.tag for tag in item.tags.all()],
|
||||
'comments': item.comments,
|
||||
'priority': item.priority,
|
||||
'label': item.label,
|
||||
'net_filtering': item.net_filtering,
|
||||
'networks': list(item.networks.all().values_list('uuid', flat=True)),
|
||||
'allowed_oss': [x for x in item.allowed_oss.split(',')] if item.allowed_oss != '' else [],
|
||||
'pools': pools,
|
||||
'pools_count': len(pools),
|
||||
'deployed_count': item.deployedServices.count(),
|
||||
'type': type_.mod_type(),
|
||||
'type_name': type_.mod_name(),
|
||||
'protocol': type_.protocol,
|
||||
'permission': permissions.effective_permissions(self._user, item),
|
||||
}
|
||||
return TransportItem(
|
||||
id=item.uuid,
|
||||
name=item.name,
|
||||
tags=[tag.tag for tag in item.tags.all()],
|
||||
comments=item.comments,
|
||||
priority=item.priority,
|
||||
label=item.label,
|
||||
net_filtering=item.net_filtering,
|
||||
networks=list(item.networks.all().values_list('uuid', flat=True)),
|
||||
allowed_oss=[x for x in item.allowed_oss.split(',')] if item.allowed_oss != '' else [],
|
||||
pools=pools,
|
||||
pools_count=len(pools),
|
||||
deployed_count=item.deployedServices.count(),
|
||||
protocol=type_.PROTOCOL,
|
||||
permission=permissions.effective_permissions(self._user, item),
|
||||
item=item,
|
||||
)
|
||||
|
||||
def pre_save(self, fields: dict[str, typing.Any]) -> None:
|
||||
fields['allowed_oss'] = ','.join(fields['allowed_oss'])
|
||||
@@ -177,7 +180,7 @@ class Transports(ModelHandler):
|
||||
fields['label'] = fields['label'].strip().replace(' ', '-')
|
||||
# And ensure small_name chars are valid [ a-zA-Z0-9:-]+
|
||||
if fields['label'] and not re.match(r'^[a-zA-Z0-9:-]+$', fields['label']):
|
||||
raise self.invalid_request_response(
|
||||
raise exceptions.rest.ValidationError(
|
||||
gettext('Label must contain only letters, numbers, ":" and "-"')
|
||||
)
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ import logging
|
||||
import typing
|
||||
|
||||
from uds import models
|
||||
from uds.core import exceptions, types
|
||||
from uds.core import consts, exceptions, types
|
||||
from uds.core.auths.auth import is_trusted_source
|
||||
from uds.core.util import log, net
|
||||
from uds.core.util.model import sql_stamp_seconds
|
||||
@@ -54,9 +54,9 @@ class TunnelTicket(Handler):
|
||||
Processes tunnel requests
|
||||
"""
|
||||
|
||||
authenticated = False # Client requests are not authenticated
|
||||
path = 'tunnel'
|
||||
name = 'ticket'
|
||||
ROLE = consts.UserRole.ANONYMOUS
|
||||
PATH = 'tunnel'
|
||||
NAME = 'ticket'
|
||||
|
||||
def get(self) -> collections.abc.MutableMapping[str, typing.Any]:
|
||||
"""
|
||||
@@ -148,12 +148,13 @@ class TunnelTicket(Handler):
|
||||
|
||||
|
||||
class TunnelRegister(ServerRegisterBase):
|
||||
needs_admin = True
|
||||
path = 'tunnel'
|
||||
name = 'register'
|
||||
ROLE = consts.UserRole.ADMIN
|
||||
|
||||
PATH = 'tunnel'
|
||||
NAME = 'register'
|
||||
|
||||
# Just a compatibility method for old tunnel servers
|
||||
def post(self) -> collections.abc.MutableMapping[str, typing.Any]:
|
||||
def post(self) -> dict[str, typing.Any]:
|
||||
self._params['type'] = types.servers.ServerType.TUNNEL
|
||||
self._params['os'] = self._params.get(
|
||||
'os', types.os.KnownOS.LINUX.os_name()
|
||||
|
||||
@@ -29,87 +29,88 @@
|
||||
"""
|
||||
Author: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
"""
|
||||
import dataclasses
|
||||
import logging
|
||||
import typing
|
||||
|
||||
from django.utils.translation import gettext
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.translation import gettext, gettext_lazy as _
|
||||
from django.db.models import Model
|
||||
|
||||
import uds.core.types.permissions
|
||||
from uds.core import exceptions, types, consts
|
||||
from uds.core.util import permissions, validators, ensure
|
||||
from uds.core.types.rest import TableInfo
|
||||
from uds.core.util import permissions, validators, ensure, ui as ui_utils
|
||||
from uds.core.util.model import process_uuid
|
||||
from uds import models
|
||||
from uds.REST.model import DetailHandler, ModelHandler
|
||||
|
||||
# Not imported at runtime, just for type checking
|
||||
if typing.TYPE_CHECKING:
|
||||
from django.db.models import Model
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TunnelServers(DetailHandler):
|
||||
# tunnels/[id]/servers
|
||||
custom_methods = ['maintenance']
|
||||
@dataclasses.dataclass
|
||||
class TunnelServerItem(types.rest.BaseRestItem):
|
||||
id: str
|
||||
hostname: str
|
||||
ip: str
|
||||
mac: str
|
||||
maintenance: bool
|
||||
|
||||
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
|
||||
|
||||
class TunnelServers(DetailHandler[TunnelServerItem]):
|
||||
CUSTOM_METHODS = ['maintenance']
|
||||
|
||||
REST_API_INFO = types.rest.api.RestApiInfo(
|
||||
name='TunnelServers', description='Tunnel servers assigned to a tunnel'
|
||||
)
|
||||
|
||||
def get_items(
|
||||
self, parent: 'Model', item: typing.Optional[str]
|
||||
) -> types.rest.ItemsResult[TunnelServerItem]:
|
||||
parent = ensure.is_instance(parent, models.ServerGroup)
|
||||
try:
|
||||
multi = False
|
||||
if item is None:
|
||||
multi = True
|
||||
q = parent.servers.all().order_by('hostname')
|
||||
q = self.filter_queryset(parent.servers.all())
|
||||
else:
|
||||
q = parent.servers.filter(uuid=process_uuid(item))
|
||||
res: list[dict[str, typing.Any]] = []
|
||||
i = None
|
||||
for i in q:
|
||||
val = {
|
||||
'id': i.uuid,
|
||||
'hostname': i.hostname,
|
||||
'ip': i.ip,
|
||||
'mac': i.mac if not multi or i.mac != consts.MAC_UNKNOWN else '',
|
||||
'maintenance': i.maintenance_mode,
|
||||
}
|
||||
res.append(val)
|
||||
res: list[TunnelServerItem] = [
|
||||
TunnelServerItem(
|
||||
id=i.uuid,
|
||||
hostname=i.hostname,
|
||||
ip=i.ip,
|
||||
mac=i.mac if i.mac != consts.NULL_MAC else '',
|
||||
maintenance=i.maintenance_mode,
|
||||
)
|
||||
for i in q
|
||||
]
|
||||
|
||||
if multi:
|
||||
return res
|
||||
if not i:
|
||||
raise Exception('Item not found')
|
||||
if not res:
|
||||
raise exceptions.rest.NotFound(f'Tunnel server {item} not found')
|
||||
return res[0]
|
||||
except exceptions.rest.HandlerError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception('REST groups')
|
||||
raise self.invalid_item_response() from e
|
||||
logger.error('Error getting tunnel servers for %s: %s', parent, e)
|
||||
raise exceptions.rest.ResponseError(_('Error getting tunnel servers')) from e
|
||||
|
||||
def get_title(self, parent: 'Model') -> str:
|
||||
def get_table(self, parent: 'Model') -> TableInfo:
|
||||
parent = ensure.is_instance(parent, models.ServerGroup)
|
||||
try:
|
||||
return _('Servers of {0}').format(parent.name)
|
||||
except Exception:
|
||||
return gettext('Servers')
|
||||
|
||||
def get_fields(self, parent: 'Model') -> list[typing.Any]:
|
||||
parent = ensure.is_instance(parent, models.ServerGroup)
|
||||
return [
|
||||
{
|
||||
'hostname': {
|
||||
'title': _('Hostname'),
|
||||
}
|
||||
},
|
||||
{'ip': {'title': _('Ip')}},
|
||||
{'mac': {'title': _('Mac')}},
|
||||
{
|
||||
'maintenance_mode': {
|
||||
'title': _('State'),
|
||||
'type': 'dict',
|
||||
'dict': {True: _('Maintenance'), False: _('Normal')},
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
def get_row_style(self, parent: 'Model') -> types.ui.RowStyleInfo:
|
||||
return types.ui.RowStyleInfo(prefix='row-maintenance-', field='maintenance_mode')
|
||||
return (
|
||||
ui_utils.TableBuilder(_('Servers of {0}').format(parent.name))
|
||||
.text_column(name='hostname', title=_('Hostname'))
|
||||
.text_column(name='ip', title=_('Ip'))
|
||||
.text_column(name='mac', title=_('Mac'))
|
||||
.dict_column(
|
||||
name='maintenance',
|
||||
title=_('State'),
|
||||
dct={True: _('Maintenance'), False: _('Normal')},
|
||||
)
|
||||
.row_style(prefix='row-maintenance-', field='maintenance')
|
||||
).build()
|
||||
|
||||
# Cannot save a tunnel server, it's not editable...
|
||||
|
||||
@@ -117,88 +118,107 @@ class TunnelServers(DetailHandler):
|
||||
parent = ensure.is_instance(parent, models.ServerGroup)
|
||||
try:
|
||||
parent.servers.remove(models.Server.objects.get(uuid=process_uuid(item)))
|
||||
except Exception:
|
||||
raise self.invalid_item_response() from None
|
||||
except models.Server.DoesNotExist:
|
||||
raise exceptions.rest.NotFound(_('Tunnel server not found')) from None
|
||||
except Exception as e:
|
||||
logger.error('Error deleting tunnel server %s from %s: %s', item, parent, e)
|
||||
raise exceptions.rest.ResponseError(_('Error deleting tunnel server')) from None
|
||||
|
||||
# Custom methods
|
||||
def maintenance(self, parent: 'Model', id: str) -> typing.Any:
|
||||
"""
|
||||
API:
|
||||
Custom method that swaps maintenance mode state for a tunnel server
|
||||
|
||||
"""
|
||||
parent = ensure.is_instance(parent, models.ServerGroup)
|
||||
"""
|
||||
Custom method that swaps maintenance mode state for a tunnel server
|
||||
:param item:
|
||||
"""
|
||||
item = models.Server.objects.get(uuid=process_uuid(id))
|
||||
self.ensure_has_access(item, uds.core.types.permissions.PermissionType.MANAGEMENT)
|
||||
self.check_access(item, uds.core.types.permissions.PermissionType.MANAGEMENT)
|
||||
item.maintenance_mode = not item.maintenance_mode
|
||||
item.save()
|
||||
return 'ok'
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class TunnelItem(types.rest.BaseRestItem):
|
||||
id: str
|
||||
name: str
|
||||
comments: str
|
||||
host: str
|
||||
port: int
|
||||
tags: list[str]
|
||||
transports_count: int
|
||||
servers_count: int
|
||||
permission: uds.core.types.permissions.PermissionType
|
||||
|
||||
|
||||
# Enclosed methods under /auth path
|
||||
class Tunnels(ModelHandler):
|
||||
path = 'tunnels'
|
||||
name = 'tunnels'
|
||||
model = models.ServerGroup
|
||||
model_filter = {'type': types.servers.ServerType.TUNNEL}
|
||||
custom_methods = [
|
||||
class Tunnels(ModelHandler[TunnelItem]):
|
||||
|
||||
PATH = 'tunnels'
|
||||
NAME = 'tunnels'
|
||||
MODEL = models.ServerGroup
|
||||
FILTER = {'type': types.servers.ServerType.TUNNEL}
|
||||
CUSTOM_METHODS = [
|
||||
types.rest.ModelCustomMethod('tunnels', needs_parent=True),
|
||||
types.rest.ModelCustomMethod('assign', needs_parent=True),
|
||||
]
|
||||
|
||||
detail = {'servers': TunnelServers}
|
||||
save_fields = ['name', 'comments', 'host:', 'port:0']
|
||||
DETAIL = {'servers': TunnelServers}
|
||||
FIELDS_TO_SAVE = ['name', 'comments', 'host:', 'port:0']
|
||||
|
||||
table_title = _('Tunnels')
|
||||
table_fields = [
|
||||
{'name': {'title': _('Name'), 'visible': True, 'type': 'iconType'}},
|
||||
{'comments': {'title': _('Comments')}},
|
||||
{'host': {'title': _('Host')}},
|
||||
{'port': {'title': _('Port')}},
|
||||
{'servers_count': {'title': _('Servers'), 'type': 'numeric', 'width': '1rem'}},
|
||||
{'tags': {'title': _('tags'), 'visible': False}},
|
||||
]
|
||||
TABLE = (
|
||||
ui_utils.TableBuilder(_('Tunnels'))
|
||||
.icon(name='name', title=_('Name'))
|
||||
.text_column(name='comments', title=_('Comments'))
|
||||
.text_column(name='host', title=_('Host'))
|
||||
.numeric_column(name='port', title=_('Port'), width='6em')
|
||||
.numeric_column(name='servers_count', title=_('Servers'), width='1rem')
|
||||
.text_column(name='tags', title=_('tags'), visible=False)
|
||||
.build()
|
||||
)
|
||||
|
||||
def get_gui(self, type_: str) -> list[typing.Any]:
|
||||
return self.add_field(
|
||||
self.add_default_fields(
|
||||
[],
|
||||
['name', 'comments', 'tags'],
|
||||
),
|
||||
[
|
||||
{
|
||||
'name': 'host',
|
||||
'value': '',
|
||||
'label': gettext('Hostname'),
|
||||
'tooltip': gettext(
|
||||
'Hostname or IP address of the server where the tunnel is visible by the users'
|
||||
),
|
||||
'type': types.ui.FieldType.TEXT,
|
||||
'order': 100, # At end
|
||||
},
|
||||
{
|
||||
'name': 'port',
|
||||
'value': 443,
|
||||
'label': gettext('Port'),
|
||||
'tooltip': gettext('Port where the tunnel is visible by the users'),
|
||||
'type': types.ui.FieldType.NUMERIC,
|
||||
'order': 101, # At end
|
||||
},
|
||||
],
|
||||
REST_API_INFO = types.rest.api.RestApiInfo(
|
||||
name='Tunnels',
|
||||
description='Tunnel management',
|
||||
typed=types.rest.api.RestApiInfoGuiType.SINGLE_TYPE,
|
||||
)
|
||||
|
||||
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
|
||||
return (
|
||||
ui_utils.GuiBuilder()
|
||||
.add_stock_field(types.rest.stock.StockField.NAME)
|
||||
.add_stock_field(types.rest.stock.StockField.COMMENTS)
|
||||
.add_stock_field(types.rest.stock.StockField.TAGS)
|
||||
.add_text(
|
||||
name='host',
|
||||
label=gettext('Hostname'),
|
||||
tooltip=gettext(
|
||||
'Hostname or IP address of the server where the tunnel is visible by the users'
|
||||
),
|
||||
)
|
||||
.add_numeric(
|
||||
name='port',
|
||||
default=443,
|
||||
label=gettext('Port'),
|
||||
tooltip=gettext('Port where the tunnel is visible by the users'),
|
||||
)
|
||||
.build()
|
||||
)
|
||||
|
||||
def item_as_dict(self, item: 'Model') -> dict[str, typing.Any]:
|
||||
def get_item(self, item: 'Model') -> TunnelItem:
|
||||
item = ensure.is_instance(item, models.ServerGroup)
|
||||
return {
|
||||
'id': item.uuid,
|
||||
'name': item.name,
|
||||
'comments': item.comments,
|
||||
'host': item.host,
|
||||
'port': item.port,
|
||||
'tags': [tag.tag for tag in item.tags.all()],
|
||||
'transports_count': item.transports.count(),
|
||||
'servers_count': item.servers.count(),
|
||||
'permission': permissions.effective_permissions(self._user, item),
|
||||
}
|
||||
return TunnelItem(
|
||||
id=item.uuid,
|
||||
name=item.name,
|
||||
comments=item.comments,
|
||||
host=item.host,
|
||||
port=item.port,
|
||||
tags=[tag.tag for tag in item.tags.all()],
|
||||
transports_count=item.transports.count(),
|
||||
servers_count=item.servers.count(),
|
||||
permission=permissions.effective_permissions(self._user, item),
|
||||
)
|
||||
|
||||
def pre_save(self, fields: dict[str, typing.Any]) -> None:
|
||||
fields['type'] = types.servers.ServerType.TUNNEL.value
|
||||
@@ -216,21 +236,24 @@ class Tunnels(ModelHandler):
|
||||
|
||||
def assign(self, parent: 'Model') -> typing.Any:
|
||||
parent = ensure.is_instance(parent, models.ServerGroup)
|
||||
self.ensure_has_access(parent, uds.core.types.permissions.PermissionType.MANAGEMENT)
|
||||
self.check_access(parent, uds.core.types.permissions.PermissionType.MANAGEMENT)
|
||||
|
||||
server: typing.Optional['models.Server'] = None # Avoid warning on reference before assignment
|
||||
|
||||
item = self._args[-1]
|
||||
|
||||
if not item:
|
||||
raise self.invalid_item_response('No server specified')
|
||||
raise exceptions.rest.RequestError('No server specified')
|
||||
|
||||
try:
|
||||
server = models.Server.objects.get(uuid=process_uuid(item))
|
||||
self.ensure_has_access(server, uds.core.types.permissions.PermissionType.READ)
|
||||
self.check_access(server, uds.core.types.permissions.PermissionType.READ)
|
||||
parent.servers.add(server)
|
||||
except Exception:
|
||||
raise self.invalid_item_response() from None
|
||||
except models.Server.DoesNotExist:
|
||||
raise exceptions.rest.NotFound(_('Tunnel server not found')) from None
|
||||
except Exception as e:
|
||||
logger.error('Error assigning server %s to %s: %s', item, parent, e)
|
||||
raise exceptions.rest.ResponseError(_('Error assigning server')) from None
|
||||
|
||||
return 'ok'
|
||||
|
||||
|
||||
@@ -31,71 +31,100 @@
|
||||
Author: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
"""
|
||||
import collections.abc
|
||||
import dataclasses
|
||||
import datetime
|
||||
import logging
|
||||
import typing
|
||||
|
||||
from django.utils.translation import gettext as _
|
||||
from django.db.models import Model
|
||||
|
||||
import uds.core.types.permissions
|
||||
from uds import models
|
||||
from uds.core import exceptions, types
|
||||
from uds.core.managers.userservice import UserServiceManager
|
||||
from uds.core.types.rest import TableInfo
|
||||
from uds.core.types.states import State
|
||||
from uds.core.util import ensure, log, permissions
|
||||
from uds.core.util import ensure, log, permissions, ui as ui_utils
|
||||
from uds.core.util.model import process_uuid
|
||||
from uds.REST.model import DetailHandler
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from django.db.models import Model
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AssignedService(DetailHandler):
|
||||
@dataclasses.dataclass
|
||||
class UserServiceItem(types.rest.BaseRestItem):
|
||||
id: str
|
||||
pool_id: str
|
||||
unique_id: str
|
||||
friendly_name: str
|
||||
state: str
|
||||
os_state: str
|
||||
state_date: datetime.datetime
|
||||
creation_date: datetime.datetime
|
||||
revision: str
|
||||
ip: str
|
||||
actor_version: str
|
||||
|
||||
# For cache
|
||||
cache_level: int | types.rest.NotRequired = types.rest.NotRequired.field()
|
||||
|
||||
# Optional, used on some cases (e.g. assigned services)
|
||||
pool_name: str | types.rest.NotRequired = types.rest.NotRequired.field()
|
||||
|
||||
# For assigned
|
||||
owner: str | types.rest.NotRequired = types.rest.NotRequired.field()
|
||||
owner_info: dict[str, str] | types.rest.NotRequired = types.rest.NotRequired.field()
|
||||
in_use: bool | types.rest.NotRequired = types.rest.NotRequired.field()
|
||||
in_use_date: datetime.datetime | types.rest.NotRequired = types.rest.NotRequired.field()
|
||||
source_host: str | types.rest.NotRequired = types.rest.NotRequired.field()
|
||||
source_ip: str | types.rest.NotRequired = types.rest.NotRequired.field()
|
||||
|
||||
|
||||
class AssignedUserService(DetailHandler[UserServiceItem]):
|
||||
"""
|
||||
Rest handler for Assigned Services, wich parent is Service
|
||||
"""
|
||||
|
||||
custom_methods = [
|
||||
'reset',
|
||||
]
|
||||
|
||||
custom_methods = ['reset']
|
||||
CUSTOM_METHODS = ['reset']
|
||||
|
||||
@staticmethod
|
||||
def item_as_dict(
|
||||
def userservice_item(
|
||||
item: models.UserService,
|
||||
props: typing.Optional[dict[str, typing.Any]] = None,
|
||||
is_cache: bool = False,
|
||||
) -> dict[str, typing.Any]:
|
||||
) -> 'UserServiceItem':
|
||||
"""
|
||||
Converts an assigned/cached service db item to a dictionary for REST response
|
||||
:param item: item to convert
|
||||
:param is_cache: If item is from cache or not
|
||||
Args:
|
||||
item: item to convert
|
||||
props: properties to include
|
||||
is_cache: If item is from cache or not
|
||||
"""
|
||||
if props is None:
|
||||
props = dict(item.properties)
|
||||
|
||||
val = {
|
||||
'id': item.uuid,
|
||||
'id_deployed_service': item.deployed_service.uuid,
|
||||
'unique_id': item.unique_id,
|
||||
'friendly_name': item.friendly_name,
|
||||
'state': (
|
||||
val = UserServiceItem(
|
||||
id=item.uuid,
|
||||
pool_id=item.deployed_service.uuid,
|
||||
unique_id=item.unique_id,
|
||||
friendly_name=item.friendly_name,
|
||||
state=(
|
||||
item.state
|
||||
if not (props.get('destroy_after') and item.state == State.PREPARING)
|
||||
else State.CANCELING
|
||||
), # Destroy after means that we need to cancel AFTER finishing preparing, but not before...
|
||||
'os_state': item.os_state,
|
||||
'state_date': item.state_date,
|
||||
'creation_date': item.creation_date,
|
||||
'revision': item.publication and item.publication.revision or '',
|
||||
'ip': props.get('ip', _('unknown')),
|
||||
'actor_version': props.get('actor_version', _('unknown')),
|
||||
}
|
||||
os_state=item.os_state,
|
||||
state_date=item.state_date,
|
||||
creation_date=item.creation_date,
|
||||
revision=item.publication and str(item.publication.revision) or '',
|
||||
ip=props.get('ip', _('unknown')),
|
||||
actor_version=props.get('actor_version', _('unknown')),
|
||||
)
|
||||
|
||||
if is_cache:
|
||||
val['cache_level'] = item.cache_level
|
||||
val.cache_level = item.cache_level
|
||||
else:
|
||||
if item.user is None:
|
||||
owner = ''
|
||||
@@ -107,19 +136,18 @@ class AssignedService(DetailHandler):
|
||||
'user_id': item.user.uuid,
|
||||
}
|
||||
|
||||
val.update(
|
||||
{
|
||||
'owner': owner,
|
||||
'owner_info': owner_info,
|
||||
'in_use': item.in_use,
|
||||
'in_use_date': item.in_use_date,
|
||||
'source_host': item.src_hostname,
|
||||
'source_ip': item.src_ip,
|
||||
}
|
||||
)
|
||||
val.owner = owner
|
||||
val.owner_info = owner_info
|
||||
val.in_use = item.in_use
|
||||
val.in_use_date = item.in_use_date
|
||||
val.source_host = item.src_hostname
|
||||
val.source_ip = item.src_ip
|
||||
|
||||
return val
|
||||
|
||||
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
|
||||
def get_items(
|
||||
self, parent: 'Model', item: typing.Optional[str]
|
||||
) -> types.rest.ItemsResult['UserServiceItem']:
|
||||
parent = ensure.is_instance(parent, models.ServicePool)
|
||||
|
||||
try:
|
||||
@@ -127,19 +155,21 @@ class AssignedService(DetailHandler):
|
||||
# First, fetch all properties for all assigned services on this pool
|
||||
# We can cache them, because they are going to be readed anyway...
|
||||
properties: dict[str, typing.Any] = collections.defaultdict(dict)
|
||||
for id, key, value in models.Properties.objects.filter(
|
||||
owner_type='userservice',
|
||||
owner_id__in=parent.assigned_user_services().values_list('uuid', flat=True),
|
||||
for id, key, value in self.filter_queryset(
|
||||
models.Properties.objects.filter(
|
||||
owner_type='userservice',
|
||||
owner_id__in=parent.assigned_user_services().values_list('uuid', flat=True),
|
||||
)
|
||||
).values_list('owner_id', 'key', 'value'):
|
||||
properties[id][key] = value
|
||||
|
||||
return [
|
||||
AssignedService.item_as_dict(k, properties.get(k.uuid, {}))
|
||||
AssignedUserService.userservice_item(k, properties.get(k.uuid, {}))
|
||||
for k in parent.assigned_user_services()
|
||||
.all()
|
||||
.prefetch_related('deployed_service', 'publication', 'user')
|
||||
]
|
||||
return AssignedService.item_as_dict(
|
||||
return AssignedUserService.userservice_item(
|
||||
parent.assigned_user_services().get(process_uuid(uuid=process_uuid(item))),
|
||||
props={
|
||||
k: v
|
||||
@@ -149,48 +179,30 @@ class AssignedService(DetailHandler):
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception('get_items')
|
||||
raise self.invalid_item_response() from e
|
||||
logger.error('Error getting user service %s: %s', item, e)
|
||||
raise exceptions.rest.ResponseError(_('Error getting user service')) from e
|
||||
|
||||
def get_title(self, parent: 'Model') -> str:
|
||||
return _('Assigned services')
|
||||
|
||||
def get_fields(self, parent: 'Model') -> list[typing.Any]:
|
||||
def get_table(self, parent: 'Model') -> types.rest.TableInfo:
|
||||
parent = ensure.is_instance(parent, models.ServicePool)
|
||||
# Revision is only shown if publication type is not None
|
||||
return (
|
||||
[
|
||||
{'creation_date': {'title': _('Creation date'), 'type': 'datetime'}},
|
||||
]
|
||||
+ (
|
||||
[
|
||||
{'revision': {'title': _('Revision')}},
|
||||
]
|
||||
if parent.service.get_type().publication_type is not None
|
||||
else []
|
||||
)
|
||||
+ [
|
||||
{'unique_id': {'title': 'Unique ID'}},
|
||||
{'ip': {'title': _('IP')}},
|
||||
{'friendly_name': {'title': _('Friendly name')}},
|
||||
{
|
||||
'state': {
|
||||
'title': _('status'),
|
||||
'type': 'dict',
|
||||
'dict': State.literals_dict(),
|
||||
}
|
||||
},
|
||||
{'state_date': {'title': _('Status date'), 'type': 'datetime'}},
|
||||
{'in_use': {'title': _('In Use')}},
|
||||
{'source_host': {'title': _('Src Host')}},
|
||||
{'source_ip': {'title': _('Src Ip')}},
|
||||
{'owner': {'title': _('Owner')}},
|
||||
{'actor_version': {'title': _('Actor version')}},
|
||||
]
|
||||
table_info = ui_utils.TableBuilder(_('Assigned Services')).datetime_column(
|
||||
name='creation_date', title=_('Creation date')
|
||||
)
|
||||
if parent.service.get_type().publication_type is not None:
|
||||
table_info.text_column(name='revision', title=_('Revision'))
|
||||
|
||||
def get_row_style(self, parent: 'Model') -> types.ui.RowStyleInfo:
|
||||
return types.ui.RowStyleInfo(prefix='row-state-', field='state')
|
||||
return (
|
||||
table_info.text_column(name='unique_id', title='Unique ID')
|
||||
.text_column(name='ip', title=_('IP'))
|
||||
.text_column(name='friendly_name', title=_('Friendly name'))
|
||||
.dict_column(name='state', title=_('status'), dct=State.literals_dict())
|
||||
.datetime_column(name='state_date', title=_('Status date'))
|
||||
.text_column(name='in_use', title=_('In Use'))
|
||||
.text_column(name='source_host', title=_('Src Host'))
|
||||
.text_column(name='source_ip', title=_('Src Ip'))
|
||||
.text_column(name='owner', title=_('Owner'))
|
||||
.text_column(name='actor_version', title=_('Actor version'))
|
||||
.row_style(prefix='row-state-', field='state')
|
||||
).build()
|
||||
|
||||
def get_logs(self, parent: 'Model', item: str) -> list[typing.Any]:
|
||||
parent = ensure.is_instance(parent, models.ServicePool)
|
||||
@@ -198,8 +210,11 @@ class AssignedService(DetailHandler):
|
||||
user_service: models.UserService = parent.assigned_user_services().get(uuid=process_uuid(item))
|
||||
logger.debug('Getting logs for %s', user_service)
|
||||
return log.get_logs(user_service)
|
||||
except models.UserService.DoesNotExist:
|
||||
raise exceptions.rest.NotFound(_('User service not found')) from None
|
||||
except Exception as e:
|
||||
raise self.invalid_item_response() from e
|
||||
logger.error('Error getting user service logs for %s: %s', item, e)
|
||||
raise exceptions.rest.ResponseError(_('Error getting user service logs')) from e
|
||||
|
||||
# This is also used by CachedService, so we use "userServices" directly and is valid for both
|
||||
def delete_item(self, parent: 'Model', item: str, cache: bool = False) -> None:
|
||||
@@ -210,8 +225,8 @@ class AssignedService(DetailHandler):
|
||||
else:
|
||||
userservice = parent.assigned_user_services().get(uuid=process_uuid(item))
|
||||
except Exception as e:
|
||||
logger.exception('delete_item')
|
||||
raise self.invalid_item_response() from e
|
||||
logger.error('Error deleting user service %s from %s: %s', item, parent, e)
|
||||
raise exceptions.rest.ResponseError(_('Error deleting user service')) from None
|
||||
|
||||
if userservice.user: # All assigned services have a user
|
||||
log_string = f'Deleted assigned user service {userservice.friendly_name} to user {userservice.user.pretty_name} by {self._user.pretty_name}'
|
||||
@@ -223,9 +238,9 @@ class AssignedService(DetailHandler):
|
||||
elif userservice.state == State.PREPARING:
|
||||
userservice.cancel()
|
||||
elif userservice.state == State.REMOVABLE:
|
||||
raise self.invalid_item_response(_('Item already being removed'))
|
||||
raise exceptions.rest.RequestError(_('Item already being removed')) from None
|
||||
else:
|
||||
raise self.invalid_item_response(_('Item is not removable'))
|
||||
raise exceptions.rest.RequestError(_('Item is not removable')) from None
|
||||
|
||||
log.log(parent, types.log.LogLevel.INFO, log_string, types.log.LogSource.ADMIN)
|
||||
log.log(userservice, types.log.LogLevel.INFO, log_string, types.log.LogSource.ADMIN)
|
||||
@@ -234,7 +249,7 @@ class AssignedService(DetailHandler):
|
||||
def save_item(self, parent: 'Model', item: typing.Optional[str]) -> typing.Any:
|
||||
parent = ensure.is_instance(parent, models.ServicePool)
|
||||
if not item:
|
||||
raise self.invalid_item_response('Only modify is allowed')
|
||||
raise exceptions.rest.RequestError('Only modify is allowed')
|
||||
fields = self.fields_from_params(['auth_id:_', 'user_id:_', 'ip:_'])
|
||||
|
||||
userservice = parent.userServices.get(uuid=process_uuid(item))
|
||||
@@ -251,7 +266,7 @@ class AssignedService(DetailHandler):
|
||||
.count()
|
||||
> 0
|
||||
):
|
||||
raise self.invalid_response_response(
|
||||
raise exceptions.rest.RequestError(
|
||||
f'There is already another user service assigned to {user.pretty_name}'
|
||||
)
|
||||
|
||||
@@ -261,7 +276,7 @@ class AssignedService(DetailHandler):
|
||||
log_string = f'Changed IP of user service {userservice.friendly_name} to {fields["ip"]} by {self._user.pretty_name}'
|
||||
userservice.log_ip(fields['ip'])
|
||||
else:
|
||||
raise self.invalid_item_response('Invalid fields')
|
||||
raise exceptions.rest.RequestError('Invalid fields')
|
||||
|
||||
# Log change
|
||||
log.log(parent, types.log.LogLevel.INFO, log_string, types.log.LogSource.ADMIN)
|
||||
@@ -274,50 +289,51 @@ class AssignedService(DetailHandler):
|
||||
UserServiceManager.manager().reset(userservice)
|
||||
|
||||
|
||||
class CachedService(AssignedService):
|
||||
class CachedService(AssignedUserService):
|
||||
"""
|
||||
Rest handler for Cached Services, which parent is ServicePool
|
||||
"""
|
||||
|
||||
custom_methods: typing.ClassVar[list[str]] = [] # Remove custom methods from assigned services
|
||||
CUSTOM_METHODS = [] # Remove custom methods from assigned services
|
||||
|
||||
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
|
||||
def get_items(
|
||||
self, parent: 'Model', item: typing.Optional[str]
|
||||
) -> types.rest.ItemsResult['UserServiceItem']:
|
||||
parent = ensure.is_instance(parent, models.ServicePool)
|
||||
|
||||
try:
|
||||
if not item:
|
||||
return [
|
||||
AssignedService.item_as_dict(k, is_cache=True)
|
||||
for k in parent.cached_users_services()
|
||||
.all()
|
||||
.prefetch_related('deployed_service', 'publication')
|
||||
AssignedUserService.userservice_item(k, is_cache=True)
|
||||
for k in self.filter_queryset(parent.cached_users_services().all()).prefetch_related(
|
||||
'deployed_service', 'publication'
|
||||
)
|
||||
]
|
||||
cached_userservice: models.UserService = parent.cached_users_services().get(uuid=process_uuid(item))
|
||||
return AssignedService.item_as_dict(cached_userservice, is_cache=True)
|
||||
return AssignedUserService.userservice_item(cached_userservice, is_cache=True)
|
||||
except models.UserService.DoesNotExist:
|
||||
raise exceptions.rest.NotFound(_('User service not found')) from None
|
||||
except Exception as e:
|
||||
logger.exception('get_items')
|
||||
raise self.invalid_item_response() from e
|
||||
logger.error('Error getting user service %s: %s', item, e)
|
||||
raise exceptions.rest.ResponseError(_('Error getting user service')) from e
|
||||
|
||||
def get_title(self, parent: 'Model') -> str:
|
||||
return _('Cached services')
|
||||
|
||||
def get_fields(self, parent: 'Model') -> list[typing.Any]:
|
||||
def get_table(self, parent: 'Model') -> types.rest.TableInfo:
|
||||
parent = ensure.is_instance(parent, models.ServicePool)
|
||||
return [
|
||||
{'creation_date': {'title': _('Creation date'), 'type': 'datetime'}},
|
||||
{'revision': {'title': _('Revision')}},
|
||||
{'unique_id': {'title': 'Unique ID'}},
|
||||
{'friendly_name': {'title': _('Friendly name')}},
|
||||
{'state': {'title': _('State'), 'type': 'dict', 'dict': State.literals_dict()}},
|
||||
] + (
|
||||
[
|
||||
{'ip': {'title': _('IP')}},
|
||||
{'cache_level': {'title': _('Cache level')}},
|
||||
{'actor_version': {'title': _('Actor version')}},
|
||||
]
|
||||
if parent.state != State.LOCKED
|
||||
else []
|
||||
table_info = (
|
||||
ui_utils.TableBuilder(_('Cached Services'))
|
||||
.datetime_column(name='creation_date', title=_('Creation date'))
|
||||
.text_column(name='revision', title=_('Revision'))
|
||||
.text_column(name='unique_id', title='Unique ID')
|
||||
.text_column(name='ip', title=_('IP'))
|
||||
.text_column(name='friendly_name', title=_('Friendly name'))
|
||||
.dict_column(name='state', title=_('State'), dct=State.literals_dict())
|
||||
)
|
||||
if parent.state != State.LOCKED:
|
||||
table_info = table_info.text_column(name='cache_level', title=_('Cache level')).text_column(
|
||||
name='actor_version', title=_('Actor version')
|
||||
)
|
||||
|
||||
return table_info.build()
|
||||
|
||||
def delete_item(self, parent: 'Model', item: str, cache: bool = False) -> None:
|
||||
return super().delete_item(parent, item, cache=True)
|
||||
@@ -328,63 +344,57 @@ class CachedService(AssignedService):
|
||||
userservice = parent.cached_users_services().get(uuid=process_uuid(item))
|
||||
logger.debug('Getting logs for %s', item)
|
||||
return log.get_logs(userservice)
|
||||
except Exception:
|
||||
raise self.invalid_item_response() from None
|
||||
except Exception as e:
|
||||
logger.error('Error getting user service logs for %s: %s', item, e)
|
||||
raise exceptions.rest.ResponseError(_('Error getting user service logs')) from None
|
||||
|
||||
|
||||
class Groups(DetailHandler):
|
||||
@dataclasses.dataclass
|
||||
class GroupItem(types.rest.BaseRestItem):
|
||||
id: str
|
||||
auth_id: str
|
||||
name: str
|
||||
group_name: str
|
||||
comments: str
|
||||
state: str
|
||||
type: str
|
||||
auth_name: str
|
||||
|
||||
|
||||
class Groups(DetailHandler[GroupItem]):
|
||||
"""
|
||||
Processes the groups detail requests of a Service Pool
|
||||
"""
|
||||
|
||||
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
|
||||
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> list['GroupItem']:
|
||||
parent = typing.cast(typing.Union['models.ServicePool', 'models.MetaPool'], parent)
|
||||
|
||||
return [
|
||||
{
|
||||
'id': group.uuid,
|
||||
'auth_id': group.manager.uuid,
|
||||
'name': group.name,
|
||||
'group_name': group.pretty_name,
|
||||
'comments': group.comments,
|
||||
'state': group.state,
|
||||
'type': 'meta' if group.is_meta else 'group',
|
||||
'auth_name': group.manager.name,
|
||||
}
|
||||
for group in typing.cast(collections.abc.Iterable[models.Group], parent.assignedGroups.all())
|
||||
GroupItem(
|
||||
id=group.uuid,
|
||||
auth_id=group.manager.uuid,
|
||||
name=group.name,
|
||||
group_name=group.pretty_name,
|
||||
comments=group.comments,
|
||||
state=group.state,
|
||||
type='meta' if group.is_meta else 'group',
|
||||
auth_name=group.manager.name,
|
||||
)
|
||||
for group in typing.cast(
|
||||
collections.abc.Iterable[models.Group], self.filter_queryset(parent.assignedGroups.all())
|
||||
)
|
||||
]
|
||||
|
||||
def get_title(self, parent: 'Model') -> str:
|
||||
def get_table(self, parent: 'Model') -> TableInfo:
|
||||
parent = typing.cast(typing.Union['models.ServicePool', 'models.MetaPool'], parent)
|
||||
return _('Assigned groups')
|
||||
|
||||
def get_fields(self, parent: 'Model') -> list[typing.Any]:
|
||||
return [
|
||||
# Note that this field is "self generated" on client table
|
||||
{
|
||||
'group_name': {
|
||||
'title': _('Name'),
|
||||
'type': 'alphanumeric',
|
||||
}
|
||||
},
|
||||
{'comments': {'title': _('comments')}},
|
||||
{
|
||||
'type': {
|
||||
'title': _('Type'),
|
||||
# Alphanumeric, default is alphanumeric
|
||||
}
|
||||
},
|
||||
{
|
||||
'state': {
|
||||
'title': _('State'),
|
||||
'type': 'dict',
|
||||
'dict': State.literals_dict(),
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
def get_row_style(self, parent: 'Model') -> types.ui.RowStyleInfo:
|
||||
return types.ui.RowStyleInfo(prefix='row-state-', field='state')
|
||||
return (
|
||||
ui_utils.TableBuilder(_('Assigned groups'))
|
||||
.text_column(name='group_name', title=_('Name'))
|
||||
.text_column(name='comments', title=_('comments'))
|
||||
.dict_column(name='state', title=_('State'), dct=State.literals_dict())
|
||||
.row_style(prefix='row-state-', field='state')
|
||||
.build()
|
||||
)
|
||||
|
||||
def save_item(self, parent: 'Model', item: typing.Optional[str]) -> typing.Any:
|
||||
parent = typing.cast(typing.Union['models.ServicePool', 'models.MetaPool'], parent)
|
||||
@@ -412,44 +422,46 @@ class Groups(DetailHandler):
|
||||
)
|
||||
|
||||
|
||||
class Transports(DetailHandler):
|
||||
@dataclasses.dataclass
|
||||
class TransportItem(types.rest.BaseRestItem):
|
||||
id: str
|
||||
name: str
|
||||
type: dict[str, typing.Any] # TypeInfo
|
||||
comments: str
|
||||
priority: int
|
||||
trans_type: str
|
||||
|
||||
|
||||
class Transports(DetailHandler[TransportItem]):
|
||||
"""
|
||||
Processes the transports detail requests of a Service Pool
|
||||
"""
|
||||
|
||||
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
|
||||
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> list['TransportItem']:
|
||||
parent = ensure.is_instance(parent, models.ServicePool)
|
||||
|
||||
def get_type(trans: 'models.Transport') -> types.rest.TypeInfoDict:
|
||||
try:
|
||||
return self.type_as_dict(trans.get_type())
|
||||
except Exception: # No type found
|
||||
raise self.invalid_item_response()
|
||||
|
||||
return [
|
||||
{
|
||||
'id': i.uuid,
|
||||
'name': i.name,
|
||||
'type': get_type(i),
|
||||
'comments': i.comments,
|
||||
'priority': i.priority,
|
||||
'trans_type': _(i.get_type().mod_name()),
|
||||
}
|
||||
for i in parent.transports.all()
|
||||
if get_type(i)
|
||||
TransportItem(
|
||||
id=trans.uuid,
|
||||
name=trans.name,
|
||||
type=type(self).as_typeinfo(trans.get_type()).as_dict(),
|
||||
comments=trans.comments,
|
||||
priority=trans.priority,
|
||||
trans_type=trans.get_type().mod_name(),
|
||||
)
|
||||
for trans in self.filter_queryset(parent.transports.all())
|
||||
]
|
||||
|
||||
def get_title(self, parent: 'Model') -> str:
|
||||
def get_table(self, parent: 'Model') -> TableInfo:
|
||||
parent = ensure.is_instance(parent, models.ServicePool)
|
||||
return _('Assigned transports')
|
||||
|
||||
def get_fields(self, parent: 'Model') -> list[typing.Any]:
|
||||
return [
|
||||
{'priority': {'title': _('Priority'), 'type': 'numeric', 'width': '6em'}},
|
||||
{'name': {'title': _('Name')}},
|
||||
{'trans_type': {'title': _('Type')}},
|
||||
{'comments': {'title': _('Comments')}},
|
||||
]
|
||||
return (
|
||||
ui_utils.TableBuilder(_('Assigned transports'))
|
||||
.numeric_column(name='priority', title=_('Priority'), width='6em')
|
||||
.text_column(name='name', title=_('Name'))
|
||||
.text_column(name='trans_type', title=_('Type'))
|
||||
.text_column(name='comments', title=_('Comments'))
|
||||
.build()
|
||||
)
|
||||
|
||||
def save_item(self, parent: 'Model', item: typing.Optional[str]) -> typing.Any:
|
||||
parent = ensure.is_instance(parent, models.ServicePool)
|
||||
@@ -476,12 +488,22 @@ class Transports(DetailHandler):
|
||||
)
|
||||
|
||||
|
||||
class Publications(DetailHandler):
|
||||
@dataclasses.dataclass
|
||||
class PublicationItem(types.rest.BaseRestItem):
|
||||
id: str
|
||||
revision: int
|
||||
publish_date: datetime.datetime
|
||||
state: str
|
||||
reason: str
|
||||
state_date: datetime.datetime
|
||||
|
||||
|
||||
class Publications(DetailHandler[PublicationItem]):
|
||||
"""
|
||||
Processes the publications detail requests of a Service Pool
|
||||
"""
|
||||
|
||||
custom_methods = ['publish', 'cancel'] # We provided these custom methods
|
||||
CUSTOM_METHODS = ['publish', 'cancel'] # We provided these custom methods
|
||||
|
||||
def publish(self, parent: 'Model') -> typing.Any:
|
||||
"""
|
||||
@@ -496,7 +518,7 @@ class Publications(DetailHandler):
|
||||
is False
|
||||
):
|
||||
logger.debug('Management Permission failed for user %s', self._user)
|
||||
raise self.access_denied_response()
|
||||
raise exceptions.rest.AccessDenied(_('Access denied to publish service pool')) from None
|
||||
|
||||
logger.debug('Custom "publish" invoked for %s', parent)
|
||||
parent.publish(change_log) # Can raise exceptions that will be processed on response
|
||||
@@ -523,7 +545,7 @@ class Publications(DetailHandler):
|
||||
is False
|
||||
):
|
||||
logger.debug('Management Permission failed for user %s', self._user)
|
||||
raise self.access_denied_response()
|
||||
raise exceptions.rest.AccessDenied(_('Access denied to cancel service pool publication')) from None
|
||||
|
||||
try:
|
||||
ds = models.ServicePoolPublication.objects.get(uuid=process_uuid(uuid))
|
||||
@@ -540,65 +562,60 @@ class Publications(DetailHandler):
|
||||
|
||||
return self.success()
|
||||
|
||||
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
|
||||
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> list['PublicationItem']:
|
||||
parent = ensure.is_instance(parent, models.ServicePool)
|
||||
return [
|
||||
{
|
||||
'id': i.uuid,
|
||||
'revision': i.revision,
|
||||
'publish_date': i.publish_date,
|
||||
'state': i.state,
|
||||
'reason': State.from_str(i.state).is_errored() and i.get_instance().error_reason() or '',
|
||||
'state_date': i.state_date,
|
||||
}
|
||||
for i in parent.publications.all()
|
||||
PublicationItem(
|
||||
id=i.uuid,
|
||||
revision=i.revision,
|
||||
publish_date=i.publish_date,
|
||||
state=i.state,
|
||||
reason=State.from_str(i.state).is_errored() and i.get_instance().error_reason() or '',
|
||||
state_date=i.state_date,
|
||||
)
|
||||
for i in self.filter_queryset(parent.publications.all())
|
||||
]
|
||||
|
||||
def get_title(self, parent: 'Model') -> str:
|
||||
def get_table(self, parent: 'Model') -> TableInfo:
|
||||
parent = ensure.is_instance(parent, models.ServicePool)
|
||||
return _('Publications')
|
||||
|
||||
def get_fields(self, parent: 'Model') -> list[typing.Any]:
|
||||
return [
|
||||
{'revision': {'title': _('Revision'), 'type': 'numeric', 'width': '6em'}},
|
||||
{'publish_date': {'title': _('Publish date'), 'type': 'datetime'}},
|
||||
{
|
||||
'state': {
|
||||
'title': _('State'),
|
||||
'type': 'dict',
|
||||
'dict': State.literals_dict(),
|
||||
}
|
||||
},
|
||||
{'reason': {'title': _('Reason')}},
|
||||
]
|
||||
|
||||
def get_row_style(self, parent: 'Model') -> types.ui.RowStyleInfo:
|
||||
return types.ui.RowStyleInfo(prefix='row-state-', field='state')
|
||||
return (
|
||||
ui_utils.TableBuilder(_('Publications'))
|
||||
.numeric_column(name='revision', title=_('Revision'), width='6em')
|
||||
.datetime_column(name='publish_date', title=_('Publish date'))
|
||||
.dict_column(name='state', title=_('State'), dct=State.literals_dict())
|
||||
.text_column(name='reason', title=_('Reason'))
|
||||
.row_style(prefix='row-state-', field='state')
|
||||
).build()
|
||||
|
||||
|
||||
class Changelog(DetailHandler):
|
||||
@dataclasses.dataclass
|
||||
class ChangelogItem(types.rest.BaseRestItem):
|
||||
revision: int
|
||||
stamp: datetime.datetime
|
||||
log: str
|
||||
|
||||
|
||||
class Changelog(DetailHandler[ChangelogItem]):
|
||||
"""
|
||||
Processes the transports detail requests of a Service Pool
|
||||
"""
|
||||
|
||||
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
|
||||
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> list['ChangelogItem']:
|
||||
parent = ensure.is_instance(parent, models.ServicePool)
|
||||
return [
|
||||
{
|
||||
'revision': i.revision,
|
||||
'stamp': i.stamp,
|
||||
'log': i.log,
|
||||
}
|
||||
for i in parent.changelog.all()
|
||||
ChangelogItem(
|
||||
revision=i.revision,
|
||||
stamp=i.stamp,
|
||||
log=i.log,
|
||||
)
|
||||
for i in self.filter_queryset(parent.changelog.all())
|
||||
]
|
||||
|
||||
def get_title(self, parent: 'Model') -> str:
|
||||
def get_table(self, parent: 'Model') -> types.rest.TableInfo:
|
||||
parent = ensure.is_instance(parent, models.ServicePool)
|
||||
return _(f'Changelog')
|
||||
|
||||
def get_fields(self, parent: 'Model') -> list[typing.Any]:
|
||||
return [
|
||||
{'revision': {'title': _('Revision'), 'type': 'numeric', 'width': '6em'}},
|
||||
{'stamp': {'title': _('Publish date'), 'type': 'datetime'}},
|
||||
{'log': {'title': _('Comment')}},
|
||||
]
|
||||
return (
|
||||
ui_utils.TableBuilder(_('Changelog'))
|
||||
.numeric_column(name='revision', title=_('Revision'), width='6em')
|
||||
.datetime_column(name='stamp', title=_('Publish date'))
|
||||
.text_column(name='log', title=_('Comment'))
|
||||
).build()
|
||||
|
||||
@@ -29,31 +29,29 @@
|
||||
"""
|
||||
Author: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
"""
|
||||
import dataclasses
|
||||
import datetime
|
||||
import logging
|
||||
import typing
|
||||
import collections.abc
|
||||
|
||||
from django.utils.translation import gettext as _
|
||||
from django.forms.models import model_to_dict
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.db.models import Model
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from uds.core.types.states import State
|
||||
|
||||
from uds.core.auths.user import User as AUser
|
||||
from uds.core.util import log, ensure
|
||||
from uds.core.util import log, ensure, ui as ui_utils
|
||||
from uds.core.util.model import process_uuid, sql_stamp_seconds
|
||||
from uds.models import Authenticator, User, Group, ServicePool
|
||||
from uds.models import Authenticator, User, Group, ServicePool, UserService
|
||||
from uds.core.managers.crypto import CryptoManager
|
||||
from uds.core import consts, exceptions, types
|
||||
|
||||
from uds.REST.model import DetailHandler
|
||||
|
||||
from .user_services import AssignedService
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from django.db.models import Model
|
||||
from uds.models import UserService
|
||||
from .user_services import AssignedUserService, UserServiceItem
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -77,8 +75,24 @@ def get_service_pools_for_groups(
|
||||
yield servicepool
|
||||
|
||||
|
||||
class Users(DetailHandler):
|
||||
custom_methods = [
|
||||
@dataclasses.dataclass
|
||||
class UserItem(types.rest.BaseRestItem):
|
||||
id: str
|
||||
name: str
|
||||
real_name: str
|
||||
comments: str
|
||||
state: str
|
||||
staff_member: bool
|
||||
is_admin: bool
|
||||
last_access: datetime.datetime
|
||||
mfa_data: str
|
||||
role: str
|
||||
parent: str | None
|
||||
groups: list[str] | types.rest.NotRequired = types.rest.NotRequired.field()
|
||||
|
||||
|
||||
class Users(DetailHandler[UserItem]):
|
||||
CUSTOM_METHODS = [
|
||||
'services_pools',
|
||||
'user_services',
|
||||
'clean_related',
|
||||
@@ -86,116 +100,67 @@ class Users(DetailHandler):
|
||||
'enable_client_logging',
|
||||
]
|
||||
|
||||
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> typing.Any:
|
||||
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ItemsResult[UserItem]:
|
||||
parent = ensure.is_instance(parent, Authenticator)
|
||||
|
||||
# processes item to change uuid key for id
|
||||
def uuid_to_id(
|
||||
iterable: collections.abc.Iterable[typing.Any],
|
||||
) -> collections.abc.Generator[typing.Any, None, None]:
|
||||
for v in iterable:
|
||||
v['id'] = v['uuid']
|
||||
del v['uuid']
|
||||
yield v
|
||||
def as_user_item(user: 'User') -> UserItem:
|
||||
return UserItem(
|
||||
id=user.uuid,
|
||||
name=user.name,
|
||||
real_name=user.real_name,
|
||||
comments=user.comments,
|
||||
state=user.state,
|
||||
staff_member=user.staff_member,
|
||||
is_admin=user.is_admin,
|
||||
last_access=user.last_access,
|
||||
mfa_data=user.mfa_data,
|
||||
parent=user.parent,
|
||||
groups=[i.uuid for i in user.get_groups()],
|
||||
role=user.get_role().as_str(),
|
||||
)
|
||||
|
||||
logger.debug(item)
|
||||
# Extract authenticator
|
||||
try:
|
||||
if item is None:
|
||||
values = list(
|
||||
uuid_to_id(
|
||||
(
|
||||
i
|
||||
for i in parent.users.all().values(
|
||||
'uuid',
|
||||
'name',
|
||||
'real_name',
|
||||
'comments',
|
||||
'state',
|
||||
'staff_member',
|
||||
'is_admin',
|
||||
'last_access',
|
||||
'parent',
|
||||
'mfa_data',
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
for res in values:
|
||||
res['role'] = (
|
||||
res['staff_member']
|
||||
and (res['is_admin'] and _('Admin') or _('Staff member'))
|
||||
or _('User')
|
||||
)
|
||||
return values
|
||||
if item is None: # All users
|
||||
return [as_user_item(i) for i in self.filter_queryset(parent.users.all())]
|
||||
|
||||
u = parent.users.get(uuid__iexact=process_uuid(item))
|
||||
res = model_to_dict(
|
||||
u,
|
||||
fields=(
|
||||
'name',
|
||||
'real_name',
|
||||
'comments',
|
||||
'state',
|
||||
'staff_member',
|
||||
'is_admin',
|
||||
'last_access',
|
||||
'parent',
|
||||
'mfa_data',
|
||||
),
|
||||
)
|
||||
res['id'] = u.uuid
|
||||
res['role'] = (
|
||||
res['staff_member'] and (res['is_admin'] and _('Admin') or _('Staff member')) or _('User')
|
||||
)
|
||||
res = as_user_item(u)
|
||||
usr = AUser(u)
|
||||
res['groups'] = [g.db_obj().uuid for g in usr.groups()]
|
||||
res.groups = [g.db_obj().uuid for g in usr.groups()]
|
||||
logger.debug('Item: %s', res)
|
||||
return res
|
||||
except User.DoesNotExist:
|
||||
raise exceptions.rest.NotFound(_('User not found')) from None
|
||||
except Exception as e:
|
||||
# User not found
|
||||
raise self.invalid_item_response() from e
|
||||
logger.error('Error getting user %s: %s', item, e)
|
||||
raise exceptions.rest.ResponseError(_('Error getting user')) from e
|
||||
|
||||
def get_title(self, parent: 'Model') -> str:
|
||||
try:
|
||||
return _('Users of {0}').format(
|
||||
Authenticator.objects.get(uuid=process_uuid(self._kwargs['parent_id'])).name
|
||||
def get_table(self, parent: 'Model') -> types.rest.TableInfo:
|
||||
parent = ensure.is_instance(parent, Authenticator)
|
||||
return (
|
||||
ui_utils.TableBuilder(_('Users of {0}').format(parent.name))
|
||||
.icon(name='name', title=_('Username'), visible=True)
|
||||
.text_column(name='role', title=_('Role'))
|
||||
.text_column(name='real_name', title=_('Name'))
|
||||
.text_column(name='comments', title=_('Comments'))
|
||||
.dict_column(
|
||||
name='state', title=_('Status'), dct={State.ACTIVE: _('Enabled'), State.INACTIVE: _('Disabled')}
|
||||
)
|
||||
except Exception:
|
||||
return _('Current users')
|
||||
|
||||
def get_fields(self, parent: 'Model') -> list[typing.Any]:
|
||||
return [
|
||||
{
|
||||
'name': {
|
||||
'title': _('Username'),
|
||||
'visible': True,
|
||||
'type': 'icon',
|
||||
'icon': 'fa fa-user text-success',
|
||||
}
|
||||
},
|
||||
{'role': {'title': _('Role')}},
|
||||
{'real_name': {'title': _('Name')}},
|
||||
{'comments': {'title': _('Comments')}},
|
||||
{
|
||||
'state': {
|
||||
'title': _('state'),
|
||||
'type': 'dict',
|
||||
'dict': {State.ACTIVE: _('Enabled'), State.INACTIVE: _('Disabled')},
|
||||
}
|
||||
},
|
||||
{'last_access': {'title': _('Last access'), 'type': 'datetime'}},
|
||||
]
|
||||
|
||||
def get_row_style(self, parent: 'Model') -> types.ui.RowStyleInfo:
|
||||
return types.ui.RowStyleInfo(prefix='row-state-', field='state')
|
||||
.datetime_column(name='last_access', title=_('Last access'))
|
||||
.row_style(prefix='row-state-', field='state')
|
||||
).build()
|
||||
|
||||
def get_logs(self, parent: 'Model', item: str) -> list[typing.Any]:
|
||||
parent = ensure.is_instance(parent, Authenticator)
|
||||
user = None
|
||||
try:
|
||||
user = parent.users.get(uuid=process_uuid(item))
|
||||
except Exception:
|
||||
raise self.invalid_item_response() from None
|
||||
except User.DoesNotExist:
|
||||
raise exceptions.rest.NotFound(_('User not found')) from None
|
||||
except Exception as e:
|
||||
logger.error('Error getting user %s: %s', item, e)
|
||||
raise exceptions.rest.ResponseError(_('Error getting user')) from e
|
||||
|
||||
return log.get_logs(user)
|
||||
|
||||
@@ -247,21 +212,21 @@ class Users(DetailHandler):
|
||||
groups = self.fields_from_params(['groups'])['groups']
|
||||
# Save but skip meta groups, they are not real groups, but just a way to group users based on rules
|
||||
user.groups.set(g for g in parent.groups.filter(uuid__in=groups) if g.is_meta is False)
|
||||
|
||||
|
||||
return {'id': user.uuid}
|
||||
except User.DoesNotExist:
|
||||
raise self.invalid_item_response() from None
|
||||
raise exceptions.rest.NotFound(_('User not found')) from None
|
||||
except IntegrityError: # Duplicate key probably
|
||||
raise exceptions.rest.RequestError(_('User already exists (duplicate key error)')) from None
|
||||
except ValidationError as e:
|
||||
raise exceptions.rest.RequestError(str(e.message)) from e
|
||||
except exceptions.auth.AuthenticatorException as e:
|
||||
raise exceptions.rest.RequestError(str(e)) from e
|
||||
except exceptions.rest.RequestError: # pylint: disable=try-except-raise
|
||||
except exceptions.rest.RequestError:
|
||||
raise # Re-raise
|
||||
except Exception as e:
|
||||
logger.exception('Saving user')
|
||||
raise self.invalid_request_response() from e
|
||||
logger.error('Error saving user %s: %s', item, e)
|
||||
raise exceptions.rest.ResponseError(_('Error saving user')) from e
|
||||
|
||||
def delete_item(self, parent: 'Model', item: str) -> None:
|
||||
parent = ensure.is_instance(parent, Authenticator)
|
||||
@@ -272,7 +237,7 @@ class Users(DetailHandler):
|
||||
'Removal of user %s denied due to insufficients rights',
|
||||
user.pretty_name,
|
||||
)
|
||||
raise self.invalid_item_response(
|
||||
raise exceptions.rest.AccessDenied(
|
||||
f'Removal of user {user.pretty_name} denied due to insufficients rights'
|
||||
)
|
||||
|
||||
@@ -290,11 +255,17 @@ class Users(DetailHandler):
|
||||
logger.exception('Saving user on removing error')
|
||||
|
||||
user.delete()
|
||||
except User.DoesNotExist:
|
||||
raise exceptions.rest.NotFound(_('User not found')) from None
|
||||
except Exception as e:
|
||||
logger.error('Error on user removal of %s.%s: %s', parent.name, item, e)
|
||||
raise self.invalid_item_response() from e
|
||||
raise exceptions.rest.ResponseError(_('Error removing user')) from e
|
||||
|
||||
def services_pools(self, parent: 'Model', item: str) -> list[dict[str, typing.Any]]:
|
||||
"""
|
||||
API:
|
||||
Returns the service pools assigned to a user
|
||||
"""
|
||||
parent = ensure.is_instance(parent, Authenticator)
|
||||
uuid = process_uuid(item)
|
||||
user = parent.users.get(uuid=process_uuid(uuid))
|
||||
@@ -315,19 +286,21 @@ class Users(DetailHandler):
|
||||
|
||||
return res
|
||||
|
||||
def user_services(self, parent: 'Authenticator', item: str) -> list[dict[str, typing.Any]]:
|
||||
def user_services(self, parent: 'Authenticator', item: str) -> list[UserServiceItem]:
|
||||
parent = ensure.is_instance(parent, Authenticator)
|
||||
uuid = process_uuid(item)
|
||||
user = parent.users.get(uuid=process_uuid(uuid))
|
||||
res: list[dict[str, typing.Any]] = []
|
||||
for i in user.userServices.all():
|
||||
if i.state == State.USABLE:
|
||||
v = AssignedService.item_as_dict(i)
|
||||
v['pool'] = i.deployed_service.name
|
||||
v['pool_id'] = i.deployed_service.uuid
|
||||
res.append(v)
|
||||
|
||||
return res
|
||||
def item_as_dict(assigned_user_service: 'UserService') -> UserServiceItem:
|
||||
base = AssignedUserService.userservice_item(assigned_user_service)
|
||||
base.pool_name = assigned_user_service.deployed_service.name
|
||||
base.pool_id = assigned_user_service.deployed_service.uuid
|
||||
return base
|
||||
|
||||
return [
|
||||
item_as_dict(i)
|
||||
for i in user.userServices.all().prefetch_related('deployed_service').filter(state=State.USABLE)
|
||||
]
|
||||
|
||||
def clean_related(self, parent: 'Authenticator', item: str) -> dict[str, str]:
|
||||
uuid = process_uuid(item)
|
||||
@@ -361,101 +334,97 @@ class Users(DetailHandler):
|
||||
return {'status': 'ok'}
|
||||
|
||||
|
||||
class Groups(DetailHandler):
|
||||
custom_methods = ['services_pools', 'users']
|
||||
@dataclasses.dataclass
|
||||
class GroupItem(types.rest.BaseRestItem):
|
||||
id: str
|
||||
name: str
|
||||
comments: str
|
||||
state: str
|
||||
type: str
|
||||
meta_if_any: bool
|
||||
skip_mfa: str
|
||||
groups: list[str] | types.rest.NotRequired = types.rest.NotRequired.field()
|
||||
pools: list[str] | types.rest.NotRequired = types.rest.NotRequired.field()
|
||||
|
||||
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
|
||||
|
||||
class Groups(DetailHandler[GroupItem]):
|
||||
CUSTOM_METHODS = ['services_pools', 'users']
|
||||
|
||||
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ItemsResult['GroupItem']:
|
||||
parent = ensure.is_instance(parent, Authenticator)
|
||||
try:
|
||||
multi = False
|
||||
if item is None:
|
||||
multi = True
|
||||
q = parent.groups.all().order_by('name')
|
||||
q = self.filter_queryset(parent.groups.all())
|
||||
else:
|
||||
q = parent.groups.filter(uuid=process_uuid(item))
|
||||
res: list[dict[str, typing.Any]] = []
|
||||
res: list[GroupItem] = []
|
||||
i = None
|
||||
for i in q:
|
||||
val: dict[str, typing.Any] = {
|
||||
'id': i.uuid,
|
||||
'name': i.name,
|
||||
'comments': i.comments,
|
||||
'state': i.state,
|
||||
'type': i.is_meta and 'meta' or 'group',
|
||||
'meta_if_any': i.meta_if_any,
|
||||
'skip_mfa': i.skip_mfa,
|
||||
}
|
||||
val = GroupItem(
|
||||
id=i.uuid,
|
||||
name=i.name,
|
||||
comments=i.comments,
|
||||
state=i.state,
|
||||
type=i.is_meta and 'meta' or 'group',
|
||||
meta_if_any=i.meta_if_any,
|
||||
skip_mfa=i.skip_mfa,
|
||||
)
|
||||
if i.is_meta:
|
||||
val['groups'] = list(x.uuid for x in i.groups.all().order_by('name'))
|
||||
val.groups = list(x.uuid for x in i.groups.all().order_by('name'))
|
||||
res.append(val)
|
||||
|
||||
if multi:
|
||||
return res
|
||||
|
||||
if not i:
|
||||
raise Exception('Item not found')
|
||||
raise exceptions.rest.NotFound(_('Group not found')) from None
|
||||
# Add pools field if 1 item only
|
||||
result = res[0]
|
||||
result['pools'] = [v.uuid for v in get_service_pools_for_groups([i])]
|
||||
return result
|
||||
res[0].pools = [v.uuid for v in get_service_pools_for_groups([i])]
|
||||
return res[0]
|
||||
except exceptions.rest.HandlerError:
|
||||
raise # Re-raise
|
||||
except Exception as e:
|
||||
logger.error('Group item not found: %s.%s: %s', parent.name, item, e)
|
||||
raise self.invalid_item_response() from e
|
||||
raise exceptions.rest.ResponseError(_('Error getting group')) from e
|
||||
|
||||
def get_title(self, parent: 'Model') -> str:
|
||||
def get_table(self, parent: 'Model') -> types.rest.TableInfo:
|
||||
parent = ensure.is_instance(parent, Authenticator)
|
||||
try:
|
||||
return _('Groups of {0}').format(parent.name)
|
||||
except Exception:
|
||||
return _('Current groups')
|
||||
return (
|
||||
ui_utils.TableBuilder(_('Groups of {0}').format(parent.name))
|
||||
.text_column(name='name', title=_('Group'), visible=True)
|
||||
.text_column(name='comments', title=_('Comments'))
|
||||
.dict_column(name='state', title=_('Status'), dct=State.literals_dict())
|
||||
.dict_column(name='skip_mfa', title=_('Skip MFA'), dct=State.literals_dict())
|
||||
).build()
|
||||
|
||||
def get_fields(self, parent: 'Model') -> list[typing.Any]:
|
||||
return [
|
||||
{
|
||||
'name': {
|
||||
'title': _('Group'),
|
||||
}
|
||||
},
|
||||
{'comments': {'title': _('Comments')}},
|
||||
{
|
||||
'state': {
|
||||
'title': _('state'),
|
||||
'type': 'dict',
|
||||
'dict': {State.ACTIVE: _('Enabled'), State.INACTIVE: _('Disabled')},
|
||||
}
|
||||
},
|
||||
{
|
||||
'skip_mfa': {
|
||||
'title': _('Skip MFA'),
|
||||
'type': 'dict',
|
||||
'dict': {State.ACTIVE: _('Enabled'), State.INACTIVE: _('Disabled')},
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
def get_types(
|
||||
def enum_types(
|
||||
self, parent: 'Model', for_type: typing.Optional[str]
|
||||
) -> collections.abc.Iterable[types.rest.TypeInfoDict]:
|
||||
) -> collections.abc.Iterable[types.rest.TypeInfo]:
|
||||
parent = ensure.is_instance(parent, Authenticator)
|
||||
types_dict: dict[str, dict[str, str]] = {
|
||||
'group': {'name': _('Group'), 'description': _('UDS Group')},
|
||||
'meta': {'name': _('Meta group'), 'description': _('UDS Meta Group')},
|
||||
}
|
||||
types_list: list[types.rest.TypeInfoDict] = [
|
||||
{
|
||||
'name': v['name'],
|
||||
'type': k,
|
||||
'description': v['description'],
|
||||
'icon': '',
|
||||
}
|
||||
types_list: list[types.rest.TypeInfo] = [
|
||||
types.rest.TypeInfo(
|
||||
name=v['name'],
|
||||
type=k,
|
||||
description=v['description'],
|
||||
icon='',
|
||||
)
|
||||
for k, v in types_dict.items()
|
||||
]
|
||||
|
||||
if for_type is None:
|
||||
if not for_type:
|
||||
return types_list
|
||||
|
||||
try:
|
||||
return [next(filter(lambda x: x['type'] == for_type, types_list))]
|
||||
except Exception:
|
||||
raise self.invalid_request_response() from None
|
||||
return [next(filter(lambda x: x.type == for_type, types_list))]
|
||||
except StopIteration:
|
||||
logger.error('Type %s not found in %s', for_type, types_list)
|
||||
raise exceptions.rest.NotFound(_('Group type not found')) from None
|
||||
|
||||
def save_item(self, parent: 'Model', item: typing.Optional[str]) -> typing.Any:
|
||||
parent = ensure.is_instance(parent, Authenticator)
|
||||
@@ -513,7 +482,7 @@ class Groups(DetailHandler):
|
||||
group.save()
|
||||
return {'id': group.uuid}
|
||||
except Group.DoesNotExist:
|
||||
raise self.invalid_item_response() from None
|
||||
raise exceptions.rest.NotFound(_('Group not found')) from None
|
||||
except IntegrityError: # Duplicate key probably
|
||||
raise exceptions.rest.RequestError(_('User already exists (duplicate key error)')) from None
|
||||
except exceptions.auth.AuthenticatorException as e:
|
||||
@@ -521,8 +490,8 @@ class Groups(DetailHandler):
|
||||
except exceptions.rest.RequestError: # pylint: disable=try-except-raise
|
||||
raise # Re-raise
|
||||
except Exception as e:
|
||||
logger.exception('Saving group')
|
||||
raise self.invalid_request_response() from e
|
||||
logger.error('Error saving group %s: %s', item, e)
|
||||
raise exceptions.rest.ResponseError(_('Error saving group')) from e
|
||||
|
||||
def delete_item(self, parent: 'Model', item: str) -> None:
|
||||
parent = ensure.is_instance(parent, Authenticator)
|
||||
@@ -530,10 +499,13 @@ class Groups(DetailHandler):
|
||||
group = parent.groups.get(uuid=item)
|
||||
|
||||
group.delete()
|
||||
except Exception:
|
||||
raise self.invalid_item_response() from None
|
||||
except exceptions.rest.NotFound:
|
||||
raise exceptions.rest.NotFound(_('Group not found')) from None
|
||||
except Exception as e:
|
||||
logger.error('Error deleting group %s: %s', item, e)
|
||||
raise exceptions.rest.ResponseError(_('Error deleting group')) from e
|
||||
|
||||
def servicesPools(self, parent: 'Model', item: str) -> list[collections.abc.Mapping[str, typing.Any]]:
|
||||
def services_pools(self, parent: 'Model', item: str) -> list[collections.abc.Mapping[str, typing.Any]]:
|
||||
parent = ensure.is_instance(parent, Authenticator)
|
||||
uuid = process_uuid(item)
|
||||
group = parent.groups.get(uuid=process_uuid(uuid))
|
||||
|
||||
@@ -40,8 +40,8 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UDSVersion(Handler):
|
||||
authenticated = False # Version requests are public
|
||||
name = 'version'
|
||||
ROLE = consts.UserRole.ANONYMOUS
|
||||
NAME = 'version'
|
||||
|
||||
def get(self) -> collections.abc.MutableMapping[str, typing.Any]:
|
||||
return {'version': consts.system.VERSION, 'build': consts.system.VERSION_STAMP}
|
||||
|
||||
@@ -32,4 +32,4 @@ Author: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
# pyright: reportUnusedImport=false
|
||||
from .base import BaseModelHandler
|
||||
from .detail import DetailHandler
|
||||
from .model import ModelHandler
|
||||
from .master import ModelHandler
|
||||
|
||||
@@ -29,9 +29,8 @@
|
||||
"""
|
||||
Author: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
"""
|
||||
# pylint: disable=too-many-public-methods
|
||||
|
||||
import inspect
|
||||
import abc
|
||||
import logging
|
||||
import typing
|
||||
|
||||
@@ -41,9 +40,10 @@ from django.utils.translation import gettext as _
|
||||
from uds.core import consts
|
||||
from uds.core import exceptions
|
||||
from uds.core import types
|
||||
from uds.core.module import Module
|
||||
from uds.core.util import permissions
|
||||
from uds.models import ManagedObjectModel, Network
|
||||
from uds.core.module import Module
|
||||
|
||||
# from uds.models import ManagedObjectModel
|
||||
|
||||
from ..handlers import Handler
|
||||
|
||||
@@ -55,247 +55,48 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
class BaseModelHandler(Handler):
|
||||
class BaseModelHandler(Handler, abc.ABC, typing.Generic[types.rest.T_Item]):
|
||||
"""
|
||||
Base Handler for Master & Detail Handlers
|
||||
"""
|
||||
|
||||
def add_field(
|
||||
self, gui: list[typing.Any], field: typing.Union[types.rest.FieldType, list[types.rest.FieldType]]
|
||||
) -> list[typing.Any]:
|
||||
"""
|
||||
Add a field to a "gui" description.
|
||||
This method checks that every required field element is in there.
|
||||
If not, defaults are assigned
|
||||
:param gui: List of "gui" items where the field will be added
|
||||
:param field: Field to be added (dictionary)
|
||||
"""
|
||||
if isinstance(field, list):
|
||||
for i in field:
|
||||
gui = self.add_field(gui, i)
|
||||
else:
|
||||
if 'values' in field:
|
||||
caller = inspect.stack()[1]
|
||||
logger.warning(
|
||||
'Field %s has "values" attribute, this is deprecated and will be removed in future versions. Use "choices" instead. Called from %s:%s',
|
||||
field.get('name', ''),
|
||||
caller.filename,
|
||||
caller.lineno,
|
||||
)
|
||||
choices = field['values']
|
||||
else:
|
||||
choices = field.get('choices', None)
|
||||
# Build gui with non empty values
|
||||
gui_description: dict[str, typing.Any] = {}
|
||||
# First, mandatory fields
|
||||
for fld in ('name', 'type'):
|
||||
if fld not in field:
|
||||
caller = inspect.stack()[1]
|
||||
logger.error(
|
||||
'Field %s does not have mandatory field %s. Called from %s:%s',
|
||||
field.get('name', ''),
|
||||
fld,
|
||||
caller.filename,
|
||||
caller.lineno,
|
||||
)
|
||||
raise exceptions.rest.RequestError(
|
||||
f'Field {fld} is mandatory on {field.get("name", "")} field.'
|
||||
)
|
||||
|
||||
if choices:
|
||||
gui_description['choices'] = choices
|
||||
# "fillable" fields (optional and mandatory on gui)
|
||||
for fld in (
|
||||
'type',
|
||||
'default',
|
||||
'required',
|
||||
'min_value',
|
||||
'max_value',
|
||||
'length',
|
||||
'lines',
|
||||
'tooltip',
|
||||
'readonly',
|
||||
):
|
||||
if fld in field and field[fld] is not None:
|
||||
gui_description[fld] = field[fld]
|
||||
|
||||
# Order and label optional, but must be present on gui
|
||||
gui_description['order'] = field.get('order', 0)
|
||||
gui_description['label'] = field.get('label', field['name'])
|
||||
|
||||
v: dict[str, typing.Any] = {
|
||||
'name': field.get('name', ''),
|
||||
'value': field.get('value', ''),
|
||||
'gui': gui_description,
|
||||
}
|
||||
if field.get('tab', None):
|
||||
v['gui']['tab'] = _(str(field['tab']))
|
||||
gui.append(v)
|
||||
return gui
|
||||
|
||||
def add_default_fields(self, gui: list[typing.Any], flds: list[str]) -> list[typing.Any]:
|
||||
"""
|
||||
Adds default fields (based in a list) to a "gui" description
|
||||
:param gui: Gui list where the "default" fielsds will be added
|
||||
:param flds: List of fields names requested to be added. Valid values are 'name', 'comments',
|
||||
'priority' and 'small_name', 'short_name', 'tags'
|
||||
"""
|
||||
if 'tags' in flds:
|
||||
self.add_field(
|
||||
gui,
|
||||
{
|
||||
'name': 'tags',
|
||||
'label': _('Tags'),
|
||||
'type': 'taglist',
|
||||
'tooltip': _('Tags for this element'),
|
||||
'order': 0 - 105,
|
||||
},
|
||||
)
|
||||
if 'name' in flds:
|
||||
self.add_field(
|
||||
gui,
|
||||
{
|
||||
'name': 'name',
|
||||
'type': 'text',
|
||||
'required': True,
|
||||
'label': _('Name'),
|
||||
'length': 128,
|
||||
'tooltip': _('Name of this element'),
|
||||
'order': 0 - 100,
|
||||
},
|
||||
)
|
||||
if 'comments' in flds:
|
||||
self.add_field(
|
||||
gui,
|
||||
{
|
||||
'name': 'comments',
|
||||
'label': _('Comments'),
|
||||
'type': 'text',
|
||||
'lines': 3,
|
||||
'tooltip': _('Comments for this element'),
|
||||
'length': 256,
|
||||
'order': 0 - 90,
|
||||
},
|
||||
)
|
||||
if 'priority' in flds:
|
||||
self.add_field(
|
||||
gui,
|
||||
{
|
||||
'name': 'priority',
|
||||
'type': 'numeric',
|
||||
'label': _('Priority'),
|
||||
'tooltip': _('Selects the priority of this element (lower number means higher priority)'),
|
||||
'required': True,
|
||||
'value': 1,
|
||||
'length': 4,
|
||||
'order': 0 - 85,
|
||||
},
|
||||
)
|
||||
if 'small_name' in flds:
|
||||
self.add_field(
|
||||
gui,
|
||||
{
|
||||
'name': 'small_name',
|
||||
'type': 'text',
|
||||
'label': _('Label'),
|
||||
'tooltip': _('Label for this element'),
|
||||
'required': True,
|
||||
'length': 128,
|
||||
'order': 0 - 80,
|
||||
},
|
||||
)
|
||||
if 'networks' in flds:
|
||||
self.add_field(
|
||||
gui,
|
||||
{
|
||||
'name': 'net_filtering',
|
||||
'value': 'n',
|
||||
'choices': [
|
||||
{'id': 'n', 'text': _('No filtering')},
|
||||
{'id': 'a', 'text': _('Allow selected networks')},
|
||||
{'id': 'd', 'text': _('Deny selected networks')},
|
||||
],
|
||||
'label': _('Network Filtering'),
|
||||
'tooltip': _(
|
||||
'Type of network filtering. Use "Disabled" to disable origin check, "Allow" to only enable for selected networks or "Deny" to deny from selected networks'
|
||||
),
|
||||
'type': 'choice',
|
||||
'order': 100, # At end
|
||||
'tab': types.ui.Tab.ADVANCED,
|
||||
},
|
||||
)
|
||||
self.add_field(
|
||||
gui,
|
||||
{
|
||||
'name': 'networks',
|
||||
'value': [],
|
||||
'choices': sorted(
|
||||
[{'id': x.uuid, 'text': x.name} for x in Network.objects.all()],
|
||||
key=lambda x: x['text'].lower(),
|
||||
),
|
||||
'label': _('Networks'),
|
||||
'tooltip': _('Networks associated. If No network selected, will mean "all networks"'),
|
||||
'type': 'multichoice',
|
||||
'order': 101,
|
||||
'tab': types.ui.Tab.ADVANCED,
|
||||
},
|
||||
)
|
||||
|
||||
return gui
|
||||
|
||||
def ensure_has_access(
|
||||
def check_access(
|
||||
self,
|
||||
obj: models.Model,
|
||||
permission: 'types.permissions.PermissionType',
|
||||
root: bool = False,
|
||||
) -> None:
|
||||
if not permissions.has_access(self._user, obj, permission, root):
|
||||
raise self.access_denied_response()
|
||||
raise exceptions.rest.AccessDenied('Access denied')
|
||||
|
||||
def get_permissions(self, obj: models.Model, root: bool = False) -> int:
|
||||
return permissions.effective_permissions(self._user, obj, root)
|
||||
|
||||
def type_info(self, type_: type['Module']) -> typing.Optional[types.rest.ExtraTypeInfo]:
|
||||
@classmethod
|
||||
def extra_type_info(cls: type[typing.Self], type_: type['Module']) -> types.rest.ExtraTypeInfo | None:
|
||||
"""
|
||||
Returns info about the type
|
||||
In fact, right now, it returns an empty dict, that will be extended by typeAsDict
|
||||
"""
|
||||
return None
|
||||
|
||||
def type_as_dict(self, type_: type['Module']) -> types.rest.TypeInfoDict:
|
||||
@typing.final
|
||||
@classmethod
|
||||
def as_typeinfo(cls: type[typing.Self], type_: type['Module']) -> types.rest.TypeInfo:
|
||||
"""
|
||||
Returns a dictionary describing the type (the name, the icon, description, etc...)
|
||||
"""
|
||||
res = types.rest.TypeInfo(
|
||||
return types.rest.TypeInfo(
|
||||
name=_(type_.mod_name()),
|
||||
type=type_.mod_type(),
|
||||
description=_(type_.description()),
|
||||
icon=type_.icon64().replace('\n', ''),
|
||||
extra=self.type_info(type_),
|
||||
extra=cls.extra_type_info(type_),
|
||||
group=getattr(type_, 'group', None),
|
||||
).as_dict()
|
||||
|
||||
return res
|
||||
|
||||
def process_table_fields(
|
||||
self,
|
||||
title: str,
|
||||
fields: list[typing.Any],
|
||||
row_style: types.ui.RowStyleInfo,
|
||||
subtitle: typing.Optional[str] = None,
|
||||
) -> dict[str, typing.Any]:
|
||||
"""
|
||||
Returns a dict containing the table fields description
|
||||
"""
|
||||
return {
|
||||
'title': title,
|
||||
'fields': fields,
|
||||
'row-style': row_style.as_dict(),
|
||||
'subtitle': subtitle or '',
|
||||
}
|
||||
)
|
||||
|
||||
def fields_from_params(
|
||||
self, fields_list: list[str], *, defaults: 'dict[str, typing.Any]|None' = None
|
||||
self, fields_list: list[str], *, defaults: dict[str, typing.Any] | None = None
|
||||
) -> dict[str, typing.Any]:
|
||||
"""
|
||||
Reads the indicated fields from the parameters received, and if
|
||||
@@ -303,9 +104,10 @@ class BaseModelHandler(Handler):
|
||||
:return: A dictionary containing all required fields
|
||||
"""
|
||||
args: dict[str, str] = {}
|
||||
default: typing.Optional[str]
|
||||
default: str | None = None
|
||||
try:
|
||||
for key in fields_list:
|
||||
# if : is in the field, it is an optional field, with an "static" default value
|
||||
if ':' in key: # optional field? get default if not present
|
||||
k, default = key.split(':')[:2]
|
||||
# Convert "None" to None
|
||||
@@ -314,14 +116,15 @@ class BaseModelHandler(Handler):
|
||||
if default == '_' and k not in self._params:
|
||||
continue
|
||||
args[k] = self._params.get(k, default)
|
||||
else:
|
||||
try:
|
||||
args[key] = self._params[key]
|
||||
except KeyError:
|
||||
if defaults is not None and key in defaults:
|
||||
else: # Required field, with a possible default on defaults dict
|
||||
if key not in self._params:
|
||||
if defaults and key in defaults:
|
||||
args[key] = defaults[key]
|
||||
else:
|
||||
raise
|
||||
raise exceptions.rest.RequestError(f'needed parameter not found in data {key}')
|
||||
else:
|
||||
# Set the value
|
||||
args[key] = self._params[key]
|
||||
|
||||
# del self._params[key]
|
||||
except KeyError as e:
|
||||
@@ -329,63 +132,58 @@ class BaseModelHandler(Handler):
|
||||
|
||||
return args
|
||||
|
||||
def fill_instance_fields(self, item: 'models.Model', res: dict[str, typing.Any]) -> dict[str, typing.Any]:
|
||||
"""
|
||||
For Managed Objects (db element that contains a serialized object), fills a dictionary with the "field" parameters values.
|
||||
For non managed objects, it does nothing
|
||||
:param item: Item to extract fields
|
||||
:param res: Dictionary to "extend" with instance key-values pairs
|
||||
"""
|
||||
if isinstance(item, ManagedObjectModel):
|
||||
i = item.get_instance()
|
||||
i.init_gui() # Defaults & stuff
|
||||
res.update(i.get_fields_as_dict())
|
||||
return res
|
||||
|
||||
# Exceptions
|
||||
def invalid_request_response(self, message: typing.Optional[str] = None) -> exceptions.rest.HandlerError:
|
||||
"""
|
||||
Raises an invalid request error with a default translated string
|
||||
:param message: Custom message to add to exception. If it is None, "Invalid Request" is used
|
||||
"""
|
||||
message = message or _('Invalid Request')
|
||||
return exceptions.rest.RequestError(f'{message} {self.__class__}: {self._args}')
|
||||
|
||||
def invalid_response_response(self, message: typing.Optional[str] = None) -> exceptions.rest.HandlerError:
|
||||
message = 'Invalid response' if message is None else message
|
||||
return exceptions.rest.ResponseError(message)
|
||||
|
||||
def invalid_method_response(self) -> exceptions.rest.HandlerError:
|
||||
"""
|
||||
Raises a NotFound exception with translated "Method not found" string to current locale
|
||||
"""
|
||||
return exceptions.rest.RequestError(_('Method not found in {}: {}').format(self.__class__, self._args))
|
||||
|
||||
def invalid_item_response(self, message: typing.Optional[str] = None) -> exceptions.rest.HandlerError:
|
||||
"""
|
||||
Raises a NotFound exception, with location info
|
||||
"""
|
||||
message = message or _('Item not found')
|
||||
return exceptions.rest.NotFound(message)
|
||||
# raise NotFound('{} {}: {}'.format(message, self.__class__, self._args))
|
||||
|
||||
def access_denied_response(self, message: typing.Optional[str] = None) -> exceptions.rest.HandlerError:
|
||||
return exceptions.rest.AccessDenied(message or _('Access denied'))
|
||||
|
||||
def not_supported_response(self, message: typing.Optional[str] = None) -> exceptions.rest.HandlerError:
|
||||
return exceptions.rest.NotSupportedError(message or _('Operation not supported'))
|
||||
|
||||
# Success methods
|
||||
def success(self) -> str:
|
||||
"""
|
||||
Utility method to be invoked for simple methods that returns nothing in fact
|
||||
Utility method to be invoked for simple methods that returns a simple OK response
|
||||
"""
|
||||
logger.debug('Returning success on %s %s', self.__class__, self._args)
|
||||
return consts.OK
|
||||
|
||||
def test(self, type_: str) -> str: # pylint: disable=unused-argument
|
||||
def test(self, type_: str) -> str:
|
||||
"""
|
||||
Invokes a test for an item
|
||||
"""
|
||||
logger.debug('Called base test for %s --> %s', self.__class__.__name__, self._params)
|
||||
raise self.invalid_method_response()
|
||||
raise exceptions.rest.NotSupportedError(_('Testing not supported'))
|
||||
|
||||
@classmethod
|
||||
def api_components(cls: type[typing.Self]) -> types.rest.api.Components:
|
||||
"""
|
||||
Default implementation does not have any component types. (for Api specification purposes)
|
||||
"""
|
||||
return types.rest.api.Components()
|
||||
|
||||
@classmethod
|
||||
def api_paths(
|
||||
cls: type[typing.Self], path: str, tags: list[str], security: str
|
||||
) -> dict[str, types.rest.api.PathItem]:
|
||||
"""
|
||||
Returns the API operations that should be registered
|
||||
"""
|
||||
return {}
|
||||
|
||||
@typing.final
|
||||
@staticmethod
|
||||
def common_components() -> types.rest.api.Components:
|
||||
"""
|
||||
Returns a list of common components for the API for ModelHandlers (Model and Detail)
|
||||
"""
|
||||
from uds.core.util import api as api_utils
|
||||
|
||||
return (
|
||||
api_utils.api_components(types.rest.TypeInfo)
|
||||
| api_utils.api_components(types.rest.TableInfo)
|
||||
| api_utils.api_components(
|
||||
types.ui.GuiElement,
|
||||
removable_fields=['value', 'gui.old_field_name', 'gui.value', 'gui.field_name'],
|
||||
)
|
||||
)
|
||||
|
||||
@typing.final
|
||||
@staticmethod
|
||||
def common_paths() -> dict[str, types.rest.api.PathItem]:
|
||||
"""
|
||||
Returns a dictionary of common paths for the API for ModelHandlers (Model and Detail)
|
||||
"""
|
||||
return {}
|
||||
|
||||
@@ -38,18 +38,18 @@ import collections.abc
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from uds.core import consts
|
||||
from uds.core import types
|
||||
from uds.core import consts, exceptions, types, module
|
||||
from uds.core.util.model import process_uuid
|
||||
from uds.core.util import api as api_utils
|
||||
from uds.REST.utils import rest_result
|
||||
|
||||
from .base import BaseModelHandler
|
||||
from ..utils import camel_and_snake_case_from
|
||||
from uds.REST.model.base import BaseModelHandler
|
||||
from uds.REST.utils import camel_and_snake_case_from
|
||||
|
||||
# Not imported at runtime, just for type checking
|
||||
if typing.TYPE_CHECKING:
|
||||
from uds.models import User
|
||||
from .model import ModelHandler
|
||||
from uds.REST.model.master import ModelHandler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -57,7 +57,7 @@ logger = logging.getLogger(__name__)
|
||||
# Details do not have types at all
|
||||
# so, right now, we only process details petitions for Handling & tables info
|
||||
# noinspection PyMissingConstructor
|
||||
class DetailHandler(BaseModelHandler):
|
||||
class DetailHandler(BaseModelHandler[types.rest.T_Item]):
|
||||
"""
|
||||
Detail handler (for relations such as provider-->services, authenticators-->users,groups, deployed services-->cache,assigned, groups, transports
|
||||
Urls recognized for GET are:
|
||||
@@ -79,22 +79,24 @@ class DetailHandler(BaseModelHandler):
|
||||
Also accepts GET methods for "custom" methods
|
||||
"""
|
||||
|
||||
custom_methods: typing.ClassVar[list[str]] = []
|
||||
_parent: typing.Optional['ModelHandler']
|
||||
CUSTOM_METHODS: typing.ClassVar[list[str]] = []
|
||||
_parent: typing.Optional[
|
||||
'ModelHandler[types.rest.T_Item]'
|
||||
] # Parent handler, that is the ModelHandler that contains this detail
|
||||
_path: str
|
||||
_params: typing.Any # _params is deserialized object from request
|
||||
_args: list[str]
|
||||
_kwargs: dict[str, typing.Any]
|
||||
_parent_item: models.Model # Parent item, that is the parent model element
|
||||
_user: 'User'
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent_handler: 'ModelHandler',
|
||||
parent_handler: 'ModelHandler[types.rest.T_Item]',
|
||||
path: str,
|
||||
params: typing.Any,
|
||||
*args: str,
|
||||
user: 'User',
|
||||
**kwargs: typing.Any,
|
||||
parent_item: models.Model,
|
||||
) -> None:
|
||||
"""
|
||||
Detail Handlers in fact "disabled" handler most initialization, that is no needed because
|
||||
@@ -106,8 +108,10 @@ class DetailHandler(BaseModelHandler):
|
||||
self._path = path
|
||||
self._params = params
|
||||
self._args = list(args)
|
||||
self._kwargs = kwargs
|
||||
self._parent_item = parent_item
|
||||
self._user = user
|
||||
self._odata = parent_handler._odata # Ref to parent OData
|
||||
self._headers = parent_handler._headers # "link" headers
|
||||
|
||||
def _check_is_custom_method(self, check: str, parent: models.Model, arg: typing.Any = None) -> typing.Any:
|
||||
"""
|
||||
@@ -116,7 +120,7 @@ class DetailHandler(BaseModelHandler):
|
||||
:param parent: Parent Model Element
|
||||
:param arg: argument to pass to custom method
|
||||
"""
|
||||
for to_check in self.custom_methods:
|
||||
for to_check in self.CUSTOM_METHODS:
|
||||
camel_case_name, snake_case_name = camel_and_snake_case_from(to_check)
|
||||
if check in (camel_case_name, snake_case_name):
|
||||
operation = getattr(self, snake_case_name, None) or getattr(self, camel_case_name, None)
|
||||
@@ -136,7 +140,7 @@ class DetailHandler(BaseModelHandler):
|
||||
logger.debug('Detail args for GET: %s', self._args)
|
||||
num_args = len(self._args)
|
||||
|
||||
parent: models.Model = self._kwargs['parent']
|
||||
parent: models.Model = self._parent_item
|
||||
|
||||
if num_args == 0:
|
||||
return self.get_items(parent, None)
|
||||
@@ -146,41 +150,40 @@ class DetailHandler(BaseModelHandler):
|
||||
if r is not consts.rest.NOT_FOUND:
|
||||
return r
|
||||
|
||||
if num_args == 1:
|
||||
match self._args[0]:
|
||||
case consts.rest.OVERVIEW:
|
||||
return self.get_items(parent, None)
|
||||
case consts.rest.TYPES:
|
||||
types_ = self.get_types(parent, None)
|
||||
logger.debug('Types: %s', types_)
|
||||
return types_
|
||||
case consts.rest.TABLEINFO:
|
||||
return self.process_table_fields(
|
||||
self.get_title(parent),
|
||||
self.get_fields(parent),
|
||||
self.get_row_style(parent),
|
||||
)
|
||||
case consts.rest.GUI: # Used on some cases to get the gui for a detail with no subtypes
|
||||
gui = self.get_processed_gui(parent, '')
|
||||
return sorted(gui, key=lambda f: f['gui']['order'])
|
||||
case _:
|
||||
# try to get id
|
||||
return self.get_items(parent, process_uuid(self._args[0]))
|
||||
|
||||
if num_args == 2:
|
||||
if self._args[0] == consts.rest.GUI:
|
||||
return self.get_processed_gui(parent, self._args[1])
|
||||
if self._args[0] == consts.rest.TYPES:
|
||||
types_ = self.get_types(parent, self._args[1])
|
||||
logger.debug('Types: %s', types_)
|
||||
return types_
|
||||
if self._args[1] == consts.rest.LOG:
|
||||
return self.get_logs(parent, self._args[0])
|
||||
|
||||
# Maybe a custom method?
|
||||
r = self._check_is_custom_method(self._args[1], parent, self._args[0])
|
||||
if r is not None:
|
||||
return r
|
||||
match self._args:
|
||||
case [consts.rest.OVERVIEW]:
|
||||
return self.get_items(parent, None)
|
||||
case [consts.rest.OVERVIEW, *_fails]:
|
||||
raise exceptions.rest.RequestError('Invalid overview request') from None
|
||||
case [consts.rest.TYPES]:
|
||||
types = self.enum_types(parent, None)
|
||||
logger.debug('Types: %s', types)
|
||||
return [i.as_dict() for i in types]
|
||||
case [consts.rest.TYPES, for_type]:
|
||||
return [i.as_dict() for i in self.enum_types(parent, for_type)]
|
||||
case [consts.rest.TYPES, for_type, *_fails]:
|
||||
raise exceptions.rest.RequestError('Invalid types request') from None
|
||||
case [consts.rest.TABLEINFO]:
|
||||
return self.get_table(parent).as_dict()
|
||||
case [consts.rest.TABLEINFO, *_fails]:
|
||||
raise exceptions.rest.RequestError('Invalid table info request') from None
|
||||
case [consts.rest.GUI]:
|
||||
return sorted(self.get_processed_gui(parent, ''), key=lambda f: f.gui.order)
|
||||
case [consts.rest.GUI, for_type]:
|
||||
return sorted(self.get_processed_gui(parent, for_type), key=lambda f: f.gui.order)
|
||||
case [consts.rest.GUI, for_type, *_fails]:
|
||||
raise exceptions.rest.RequestError('Invalid GUI request') from None
|
||||
case [item_id, consts.rest.LOG]:
|
||||
return self.get_logs(parent, item_id)
|
||||
case [consts.rest.LOG, *_fails]:
|
||||
raise exceptions.rest.RequestError('Invalid log request') from None
|
||||
case [one_arg]:
|
||||
return self.get_items(parent, process_uuid(one_arg))
|
||||
case _:
|
||||
# Maybe a custom method?
|
||||
r = self._check_is_custom_method(self._args[1], parent, self._args[0])
|
||||
if r is not None:
|
||||
return r
|
||||
|
||||
# Not understood, fallback, maybe the derived class can understand it
|
||||
return self.fallback_get()
|
||||
@@ -193,7 +196,7 @@ class DetailHandler(BaseModelHandler):
|
||||
"""
|
||||
logger.debug('Detail args for PUT: %s, %s', self._args, self._params)
|
||||
|
||||
parent: models.Model = self._kwargs['parent']
|
||||
parent: models.Model = self._parent_item
|
||||
|
||||
# if has custom methods, look for if this request matches any of them
|
||||
if len(self._args) > 1:
|
||||
@@ -206,7 +209,7 @@ class DetailHandler(BaseModelHandler):
|
||||
if len(self._args) == 1:
|
||||
item = self._args[0]
|
||||
elif len(self._args) > 1: # PUT expects 0 or 1 parameters. 0 == NEW, 1 = EDIT
|
||||
raise self.invalid_request_response()
|
||||
raise exceptions.rest.RequestError('Invalid PUT request') from None
|
||||
|
||||
logger.debug('Invoking proper saving detail item %s', item)
|
||||
return rest_result(self.save_item(parent, item))
|
||||
@@ -217,7 +220,7 @@ class DetailHandler(BaseModelHandler):
|
||||
Post can be used for, for example, testing.
|
||||
Right now is an invalid method for Detail elements
|
||||
"""
|
||||
raise self.invalid_request_response('This method does not accepts POST')
|
||||
raise exceptions.rest.RequestError('This method does not accepts POST') from None
|
||||
|
||||
def delete(self) -> typing.Any:
|
||||
"""
|
||||
@@ -226,10 +229,10 @@ class DetailHandler(BaseModelHandler):
|
||||
"""
|
||||
logger.debug('Detail args for DELETE: %s', self._args)
|
||||
|
||||
parent = self._kwargs['parent']
|
||||
parent = self._parent_item
|
||||
|
||||
if len(self._args) != 1:
|
||||
raise self.invalid_request_response()
|
||||
raise exceptions.rest.RequestError('Invalid DELETE request') from None
|
||||
|
||||
self.delete_item(parent, self._args[0])
|
||||
|
||||
@@ -240,11 +243,13 @@ class DetailHandler(BaseModelHandler):
|
||||
Invoked if default get can't process request.
|
||||
Here derived classes can process "non default" (and so, not understood) GET constructions
|
||||
"""
|
||||
raise self.invalid_request_response('Fallback invoked')
|
||||
raise exceptions.rest.RequestError('Invalid GET request') from None
|
||||
|
||||
# Override this to provide functionality
|
||||
# Default (as sample) get_items
|
||||
def get_items(self, parent: models.Model, item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
|
||||
def get_items(
|
||||
self, parent: models.Model, item: typing.Optional[str]
|
||||
) -> types.rest.ItemsResult[types.rest.T_Item]:
|
||||
"""
|
||||
This MUST be overridden by derived classes
|
||||
Excepts to return a list of dictionaries or a single dictionary, depending on "item" param
|
||||
@@ -257,7 +262,7 @@ class DetailHandler(BaseModelHandler):
|
||||
raise NotImplementedError(f'Must provide an get_items method for {self.__class__} class')
|
||||
|
||||
# Default save
|
||||
def save_item(self, parent: models.Model, item: typing.Optional[str]) -> typing.Any:
|
||||
def save_item(self, parent: models.Model, item: typing.Optional[str]) -> types.rest.T_Item:
|
||||
"""
|
||||
Invoked for a valid "put" operation
|
||||
If this method is not overridden, the detail class will not have "Save/modify" operations.
|
||||
@@ -267,7 +272,7 @@ class DetailHandler(BaseModelHandler):
|
||||
:return: Normally "success" is expected, but can throw any "exception"
|
||||
"""
|
||||
logger.debug('Default save_item handler caller for %s', self._path)
|
||||
raise self.invalid_request_response()
|
||||
raise exceptions.rest.RequestError('Invalid PUT request') from None
|
||||
|
||||
# Default delete
|
||||
def delete_item(self, parent: models.Model, item: str) -> None:
|
||||
@@ -278,41 +283,17 @@ class DetailHandler(BaseModelHandler):
|
||||
:param item: Item id (uuid)
|
||||
:return: Normally "success" is expected, but can throw any "exception"
|
||||
"""
|
||||
raise self.invalid_request_response()
|
||||
raise exceptions.rest.InvalidMethodError('Object does not support delete')
|
||||
|
||||
# A detail handler must also return title & fields for tables
|
||||
def get_title(self, parent: models.Model) -> str: # pylint: disable=no-self-use
|
||||
def get_table(self, parent: models.Model) -> types.rest.TableInfo:
|
||||
"""
|
||||
A "generic" title for a view based on this detail.
|
||||
If not overridden, defaults to ''
|
||||
Returns the table info for this detail, that is the title, fields and row style
|
||||
:param parent: Parent object
|
||||
:return: Expected to return an string that is the "title".
|
||||
:return: TableInfo object with title, fields and row style
|
||||
"""
|
||||
return ''
|
||||
return types.rest.TableInfo.null()
|
||||
|
||||
def get_fields(self, parent: models.Model) -> list[typing.Any]:
|
||||
"""
|
||||
A "generic" list of fields for a view based on this detail.
|
||||
If not overridden, defaults to emty list
|
||||
:param parent: Parent object
|
||||
:return: Expected to return a list of fields
|
||||
"""
|
||||
return []
|
||||
|
||||
def get_row_style(self, parent: models.Model) -> types.ui.RowStyleInfo:
|
||||
"""
|
||||
A "generic" row style based on row field content.
|
||||
If not overridden, defaults to {}
|
||||
|
||||
Args:
|
||||
parent (models.Model): Parent object
|
||||
|
||||
Return:
|
||||
dict[str, typing.Any]: A dictionary with 'field' and 'prefix' keys
|
||||
"""
|
||||
return types.ui.RowStyleInfo.null()
|
||||
|
||||
def get_gui(self, parent: models.Model, for_type: str) -> collections.abc.Iterable[typing.Any]:
|
||||
def get_gui(self, parent: models.Model, for_type: str) -> list[types.ui.GuiElement]:
|
||||
"""
|
||||
Gets the gui that is needed in order to "edit/add" new items on this detail
|
||||
If not overriden, means that the detail has no edit/new Gui
|
||||
@@ -322,21 +303,21 @@ class DetailHandler(BaseModelHandler):
|
||||
for_type (str): Type of object needing gui
|
||||
|
||||
Return:
|
||||
collections.abc.Iterable[typing.Any]: A list of gui fields
|
||||
list[types.ui.GuiElement]: A list of gui fields
|
||||
"""
|
||||
# raise RequestError('Gui not provided for this type of object')
|
||||
return []
|
||||
|
||||
def get_processed_gui(self, parent: models.Model, for_type: str) -> collections.abc.Iterable[typing.Any]:
|
||||
gui = self.get_gui(parent, for_type)
|
||||
return sorted(gui, key=lambda f: f['gui']['order'])
|
||||
def get_processed_gui(self, parent: models.Model, for_type: str) -> list[types.ui.GuiElement]:
|
||||
return sorted(self.get_gui(parent, for_type), key=lambda f: f.gui.order)
|
||||
|
||||
def get_types(
|
||||
def enum_types(
|
||||
self, parent: models.Model, for_type: typing.Optional[str]
|
||||
) -> collections.abc.Iterable[types.rest.TypeInfoDict]:
|
||||
) -> collections.abc.Iterable[types.rest.TypeInfo]:
|
||||
"""
|
||||
The default is that detail element will not have any types (they are "homogeneous")
|
||||
but we provided this method, that can be overridden, in case one detail needs it
|
||||
(for example, on services)
|
||||
|
||||
Args:
|
||||
parent (models.Model): Parent object
|
||||
@@ -347,6 +328,15 @@ class DetailHandler(BaseModelHandler):
|
||||
"""
|
||||
return [] # Default is that details do not have types
|
||||
|
||||
@classmethod
|
||||
def possible_types(cls: type[typing.Self]) -> collections.abc.Iterable[type[module.Module]]:
|
||||
"""
|
||||
Note: This method returns ALL POSSIBLE TYPES for the specific model, not just those
|
||||
related to the father. Is used for api composition.
|
||||
enum_types, hear, is the one to filter types by parent, etc..
|
||||
"""
|
||||
return []
|
||||
|
||||
def get_logs(self, parent: models.Model, item: str) -> list[typing.Any]:
|
||||
"""
|
||||
If the detail has any log associated with it items, provide it overriding this method
|
||||
@@ -354,4 +344,21 @@ class DetailHandler(BaseModelHandler):
|
||||
:param item:
|
||||
:return: a list of log elements (normally got using "uds.core.util.log.get_logs" method)
|
||||
"""
|
||||
raise self.invalid_method_response()
|
||||
raise exceptions.rest.InvalidMethodError('Object does not support logs')
|
||||
|
||||
@classmethod
|
||||
def api_components(cls: type[typing.Self]) -> types.rest.api.Components:
|
||||
"""
|
||||
Default implementation does not have any component types. (for Api specification purposes)
|
||||
"""
|
||||
# If no get_items, has no components (if custom components is needed, override this classmethod)
|
||||
return api_utils.get_component_from_type(cls)
|
||||
|
||||
@classmethod
|
||||
def api_paths(cls: type[typing.Self], path: str, tags: list[str], security: str) -> dict[str, types.rest.api.PathItem]:
|
||||
"""
|
||||
Returns the API operations that should be registered
|
||||
"""
|
||||
from .api_helpers import api_paths
|
||||
|
||||
return api_paths(cls, path, tags=tags, security=security)
|
||||
205
server/src/uds/REST/model/detail/api_helpers.py
Normal file
205
server/src/uds/REST/model/detail/api_helpers.py
Normal file
@@ -0,0 +1,205 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (c) 2014-2023 Virtual Cable S.L.U.
|
||||
# All rights reserved.
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without modification,
|
||||
# are permitted provided that the following conditions are met:
|
||||
#
|
||||
# * Redistributions of source code must retain the above copyright notice,
|
||||
# this list of conditions and the following disclaimer.
|
||||
# * Redistributions in binary form must reproduce the above copyright notice,
|
||||
# this list of conditions and the following disclaimer in the documentation
|
||||
# and/or other materials provided with the distribution.
|
||||
# * Neither the name of Virtual Cable S.L.U. nor the names of its contributors
|
||||
# may be used to endorse or promote products derived from this software
|
||||
# without specific prior written permission.
|
||||
#
|
||||
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
"""
|
||||
Author: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
"""
|
||||
# pylint: disable=too-many-public-methods
|
||||
|
||||
import logging
|
||||
import typing
|
||||
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from uds.core import consts
|
||||
from uds.core import types
|
||||
from uds.core.util import api as api_utils
|
||||
|
||||
|
||||
# Not imported at runtime, just for type checking
|
||||
if typing.TYPE_CHECKING:
|
||||
from uds.REST.model.master import DetailHandler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
T = typing.TypeVar('T', bound=models.Model)
|
||||
|
||||
|
||||
def api_paths(
|
||||
cls: type['DetailHandler[types.rest.T_Item]'], path: str, tags: list[str], security: str
|
||||
) -> dict[str, types.rest.api.PathItem]:
|
||||
"""
|
||||
Returns the API operations that should be registered
|
||||
"""
|
||||
|
||||
name = cls.REST_API_INFO.name if cls.REST_API_INFO.name else path.split('/')[-1].capitalize()
|
||||
get_tags = tags
|
||||
put_tags = tags # + ['Create', 'Modify']
|
||||
# post_tags = tags + ['Create']
|
||||
delete_tags = tags # + ['Delete']
|
||||
|
||||
base_type = next(iter(api_utils.get_generic_types(cls)), None)
|
||||
if base_type is None:
|
||||
logger.error('Base type not detected: %s', cls)
|
||||
return {} # Skip
|
||||
else:
|
||||
base_type_name = base_type.__name__
|
||||
# TODO: Append "custom" methods
|
||||
api_desc = {
|
||||
path: types.rest.api.PathItem(
|
||||
get=types.rest.api.Operation(
|
||||
summary=f'Get all {name} items',
|
||||
description=f'Retrieve a list of all {name} items',
|
||||
parameters=api_utils.gen_odata_parameters(),
|
||||
responses=api_utils.gen_response(base_type_name, single=False),
|
||||
tags=get_tags,
|
||||
security=security,
|
||||
),
|
||||
put=types.rest.api.Operation(
|
||||
summary=f'Creates a new {name} items',
|
||||
description=f'Update an existing {name} item',
|
||||
parameters=[],
|
||||
responses=api_utils.gen_response(base_type_name, single=True),
|
||||
tags=put_tags,
|
||||
security=security,
|
||||
),
|
||||
),
|
||||
f'{path}/{{uuid}}': types.rest.api.PathItem(
|
||||
get=types.rest.api.Operation(
|
||||
summary=f'Get {name} item by UUID',
|
||||
description=f'Retrieve a {name} item by UUID',
|
||||
parameters=api_utils.gen_uuid_parameters(with_odata=True),
|
||||
responses=api_utils.gen_response(base_type_name, single=True),
|
||||
tags=get_tags,
|
||||
security=security,
|
||||
),
|
||||
put=types.rest.api.Operation(
|
||||
summary=f'Update {name} item by UUID',
|
||||
description=f'Update an existing {name} item by UUID',
|
||||
parameters=api_utils.gen_uuid_parameters(with_odata=True),
|
||||
responses=api_utils.gen_response(base_type_name, single=True),
|
||||
tags=put_tags,
|
||||
security=security,
|
||||
),
|
||||
delete=types.rest.api.Operation(
|
||||
summary=f'Delete {name} item by UUID',
|
||||
description=f'Delete a {name} item by UUID',
|
||||
parameters=api_utils.gen_uuid_parameters(with_odata=True),
|
||||
responses=api_utils.gen_response(base_type_name, single=True),
|
||||
tags=delete_tags,
|
||||
security=security,
|
||||
),
|
||||
),
|
||||
f'{path}/{consts.rest.OVERVIEW}': types.rest.api.PathItem(
|
||||
get=types.rest.api.Operation(
|
||||
summary=f'Get overview of {name} items',
|
||||
description=f'Retrieve an overview of {name} items',
|
||||
parameters=api_utils.gen_odata_parameters(),
|
||||
responses=api_utils.gen_response(base_type_name, single=False),
|
||||
tags=get_tags,
|
||||
security=security,
|
||||
)
|
||||
),
|
||||
f'{path}/{consts.rest.TABLEINFO}': types.rest.api.PathItem(
|
||||
get=types.rest.api.Operation(
|
||||
summary=f'Get table info of {name} items',
|
||||
description=f'Retrieve table info of {name} items',
|
||||
parameters=[],
|
||||
responses=api_utils.gen_response('TableInfo'),
|
||||
tags=get_tags,
|
||||
security=security,
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
if cls.REST_API_INFO.typed.is_single_type():
|
||||
api_desc[f'{path}/{consts.rest.GUI}'] = types.rest.api.PathItem(
|
||||
get=types.rest.api.Operation(
|
||||
summary=f'Get GUI representation of {name} items',
|
||||
description=f'Retrieve the GUI representation of {name} items',
|
||||
parameters=[],
|
||||
responses=api_utils.gen_response('GuiElement', single=False),
|
||||
tags=get_tags,
|
||||
security=security,
|
||||
)
|
||||
)
|
||||
|
||||
if cls.REST_API_INFO.typed.supports_multiple_types():
|
||||
api_desc.update(
|
||||
{
|
||||
f'{path}/{consts.rest.GUI}/{{type}}': types.rest.api.PathItem(
|
||||
get=types.rest.api.Operation(
|
||||
summary=f'Get GUI representation of {name} type',
|
||||
description=f'Retrieve a {name} GUI representation by type',
|
||||
parameters=[
|
||||
types.rest.api.Parameter(
|
||||
name='type',
|
||||
in_='path',
|
||||
required=True,
|
||||
description=f'The type of the {name} GUI representation',
|
||||
schema=types.rest.api.Schema(type='string'),
|
||||
)
|
||||
],
|
||||
responses=api_utils.gen_response('GuiElement', single=True),
|
||||
tags=get_tags,
|
||||
security=security,
|
||||
)
|
||||
),
|
||||
f'{path}/{consts.rest.TYPES}': types.rest.api.PathItem(
|
||||
get=types.rest.api.Operation(
|
||||
summary=f'Get types of {name} items',
|
||||
description=f'Retrieve types of {name} items',
|
||||
parameters=[],
|
||||
responses=api_utils.gen_response('TypeInfo', single=False),
|
||||
tags=get_tags,
|
||||
security=security,
|
||||
)
|
||||
),
|
||||
f'{path}/{consts.rest.TYPES}/{{type}}': types.rest.api.PathItem(
|
||||
get=types.rest.api.Operation(
|
||||
summary=f'Get {name} item by type',
|
||||
description=f'Retrieve a {name} item by type',
|
||||
parameters=[
|
||||
types.rest.api.Parameter(
|
||||
name='type',
|
||||
in_='path',
|
||||
required=True,
|
||||
description='The type of the item',
|
||||
schema=types.rest.api.Schema(type='string'),
|
||||
)
|
||||
],
|
||||
responses=api_utils.gen_response('TypeInfo', single=True),
|
||||
tags=get_tags,
|
||||
security=security,
|
||||
)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
return api_desc
|
||||
@@ -33,29 +33,33 @@ Author: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
|
||||
import logging
|
||||
import typing
|
||||
import abc
|
||||
import collections.abc
|
||||
|
||||
from django.db import IntegrityError, models
|
||||
from django.db.models import QuerySet
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from uds.core import consts
|
||||
from uds.core import exceptions
|
||||
from uds.core import types
|
||||
from uds.core.module import Module
|
||||
from uds.core.util import log, permissions
|
||||
from uds.core.util import log, permissions, api as api_utils
|
||||
from uds.models import ManagedObjectModel, Tag, TaggingMixin
|
||||
|
||||
from .base import BaseModelHandler
|
||||
from ..utils import camel_and_snake_case_from
|
||||
from uds.REST.model.base import BaseModelHandler
|
||||
from uds.REST.utils import camel_and_snake_case_from
|
||||
|
||||
# Not imported at runtime, just for type checking
|
||||
if typing.TYPE_CHECKING:
|
||||
from .detail import DetailHandler
|
||||
from uds.REST.model.detail import DetailHandler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
T = typing.TypeVar('T', bound=models.Model)
|
||||
|
||||
class ModelHandler(BaseModelHandler):
|
||||
|
||||
class ModelHandler(BaseModelHandler[types.rest.T_Item], abc.ABC):
|
||||
"""
|
||||
Basic Handler for a model
|
||||
Basically we will need same operations for all models, so we can
|
||||
@@ -72,90 +76,62 @@ class ModelHandler(BaseModelHandler):
|
||||
"""
|
||||
|
||||
# Authentication related
|
||||
authenticated = True
|
||||
needs_staff = True
|
||||
ROLE = consts.UserRole.STAFF
|
||||
|
||||
# Which model does this manage, must be a django model ofc
|
||||
model: 'typing.ClassVar[type[models.Model]]'
|
||||
MODEL: 'typing.ClassVar[type[models.Model]]'
|
||||
# If the model is filtered (for overviews)
|
||||
model_filter: 'typing.ClassVar[typing.Optional[collections.abc.Mapping[str, typing.Any]]]' = None
|
||||
FILTER: 'typing.ClassVar[typing.Optional[collections.abc.Mapping[str, typing.Any]]]' = None
|
||||
# Same, but for exclude
|
||||
model_exclude: 'typing.ClassVar[typing.Optional[collections.abc.Mapping[str, typing.Any]]]' = None
|
||||
|
||||
# By default, filter is empty
|
||||
fltr: typing.Optional[str] = None
|
||||
EXCLUDE: 'typing.ClassVar[typing.Optional[collections.abc.Mapping[str, typing.Any]]]' = None
|
||||
|
||||
# If this model respond to "custom" methods, we will declare them here
|
||||
# This is an array of tuples of two items, where first is method and second inticates if method needs parent id (normal behavior is it needs it)
|
||||
# For example ('services', True) -- > .../id_parent/services
|
||||
# ('services', False) --> ..../services
|
||||
custom_methods: typing.ClassVar[list[tuple[str, bool]]] = (
|
||||
[]
|
||||
) # If this model respond to "custom" methods, we will declare them here
|
||||
CUSTOM_METHODS: typing.ClassVar[list[types.rest.ModelCustomMethod]] = []
|
||||
|
||||
# If this model has details, which ones
|
||||
detail: typing.ClassVar[typing.Optional[dict[str, type['DetailHandler']]]] = (
|
||||
None # Dictionary containing detail routing
|
||||
)
|
||||
# Dictionary containing detail routing
|
||||
DETAIL: typing.ClassVar[typing.Optional[dict[str, type['DetailHandler[typing.Any]']]]] = None
|
||||
# Fields that are going to be saved directly
|
||||
# * If a field is in the form "field:default" and field is not present in the request, default will be used
|
||||
# * If the "default" is the string "None", then the default will be None
|
||||
# * If the "default" is _ (underscore), then the field will be ignored (not saved) if not present in the request
|
||||
# Note that these fields has to be present in the model, and they can be "edited" in the pre_save method
|
||||
save_fields: typing.ClassVar[list[str]] = []
|
||||
FIELDS_TO_SAVE: typing.ClassVar[list[str]] = []
|
||||
# Put removable fields before updating
|
||||
remove_fields: typing.ClassVar[list[str]] = []
|
||||
EXCLUDED_FIELDS: typing.ClassVar[list[str]] = []
|
||||
# Table info needed fields and title
|
||||
table_fields: typing.ClassVar[list[typing.Any]] = []
|
||||
table_row_style: typing.ClassVar[types.ui.RowStyleInfo] = types.ui.RowStyleInfo.null()
|
||||
table_title: typing.ClassVar[str] = ''
|
||||
table_subtitle: typing.ClassVar[str] = ''
|
||||
|
||||
TABLE: typing.ClassVar[types.rest.TableInfo] = types.rest.TableInfo.null()
|
||||
|
||||
# This methods must be override, depending on what is provided
|
||||
|
||||
# Data related
|
||||
def item_as_dict(self, item: models.Model) -> types.rest.ItemDictType:
|
||||
"""
|
||||
Must be overriden by descendants.
|
||||
Expects the return of an item as a dictionary
|
||||
"""
|
||||
return {}
|
||||
|
||||
def item_as_dict_overview(self, item: models.Model) -> dict[str, typing.Any]:
|
||||
"""
|
||||
Invoked when request is an "overview"
|
||||
default behavior is return item_as_dict
|
||||
"""
|
||||
return self.item_as_dict(item)
|
||||
|
||||
# types related
|
||||
def enum_types(self) -> collections.abc.Iterable[type['Module']]: # override this
|
||||
# def possible_types(cls: type[typing.Self]) -> collections.abc.Iterable[type[module.Module]]:
|
||||
@classmethod
|
||||
def possible_types(cls: type[typing.Self]) -> collections.abc.Iterable[type['Module']]: # override this
|
||||
"""
|
||||
Must be overriden by desdencents if they support types
|
||||
Excpetcs the list of types that the handler supports
|
||||
"""
|
||||
return []
|
||||
|
||||
def get_types(
|
||||
self, *args: typing.Any, **kwargs: typing.Any
|
||||
) -> typing.Generator[types.rest.TypeInfoDict, None, None]:
|
||||
for type_ in self.enum_types():
|
||||
yield self.type_as_dict(type_)
|
||||
def enum_types(self) -> typing.Generator[types.rest.TypeInfo, None, None]:
|
||||
for type_ in self.possible_types():
|
||||
yield type(self).as_typeinfo(type_)
|
||||
|
||||
def get_type(self, type_: str) -> types.rest.TypeInfoDict:
|
||||
found = None
|
||||
for v in self.get_types():
|
||||
if v['type'] == type_:
|
||||
found = v
|
||||
break
|
||||
def get_type(self, type_: str) -> types.rest.TypeInfo:
|
||||
for v in self.enum_types():
|
||||
if v.type == type_:
|
||||
return v
|
||||
|
||||
if found is None:
|
||||
raise exceptions.rest.NotFound('type not found')
|
||||
|
||||
logger.debug('Found type %s', found)
|
||||
return found
|
||||
raise exceptions.rest.NotFound('type not found')
|
||||
|
||||
# log related
|
||||
def get_logs(self, item: models.Model) -> list[dict[typing.Any, typing.Any]]:
|
||||
self.ensure_has_access(item, types.permissions.PermissionType.READ)
|
||||
self.check_access(item, types.permissions.PermissionType.READ)
|
||||
try:
|
||||
return log.get_logs(item)
|
||||
except Exception as e:
|
||||
@@ -163,10 +139,13 @@ class ModelHandler(BaseModelHandler):
|
||||
return []
|
||||
|
||||
# gui related
|
||||
def get_gui(self, type_: str) -> list[typing.Any]:
|
||||
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
|
||||
return []
|
||||
# raise self.invalidRequestException()
|
||||
|
||||
def get_processed_gui(self, for_type: str) -> list[types.ui.GuiElement]:
|
||||
return sorted(self.get_gui(for_type), key=lambda f: f.gui.order)
|
||||
|
||||
# Delete related, checks if the item can be deleted
|
||||
# If it can't be so, raises an exception
|
||||
def validate_delete(self, item: models.Model) -> None:
|
||||
@@ -192,7 +171,7 @@ class ModelHandler(BaseModelHandler):
|
||||
def process_detail(self) -> typing.Any:
|
||||
logger.debug('Processing detail %s for with params %s', self._path, self._params)
|
||||
try:
|
||||
item: models.Model = self.model.objects.get(uuid__iexact=self._args[0])
|
||||
item: models.Model = self.MODEL.objects.get(uuid__iexact=self._args[0])
|
||||
# If we do not have access to parent to, at least, read...
|
||||
|
||||
if self._operation in ('put', 'post', 'delete'):
|
||||
@@ -206,61 +185,71 @@ class ModelHandler(BaseModelHandler):
|
||||
self._user,
|
||||
required_permission,
|
||||
)
|
||||
raise self.access_denied_response()
|
||||
raise exceptions.rest.AccessDenied()
|
||||
|
||||
if not self.detail:
|
||||
raise self.invalid_request_response()
|
||||
if not self.DETAIL:
|
||||
raise exceptions.rest.NotFound('Detail not found')
|
||||
|
||||
# pylint: disable=unsubscriptable-object
|
||||
handler_type = self.detail[self._args[1]]
|
||||
handler_type = self.DETAIL[self._args[1]]
|
||||
args = list(self._args[2:])
|
||||
path = self._path + '/' + '/'.join(args[:2])
|
||||
detail_handler = handler_type(self, path, self._params, *args, parent=item, user=self._user)
|
||||
detail_handler = handler_type(self, path, self._params, *args, parent_item=item, user=self._user)
|
||||
method = getattr(detail_handler, self._operation)
|
||||
|
||||
return method()
|
||||
except self.model.DoesNotExist:
|
||||
raise self.invalid_item_response()
|
||||
except self.MODEL.DoesNotExist:
|
||||
raise exceptions.rest.NotFound('Item not found on model {self.MODEL.__name__}')
|
||||
except (KeyError, AttributeError) as e:
|
||||
raise self.invalid_method_response() from e
|
||||
raise exceptions.rest.InvalidMethodError(f'Invalid method {self._operation}') from e
|
||||
except exceptions.rest.HandlerError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error('Exception processing detail: %s', e)
|
||||
raise self.invalid_request_response() from e
|
||||
raise exceptions.rest.RequestError(f'Error processing detail: {e}') from e
|
||||
|
||||
# Data related
|
||||
def get_item(self, item: models.Model) -> types.rest.T_Item:
|
||||
"""
|
||||
Must be overriden by descendants.
|
||||
Expects the return of an item as a dictionary
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_item_summary(self, item: models.Model) -> types.rest.T_Item:
|
||||
"""
|
||||
Invoked when request is an "overview"
|
||||
default behavior is return item_as_dict
|
||||
"""
|
||||
return self.get_item(item)
|
||||
|
||||
def get_items(
|
||||
self, *args: typing.Any, **kwargs: typing.Any
|
||||
) -> typing.Generator[types.rest.ItemDictType, None, None]:
|
||||
if 'overview' in kwargs:
|
||||
overview = kwargs['overview']
|
||||
del kwargs['overview']
|
||||
self, *, overview: bool = False, query: QuerySet[T] | None = None
|
||||
) -> typing.Generator[types.rest.T_Item, None, None]:
|
||||
"""
|
||||
Get items from the model.
|
||||
Args:
|
||||
overview: If True, return a summary of the items.
|
||||
query: Optional queryset to filter the items. Used to optimize the process for some models
|
||||
(such as ServicePools)
|
||||
|
||||
"""
|
||||
|
||||
# Basic model filter
|
||||
if query:
|
||||
qs = query
|
||||
else:
|
||||
overview = True
|
||||
qs = self.MODEL.objects.all()
|
||||
if self.FILTER is not None:
|
||||
qs = qs.filter(**self.FILTER)
|
||||
if self.EXCLUDE is not None:
|
||||
qs = qs.exclude(**self.EXCLUDE)
|
||||
|
||||
if 'prefetch' in kwargs:
|
||||
prefetch = kwargs['prefetch']
|
||||
logger.debug('Prefetching %s', prefetch)
|
||||
del kwargs['prefetch']
|
||||
else:
|
||||
prefetch = []
|
||||
qs = self.filter_queryset(qs)
|
||||
|
||||
if 'query' in kwargs:
|
||||
query = kwargs['query'] # We are using a prebuilt query on args
|
||||
logger.debug('Got query: %s', query)
|
||||
del kwargs['query']
|
||||
else:
|
||||
logger.debug('Args: %s, kwargs: %s', args, kwargs)
|
||||
query = self.model.objects.filter(*args, **kwargs).prefetch_related(*prefetch)
|
||||
|
||||
if self.model_filter is not None:
|
||||
query = query.filter(**self.model_filter)
|
||||
|
||||
if self.model_exclude is not None:
|
||||
query = query.exclude(**self.model_exclude)
|
||||
|
||||
for item in query:
|
||||
for item in qs:
|
||||
try:
|
||||
# Note: Due to this, the response may not have the required elements, but a subset will be returned
|
||||
if (
|
||||
permissions.has_access(
|
||||
self._user,
|
||||
@@ -270,24 +259,12 @@ class ModelHandler(BaseModelHandler):
|
||||
is False
|
||||
):
|
||||
continue
|
||||
if overview:
|
||||
yield self.item_as_dict_overview(item)
|
||||
else:
|
||||
res = self.item_as_dict(item)
|
||||
self.fill_instance_fields(item, res)
|
||||
yield res
|
||||
yield self.get_item_summary(item) if overview else self.get_item(item)
|
||||
except Exception as e: # maybe an exception is thrown to skip an item
|
||||
logger.debug('Got exception processing item from model: %s', e)
|
||||
# logger.exception('Exception getting item from {0}'.format(self.model))
|
||||
|
||||
def get(self) -> typing.Any:
|
||||
"""
|
||||
Wraps real get method so we can process filters if they exists
|
||||
"""
|
||||
return self.process_get()
|
||||
|
||||
# pylint: disable=too-many-return-statements
|
||||
def process_get(self) -> typing.Any:
|
||||
logger.debug('method GET for %s, %s', self.__class__.__name__, self._args)
|
||||
number_of_args = len(self._args)
|
||||
|
||||
@@ -295,10 +272,10 @@ class ModelHandler(BaseModelHandler):
|
||||
return list(self.get_items(overview=False))
|
||||
|
||||
# if has custom methods, look for if this request matches any of them
|
||||
for cm in self.custom_methods:
|
||||
for cm in self.CUSTOM_METHODS:
|
||||
# Convert to snake case
|
||||
camel_case_name, snake_case_name = camel_and_snake_case_from(cm[0])
|
||||
if number_of_args > 1 and cm[1] is True: # Method needs parent (existing item)
|
||||
camel_case_name, snake_case_name = camel_and_snake_case_from(cm.name)
|
||||
if number_of_args > 1 and cm.needs_parent: # Method needs parent (existing item)
|
||||
if self._args[1] in (camel_case_name, snake_case_name):
|
||||
item = None
|
||||
# Check if operation method exists
|
||||
@@ -306,9 +283,9 @@ class ModelHandler(BaseModelHandler):
|
||||
try:
|
||||
if not operation:
|
||||
raise Exception() # Operation not found
|
||||
item = self.model.objects.get(uuid__iexact=self._args[0])
|
||||
except self.model.DoesNotExist:
|
||||
raise self.invalid_item_response()
|
||||
item = self.MODEL.objects.get(uuid__iexact=self._args[0])
|
||||
except self.MODEL.DoesNotExist:
|
||||
raise exceptions.rest.NotFound('Item not found') from None
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'Invalid custom method exception %s/%s/%s: %s',
|
||||
@@ -317,74 +294,63 @@ class ModelHandler(BaseModelHandler):
|
||||
self._params,
|
||||
e,
|
||||
)
|
||||
raise self.invalid_method_response()
|
||||
raise exceptions.rest.ResponseError(
|
||||
f'Error processing custom method: {self.__class__.__name__}/{self._args}'
|
||||
) from e
|
||||
|
||||
return operation(item)
|
||||
|
||||
elif self._args[0] in (snake_case_name, snake_case_name):
|
||||
operation = getattr(self, snake_case_name) or getattr(self, snake_case_name)
|
||||
if not operation:
|
||||
raise self.invalid_method_response()
|
||||
raise exceptions.rest.InvalidMethodError(f'Invalid method {self._operation}') from None
|
||||
|
||||
return operation()
|
||||
|
||||
if number_of_args == 1:
|
||||
if self._args[0] == consts.rest.OVERVIEW:
|
||||
return list(self.get_items())
|
||||
if self._args[0] == consts.rest.TYPES:
|
||||
return list(self.get_types())
|
||||
if self._args[0] == consts.rest.TABLEINFO:
|
||||
return self.process_table_fields(
|
||||
self.table_title,
|
||||
self.table_fields,
|
||||
self.table_row_style,
|
||||
self.table_subtitle,
|
||||
)
|
||||
if self._args[0] == consts.rest.GUI:
|
||||
return self.get_gui('')
|
||||
match self._args:
|
||||
case []: # Same as overview, but with all data
|
||||
return [i.as_dict() for i in self.get_items(overview=False)]
|
||||
case [consts.rest.OVERVIEW]:
|
||||
return [i.as_dict() for i in self.get_items()]
|
||||
case [consts.rest.OVERVIEW, *_fails]:
|
||||
raise exceptions.rest.RequestError('Invalid overview request') from None
|
||||
case [consts.rest.TABLEINFO]:
|
||||
return self.TABLE.as_dict()
|
||||
case [consts.rest.TABLEINFO, *_fails]:
|
||||
raise exceptions.rest.RequestError('Invalid table info request') from None
|
||||
case [consts.rest.TYPES]:
|
||||
return [i.as_dict() for i in self.enum_types()]
|
||||
case [consts.rest.TYPES, for_type]:
|
||||
return self.get_type(for_type).as_dict()
|
||||
case [consts.rest.TYPES, for_type, *_fails]:
|
||||
raise exceptions.rest.RequestError('Invalid type request') from None
|
||||
case [consts.rest.GUI]:
|
||||
return self.get_processed_gui('')
|
||||
case [consts.rest.GUI, for_type]:
|
||||
return self.get_processed_gui(for_type)
|
||||
case [consts.rest.GUI, for_type, *_fails]:
|
||||
raise exceptions.rest.RequestError('Invalid GUI request') from None
|
||||
case _: # Maybe an item or a detail
|
||||
if number_of_args == 1:
|
||||
try:
|
||||
item = self.MODEL.objects.get(uuid__iexact=self._args[0].lower())
|
||||
self.check_access(item, types.permissions.PermissionType.READ)
|
||||
return self.get_item(item).as_dict()
|
||||
except Exception as e:
|
||||
logger.exception('Got Exception looking for item')
|
||||
raise exceptions.rest.NotFound('Item not found') from e
|
||||
elif number_of_args == 2:
|
||||
if self._args[1] == consts.rest.LOG:
|
||||
try:
|
||||
item = self.MODEL.objects.get(uuid__iexact=self._args[0].lower())
|
||||
return self.get_logs(item)
|
||||
except Exception as e:
|
||||
raise exceptions.rest.NotFound('Item not found') from e
|
||||
|
||||
# get item ID
|
||||
try:
|
||||
item = self.model.objects.get(uuid__iexact=self._args[0].lower())
|
||||
if self.DETAIL is not None:
|
||||
return self.process_detail()
|
||||
|
||||
self.ensure_has_access(item, types.permissions.PermissionType.READ)
|
||||
|
||||
res = self.item_as_dict(item)
|
||||
self.fill_instance_fields(item, res)
|
||||
return res
|
||||
except Exception as e:
|
||||
logger.exception('Got Exception looking for item')
|
||||
raise self.invalid_item_response() from e
|
||||
|
||||
# nArgs > 1
|
||||
# Request type info or gui, or detail
|
||||
if self._args[0] == consts.rest.OVERVIEW:
|
||||
if number_of_args != 2:
|
||||
raise self.invalid_request_response()
|
||||
elif self._args[0] == consts.rest.TYPES:
|
||||
if number_of_args != 2:
|
||||
raise self.invalid_request_response()
|
||||
return self.get_type(self._args[1])
|
||||
elif self._args[0] == consts.rest.GUI:
|
||||
if number_of_args != 2:
|
||||
raise self.invalid_request_response()
|
||||
gui = self.get_gui(self._args[1])
|
||||
return sorted(gui, key=lambda f: f['gui']['order'])
|
||||
elif self._args[1] == consts.rest.LOG:
|
||||
if number_of_args != 2:
|
||||
raise self.invalid_request_response()
|
||||
try:
|
||||
# DB maybe case sensitive??, anyway, uuids are stored in lowercase
|
||||
item = self.model.objects.get(uuid__iexact=self._args[0].lower())
|
||||
return self.get_logs(item)
|
||||
except Exception as e:
|
||||
raise self.invalid_item_response() from e
|
||||
|
||||
# If has detail and is requesting detail
|
||||
if self.detail is not None:
|
||||
return self.process_detail()
|
||||
|
||||
raise self.invalid_request_response() # Will not return
|
||||
raise exceptions.rest.RequestError('Invalid request') from None
|
||||
|
||||
def post(self) -> typing.Any:
|
||||
"""
|
||||
@@ -396,7 +362,7 @@ class ModelHandler(BaseModelHandler):
|
||||
if self._args[0] == 'test':
|
||||
return self.test(self._args[1])
|
||||
|
||||
raise self.invalid_method_response() # Will not return
|
||||
raise exceptions.rest.InvalidMethodError(f'Invalid method {self._operation}') from None
|
||||
|
||||
def put(self) -> typing.Any:
|
||||
"""
|
||||
@@ -410,21 +376,21 @@ class ModelHandler(BaseModelHandler):
|
||||
|
||||
delete_on_error = False
|
||||
|
||||
if len(self._args) > 1: # Detail?
|
||||
if len(self._args) > 1: # Detail (1 arg means ID, more means detail/ID)?
|
||||
return self.process_detail()
|
||||
|
||||
# Here, self.model() indicates an "django model object with default params"
|
||||
self.ensure_has_access(
|
||||
self.model(), types.permissions.PermissionType.ALL, root=True
|
||||
self.check_access(
|
||||
self.MODEL(), types.permissions.PermissionType.ALL, root=True
|
||||
) # Must have write permissions to create, modify, etc..
|
||||
|
||||
try:
|
||||
# Extract fields
|
||||
args = self.fields_from_params(self.save_fields)
|
||||
args = self.fields_from_params(self.FIELDS_TO_SAVE)
|
||||
logger.debug('Args: %s', args)
|
||||
self.pre_save(args)
|
||||
# If tags is in save fields, treat it "specially"
|
||||
if 'tags' in self.save_fields:
|
||||
if 'tags' in self.FIELDS_TO_SAVE:
|
||||
tags = args['tags']
|
||||
del args['tags']
|
||||
else:
|
||||
@@ -433,12 +399,12 @@ class ModelHandler(BaseModelHandler):
|
||||
delete_on_error = False
|
||||
item: models.Model
|
||||
if not self._args: # create new?
|
||||
item = self.model.objects.create(**args)
|
||||
item = self.MODEL.objects.create(**args)
|
||||
delete_on_error = True
|
||||
else: # Must have 1 arg
|
||||
# We have to take care with this case, update will efectively update records on db
|
||||
item = self.model.objects.get(uuid__iexact=self._args[0].lower())
|
||||
for v in self.remove_fields:
|
||||
item = self.MODEL.objects.get(uuid__iexact=self._args[0].lower())
|
||||
for v in self.EXCLUDED_FIELDS:
|
||||
if v in args:
|
||||
del args[v]
|
||||
# Upadte fields from args
|
||||
@@ -464,12 +430,14 @@ class ModelHandler(BaseModelHandler):
|
||||
data_type: typing.Optional[str] = self._params.get('data_type', self._params.get('type'))
|
||||
if data_type:
|
||||
item.data_type = data_type
|
||||
item.data = item.get_instance(self._params).serialize()
|
||||
# TODO: Currently support parameters outside "instance". Will be removed after tests
|
||||
item.data = item.get_instance(
|
||||
self._params['instance'] if 'instance' in self._params else self._params
|
||||
).serialize()
|
||||
|
||||
item.save()
|
||||
|
||||
res = self.item_as_dict(item)
|
||||
self.fill_instance_fields(item, res)
|
||||
res = self.get_item(item)
|
||||
except Exception:
|
||||
logger.exception('Exception on put')
|
||||
if delete_on_error:
|
||||
@@ -478,9 +446,9 @@ class ModelHandler(BaseModelHandler):
|
||||
|
||||
self.post_save(item)
|
||||
|
||||
return res
|
||||
return res.as_dict()
|
||||
|
||||
except self.model.DoesNotExist:
|
||||
except self.MODEL.DoesNotExist:
|
||||
raise exceptions.rest.NotFound('Item not found') from None
|
||||
except IntegrityError: # Duplicate key probably
|
||||
raise exceptions.rest.RequestError('Element already exists (duplicate key error)') from None
|
||||
@@ -503,15 +471,15 @@ class ModelHandler(BaseModelHandler):
|
||||
if len(self._args) != 1:
|
||||
raise exceptions.rest.RequestError('Delete need one and only one argument')
|
||||
|
||||
self.ensure_has_access(
|
||||
self.model(), types.permissions.PermissionType.ALL, root=True
|
||||
self.check_access(
|
||||
self.MODEL(), types.permissions.PermissionType.ALL, root=True
|
||||
) # Must have write permissions to delete
|
||||
|
||||
try:
|
||||
item = self.model.objects.get(uuid__iexact=self._args[0].lower())
|
||||
item = self.MODEL.objects.get(uuid__iexact=self._args[0].lower())
|
||||
self.validate_delete(item)
|
||||
self.delete_item(item)
|
||||
except self.model.DoesNotExist:
|
||||
except self.MODEL.DoesNotExist:
|
||||
raise exceptions.rest.NotFound('Element do not exists') from None
|
||||
|
||||
return consts.OK
|
||||
@@ -521,3 +489,18 @@ class ModelHandler(BaseModelHandler):
|
||||
Basic, overridable method for deleting an item
|
||||
"""
|
||||
item.delete()
|
||||
|
||||
@classmethod
|
||||
def api_components(cls: type[typing.Self]) -> types.rest.api.Components:
|
||||
return api_utils.get_component_from_type(cls)
|
||||
|
||||
@classmethod
|
||||
def api_paths(
|
||||
cls: type[typing.Self], path: str, tags: list[str], security: str
|
||||
) -> dict[str, types.rest.api.PathItem]:
|
||||
"""
|
||||
Returns the API operations that should be registered
|
||||
"""
|
||||
from .api_helpers import api_paths
|
||||
|
||||
return api_paths(cls, path, tags=tags, security=security)
|
||||
205
server/src/uds/REST/model/master/api_helpers.py
Normal file
205
server/src/uds/REST/model/master/api_helpers.py
Normal file
@@ -0,0 +1,205 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (c) 2014-2023 Virtual Cable S.L.U.
|
||||
# All rights reserved.
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without modification,
|
||||
# are permitted provided that the following conditions are met:
|
||||
#
|
||||
# * Redistributions of source code must retain the above copyright notice,
|
||||
# this list of conditions and the following disclaimer.
|
||||
# * Redistributions in binary form must reproduce the above copyright notice,
|
||||
# this list of conditions and the following disclaimer in the documentation
|
||||
# and/or other materials provided with the distribution.
|
||||
# * Neither the name of Virtual Cable S.L.U. nor the names of its contributors
|
||||
# may be used to endorse or promote products derived from this software
|
||||
# without specific prior written permission.
|
||||
#
|
||||
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
"""
|
||||
Author: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
"""
|
||||
# pylint: disable=too-many-public-methods
|
||||
|
||||
import logging
|
||||
import typing
|
||||
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from uds.core import consts
|
||||
from uds.core import types
|
||||
from uds.core.util import api as api_utils
|
||||
|
||||
# Not imported at runtime, just for type checking
|
||||
if typing.TYPE_CHECKING:
|
||||
from uds.REST.model.master import ModelHandler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
T = typing.TypeVar('T', bound=models.Model)
|
||||
|
||||
|
||||
def api_paths(
|
||||
cls: type['ModelHandler[types.rest.T_Item]'], path: str, tags: list[str], security: str
|
||||
) -> dict[str, types.rest.api.PathItem]:
|
||||
"""
|
||||
Returns the API operations that should be registered
|
||||
"""
|
||||
|
||||
name = cls.REST_API_INFO.name if cls.REST_API_INFO.name else cls.MODEL.__name__
|
||||
get_tags = tags
|
||||
put_tags = tags # + ['Create', 'Modify']
|
||||
# post_tags = tags + ['Create']
|
||||
delete_tags = tags # + ['Delete']
|
||||
|
||||
base_type = next(iter(api_utils.get_generic_types(cls)), None)
|
||||
if base_type is None:
|
||||
logger.error('Base type not detected: %s', cls)
|
||||
return {} # Skip
|
||||
else:
|
||||
base_type_name = base_type.__name__
|
||||
|
||||
api_desc = {
|
||||
path: types.rest.api.PathItem(
|
||||
get=types.rest.api.Operation(
|
||||
summary=f'Get all {name} items',
|
||||
description=f'Retrieve a list of all {name} items',
|
||||
parameters=api_utils.gen_odata_parameters(),
|
||||
responses=api_utils.gen_response(base_type_name, single=False),
|
||||
tags=get_tags,
|
||||
security=security,
|
||||
),
|
||||
put=types.rest.api.Operation(
|
||||
summary=f'Creates a new {name} item',
|
||||
description=f'Creates a new, nonexisting {name} item',
|
||||
parameters=[],
|
||||
requestBody=api_utils.gen_request_body(base_type_name, create=True),
|
||||
responses=api_utils.gen_response(base_type_name, single=True),
|
||||
tags=put_tags,
|
||||
security=security,
|
||||
),
|
||||
),
|
||||
f'{path}/{{uuid}}': types.rest.api.PathItem(
|
||||
get=types.rest.api.Operation(
|
||||
summary=f'Get {name} item by UUID',
|
||||
description=f'Retrieve a {name} item by UUID',
|
||||
parameters=api_utils.gen_uuid_parameters(with_odata=True),
|
||||
responses=api_utils.gen_response(base_type_name, single=True),
|
||||
tags=get_tags,
|
||||
security=security,
|
||||
),
|
||||
put=types.rest.api.Operation(
|
||||
summary=f'Update {name} item by UUID',
|
||||
description=f'Update an existing {name} item by UUID',
|
||||
parameters=api_utils.gen_uuid_parameters(with_odata=False),
|
||||
requestBody=api_utils.gen_request_body(base_type_name, create=False),
|
||||
responses=api_utils.gen_response(base_type_name, single=True),
|
||||
tags=put_tags,
|
||||
security=security,
|
||||
),
|
||||
delete=types.rest.api.Operation(
|
||||
summary=f'Delete {name} item by UUID',
|
||||
description=f'Delete a {name} item by UUID',
|
||||
parameters=api_utils.gen_uuid_parameters(with_odata=False),
|
||||
responses=api_utils.gen_response(base_type_name, single=True),
|
||||
tags=delete_tags,
|
||||
security=security,
|
||||
),
|
||||
),
|
||||
f'{path}/{consts.rest.OVERVIEW}': types.rest.api.PathItem(
|
||||
get=types.rest.api.Operation(
|
||||
summary=f'Get overview of {name} items',
|
||||
description=f'Retrieve an overview of {name} items',
|
||||
parameters=api_utils.gen_odata_parameters(),
|
||||
responses=api_utils.gen_response(base_type_name, single=False),
|
||||
tags=get_tags,
|
||||
security=security,
|
||||
)
|
||||
),
|
||||
f'{path}/{consts.rest.TABLEINFO}': types.rest.api.PathItem(
|
||||
get=types.rest.api.Operation(
|
||||
summary=f'Get table info of {name} items',
|
||||
description=f'Retrieve table info of {name} items',
|
||||
parameters=[],
|
||||
responses=api_utils.gen_response('TableInfo', single=True),
|
||||
tags=get_tags,
|
||||
security=security,
|
||||
)
|
||||
),
|
||||
}
|
||||
if cls.REST_API_INFO.typed.is_single_type():
|
||||
api_desc[f'{path}/{consts.rest.GUI}'] = types.rest.api.PathItem(
|
||||
get=types.rest.api.Operation(
|
||||
summary=f'Get GUI representation of {name} items',
|
||||
description=f'Retrieve the GUI representation of {name} items',
|
||||
parameters=[],
|
||||
responses=api_utils.gen_response('GuiElement', single=False),
|
||||
tags=get_tags,
|
||||
security=security,
|
||||
)
|
||||
)
|
||||
|
||||
if cls.REST_API_INFO.typed.supports_multiple_types():
|
||||
api_desc.update(
|
||||
{
|
||||
f'{path}/{consts.rest.GUI}/{{type}}': types.rest.api.PathItem(
|
||||
get=types.rest.api.Operation(
|
||||
summary=f'Get GUI representation of {name} type',
|
||||
description=f'Retrieve a {name} GUI representation by type',
|
||||
parameters=[
|
||||
types.rest.api.Parameter(
|
||||
name='type',
|
||||
in_='path',
|
||||
required=True,
|
||||
description=f'The type of the {name} GUI representation',
|
||||
schema=types.rest.api.Schema(type='string'),
|
||||
)
|
||||
],
|
||||
responses=api_utils.gen_response('GuiElement', single=True),
|
||||
tags=get_tags,
|
||||
security=security,
|
||||
)
|
||||
),
|
||||
f'{path}/{consts.rest.TYPES}': types.rest.api.PathItem(
|
||||
get=types.rest.api.Operation(
|
||||
summary=f'Get types of {name} items',
|
||||
description=f'Retrieve types of {name} items',
|
||||
parameters=[],
|
||||
responses=api_utils.gen_response('TypeInfo', single=False),
|
||||
tags=get_tags,
|
||||
security=security,
|
||||
)
|
||||
),
|
||||
f'{path}/{consts.rest.TYPES}/{{type}}': types.rest.api.PathItem(
|
||||
get=types.rest.api.Operation(
|
||||
summary=f'Get {name} item by type',
|
||||
description=f'Retrieve a {name} item by type',
|
||||
parameters=[
|
||||
types.rest.api.Parameter(
|
||||
name='type',
|
||||
in_='path',
|
||||
required=True,
|
||||
description='The type of the item',
|
||||
schema=types.rest.api.Schema(type='string'),
|
||||
)
|
||||
],
|
||||
responses=api_utils.gen_response('TypeInfo', single=True),
|
||||
tags=get_tags,
|
||||
security=security,
|
||||
)
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
return api_desc
|
||||
@@ -31,16 +31,16 @@
|
||||
Author: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
"""
|
||||
import collections.abc
|
||||
import dataclasses
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import typing
|
||||
|
||||
from django.http import HttpResponse
|
||||
from django.utils.functional import Promise as DjangoPromise
|
||||
|
||||
from uds.core import consts
|
||||
from uds.core import consts, types
|
||||
|
||||
from .utils import to_incremental_json
|
||||
|
||||
@@ -65,9 +65,14 @@ class ContentProcessor:
|
||||
extensions: typing.ClassVar[collections.abc.Iterable[str]] = []
|
||||
|
||||
_request: 'HttpRequest'
|
||||
_odata: 'types.rest.api.ODataParams|None' = None
|
||||
|
||||
def __init__(self, request: 'HttpRequest'):
|
||||
self._request = request
|
||||
self._odata = None
|
||||
|
||||
def set_odata(self, odata: 'types.rest.api.ODataParams') -> None:
|
||||
self._odata = odata
|
||||
|
||||
def process_get_parameters(self) -> dict[str, typing.Any]:
|
||||
"""
|
||||
@@ -105,38 +110,63 @@ class ContentProcessor:
|
||||
yield self.render(obj).encode('utf8')
|
||||
|
||||
@staticmethod
|
||||
def process_for_render(obj: typing.Any) -> typing.Any:
|
||||
def process_for_render(
|
||||
obj: typing.Any,
|
||||
data_transformer: collections.abc.Callable[[dict[str, typing.Any]], dict[str, typing.Any]],
|
||||
) -> typing.Any:
|
||||
"""
|
||||
Helper for renderers. Alters some types so they can be serialized correctly (as we want them to be)
|
||||
"""
|
||||
if obj is None or isinstance(obj, (bool, int, float, str)):
|
||||
return obj
|
||||
match obj:
|
||||
case types.rest.BaseRestItem():
|
||||
return ContentProcessor.process_for_render(obj.as_dict(), data_transformer)
|
||||
# Dataclass
|
||||
case None | bool() | int() | float() | str():
|
||||
return obj
|
||||
case dict():
|
||||
return data_transformer(
|
||||
{
|
||||
k: ContentProcessor.process_for_render(v, data_transformer)
|
||||
for k, v in typing.cast(dict[str, typing.Any], obj).items()
|
||||
if not isinstance(v, types.rest.NotRequired) # Skip
|
||||
}
|
||||
)
|
||||
|
||||
if isinstance(obj, DjangoPromise):
|
||||
return str(obj) # This is for translations
|
||||
case DjangoPromise():
|
||||
return str(obj) # This is for translations
|
||||
|
||||
if isinstance(obj, dict):
|
||||
return {
|
||||
k: ContentProcessor.process_for_render(v)
|
||||
for k, v in typing.cast(dict[str, typing.Any], obj).items()
|
||||
}
|
||||
case bytes():
|
||||
return obj.decode('utf-8')
|
||||
|
||||
if isinstance(obj, bytes):
|
||||
return obj.decode('utf-8')
|
||||
case collections.abc.Iterable():
|
||||
return [
|
||||
ContentProcessor.process_for_render(v, data_transformer)
|
||||
for v in typing.cast(collections.abc.Iterable[typing.Any], obj)
|
||||
]
|
||||
|
||||
if isinstance(obj, collections.abc.Iterable):
|
||||
return [
|
||||
ContentProcessor.process_for_render(v)
|
||||
for v in typing.cast(collections.abc.Iterable[typing.Any], obj)
|
||||
]
|
||||
case datetime.datetime():
|
||||
return int(obj.timestamp())
|
||||
|
||||
if isinstance(obj, (datetime.datetime,)): # Datetime as timestamp
|
||||
return int(time.mktime(obj.timetuple()))
|
||||
case datetime.date():
|
||||
return '{}-{:02d}-{:02d}'.format(obj.year, obj.month, obj.day)
|
||||
|
||||
if isinstance(obj, (datetime.date,)): # Date as string
|
||||
return '{}-{:02d}-{:02d}'.format(obj.year, obj.month, obj.day)
|
||||
case _:
|
||||
# Any class with as_dict method shoud be processed
|
||||
if as_dict := getattr(obj, 'as_dict', None):
|
||||
try:
|
||||
obj = as_dict()
|
||||
return ContentProcessor.process_for_render(obj, data_transformer)
|
||||
except Exception as e:
|
||||
# Maybe the as_dict method is not implemented as we expect.. should not happen
|
||||
logger.warning('Obj has as_dict method but failed to call it: %s', e)
|
||||
# Will return obj as str in this case, or if it is a dataclass, can return as dict
|
||||
|
||||
return str(obj)
|
||||
if dataclasses.is_dataclass(obj):
|
||||
# If already has a "as_dict" method, use it, and if not, default
|
||||
obj = dataclasses.asdict(typing.cast(typing.Any, obj))
|
||||
return ContentProcessor.process_for_render(obj, data_transformer)
|
||||
|
||||
return str(obj)
|
||||
|
||||
|
||||
class MarshallerProcessor(ContentProcessor):
|
||||
@@ -169,7 +199,11 @@ class MarshallerProcessor(ContentProcessor):
|
||||
raise ParametersException(str(e))
|
||||
|
||||
def render(self, obj: typing.Any) -> str:
|
||||
return self.marshaller.dumps(ContentProcessor.process_for_render(obj))
|
||||
def none_transformer(dct: dict[str, typing.Any]) -> dict[str, typing.Any]:
|
||||
return dct
|
||||
|
||||
dct_filter = none_transformer if self._odata is None else self._odata.select_filter
|
||||
return self.marshaller.dumps(ContentProcessor.process_for_render(obj, dct_filter))
|
||||
|
||||
|
||||
# ---------------
|
||||
|
||||
@@ -34,7 +34,6 @@ import logging
|
||||
from django.http import HttpResponse
|
||||
from django.middleware import csrf
|
||||
from django.shortcuts import render
|
||||
from django.template import RequestContext, loader
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from uds.core import consts
|
||||
@@ -46,7 +45,7 @@ if typing.TYPE_CHECKING:
|
||||
from django.http import HttpRequest
|
||||
|
||||
|
||||
@weblogin_required(admin=True)
|
||||
@weblogin_required(role=consts.UserRole.ADMIN)
|
||||
def index(request: 'HttpRequest') -> HttpResponse:
|
||||
# Gets csrf token
|
||||
csrf_token = csrf.get_token(request)
|
||||
@@ -57,21 +56,14 @@ def index(request: 'HttpRequest') -> HttpResponse:
|
||||
{'csrf_field': consts.auth.CSRF_FIELD, 'csrf_token': csrf_token},
|
||||
)
|
||||
|
||||
|
||||
# Samples, not used in fact from anywhere
|
||||
# Usef for reference
|
||||
@weblogin_required(admin=True)
|
||||
def tmpl(request: 'HttpRequest', template: str) -> HttpResponse:
|
||||
try:
|
||||
t = loader.get_template('uds/admin/tmpl/' + template + ".html")
|
||||
c = RequestContext(request)
|
||||
resp = t.render(c.flatten())
|
||||
except Exception as e:
|
||||
logger.debug('Exception getting template: %s', e)
|
||||
resp = _('requested a template that do not exist')
|
||||
return HttpResponse(resp, content_type="text/plain")
|
||||
|
||||
|
||||
@weblogin_required(admin=True)
|
||||
def sample(request: 'HttpRequest') -> HttpResponse:
|
||||
return render(request, 'uds/admin/sample.html')
|
||||
# from django.template import RequestContext, loader
|
||||
# @weblogin_required(role=consts.Roles.ADMIN)
|
||||
# def tmpl(request: 'HttpRequest', template: str) -> HttpResponse:
|
||||
# try:
|
||||
# t = loader.get_template('uds/admin/tmpl/' + template + ".html")
|
||||
# c = RequestContext(request)
|
||||
# resp = t.render(c.flatten())
|
||||
# except Exception as e:
|
||||
# logger.debug('Exception getting template: %s', e)
|
||||
# resp = _('requested a template that do not exist')
|
||||
# return HttpResponse(resp, content_type="text/plain")
|
||||
|
||||
@@ -119,19 +119,8 @@ class OAuth2Authenticator(auths.Authenticator):
|
||||
required=True,
|
||||
default='code',
|
||||
choices=[
|
||||
{'id': v, 'text': v.as_text}
|
||||
gui.choice_item(v, v.as_text)
|
||||
for v in oauth2_types.ResponseType
|
||||
# {'id': 'code', 'text': _('Code (authorization code flow)')},
|
||||
# {'id': 'pkce', 'text': _('PKCE (authorization code flow with PKCE)')},
|
||||
# {'id': 'token', 'text': _('Token (implicit flow)')},
|
||||
# {
|
||||
# 'id': 'openid+token_id',
|
||||
# 'text': _('OpenID Connect Token (implicit flow with OpenID Connect)'),
|
||||
# },
|
||||
# {
|
||||
# 'id': 'openid+code',
|
||||
# 'text': _('OpenID Connect Code (authorization code flow with OpenID Connect)'),
|
||||
# },
|
||||
],
|
||||
tab=types.ui.Tab.ADVANCED,
|
||||
)
|
||||
|
||||
@@ -163,7 +163,7 @@ class RegexLdap(auths.Authenticator):
|
||||
# Label for password field
|
||||
label_password = _("Password")
|
||||
|
||||
_connection: typing.Optional['ldaputil.LDAPObject'] = None
|
||||
_connection: typing.Optional['ldaputil.LDAPConnection'] = None
|
||||
|
||||
def initialize(self, values: typing.Optional[dict[str, typing.Any]]) -> None:
|
||||
if values:
|
||||
@@ -235,7 +235,7 @@ class RegexLdap(auths.Authenticator):
|
||||
|
||||
self.mark_for_upgrade() # Old version, so flag for upgrade if possible
|
||||
|
||||
def _stablish_connection(self) -> 'ldaputil.LDAPObject':
|
||||
def _stablish_connection(self) -> 'ldaputil.LDAPConnection':
|
||||
"""
|
||||
Tries to connect to ldap. If username is None, it tries to connect using user provided credentials.
|
||||
@return: Connection established
|
||||
@@ -254,7 +254,7 @@ class RegexLdap(auths.Authenticator):
|
||||
|
||||
return self._connection
|
||||
|
||||
def _stablish_connection_as(self, username: str, password: str) -> 'ldaputil.LDAPObject':
|
||||
def _stablish_connection_as(self, username: str, password: str) -> 'ldaputil.LDAPConnection':
|
||||
return ldaputil.connection(
|
||||
username,
|
||||
password,
|
||||
|
||||
@@ -486,6 +486,8 @@ class SAMLAuthenticator(auths.Authenticator):
|
||||
verify=self.check_https_certificate.as_bool(),
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
raise Exception(f'Invalid response code: {resp.status_code} ({resp.content})')
|
||||
val = resp.content.decode()
|
||||
# 10 years, unless edited the metadata will be kept
|
||||
self.cache.put('idpMetadata', val, 86400 * 365 * 10)
|
||||
@@ -657,7 +659,22 @@ class SAMLAuthenticator(auths.Authenticator):
|
||||
raise exceptions.auth.AuthenticatorException(gettext('Error processing SAML response: ') + str(e))
|
||||
errors = typing.cast(list[str], auth.get_errors())
|
||||
if errors:
|
||||
raise exceptions.auth.AuthenticatorException('SAML response error: ' + str(errors))
|
||||
logger.debug('Errors processing SAML response: %s (%s)', errors, auth.# The above code
|
||||
# seems to be a
|
||||
# comment in Python.
|
||||
# It is not
|
||||
# performing any
|
||||
# action in the code
|
||||
# but is simply
|
||||
# providing a
|
||||
# description or
|
||||
# note about the
|
||||
# purpose of the
|
||||
# code that follows.
|
||||
get_last_error_reason()) # pyright: ignore reportUnknownVariableType
|
||||
logger.debug('post_data: %s', req['post_data'])
|
||||
logger.info('Response XML: %s', auth.get_last_response_xml()) # pyright: ignore reportUnknownVariableType
|
||||
raise exceptions.auth.AuthenticatorException(f'SAML response error: {errors} ({auth.get_last_error_reason()})')
|
||||
|
||||
if not auth.is_authenticated():
|
||||
raise exceptions.auth.AuthenticatorException(gettext('SAML response not authenticated'))
|
||||
|
||||
@@ -33,14 +33,13 @@ import logging
|
||||
import typing
|
||||
import collections.abc
|
||||
|
||||
import ldap # pyright: ignore # Needed to import ldap.filter without errors
|
||||
import ldap.filter
|
||||
from uds.core.util import ldaputil
|
||||
from django.utils.translation import gettext_noop as _
|
||||
|
||||
from uds.core import auths, environment, types, exceptions
|
||||
from uds.core.auths.auth import log_login
|
||||
from uds.core.ui import gui
|
||||
from uds.core.util import ensure, fields, ldaputil, validators, auth as auth_utils
|
||||
from uds.core.util import fields, ldaputil, validators, auth as auth_utils
|
||||
|
||||
# Not imported at runtime, just for type checking
|
||||
if typing.TYPE_CHECKING:
|
||||
@@ -183,7 +182,7 @@ class SimpleLDAPAuthenticator(auths.Authenticator):
|
||||
# Label for password field
|
||||
label_password = _("Password")
|
||||
|
||||
_connection: typing.Optional['ldaputil.LDAPObject'] = None
|
||||
_connection: typing.Optional['ldaputil.LDAPConnection'] = None
|
||||
|
||||
def initialize(self, values: typing.Optional[dict[str, typing.Any]]) -> None:
|
||||
if values:
|
||||
@@ -232,51 +231,54 @@ class SimpleLDAPAuthenticator(auths.Authenticator):
|
||||
def mfa_identifier(self, username: str) -> str:
|
||||
return self.storage.read_pickled(self.mfa_storage_key(username)) or ''
|
||||
|
||||
def _get_connection(self) -> 'ldaputil.LDAPObject':
|
||||
def _get_connection(self) -> 'ldaputil.LDAPConnection':
|
||||
"""
|
||||
Tries to connect to ldap. If username is None, it tries to connect using user provided credentials.
|
||||
@return: Connection established
|
||||
@raise exception: If connection could not be established
|
||||
Tries to connect to LDAP using ldaputil. If username is None, it tries to connect using user provided credentials.
|
||||
Returns:
|
||||
Connection established
|
||||
Raises:
|
||||
Exception if connection could not be established
|
||||
"""
|
||||
if self._connection is None: # We are not connected
|
||||
if self._connection is None:
|
||||
self._connection = ldaputil.connection(
|
||||
self.username.as_str(),
|
||||
self.password.as_str(),
|
||||
self.host.as_str(),
|
||||
username=self.username.as_str(),
|
||||
passwd=self.password.as_str(),
|
||||
host=self.host.as_str(),
|
||||
port=self.port.as_int(),
|
||||
ssl=self.use_ssl.as_bool(),
|
||||
use_ssl=self.use_ssl.as_bool(),
|
||||
timeout=self.timeout.as_int(),
|
||||
debug=False,
|
||||
verify_ssl=self.verify_ssl.as_bool(),
|
||||
certificate=self.certificate.as_str(),
|
||||
certificate_data=self.certificate.as_str(),
|
||||
)
|
||||
|
||||
return self._connection
|
||||
|
||||
def _connect_as(self, username: str, password: str) -> typing.Any:
|
||||
return ldaputil.connection(
|
||||
username,
|
||||
password,
|
||||
self.host.as_str(),
|
||||
username=username,
|
||||
passwd=password,
|
||||
host=self.host.as_str(),
|
||||
port=self.port.as_int(),
|
||||
ssl=self.use_ssl.as_bool(),
|
||||
use_ssl=self.use_ssl.as_bool(),
|
||||
timeout=self.timeout.as_int(),
|
||||
debug=False,
|
||||
verify_ssl=self.verify_ssl.as_bool(),
|
||||
certificate=self.certificate.as_str(),
|
||||
certificate_data=self.certificate.as_str(),
|
||||
)
|
||||
|
||||
def _get_user(self, username: str) -> typing.Optional[ldaputil.LDAPResultType]:
|
||||
"""
|
||||
Searchs for the username and returns its LDAP entry
|
||||
@param username: username to search, using user provided parameters at configuration to map search entries.
|
||||
@return: None if username is not found, an dictionary of LDAP entry attributes if found.
|
||||
@note: Active directory users contains the groups it belongs to in "memberOf" attribute
|
||||
Searches for the username and returns its LDAP entry.
|
||||
Args:
|
||||
username: username to search, using user provided parameters at configuration to map search entries.
|
||||
Returns:
|
||||
None if username is not found, a dictionary of LDAP entry attributes if found.
|
||||
Note:
|
||||
Active directory users contain the groups it belongs to in "memberOf" attribute
|
||||
"""
|
||||
attributes = self.username_attr.as_str().split(',') + [self.user_id_attr.as_str()]
|
||||
if self.mfa_attribute.as_str():
|
||||
attributes = attributes + [self.mfa_attribute.as_str()]
|
||||
|
||||
return ldaputil.first(
|
||||
con=self._get_connection(),
|
||||
base=self.ldap_base.as_str(),
|
||||
@@ -289,13 +291,11 @@ class SimpleLDAPAuthenticator(auths.Authenticator):
|
||||
|
||||
def _get_group(self, groupname: str) -> typing.Optional[ldaputil.LDAPResultType]:
|
||||
"""
|
||||
Searchs for the groupname and returns its LDAP entry
|
||||
|
||||
Searches for the groupname and returns its LDAP entry.
|
||||
Args:
|
||||
groupname (str): groupname to search, using user provided parameters at configuration to map search entries.
|
||||
|
||||
Returns:
|
||||
typing.Optional[ldaputil.LDAPResultType]: None if groupname is not found, an dictionary of LDAP entry attributes if found.
|
||||
typing.Optional[ldaputil.LDAPResultType]: None if groupname is not found, a dictionary of LDAP entry attributes if found.
|
||||
"""
|
||||
return ldaputil.first(
|
||||
con=self._get_connection(),
|
||||
@@ -309,17 +309,14 @@ class SimpleLDAPAuthenticator(auths.Authenticator):
|
||||
|
||||
def _get_groups(self, user: ldaputil.LDAPResultType) -> list[str]:
|
||||
"""
|
||||
Searchs for the groups the user belongs to and returns a list of group names
|
||||
|
||||
Searches for the groups the user belongs to and returns a list of group names.
|
||||
Args:
|
||||
user (ldaputil.LDAPResultType): The user to search for groups
|
||||
|
||||
Returns:
|
||||
list[str]: A list of group names the user belongs to
|
||||
"""
|
||||
try:
|
||||
groups: list[str] = []
|
||||
|
||||
filter_ = f'(&(objectClass={self.group_class.as_str()})(|({self.member_attr.as_str()}={user["_id"]})({self.member_attr.as_str()}={user["dn"]})))'
|
||||
for d in ldaputil.as_dict(
|
||||
con=self._get_connection(),
|
||||
@@ -331,19 +328,17 @@ class SimpleLDAPAuthenticator(auths.Authenticator):
|
||||
if self.group_id_attr.as_str() in d:
|
||||
for k in d[self.group_id_attr.as_str()]:
|
||||
groups.append(k)
|
||||
|
||||
logger.debug('Groups: %s', groups)
|
||||
return groups
|
||||
|
||||
except Exception:
|
||||
logger.exception('Exception at __getGroups')
|
||||
logger.exception('Exception at _get_groups')
|
||||
return []
|
||||
|
||||
def _get_user_realname(self, user: ldaputil.LDAPResultType) -> str:
|
||||
'''
|
||||
Tries to extract the real name for this user. Will return all atttributes (joint)
|
||||
specified in _userNameAttr (comma separated).
|
||||
'''
|
||||
"""
|
||||
Tries to extract the real name for this user. Will return all attributes (joined)
|
||||
specified in username_attr (comma separated).
|
||||
"""
|
||||
return ' '.join(auth_utils.process_regex_field(self.username_attr.value, user))
|
||||
|
||||
def authenticate(
|
||||
@@ -353,41 +348,26 @@ class SimpleLDAPAuthenticator(auths.Authenticator):
|
||||
groups_manager: 'auths.GroupsManager',
|
||||
request: 'ExtendedHttpRequest',
|
||||
) -> types.auth.AuthenticationResult:
|
||||
'''
|
||||
Must authenticate the user.
|
||||
We can have to different situations here:
|
||||
1.- The authenticator is external source, what means that users may be unknown to system before callig this
|
||||
2.- The authenticator isn't external source, what means that users have been manually added to system and are known before this call
|
||||
We receive the username, the credentials used (normally password, but can be a public key or something related to pk) and a group manager.
|
||||
The group manager is responsible for letting know the authenticator which groups we currently has active.
|
||||
@see: uds.core.auths.groups_manager
|
||||
'''
|
||||
"""
|
||||
Authenticates the user using ldaputil.
|
||||
"""
|
||||
try:
|
||||
# Locate the user at LDAP
|
||||
user = self._get_user(username)
|
||||
|
||||
if user is None:
|
||||
log_login(request, self.db_obj(), username, 'Invalid user', as_error=True)
|
||||
return types.auth.FAILED_AUTH
|
||||
|
||||
try:
|
||||
# Let's see first if it credentials are fine
|
||||
self._connect_as(user['dn'], credentials) # Will raise an exception if it can't connect
|
||||
self._connect_as(user['dn'], credentials)
|
||||
except Exception:
|
||||
log_login(request, self.db_obj(), username, 'Invalid password', as_error=True)
|
||||
return types.auth.FAILED_AUTH
|
||||
|
||||
# store the user mfa attribute if it is set
|
||||
if self.mfa_attribute.as_str():
|
||||
self.storage.save_pickled(
|
||||
self.mfa_storage_key(username),
|
||||
user[self.mfa_attribute.as_str()][0],
|
||||
)
|
||||
|
||||
groups_manager.validate(self._get_groups(user))
|
||||
|
||||
return types.auth.SUCCESS_AUTH
|
||||
|
||||
except Exception:
|
||||
return types.auth.FAILED_AUTH
|
||||
|
||||
@@ -470,120 +450,104 @@ class SimpleLDAPAuthenticator(auths.Authenticator):
|
||||
except Exception as e:
|
||||
return types.core.TestResult(False, str(e))
|
||||
|
||||
# Test base search
|
||||
try:
|
||||
con.search_s( # pyright: ignore reportGeneralTypeIssues
|
||||
base=self.ldap_base.as_str(),
|
||||
scope=ldaputil.SCOPE_BASE, # pyright: ignore reportGeneralTypeIssues
|
||||
)
|
||||
|
||||
next(ldaputil.as_dict(
|
||||
con,
|
||||
self.ldap_base.as_str(),
|
||||
'(objectClass=*)',
|
||||
limit=1,
|
||||
scope=ldaputil.SCOPE_BASE,
|
||||
))
|
||||
except Exception:
|
||||
return types.core.TestResult(False, _('Ldap search base is incorrect'))
|
||||
|
||||
# Test user class
|
||||
try:
|
||||
if (
|
||||
len(
|
||||
ensure.as_list(
|
||||
con.search_ext_s( # pyright: ignore reportGeneralTypeIssues
|
||||
base=self.ldap_base.as_str(),
|
||||
scope=ldaputil.SCOPE_SUBTREE, # pyright: ignore reportGeneralTypeIssues
|
||||
filterstr=f'(objectClass={self.user_class.as_str()})',
|
||||
sizelimit=1,
|
||||
)
|
||||
)
|
||||
)
|
||||
!= 1
|
||||
):
|
||||
raise Exception(_('Ldap user class seems to be incorrect (no user found by that class)'))
|
||||
count = sum(1 for _ in ldaputil.as_dict(
|
||||
con,
|
||||
self.ldap_base.as_str(),
|
||||
f'(objectClass={self.user_class.as_str()})',
|
||||
limit=1,
|
||||
scope=ldaputil.SCOPE_SUBTREE,
|
||||
))
|
||||
if count == 0:
|
||||
return types.core.TestResult(False, _('Ldap user class seems to be incorrect (no user found by that class)'))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if (
|
||||
len(
|
||||
ensure.as_list(
|
||||
con.search_ext_s( # pyright: ignore reportGeneralTypeIssues
|
||||
base=self.ldap_base.as_str(),
|
||||
scope=ldaputil.SCOPE_SUBTREE, # pyright: ignore reportGeneralTypeIssues
|
||||
filterstr=f'(objectClass={self.group_class.as_str()})',
|
||||
sizelimit=1,
|
||||
)
|
||||
)
|
||||
)
|
||||
!= 1
|
||||
):
|
||||
raise Exception(_('Ldap group class seems to be incorrect (no group found by that class)'))
|
||||
# Test group class
|
||||
try:
|
||||
count = sum(1 for _ in ldaputil.as_dict(
|
||||
con,
|
||||
self.ldap_base.as_str(),
|
||||
f'(objectClass={self.group_class.as_str()})',
|
||||
limit=1,
|
||||
scope=ldaputil.SCOPE_SUBTREE,
|
||||
))
|
||||
if count == 0:
|
||||
return types.core.TestResult(False, _('Ldap group class seems to be incorrect (no group found by that class)'))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if (
|
||||
len(
|
||||
ensure.as_list(
|
||||
con.search_ext_s( # pyright: ignore reportGeneralTypeIssues
|
||||
base=self.ldap_base.as_str(),
|
||||
scope=ldaputil.SCOPE_SUBTREE, # pyright: ignore reportGeneralTypeIssues
|
||||
filterstr=f'({self.user_id_attr.as_str()}=*)',
|
||||
sizelimit=1,
|
||||
)
|
||||
)
|
||||
)
|
||||
!= 1
|
||||
):
|
||||
raise Exception(
|
||||
_('Ldap user id attribute seems to be incorrect (no user found by that attribute)')
|
||||
)
|
||||
# Test user id attribute
|
||||
try:
|
||||
count = sum(1 for _ in ldaputil.as_dict(
|
||||
con,
|
||||
self.ldap_base.as_str(),
|
||||
f'({self.user_id_attr.as_str()}=*)',
|
||||
limit=1,
|
||||
scope=ldaputil.SCOPE_SUBTREE,
|
||||
))
|
||||
if count == 0:
|
||||
return types.core.TestResult(False, _('Ldap user id attribute seems to be incorrect (no user found by that attribute)'))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if (
|
||||
len(
|
||||
ensure.as_list(
|
||||
con.search_ext_s( # pyright: ignore reportGeneralTypeIssues
|
||||
base=self.ldap_base.as_str(),
|
||||
scope=ldaputil.SCOPE_SUBTREE, # pyright: ignore reportGeneralTypeIssues
|
||||
filterstr=f'({self.group_id_attr.as_str()}=*)',
|
||||
sizelimit=1,
|
||||
)
|
||||
)
|
||||
)
|
||||
!= 1
|
||||
):
|
||||
raise Exception(
|
||||
_('Ldap group id attribute seems to be incorrect (no group found by that attribute)')
|
||||
)
|
||||
# Test group id attribute
|
||||
try:
|
||||
count = sum(1 for _ in ldaputil.as_dict(
|
||||
con,
|
||||
self.ldap_base.as_str(),
|
||||
f'({self.group_id_attr.as_str()}=*)',
|
||||
limit=1,
|
||||
scope=ldaputil.SCOPE_SUBTREE,
|
||||
))
|
||||
if count == 0:
|
||||
return types.core.TestResult(False, _('Ldap group id attribute seems to be incorrect (no group found by that attribute)'))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if (
|
||||
len(
|
||||
ensure.as_list(
|
||||
con.search_ext_s( # pyright: ignore reportGeneralTypeIssues
|
||||
base=self.ldap_base.as_str(),
|
||||
scope=ldaputil.SCOPE_SUBTREE, # pyright: ignore reportGeneralTypeIssues
|
||||
filterstr=f'(&(objectClass={self.user_class.as_str()})({self.user_id_attr.as_str()}=*))',
|
||||
sizelimit=1,
|
||||
)
|
||||
)
|
||||
)
|
||||
!= 1
|
||||
):
|
||||
raise Exception(
|
||||
_(
|
||||
'Ldap user class or user id attr is probably wrong (can\'t find any user with both conditions)'
|
||||
)
|
||||
)
|
||||
# Test user class and user id attribute together
|
||||
try:
|
||||
count = sum(1 for _ in ldaputil.as_dict(
|
||||
con,
|
||||
self.ldap_base.as_str(),
|
||||
f'(&(objectClass={self.user_class.as_str()})({self.user_id_attr.as_str()}=*))',
|
||||
limit=1,
|
||||
scope=ldaputil.SCOPE_SUBTREE,
|
||||
))
|
||||
if count == 0:
|
||||
return types.core.TestResult(False, _('Ldap user class or user id attr is probably wrong (can\'t find any user with both conditions)'))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
res = ensure.as_list(
|
||||
con.search_ext_s( # pyright: ignore reportGeneralTypeIssues
|
||||
base=self.ldap_base.as_str(),
|
||||
scope=ldaputil.SCOPE_SUBTREE, # pyright: ignore reportGeneralTypeIssues
|
||||
filterstr=f'(&(objectClass={self.group_class.as_str()})({self.group_id_attr.as_str()}=*))',
|
||||
attrlist=[self.member_attr.as_str()],
|
||||
)
|
||||
)
|
||||
if not res:
|
||||
raise Exception(
|
||||
_(
|
||||
'Ldap group class or group id attr is probably wrong (can\'t find any group with both conditions)'
|
||||
)
|
||||
)
|
||||
ok = False
|
||||
for r in res:
|
||||
if self.member_attr.as_str() in r[1]:
|
||||
ok = True
|
||||
# Test group class and group id attribute together
|
||||
try:
|
||||
found = False
|
||||
for r in ldaputil.as_dict(
|
||||
con,
|
||||
self.ldap_base.as_str(),
|
||||
f'(&(objectClass={self.group_class.as_str()})({self.group_id_attr.as_str()}=*))',
|
||||
attributes=[self.member_attr.as_str()],
|
||||
limit=LDAP_RESULT_LIMIT,
|
||||
scope=ldaputil.SCOPE_SUBTREE,
|
||||
):
|
||||
if self.member_attr.as_str() in r:
|
||||
found = True
|
||||
break
|
||||
if ok is False:
|
||||
raise Exception(_('Can\'t locate any group with the membership attribute specified'))
|
||||
if not found:
|
||||
return types.core.TestResult(False, _('Can\'t locate any group with the membership attribute specified'))
|
||||
except Exception as e:
|
||||
return types.core.TestResult(False, str(e))
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (c) 2012-2021 Virtual Cable S.L.U.
|
||||
# Copyright (c) 2012-2025 Virtual Cable S.L.U.
|
||||
# All rights reserved.
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without modification,
|
||||
@@ -124,17 +124,14 @@ def root_user() -> models.User:
|
||||
|
||||
# Decorator to make easier protect pages that needs to be logged in
|
||||
def weblogin_required(
|
||||
admin: typing.Union[bool, typing.Literal['admin']] = False,
|
||||
role: typing.Optional[consts.UserRole] = None,
|
||||
) -> collections.abc.Callable[
|
||||
[collections.abc.Callable[..., HttpResponse]], collections.abc.Callable[..., HttpResponse]
|
||||
]:
|
||||
"""Decorator to set protection to access page
|
||||
Look for samples at uds.core.web.views
|
||||
if admin == True, needs admin or staff
|
||||
if admin == 'admin', needs admin
|
||||
|
||||
|
||||
Args:
|
||||
admin (bool, optional): If True, needs admin or staff. Is it's "admin" literal, needs admin . Defaults to False (any user).
|
||||
role (str, optional): If set, needs this role. Defaults to None.
|
||||
|
||||
Returns:
|
||||
collections.abc.Callable[[collections.abc.Callable[..., HttpResponse]], collections.abc.Callable[..., HttpResponse]]: Decorator
|
||||
@@ -142,10 +139,11 @@ def weblogin_required(
|
||||
Note:
|
||||
This decorator is used to protect pages that needs to be logged in.
|
||||
To protect against ajax calls, use `denyNonAuthenticated` instead
|
||||
Roles as "inclusive", that is, if you set role to USER, it will allow all users that are not anonymous. (USER, STAFF, ADMIN)
|
||||
"""
|
||||
|
||||
def decorator(
|
||||
view_func: collections.abc.Callable[..., HttpResponse],
|
||||
view_func: collections.abc.Callable[..., HttpResponse]
|
||||
) -> collections.abc.Callable[..., HttpResponse]:
|
||||
@wraps(view_func)
|
||||
def _wrapped_view(
|
||||
@@ -158,8 +156,8 @@ def weblogin_required(
|
||||
if not request.user or not request.authorized:
|
||||
return weblogout(request)
|
||||
|
||||
if admin in (True, 'admin'):
|
||||
if request.user.is_staff() is False or (admin == 'admin' and not request.user.is_admin):
|
||||
if role in (consts.UserRole.ADMIN, consts.UserRole.STAFF):
|
||||
if request.user.is_staff() is False or (role == consts.UserRole.ADMIN and not request.user.is_admin):
|
||||
return HttpResponseForbidden(_('Forbidden'))
|
||||
|
||||
return view_func(request, *args, **kwargs)
|
||||
@@ -180,7 +178,7 @@ def is_trusted_ip_forwarder(ip: str) -> bool:
|
||||
|
||||
# Decorator to protect pages that needs to be accessed from "trusted sites"
|
||||
def needs_trusted_source(
|
||||
view_func: collections.abc.Callable[..., HttpResponse],
|
||||
view_func: collections.abc.Callable[..., HttpResponse]
|
||||
) -> collections.abc.Callable[..., HttpResponse]:
|
||||
"""
|
||||
Decorator to set protection to access page
|
||||
@@ -420,7 +418,7 @@ def weblogin(
|
||||
|
||||
request.session[consts.auth.SESSION_USER_KEY] = user.id
|
||||
request.session[consts.auth.SESSION_PASS_KEY] = codecs.encode(
|
||||
CryptoManager().symmetric_encrypt(password, cookie), "base64"
|
||||
CryptoManager.manager().symmetric_encrypt(password, cookie), "base64"
|
||||
).decode() # as str
|
||||
|
||||
# Ensures that this user will have access through REST api if logged in through web interface
|
||||
@@ -432,8 +430,6 @@ def weblogin(
|
||||
password,
|
||||
get_language() or '',
|
||||
request.os.os.name,
|
||||
user.is_admin,
|
||||
user.staff_member,
|
||||
cookie,
|
||||
)
|
||||
return True
|
||||
|
||||
@@ -330,7 +330,7 @@ class Authenticator(Module):
|
||||
return ''
|
||||
|
||||
@classmethod
|
||||
def provides_mfa(cls: typing.Type['Authenticator']) -> bool:
|
||||
def provides_mfa_identifier(cls: typing.Type['Authenticator']) -> bool:
|
||||
"""
|
||||
Returns if this authenticator provides a MFA identifier
|
||||
"""
|
||||
|
||||
@@ -31,18 +31,21 @@
|
||||
Author: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
"""
|
||||
# pyright: reportUnusedImport=false
|
||||
import enum
|
||||
import time
|
||||
import typing
|
||||
from datetime import datetime
|
||||
import datetime
|
||||
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from . import actor, auth, cache, calendar, images, net, os, system, ticket, rest, services, transports, ui
|
||||
|
||||
# Date related constants
|
||||
NEVER: typing.Final[datetime] = datetime(1972, 7, 1)
|
||||
NEVER: typing.Final[datetime.datetime] = datetime.datetime(1972, 7, 1, tzinfo=datetime.timezone.utc)
|
||||
NEVER_UNIX: typing.Final[int] = int(time.mktime(NEVER.timetuple()))
|
||||
|
||||
# Unknown mac address "magic" value
|
||||
MAC_UNKNOWN: typing.Final[str] = '00:00:00:00:00:00'
|
||||
# Null mac address "magic" value
|
||||
NULL_MAC: typing.Final[str] = '00:00:00:00:00:00'
|
||||
|
||||
# REST Related constants
|
||||
OK: typing.Final[str] = 'ok' # Constant to be returned when result is just "operation complete successfully"
|
||||
@@ -74,5 +77,62 @@ UNLIMITED: typing.Final[int] = -1
|
||||
|
||||
# Constant marking no more names available
|
||||
NO_MORE_NAMES: typing.Final[str] = 'NO-NAME-ERROR'
|
||||
# For convenience, same as MAC_UNKNOWN, but different meaning
|
||||
NO_MORE_MACS: typing.Final[str] = '00:00:00:00:00:00'
|
||||
# For convenience, same as NULL_MAC, but different meaning
|
||||
NO_MORE_MACS: typing.Final[str] = NULL_MAC
|
||||
|
||||
|
||||
class UserRole(enum.StrEnum):
|
||||
"""
|
||||
Roles for users
|
||||
"""
|
||||
|
||||
ADMIN = 'admin'
|
||||
STAFF = 'staff'
|
||||
USER = 'user'
|
||||
ANONYMOUS = 'anonymous'
|
||||
|
||||
@property
|
||||
def needs_authentication(self) -> bool:
|
||||
"""
|
||||
Checks if this role needs authentication
|
||||
|
||||
Returns:
|
||||
True if this role needs authentication, False otherwise
|
||||
"""
|
||||
return self != UserRole.ANONYMOUS
|
||||
|
||||
def can_access(self, role: 'UserRole') -> bool:
|
||||
"""
|
||||
Checks if this role can access to the requested role
|
||||
|
||||
That is, if this role is greater or equal to the requested role
|
||||
|
||||
Args:
|
||||
role: Role to check against
|
||||
|
||||
Returns:
|
||||
True if this role can access to the requested role, False otherwise
|
||||
"""
|
||||
ROLE_PRECEDENCE: typing.Final = {
|
||||
UserRole.ADMIN: 3,
|
||||
UserRole.STAFF: 2,
|
||||
UserRole.USER: 1,
|
||||
UserRole.ANONYMOUS: 0,
|
||||
}
|
||||
|
||||
return ROLE_PRECEDENCE[self] >= ROLE_PRECEDENCE[role]
|
||||
|
||||
def as_str(self) -> str:
|
||||
"""
|
||||
Returns the string representation of the role
|
||||
|
||||
Returns:
|
||||
The string representation of the role
|
||||
"""
|
||||
# _('Admin') or _('Staff member')) or _('User')
|
||||
return {
|
||||
UserRole.ADMIN: _('Admin'),
|
||||
UserRole.STAFF: _('Staff member'),
|
||||
UserRole.USER: _('User'),
|
||||
UserRole.ANONYMOUS: _('Anonymous'),
|
||||
}.get(self, _('Unknown role')) # Default case, should not happen
|
||||
|
||||
@@ -39,6 +39,8 @@ MAX_TICKET_VALIDITY_TIME: typing.Final[int] = 60 * 60 * 24 * 7 # 1 week
|
||||
TUNNEL_TICKET_VALIDITY_TIME: typing.Final[int] = 60 * 60 * 24 * 7 # 1 week
|
||||
|
||||
TICKET_SECURED_ONWER: typing.Final[str] = '#SECURE#' # Just a "different" owner. If used anywhere, it's not important (will not fail), but weird enough
|
||||
# Note that the tunnel ticket will be the the ticket itself + owner, so it will be 48 chars long (Secured or not) (Only valid for tunnel tickets)
|
||||
TICKET_LENGTH: typing.Final[int] = 40 # Ticket length must much the length of the ticket length on tunnel server!!! (take care with previous note) -
|
||||
# The old comment about length of ticket, does not apply anymore, because the Owner has been moved to an own field
|
||||
TICKET_LENGTH: typing.Final[int] = 48 # Ticket length must much the length of the ticket length on tunnel server!!! (take care with previous note) -
|
||||
LEGACY_TICKET_LENGTH: typing.Final[int] = 40 # Short ticket length - Used for client compatibility
|
||||
|
||||
SCRAMBLER_LENGTH: typing.Final[int] = 32 # Scrambler length
|
||||
@@ -31,50 +31,83 @@ Author: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
"""
|
||||
from uds.core.exceptions.common import UDSException
|
||||
|
||||
|
||||
class HandlerError(UDSException):
|
||||
"""
|
||||
Generic error for a REST handler
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class NotFound(HandlerError):
|
||||
"""
|
||||
Item not found error
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class AccessDenied(HandlerError):
|
||||
"""
|
||||
Access denied error
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class RequestError(HandlerError):
|
||||
"""
|
||||
Request is invalid error
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ResponseError(HandlerError):
|
||||
"""
|
||||
Generic response error
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class NotSupportedError(HandlerError):
|
||||
class NotSupportedError(RequestError):
|
||||
"""
|
||||
Some elements do not support some operations (as searching over an authenticator that does not supports it)
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# Exception to "rethrow" on save error
|
||||
class SaveException(HandlerError):
|
||||
"""
|
||||
Exception thrown if couldn't save
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class BlockAccess(UDSException):
|
||||
"""
|
||||
Exception used to signal that the access to a resource is blocked
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ValidationError(RequestError):
|
||||
"""
|
||||
Exception raised for validation errors
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class InvalidMethodError(RequestError):
|
||||
"""
|
||||
Exception raised for invalid HTTP methods
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
@@ -33,13 +33,20 @@ import hashlib
|
||||
import array
|
||||
import uuid
|
||||
import codecs
|
||||
import datetime
|
||||
import struct
|
||||
import re
|
||||
import string
|
||||
import logging
|
||||
import typing
|
||||
import secrets
|
||||
import base64
|
||||
|
||||
|
||||
uuid7: None|typing.Callable[[], 'uuid.UUID']
|
||||
try:
|
||||
from edwh_uuid7 import uuid7 # type: ignore
|
||||
except ImportError:
|
||||
uuid7 = None # type: ignore
|
||||
|
||||
# For password secrets
|
||||
from argon2 import PasswordHasher, Type as ArgonType
|
||||
@@ -49,9 +56,11 @@ from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes, aead
|
||||
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
from uds.core.util import singleton
|
||||
|
||||
@@ -306,10 +315,12 @@ class CryptoManager(metaclass=singleton.Singleton):
|
||||
|
||||
def uuid(self, obj: typing.Any = None) -> str:
|
||||
"""Generates an uuid from obj. (lower case)
|
||||
If obj is None, returns an uuid based on a random string
|
||||
If obj is None, returns a non-deterministic uuid (preferably uuid7 if available, else uuid4)
|
||||
"""
|
||||
if obj is None:
|
||||
obj = self.random_string()
|
||||
if obj is None: # Non deterministic, try to use uuid7 if available
|
||||
if uuid7 is not None:
|
||||
return str(uuid7())
|
||||
return str(uuid.uuid4())
|
||||
elif isinstance(obj, bytes):
|
||||
obj = obj.decode('utf8') # To string
|
||||
else:
|
||||
@@ -318,9 +329,32 @@ class CryptoManager(metaclass=singleton.Singleton):
|
||||
except Exception:
|
||||
obj = str(hash(obj)) # Get hash of object
|
||||
|
||||
return str(
|
||||
uuid.uuid5(self._namespace, obj)
|
||||
).lower() # I believe uuid returns a lowercase uuid always, but in case... :)
|
||||
return str(uuid.uuid5(self._namespace, obj)) # Uuid is always lower case
|
||||
|
||||
# Used to encode fields that will go inside json
|
||||
def encrypt_field_b64(self, plaintext: str, key_ascii32: str, nonce_seq: int) -> str:
|
||||
"""
|
||||
Cipher a `plaintext` with AES-256-GCM using `key_ascii32` (32 bytes ASCII)
|
||||
and a nonce of 12 bytes with last one being a simple seq, starting at 1.
|
||||
|
||||
Args:
|
||||
plaintext: The plaintext to encrypt.
|
||||
key_ascii32: The 32 bytes ASCII key to use for encryption.
|
||||
nonce_seq: The nonce sequence number (1, 2, 3...).
|
||||
|
||||
Returns the ciphertext+tag in standard Base64.
|
||||
"""
|
||||
key_bytes = key_ascii32.encode("ascii")
|
||||
if len(key_bytes) != 32:
|
||||
raise ValueError("The key must be exactly 32 bytes ASCII")
|
||||
|
||||
# Nonce is 12 bytes with the last byte = nonce_seq
|
||||
nonce = bytearray(12)
|
||||
nonce[-1] = nonce_seq # 1, 2, 3...
|
||||
|
||||
# Initialize AES-GCM
|
||||
aesgcm = aead.AESGCM(key_bytes)
|
||||
return base64.b64encode(aesgcm.encrypt(bytes(nonce), plaintext.encode("utf-8"), None)).decode()
|
||||
|
||||
def random_string(self, length: int = 40, digits: bool = True, punctuation: bool = False) -> str:
|
||||
base = (
|
||||
@@ -332,7 +366,7 @@ class CryptoManager(metaclass=singleton.Singleton):
|
||||
|
||||
def unique(self) -> str:
|
||||
return hashlib.sha3_256(
|
||||
(self.random_string(24, True) + datetime.datetime.now().strftime('%H%M%S%f')).encode()
|
||||
(self.random_string(24, True) + timezone.localtime().strftime('%H%M%S%f')).encode()
|
||||
).hexdigest()
|
||||
|
||||
def sha(self, value: typing.Union[str, bytes]) -> str:
|
||||
|
||||
@@ -67,7 +67,7 @@ class LogManager(metaclass=singleton.Singleton):
|
||||
Logs a message associated to owner
|
||||
"""
|
||||
# Ensure message fits on space
|
||||
message = str(message)[:4096]
|
||||
message = str(message)[:4000]
|
||||
|
||||
# now, we add new log
|
||||
try:
|
||||
|
||||
@@ -38,6 +38,7 @@ from concurrent.futures import ThreadPoolExecutor
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext as _
|
||||
from django.utils import timezone
|
||||
|
||||
from uds import models
|
||||
from uds.core import exceptions, types
|
||||
@@ -61,7 +62,7 @@ class ServerManager(metaclass=singleton.Singleton):
|
||||
BASE_PROPERTY_NAME: typing.Final[str] = 'sm_usr_'
|
||||
|
||||
# Singleton, can initialize here
|
||||
last_counters_clean: datetime.datetime = datetime.datetime.now() # This is local to server, so it's ok
|
||||
last_counters_clean: datetime.datetime = timezone.localtime() # This is local to server, so it's ok
|
||||
|
||||
@staticmethod
|
||||
def manager() -> 'ServerManager':
|
||||
@@ -71,8 +72,8 @@ class ServerManager(metaclass=singleton.Singleton):
|
||||
def counter_storage(self) -> typing.Iterator[StorageAsDict]:
|
||||
with Storage(self.STORAGE_NAME).as_dict(atomic=True, group='counters') as storage:
|
||||
# If counters are too old, restart them
|
||||
if datetime.datetime.now() - self.last_counters_clean > self.MAX_COUNTERS_AGE:
|
||||
self.last_counters_clean = datetime.datetime.now()
|
||||
if timezone.localtime() - self.last_counters_clean > self.MAX_COUNTERS_AGE:
|
||||
self.last_counters_clean = timezone.localtime()
|
||||
storage.clear()
|
||||
yield storage
|
||||
|
||||
|
||||
@@ -35,6 +35,8 @@ import logging
|
||||
import time
|
||||
import typing
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from uds.core import types
|
||||
from uds.core.util import singleton
|
||||
from uds.core.util.config import GlobalConfig
|
||||
@@ -183,6 +185,7 @@ class StatsManager(metaclass=singleton.Singleton):
|
||||
to = sql_now()
|
||||
elif isinstance(to, int):
|
||||
to = datetime.datetime.fromtimestamp(to)
|
||||
to = timezone.make_aware(to)
|
||||
|
||||
if since is None:
|
||||
if points is None:
|
||||
@@ -190,6 +193,7 @@ class StatsManager(metaclass=singleton.Singleton):
|
||||
since = to - datetime.timedelta(seconds=interval_type.seconds() * points)
|
||||
elif isinstance(since, int):
|
||||
since = datetime.datetime.fromtimestamp(since)
|
||||
since = timezone.make_aware(since)
|
||||
|
||||
# If points has any value, ensure since..to is points long
|
||||
if points is not None:
|
||||
|
||||
@@ -487,6 +487,12 @@ class UserServiceManager(metaclass=singleton.Singleton):
|
||||
with transaction.atomic():
|
||||
userservice = UserService.objects.select_for_update().get(id=userservice.id)
|
||||
operations_logger.info('Removing userservice %a', userservice.name)
|
||||
|
||||
# If already removing or removed, do nothing
|
||||
if State.from_str(userservice.state) in (State.REMOVING, State.REMOVED):
|
||||
logger.debug('Userservice %s already removing or removed', userservice.name)
|
||||
return
|
||||
|
||||
if userservice.is_usable() is False and State.from_str(userservice.state).is_removable() is False:
|
||||
if not forced:
|
||||
raise OperationException(
|
||||
@@ -1075,7 +1081,7 @@ class UserServiceManager(metaclass=singleton.Singleton):
|
||||
)
|
||||
self.notify_preconnect(
|
||||
userservice,
|
||||
transport_instance.get_connection_info(userservice, user, ''),
|
||||
transport_instance.get_connection_info(userservice, user, '', for_notify=True),
|
||||
)
|
||||
trace_logger.info(
|
||||
'READY on service "%s" for user "%s" with transport "%s" (ip:%s)',
|
||||
|
||||
@@ -59,6 +59,7 @@ def _execute_actor_request(
|
||||
Returns request response value interpreted as json
|
||||
"""
|
||||
url = userservice.get_comms_endpoint()
|
||||
secret = userservice.get_comms_secret()
|
||||
if not url:
|
||||
raise exceptions.actor.NoActorComms(f'No notification urls for {userservice.friendly_name}')
|
||||
|
||||
@@ -87,7 +88,12 @@ def _execute_actor_request(
|
||||
verify = False
|
||||
session = secure_requests_session(verify=cert)
|
||||
if data is None:
|
||||
r = session.get(url, verify=verify, timeout=TIMEOUT)
|
||||
headers = {
|
||||
'X-Actor-Secret': secret if secret else '',
|
||||
'content-type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
}
|
||||
r = session.get(url, verify=verify, headers=headers, timeout=TIMEOUT)
|
||||
else:
|
||||
r = session.post(
|
||||
url,
|
||||
@@ -96,7 +102,7 @@ def _execute_actor_request(
|
||||
verify=verify,
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
if not(isinstance(verify, bool)):
|
||||
if not (isinstance(verify, bool)):
|
||||
try:
|
||||
os.remove(verify)
|
||||
except Exception:
|
||||
@@ -165,9 +171,9 @@ def check_user_service_uuid(user_service: 'UserService') -> bool:
|
||||
def request_screenshot(userservice: 'UserService') -> None:
|
||||
"""
|
||||
Requests an screenshot to an actor on an user service
|
||||
|
||||
|
||||
This method is used to request an screenshot to an actor on an user service.
|
||||
|
||||
|
||||
Args:
|
||||
userservice: User service to request screenshot from
|
||||
|
||||
@@ -180,7 +186,7 @@ def request_screenshot(userservice: 'UserService') -> None:
|
||||
userservice, 'screenshot', data={}, min_actor_version='4.0.0'
|
||||
) # First valid version with screenshot is 3.0
|
||||
except exceptions.actor.NoActorComms:
|
||||
pass # No actor comms, nothing to do
|
||||
pass # No actor comms, nothing to do
|
||||
|
||||
|
||||
def send_script(userservice: 'UserService', script: str, exec_on_user: bool = False) -> None:
|
||||
|
||||
@@ -31,13 +31,13 @@
|
||||
Author: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
"""
|
||||
import codecs
|
||||
import datetime
|
||||
import logging
|
||||
import typing
|
||||
|
||||
from weasyprint import HTML, CSS, default_url_fetcher # pyright: ignore[reportUnknownVariableType]
|
||||
|
||||
from django.utils.translation import gettext, gettext_noop as _
|
||||
from django.utils import timezone
|
||||
from django.template import loader
|
||||
|
||||
from uds.core.ui import UserInterface, gui
|
||||
@@ -178,7 +178,7 @@ class Report(UserInterface):
|
||||
.replace('{water}', water or 'UDS Report')
|
||||
.replace(
|
||||
'{printed}',
|
||||
_('Printed in {now:%Y, %b %d} at {now:%H:%M}').format(now=datetime.datetime.now()),
|
||||
_('Printed in {now:%Y, %b %d} at {now:%H:%M}').format(now=timezone.localtime()),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -330,7 +330,9 @@ class DynamicUserService(services.UserService, autoserializable.AutoSerializable
|
||||
"""
|
||||
name = self.get_name()
|
||||
if name == consts.NO_MORE_NAMES:
|
||||
return consts.NO_MORE_NAMES
|
||||
raise Exception(
|
||||
'No more names available for this service. (Increase digits for this service to fix)'
|
||||
)
|
||||
|
||||
return self.service().sanitized_name(f'UDS_{name}') # Default implementation
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ class Transport(Module):
|
||||
own_link: bool = False
|
||||
|
||||
# Protocol "type". This is not mandatory, but will help
|
||||
protocol: types.transports.Protocol = types.transports.Protocol.NONE
|
||||
PROTOCOL: typing.ClassVar[types.transports.Protocol] = types.transports.Protocol.NONE
|
||||
|
||||
# For allowing grouping transport on dashboard "new" menu, and maybe other places
|
||||
group: typing.ClassVar[types.transports.Grouping] = types.transports.Grouping.DIRECT
|
||||
@@ -146,12 +146,12 @@ class Transport(Module):
|
||||
return f'Not accessible (using service ip {ip})'
|
||||
|
||||
@classmethod
|
||||
def supports_protocol(cls, protocol: typing.Union[collections.abc.Iterable[str], str]) -> bool:
|
||||
def is_protocol_supported(cls, protocol: typing.Union[collections.abc.Iterable[str], str]) -> bool:
|
||||
if isinstance(protocol, str):
|
||||
return protocol.lower() == cls.protocol.lower()
|
||||
return protocol.lower() == cls.PROTOCOL.lower()
|
||||
# Not string group of strings
|
||||
for v in protocol:
|
||||
if cls.supports_protocol(v):
|
||||
if cls.is_protocol_supported(v):
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -175,6 +175,8 @@ class Transport(Module):
|
||||
userservice: typing.Union['models.UserService', 'models.ServicePool'],
|
||||
user: 'models.User',
|
||||
password: str,
|
||||
*,
|
||||
for_notify: bool = False, # To differentiate SSO from information
|
||||
) -> types.connections.ConnectionData:
|
||||
"""
|
||||
This method must provide information about connection.
|
||||
@@ -200,7 +202,7 @@ class Transport(Module):
|
||||
else:
|
||||
username = self.processed_username(userservice, user)
|
||||
return types.connections.ConnectionData(
|
||||
protocol=self.protocol,
|
||||
protocol=self.PROTOCOL,
|
||||
username=username,
|
||||
service_type=types.services.ServiceType.VDI,
|
||||
password='', # nosec: password is empty string, no password
|
||||
|
||||
@@ -41,7 +41,6 @@ from . import (
|
||||
permissions,
|
||||
pools,
|
||||
requests,
|
||||
rest,
|
||||
servers,
|
||||
services,
|
||||
states,
|
||||
@@ -51,6 +50,7 @@ from . import (
|
||||
core,
|
||||
log,
|
||||
net,
|
||||
rest,
|
||||
)
|
||||
|
||||
# Log is not imported here, as it is a special case with lots of dependencies
|
||||
|
||||
@@ -119,8 +119,12 @@ class LoginResult:
|
||||
|
||||
@dataclasses.dataclass
|
||||
class SearchResultItem:
|
||||
class ItemDict(typing.TypedDict):
|
||||
id: str
|
||||
name: str
|
||||
|
||||
id: str
|
||||
name: str
|
||||
|
||||
def as_dict(self) -> typing.Dict[str, str]:
|
||||
return dataclasses.asdict(self)
|
||||
def as_dict(self) -> 'SearchResultItem.ItemDict':
|
||||
return typing.cast(SearchResultItem.ItemDict, dataclasses.asdict(self))
|
||||
|
||||
@@ -34,9 +34,7 @@ import typing
|
||||
import dataclasses
|
||||
|
||||
# Module values type
|
||||
ValuesType = typing.Optional[
|
||||
dict[str, typing.Any]
|
||||
]
|
||||
ValuesType = dict[str, typing.Any] | None
|
||||
|
||||
|
||||
# Module Test Result type
|
||||
|
||||
@@ -40,32 +40,13 @@ class ActorData:
|
||||
name: str
|
||||
custom: dict[str, typing.Any] = dataclasses.field(default_factory=dict[str, typing.Any])
|
||||
|
||||
# Items to be removed. Kept for compat with 3.6 actor
|
||||
@dataclasses.dataclass
|
||||
class Compat:
|
||||
username: str = ''
|
||||
password: str = ''
|
||||
new_password: str = ''
|
||||
ad: str = ''
|
||||
ou: str = ''
|
||||
|
||||
def as_dict(self) -> dict[str, typing.Any]:
|
||||
return {
|
||||
'username': self.username,
|
||||
'password': self.password,
|
||||
'new_password': self.new_password,
|
||||
'ad': self.ad,
|
||||
'ou': self.ou,
|
||||
}
|
||||
|
||||
compat: Compat = dataclasses.field(default_factory=Compat)
|
||||
|
||||
def as_dict(self) -> dict[str, typing.Any]:
|
||||
return {
|
||||
'action': self.action,
|
||||
'name': self.name,
|
||||
'custom': self.custom,
|
||||
} | self.compat.as_dict()
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def null() -> 'ActorData':
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (c) 2023 Virtual Cable S.L.U.
|
||||
# All rights reserved.
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without modification,
|
||||
# are permitted provided that the following conditions are met:
|
||||
#
|
||||
# * Redistributions of source code must retain the above copyright notice,
|
||||
# this list of conditions and the following disclaimer.
|
||||
# * Redistributions in binary form must reproduce the above copyright notice,
|
||||
# this list of conditions and the following disclaimer in the documentation
|
||||
# and/or other materials provided with the distribution.
|
||||
# * Neither the name of Virtual Cable S.L.U. nor the names of its contributors
|
||||
# may be used to endorse or promote products derived from this software
|
||||
# without specific prior written permission.
|
||||
#
|
||||
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
"""
|
||||
Author: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
"""
|
||||
import abc
|
||||
import typing
|
||||
import dataclasses
|
||||
import collections.abc
|
||||
|
||||
TypeInfoDict = dict[str, typing.Any] # Alias for type info dict
|
||||
|
||||
|
||||
class ExtraTypeInfo(abc.ABC):
|
||||
def as_dict(self) -> TypeInfoDict:
|
||||
return {}
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class AuthenticatorTypeInfo(ExtraTypeInfo):
|
||||
search_users_supported: bool
|
||||
search_groups_supported: bool
|
||||
needs_password: bool
|
||||
label_username: str
|
||||
label_groupname: str
|
||||
label_password: str
|
||||
create_users_supported: bool
|
||||
is_external: bool
|
||||
mfa_data_enabled: bool
|
||||
mfa_supported: bool
|
||||
|
||||
def as_dict(self) -> TypeInfoDict:
|
||||
return dataclasses.asdict(self)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class TypeInfo:
|
||||
name: str
|
||||
type: str
|
||||
description: str
|
||||
icon: str
|
||||
|
||||
group: typing.Optional[str] = None
|
||||
|
||||
extra: 'ExtraTypeInfo|None' = None
|
||||
|
||||
def as_dict(self) -> TypeInfoDict:
|
||||
res: dict[str, typing.Any] = {
|
||||
'name': self.name,
|
||||
'type': self.type,
|
||||
'description': self.description,
|
||||
'icon': self.icon,
|
||||
}
|
||||
# Add optional fields
|
||||
if self.group:
|
||||
res['group'] = self.group
|
||||
|
||||
if self.extra:
|
||||
res.update(self.extra.as_dict())
|
||||
|
||||
return res
|
||||
|
||||
@staticmethod
|
||||
def null() -> 'TypeInfo':
|
||||
return TypeInfo(name='', type='', description='', icon='', extra=None)
|
||||
|
||||
|
||||
# This is a named tuple for convenience, and must be
|
||||
# compatible with tuple[str, bool] (name, needs_parent)
|
||||
class ModelCustomMethod(typing.NamedTuple):
|
||||
name: str
|
||||
needs_parent: bool = True
|
||||
|
||||
|
||||
# Alias for item type
|
||||
ItemDictType = dict[str, typing.Any]
|
||||
ItemListType = list[ItemDictType]
|
||||
ItemGeneratorType = typing.Generator[ItemDictType, None, None]
|
||||
|
||||
# Alias for get_items return type
|
||||
ManyItemsDictType = typing.Union[ItemListType, ItemDictType, ItemGeneratorType]
|
||||
|
||||
#
|
||||
FieldType = collections.abc.Mapping[str, typing.Any]
|
||||
391
server/src/uds/core/types/rest/__init__.py
Normal file
391
server/src/uds/core/types/rest/__init__.py
Normal file
@@ -0,0 +1,391 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (c) 2023 Virtual Cable S.L.U.
|
||||
# All rights reserved.
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without modification,
|
||||
# are permitted provided that the following conditions are met:
|
||||
#
|
||||
# * Redistributions of source code must retain the above copyright notice,
|
||||
# this list of conditions and the following disclaimer.
|
||||
# * Redistributions in binary form must reproduce the above copyright notice,
|
||||
# this list of conditions and the following disclaimer in the documentation
|
||||
# and/or other materials provided with the distribution.
|
||||
# * Neither the name of Virtual Cable S.L.U. nor the names of its contributors
|
||||
# may be used to endorse or promote products derived from this software
|
||||
# without specific prior written permission.
|
||||
#
|
||||
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
"""
|
||||
Author: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
"""
|
||||
# pyright: reportUnusedImport=false
|
||||
|
||||
import abc
|
||||
import enum
|
||||
import typing
|
||||
import dataclasses
|
||||
|
||||
from . import stock
|
||||
from . import actor
|
||||
from . import api
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from uds.REST.handlers import Handler
|
||||
from uds.core.module import Module
|
||||
from uds.models.managed_object_model import ManagedObjectModel
|
||||
|
||||
|
||||
T_Model = typing.TypeVar('T_Model', bound='ManagedObjectModel')
|
||||
T_Item = typing.TypeVar("T_Item", bound='BaseRestItem')
|
||||
|
||||
|
||||
class NotRequired:
|
||||
"""
|
||||
This is a marker class to indicate that a field is not required.
|
||||
It is used to indicate that a field is optional in the REST API.
|
||||
"""
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return False
|
||||
|
||||
def __str__(self) -> str:
|
||||
return 'NotRequired'
|
||||
|
||||
# Field generator for dataclasses
|
||||
@staticmethod
|
||||
def field() -> typing.Any:
|
||||
"""
|
||||
Returns a field that is not required.
|
||||
This is used to indicate that a field is optional in the REST API.
|
||||
"""
|
||||
return dataclasses.field(default_factory=lambda: NotRequired(), repr=False, compare=False)
|
||||
|
||||
|
||||
# This is a named tuple for convenience, and must be
|
||||
# compatible with tuple[str, bool] (name, needs_parent)
|
||||
@dataclasses.dataclass
|
||||
class ModelCustomMethod:
|
||||
name: str
|
||||
needs_parent: bool = True
|
||||
|
||||
|
||||
# Note that for this item to work with documentation
|
||||
# no forward references can be used (that is, do not use quotes around the inner field types)
|
||||
@dataclasses.dataclass
|
||||
class BaseRestItem:
|
||||
|
||||
def as_dict(self) -> dict[str, typing.Any]:
|
||||
"""
|
||||
Returns a dictionary representation of the item.
|
||||
By default, it returns the dataclass fields as a dictionary.
|
||||
"""
|
||||
return dataclasses.asdict(self)
|
||||
|
||||
# NOTE: the json processor should take care of converting "sub-items" to valid dictionaries
|
||||
# (as it already does)
|
||||
|
||||
@classmethod
|
||||
def api_components(cls: type[typing.Self]) -> api.Components:
|
||||
from uds.core.util import api as api_util # Avoid circular import
|
||||
|
||||
return api_util.api_components(cls)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class ManagedObjectItem(BaseRestItem, typing.Generic[T_Model]):
|
||||
"""
|
||||
Represents a managed object type, with its name and type.
|
||||
This is used to represent the type of a managed object in the REST API.
|
||||
"""
|
||||
|
||||
item: T_Model
|
||||
|
||||
def as_dict(self) -> dict[str, typing.Any]:
|
||||
"""
|
||||
Returns a dictionary representation of the managed object item.
|
||||
"""
|
||||
base = super().as_dict()
|
||||
# Remove the fields that are not needed in the dictionary
|
||||
base.pop('item')
|
||||
item = self.item.get_instance()
|
||||
# item.init_gui() # Defaults & stuff
|
||||
fields = item.get_fields_as_dict()
|
||||
|
||||
# TODO: This will be removed in future versions, as it will be overseed by "instance" key
|
||||
base.update(fields) # Add fields to dict
|
||||
base.update(
|
||||
{
|
||||
'type': item.mod_type(), # Add type
|
||||
'type_name': item.mod_name(), # Add type name
|
||||
'instance': fields, # Future implementation will insert instance fields into "instance" key
|
||||
}
|
||||
)
|
||||
|
||||
return base
|
||||
|
||||
@classmethod
|
||||
def api_components(cls: type[typing.Self]) -> api.Components:
|
||||
component = super().api_components()
|
||||
# Add any additional components specific to this item, that are "type", "type_name" and "instance"
|
||||
# get reference
|
||||
schema = component.schemas.get(cls.__name__)
|
||||
if isinstance(schema, api.Schema):
|
||||
assert schema is not None, f'Schema for {cls.__name__} not found in components'
|
||||
# item is not an real field, remove it from components description and required
|
||||
schema.properties.pop('item', None)
|
||||
schema.required.remove('item')
|
||||
|
||||
# Add the specific fields to the schema
|
||||
# Note that 'instance' is incomplete, must be completed with item fields
|
||||
# But as long as python has not "real" generics, we cannot estimate the type of item
|
||||
schema.properties.update(
|
||||
{
|
||||
'type': api.SchemaProperty(type='string'),
|
||||
'type_name': api.SchemaProperty(type='string'),
|
||||
'instance': api.SchemaProperty(type='object'),
|
||||
}
|
||||
)
|
||||
schema.required.extend(['type', 'instance']) # type_name is not required
|
||||
|
||||
return component
|
||||
|
||||
|
||||
# Alias for get_items return type
|
||||
ItemsResult: typing.TypeAlias = list[T_Item] | BaseRestItem | typing.Iterator[T_Item]
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class TypeInfo:
|
||||
name: str = dataclasses.field(metadata={'description': 'Name of the type (Human readable)'})
|
||||
type: str = dataclasses.field(metadata={'description': 'Type name used to identify the type'})
|
||||
description: str = dataclasses.field(metadata={'description': 'Description for this type'})
|
||||
icon: str = dataclasses.field(metadata={'description': 'Icon of the type, in base64'})
|
||||
|
||||
group: typing.Optional[str] = dataclasses.field(
|
||||
default=None, metadata={'description': 'Group name used for grouping "similar" types'}
|
||||
)
|
||||
|
||||
extra: 'ExtraTypeInfo|None' = dataclasses.field(
|
||||
default=None, metadata={'description': 'Extra type info. Depends on specific type.'}
|
||||
)
|
||||
|
||||
def as_dict(self) -> dict[str, typing.Any]:
|
||||
res: dict[str, typing.Any] = {
|
||||
'name': self.name,
|
||||
'type': self.type,
|
||||
'description': self.description,
|
||||
'icon': self.icon,
|
||||
}
|
||||
# Add optional fields
|
||||
if self.group:
|
||||
res['group'] = self.group
|
||||
|
||||
if self.extra:
|
||||
res.update(self.extra.as_dict())
|
||||
|
||||
return res
|
||||
|
||||
@staticmethod
|
||||
def null() -> 'TypeInfo':
|
||||
return TypeInfo(name='', type='', description='', icon='', extra=None)
|
||||
|
||||
|
||||
class ExtraTypeInfo(abc.ABC):
|
||||
def as_dict(self) -> dict[str, typing.Any]:
|
||||
return {}
|
||||
|
||||
|
||||
class TableFieldType(enum.StrEnum):
|
||||
"""
|
||||
Enum for table field types.
|
||||
This is used to define the type of a field in a table.
|
||||
"""
|
||||
|
||||
NUMERIC = 'numeric'
|
||||
ALPHANUMERIC = 'alphanumeric'
|
||||
BOOLEAN = 'boolean'
|
||||
DATETIME = 'datetime'
|
||||
DATETIMESEC = 'datetimesec'
|
||||
DATE = 'date'
|
||||
TIME = 'time'
|
||||
ICON = 'icon'
|
||||
DICTIONARY = 'dictionary'
|
||||
IMAGE = 'image'
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class TableField:
|
||||
"""
|
||||
Represents a field in a table, with its title and type.
|
||||
This is used to describe the fields of a table in the REST API.
|
||||
"""
|
||||
|
||||
name: str # Name of the field, used as key in the table
|
||||
|
||||
title: str # Title of the field
|
||||
type: TableFieldType = TableFieldType.ALPHANUMERIC # Type of the field, defaults to alphanumeric
|
||||
visible: bool = True
|
||||
width: str | None = None # Width of the field, if applicable
|
||||
dct: dict[typing.Any, typing.Any] | None = None # Dictionary for dictionary fields, if applicable
|
||||
|
||||
def as_dict(self) -> dict[str, typing.Any]:
|
||||
# Only return the fields that are set
|
||||
|
||||
res: dict[str | int, typing.Any] = {
|
||||
'title': self.title,
|
||||
'type': self.type.value,
|
||||
'visible': self.visible,
|
||||
}
|
||||
if self.dct:
|
||||
res['dict'] = self.dct
|
||||
if self.width:
|
||||
res['width'] = self.width
|
||||
return {self.name: res} # Return as a dictionary with the field name as key
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class RowStyleInfo:
|
||||
prefix: str
|
||||
field: str
|
||||
|
||||
def as_dict(self) -> dict[str, typing.Any]:
|
||||
"""Returns a dict with all fields that are not None"""
|
||||
return dataclasses.asdict(self)
|
||||
|
||||
@staticmethod
|
||||
def null() -> 'RowStyleInfo':
|
||||
return RowStyleInfo('', '')
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class TableInfo:
|
||||
"""
|
||||
Represents the table info for a REST API endpoint.
|
||||
This is used to describe the table fields and row style.
|
||||
"""
|
||||
|
||||
title: str
|
||||
fields: list[TableField] # List of fields in the table
|
||||
row_style: 'RowStyleInfo'
|
||||
subtitle: typing.Optional[str] = None
|
||||
|
||||
def as_dict(self) -> dict[str, typing.Any]:
|
||||
return {
|
||||
'title': self.title,
|
||||
'fields': [field.as_dict() for field in self.fields],
|
||||
'row_style': self.row_style.as_dict(),
|
||||
'subtitle': self.subtitle or '',
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def null() -> 'TableInfo':
|
||||
"""
|
||||
Returns a null TableInfo instance, with no fields and an empty title.
|
||||
"""
|
||||
return TableInfo(title='', fields=[], row_style=RowStyleInfo.null(), subtitle=None)
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class HandlerNode:
|
||||
"""
|
||||
Represents a node on the handler tree for rest services
|
||||
"""
|
||||
|
||||
name: str
|
||||
handler: typing.Optional[type['Handler']]
|
||||
parent: typing.Optional['HandlerNode']
|
||||
children: dict[str, 'HandlerNode']
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f'HandlerNode({self.name}, {self.handler}, {self.children})'
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return str(self)
|
||||
|
||||
# Visit all nodes recursively, invoking a callback for each node with the node and path
|
||||
def visit(
|
||||
self,
|
||||
callback: typing.Callable[
|
||||
['HandlerNode', str, typing.Literal['handler', 'custom_method', 'detail_method'], int], None
|
||||
],
|
||||
path: str = '',
|
||||
level: int = 0,
|
||||
) -> None:
|
||||
from uds.REST.model import ModelHandler
|
||||
|
||||
if self.handler:
|
||||
callback(self, path, 'handler', level)
|
||||
|
||||
if issubclass(self.handler, ModelHandler):
|
||||
handler = typing.cast(
|
||||
type[ModelHandler[typing.Any]], self.handler # pyright: ignore[reportUnknownMemberType]
|
||||
)
|
||||
for method in handler.CUSTOM_METHODS:
|
||||
callback(self, f'{path}/{method.name}' if path else method.name, 'custom_method', level + 1)
|
||||
for detail_name in handler.DETAIL.keys() if handler.DETAIL else typing.cast(list[str], []):
|
||||
callback(self, f'{path}/{detail_name}' if path else detail_name, 'detail_method', level + 1)
|
||||
|
||||
for child in self.children.values():
|
||||
child.visit(callback, f'{path}/{child.name}' if path else child.name, level + 1)
|
||||
|
||||
def tree(self) -> str:
|
||||
"""
|
||||
Returns a string representation of the tree
|
||||
"""
|
||||
ret = ''
|
||||
|
||||
def _tree(
|
||||
node: HandlerNode,
|
||||
path: str,
|
||||
type_: typing.Literal['handler', 'custom_method', 'detail_method'],
|
||||
level: int,
|
||||
) -> None:
|
||||
nonlocal ret
|
||||
|
||||
if not node.handler:
|
||||
raise ValueError(f'Node {node.name} has no handler, cannot generate tree')
|
||||
|
||||
ret += f'{" " * level}* {path} {node.handler.__name__} ({type_})\n'
|
||||
|
||||
self.visit(_tree)
|
||||
return ret
|
||||
|
||||
def find_path(self, path: str | list[str]) -> typing.Optional['HandlerNode']:
|
||||
"""
|
||||
Returns the node for a given path, or None if not found
|
||||
"""
|
||||
if not path or not self.children:
|
||||
return self
|
||||
|
||||
# Remove any trailing '/' to allow some "bogus" paths with trailing slashes
|
||||
path = path.lstrip('/').split('/') if isinstance(path, str) else path
|
||||
|
||||
if path[0] not in self.children:
|
||||
return None
|
||||
|
||||
return self.children[path[0]].find_path(path[1:]) # Recursive call
|
||||
|
||||
def full_path(self) -> str:
|
||||
"""
|
||||
Returns the full path of this node
|
||||
"""
|
||||
if self.name == '' or self.parent is None:
|
||||
return ''
|
||||
|
||||
parent_full_path = self.parent.full_path()
|
||||
|
||||
if parent_full_path == '':
|
||||
return self.name
|
||||
|
||||
return f'{parent_full_path}/{self.name}'
|
||||
42
server/src/uds/core/types/rest/actor.py
Normal file
42
server/src/uds/core/types/rest/actor.py
Normal file
@@ -0,0 +1,42 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (c) 2025 Virtual Cable S.L.U.
|
||||
# All rights reserved.
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without modification,
|
||||
# are permitted provided that the following conditions are met:
|
||||
#
|
||||
# * Redistributions of source code must retain the above copyright notice,
|
||||
# this list of conditions and the following disclaimer.
|
||||
# * Redistributions in binary form must reproduce the above copyright notice,
|
||||
# this list of conditions and the following disclaimer in the documentation
|
||||
# and/or other materials provided with the distribution.
|
||||
# * Neither the name of Virtual Cable S.L.U. nor the names of its contributors
|
||||
# may be used to endorse or promote products derived from this software
|
||||
# without specific prior written permission.
|
||||
#
|
||||
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
"""
|
||||
Author: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
"""
|
||||
import enum
|
||||
|
||||
|
||||
class NotifyActionType(enum.StrEnum):
|
||||
LOGIN = 'login'
|
||||
LOGOUT = 'logout'
|
||||
DATA = 'data'
|
||||
|
||||
@staticmethod
|
||||
def valid_names() -> list[str]:
|
||||
return [e.value for e in NotifyActionType]
|
||||
454
server/src/uds/core/types/rest/api.py
Normal file
454
server/src/uds/core/types/rest/api.py
Normal file
@@ -0,0 +1,454 @@
|
||||
import enum
|
||||
import typing
|
||||
import dataclasses
|
||||
|
||||
from uds.core import exceptions
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from uds.core.types import ui
|
||||
|
||||
|
||||
# Helper to clean None values from dicts
|
||||
def _as_dict_without_none(v: typing.Any) -> typing.Any:
|
||||
if hasattr(v, 'as_dict'):
|
||||
return _as_dict_without_none(v.as_dict())
|
||||
elif dataclasses.is_dataclass(v):
|
||||
return _as_dict_without_none(dataclasses.asdict(typing.cast(typing.Any, v)))
|
||||
elif isinstance(v, list):
|
||||
return [_as_dict_without_none(item) for item in typing.cast(list[typing.Any], v) if item is not None]
|
||||
elif isinstance(v, dict):
|
||||
return {
|
||||
k: _as_dict_without_none(val)
|
||||
for k, val in typing.cast(dict[str, typing.Any], v).items()
|
||||
if val is not None
|
||||
}
|
||||
elif hasattr(v, 'as_dict'):
|
||||
return v.as_dict()
|
||||
return v
|
||||
|
||||
|
||||
# This class is used to provide extra information about a handler
|
||||
# (handler, model, detail, etc.)
|
||||
# So we can override names or whatever we need
|
||||
|
||||
# Types of GUI info that can be provided
|
||||
class RestApiInfoGuiType(enum.Enum):
|
||||
SINGLE_TYPE = 0
|
||||
MULTIPLE_TYPES = 1
|
||||
UNTYPED = 3
|
||||
|
||||
def is_single_type(self) -> bool:
|
||||
return self == RestApiInfoGuiType.SINGLE_TYPE
|
||||
|
||||
def supports_multiple_types(self) -> bool:
|
||||
return self == RestApiInfoGuiType.MULTIPLE_TYPES
|
||||
|
||||
@dataclasses.dataclass
|
||||
class RestApiInfo:
|
||||
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
# Models can be typed, untyped or :
|
||||
# - SINGLE_TYPE: the gui returns with no type specified (for example, /gui)
|
||||
# - MULTI_TYPED: the gui returns with a type specified (for example, /gui/whatever_type)
|
||||
# - UNTYPED: no gui is provided
|
||||
typed: 'RestApiInfoGuiType' = RestApiInfoGuiType.UNTYPED
|
||||
|
||||
|
||||
# Parameter
|
||||
@dataclasses.dataclass
|
||||
class Parameter:
|
||||
name: str
|
||||
in_: str # 'query', 'path', 'header', etc.
|
||||
required: bool
|
||||
schema: 'Schema'
|
||||
description: str | None = None
|
||||
style: str | None = None
|
||||
explode: bool | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, typing.Any]:
|
||||
return _as_dict_without_none(
|
||||
{
|
||||
'name': self.name,
|
||||
'in': self.in_,
|
||||
'required': self.required,
|
||||
'schema': self.schema.as_dict(),
|
||||
'description': self.description,
|
||||
'style': self.style,
|
||||
'explode': self.explode,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class Content:
|
||||
media_type: str
|
||||
schema: 'SchemaProperty'
|
||||
|
||||
def as_dict(self) -> dict[str, typing.Any]:
|
||||
return _as_dict_without_none(
|
||||
{
|
||||
self.media_type: {
|
||||
'schema': self.schema.as_dict(),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# Request body
|
||||
@dataclasses.dataclass
|
||||
class RequestBody:
|
||||
required: bool
|
||||
content: Content # e.g. {'application/json': {'schema': {...}}}
|
||||
description: str | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, typing.Any]:
|
||||
return _as_dict_without_none(
|
||||
{
|
||||
'required': self.required,
|
||||
'content': self.content.as_dict(),
|
||||
'description': self.description,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# Response
|
||||
@dataclasses.dataclass
|
||||
class Response:
|
||||
description: str
|
||||
content: Content | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, typing.Any]:
|
||||
return _as_dict_without_none(
|
||||
{
|
||||
'description': self.description,
|
||||
'content': self.content.as_dict() if self.content else None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# Operación (GET, POST, etc.)
|
||||
@dataclasses.dataclass
|
||||
class Operation:
|
||||
|
||||
summary: str | None = None
|
||||
description: str | None = None
|
||||
parameters: list[Parameter] = dataclasses.field(default_factory=list[Parameter])
|
||||
requestBody: RequestBody | None = None
|
||||
responses: dict[str, Response] = dataclasses.field(default_factory=dict[str, Response])
|
||||
security: str | None = None
|
||||
tags: list[str] = dataclasses.field(default_factory=list[str])
|
||||
|
||||
def as_dict(self) -> dict[str, typing.Any]:
|
||||
data = _as_dict_without_none(
|
||||
{
|
||||
'summary': self.summary,
|
||||
'description': self.description,
|
||||
'parameters': [param.as_dict() for param in self.parameters],
|
||||
'requestBody': self.requestBody.as_dict() if self.requestBody else None,
|
||||
'responses': {k: v.as_dict() for k, v in self.responses.items()},
|
||||
'tags': self.tags,
|
||||
}
|
||||
)
|
||||
if self.security:
|
||||
data['security'] = [{self.security: []}]
|
||||
return data
|
||||
|
||||
|
||||
# Path item
|
||||
@dataclasses.dataclass
|
||||
class PathItem:
|
||||
get: Operation | None = None
|
||||
post: Operation | None = None
|
||||
put: Operation | None = None
|
||||
delete: Operation | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, typing.Any]:
|
||||
return _as_dict_without_none(
|
||||
{
|
||||
'get': self.get.as_dict() if self.get else None,
|
||||
'post': self.post.as_dict() if self.post else None,
|
||||
'put': self.put.as_dict() if self.put else None,
|
||||
'delete': self.delete.as_dict() if self.delete else None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# Schema property
|
||||
@dataclasses.dataclass
|
||||
class SchemaProperty:
|
||||
type: str | list[str]
|
||||
format: str | None = None # e.g. 'date-time', 'int32', etc.
|
||||
description: str | None = None
|
||||
example: typing.Any | None = None
|
||||
items: 'SchemaProperty | None' = None # For arrays
|
||||
additionalProperties: 'SchemaProperty | None' = None # For objects
|
||||
discriminator: str | None = None # For polymorphic types
|
||||
enum: list[str | int] | None = None # For enum types
|
||||
properties: dict[str, 'SchemaProperty'] | None = None
|
||||
one_of: list['SchemaProperty'] | None = None
|
||||
|
||||
def __eq__(self, value: object) -> bool:
|
||||
if not isinstance(value, SchemaProperty):
|
||||
return False
|
||||
return (
|
||||
self.type == value.type
|
||||
and self.format == value.format
|
||||
and self.description == value.description
|
||||
and self.example == value.example
|
||||
and self.items == value.items
|
||||
and self.additionalProperties == value.additionalProperties
|
||||
and self.discriminator == value.discriminator
|
||||
and self.enum == value.enum
|
||||
and self.properties == value.properties
|
||||
and sorted(self.one_of or [], key=lambda x: x.type)
|
||||
== sorted(value.one_of or [], key=lambda x: x.type)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def from_field_desc(desc: 'ui.GuiElement') -> 'SchemaProperty|None':
|
||||
from uds.core.types import ui # avoid circular import
|
||||
|
||||
def base_schema() -> 'SchemaProperty|None':
|
||||
'''Returns the API type for this field type'''
|
||||
match desc.gui.type:
|
||||
case ui.FieldType.TEXT:
|
||||
return SchemaProperty(type='string')
|
||||
case ui.FieldType.TEXT_AUTOCOMPLETE:
|
||||
return SchemaProperty(type='string')
|
||||
case ui.FieldType.NUMERIC:
|
||||
return SchemaProperty(type='number')
|
||||
case ui.FieldType.PASSWORD:
|
||||
return SchemaProperty(type='string')
|
||||
case ui.FieldType.HIDDEN:
|
||||
return None
|
||||
case ui.FieldType.CHOICE:
|
||||
return SchemaProperty(type='string')
|
||||
case ui.FieldType.MULTICHOICE:
|
||||
return SchemaProperty(type='array', items=SchemaProperty(type='string'))
|
||||
case ui.FieldType.EDITABLELIST:
|
||||
return SchemaProperty(type='array', items=SchemaProperty(type='string'))
|
||||
case ui.FieldType.CHECKBOX:
|
||||
return SchemaProperty(type='boolean')
|
||||
case ui.FieldType.IMAGECHOICE:
|
||||
return SchemaProperty(type='string')
|
||||
case ui.FieldType.DATE:
|
||||
return SchemaProperty(type='string')
|
||||
case ui.FieldType.INFO:
|
||||
return None
|
||||
case ui.FieldType.TAGLIST:
|
||||
return SchemaProperty(type='array', items=SchemaProperty(type='string'))
|
||||
|
||||
schema = base_schema()
|
||||
if schema is None:
|
||||
return None
|
||||
schema.description = f'{desc.gui.label}.{desc.gui.tooltip}'
|
||||
return schema
|
||||
|
||||
def as_dict(self) -> dict[str, typing.Any]:
|
||||
val = {
|
||||
'type': self.type,
|
||||
'format': self.format,
|
||||
'description': self.description,
|
||||
'example': self.example,
|
||||
'items': self.items.as_dict() if self.items else None,
|
||||
'additionalProperties': self.additionalProperties.as_dict() if self.additionalProperties else None,
|
||||
'discriminator': self.discriminator,
|
||||
'enum': self.enum,
|
||||
'properties': {k: v.as_dict() for k, v in self.properties.items()} if self.properties else None,
|
||||
}
|
||||
|
||||
# Convert type to oneOf if necesary, and add refs if needed
|
||||
def one_of_ref(type_: str) -> dict[str, typing.Any]:
|
||||
if type_.startswith('#'):
|
||||
return {'$ref': type_}
|
||||
return {'type': type_}
|
||||
|
||||
if self.one_of: # Ignore type, ose one_of values
|
||||
val['oneOf'] = [i.as_dict() for i in self.one_of]
|
||||
del val['type']
|
||||
elif isinstance(self.type, list):
|
||||
# If one_of is defined, we should not use type, but one_of
|
||||
val['oneOf'] = [one_of_ref(ref) for ref in self.type]
|
||||
del val['type']
|
||||
elif self.type:
|
||||
del val['type'] # Remove existing type
|
||||
val.update(one_of_ref(self.type))
|
||||
return _as_dict_without_none(val)
|
||||
|
||||
|
||||
# Schema
|
||||
@dataclasses.dataclass
|
||||
class Schema:
|
||||
type: str
|
||||
format: str | None = None
|
||||
properties: dict[str, SchemaProperty] = dataclasses.field(default_factory=dict[str, SchemaProperty])
|
||||
required: list[str] = dataclasses.field(default_factory=list[str])
|
||||
description: str | None = None
|
||||
minimum: int | None = None
|
||||
maximum: int | None = None
|
||||
|
||||
# For use on generating schemas
|
||||
def as_dict(self) -> dict[str, typing.Any]:
|
||||
return _as_dict_without_none(
|
||||
{
|
||||
'type': self.type,
|
||||
'format': self.format,
|
||||
'properties': {k: v.as_dict() for k, v in self.properties.items()} if self.properties else None,
|
||||
'required': self.required if self.required else None,
|
||||
'description': self.description,
|
||||
'minimum': self.minimum,
|
||||
'maximum': self.maximum,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class RelatedSchema:
|
||||
property: str
|
||||
mappings: list[tuple[str, str]] # list of (type, ref)
|
||||
|
||||
def as_dict(self) -> dict[str, typing.Any]:
|
||||
return _as_dict_without_none(
|
||||
{
|
||||
'oneOf': [{'$ref': i[1]} for i in self.mappings],
|
||||
'discriminator': {
|
||||
'propertyName': self.property,
|
||||
'mapping': {i[0]: i[1] for i in self.mappings},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# Componentes
|
||||
@dataclasses.dataclass
|
||||
class Components:
|
||||
schemas: dict[str, Schema | RelatedSchema] = dataclasses.field(
|
||||
default_factory=dict[str, Schema | RelatedSchema]
|
||||
)
|
||||
securitySchemes: dict[str, typing.Any] = dataclasses.field(default_factory=dict[str, typing.Any])
|
||||
|
||||
def as_dict(self) -> dict[str, typing.Any]:
|
||||
return _as_dict_without_none(
|
||||
{
|
||||
'schemas': {k: v.as_dict() for k, v in self.schemas.items()},
|
||||
'securitySchemes': self.securitySchemes if self.securitySchemes else None,
|
||||
}
|
||||
)
|
||||
|
||||
def union(self, other: 'Components') -> 'Components':
|
||||
'''Returns a new Components instance that is the union of this and another Components.'''
|
||||
new_components = Components()
|
||||
new_components.schemas = {**self.schemas, **other.schemas}
|
||||
if other.securitySchemes:
|
||||
new_components.securitySchemes = {**self.securitySchemes, **other.securitySchemes}
|
||||
return new_components
|
||||
|
||||
# Operator | will union two Components
|
||||
def __or__(self, other: 'Components') -> 'Components':
|
||||
return self.union(other)
|
||||
|
||||
def is_empty(self) -> bool:
|
||||
return not self.schemas
|
||||
|
||||
|
||||
# Info general for OpenApi
|
||||
@dataclasses.dataclass
|
||||
class Info:
|
||||
title: str
|
||||
version: str
|
||||
description: str | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, typing.Any]:
|
||||
return {
|
||||
'title': self.title,
|
||||
'version': self.version,
|
||||
'description': self.description,
|
||||
}
|
||||
|
||||
|
||||
# Documento OpenAPI completo
|
||||
@dataclasses.dataclass
|
||||
class OpenAPI:
|
||||
@staticmethod
|
||||
def _get_system_version() -> Info:
|
||||
from uds.core.consts import system
|
||||
|
||||
return Info(title='UDS API', version=system.VERSION, description='UDS REST API Documentation')
|
||||
|
||||
openapi: str = '3.1.0'
|
||||
info: Info = dataclasses.field(default_factory=lambda: OpenAPI._get_system_version())
|
||||
paths: dict[str, PathItem] = dataclasses.field(default_factory=dict[str, PathItem])
|
||||
components: Components = dataclasses.field(default_factory=Components)
|
||||
|
||||
def as_dict(self) -> dict[str, typing.Any]:
|
||||
return _as_dict_without_none(
|
||||
{
|
||||
'openapi': self.openapi,
|
||||
'info': self.info.as_dict(),
|
||||
'paths': {k: v.as_dict() for k, v in self.paths.items()},
|
||||
'components': self.components.as_dict(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class ODataParams:
|
||||
"""
|
||||
OData query parameters converter
|
||||
"""
|
||||
|
||||
filter: str | None = None # $filter=....
|
||||
start: int | None = None # $skip=... zero based
|
||||
limit: int | None = None # $top=... defaults to unlimited right now
|
||||
orderby: list[str] = dataclasses.field(default_factory=list[str]) # $orderby=xxx, yyy asc, zzz desc
|
||||
select: set[str] = dataclasses.field(default_factory=set[str]) # $select=...
|
||||
|
||||
@staticmethod
|
||||
def from_dict(data: dict[str, typing.Any]) -> 'ODataParams':
|
||||
try:
|
||||
# extract order by, split by ',' and replace asc by '' and desc by a '-' stripping text.
|
||||
# After this, move the - to the beginning when needed
|
||||
order_fld = typing.cast(str, data.get('$orderby', ''))
|
||||
order_by = list(
|
||||
map(
|
||||
lambda x: f'-{x.rstrip("-")}' if x.endswith('-') else x,
|
||||
[
|
||||
item.strip().replace(' asc', '').replace(' desc', '-')
|
||||
for item in order_fld.split(',')
|
||||
if item
|
||||
],
|
||||
)
|
||||
)
|
||||
select_fld = typing.cast(str, data.get('$select', ''))
|
||||
select = {item.strip() for item in select_fld.split(',') if item}
|
||||
start = int(data.get('$skip', 0)) if data.get('$skip') is not None else None
|
||||
limit = int(data.get('$top', 0)) if data.get('$top') is not None else None
|
||||
return ODataParams(
|
||||
filter=data.get('$filter'),
|
||||
start=start,
|
||||
limit=limit,
|
||||
orderby=order_by,
|
||||
select=select,
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
raise exceptions.rest.RequestError('Invalid OData query parameters')
|
||||
|
||||
def select_filter(self, d: dict[str, typing.Any]) -> dict[str, typing.Any]:
|
||||
"""
|
||||
Filters a dictionary by the OData parameters.
|
||||
|
||||
Args:
|
||||
d: The dictionary to filter.
|
||||
|
||||
Returns:
|
||||
A new dictionary containing only the keys from the original dictionary that are in the OData select set.
|
||||
|
||||
Note:
|
||||
If the OData select set is empty, all keys are kept.
|
||||
"""
|
||||
if not self.select:
|
||||
return d
|
||||
|
||||
return {k: v for k, v in d.items() if k in self.select}
|
||||
33
server/src/uds/core/types/rest/stock/__init__.py
Normal file
33
server/src/uds/core/types/rest/stock/__init__.py
Normal file
@@ -0,0 +1,33 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (c) 2025 Virtual Cable S.L.U.
|
||||
# All rights reserved.
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without modification,
|
||||
# are permitted provided that the following conditions are met:
|
||||
#
|
||||
# * Redistributions of source code must retain the above copyright notice,
|
||||
# this list of conditions and the following disclaimer.
|
||||
# * Redistributions in binary form must reproduce the above copyright notice,
|
||||
# this list of conditions and the following disclaimer in the documentation
|
||||
# and/or other materials provided with the distribution.
|
||||
# * Neither the name of Virtual Cable S.L.U. nor the names of its contributors
|
||||
# may be used to endorse or promote products derived from this software
|
||||
# without specific prior written permission.
|
||||
#
|
||||
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
"""
|
||||
Author: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
"""
|
||||
# pyright: reportUnusedImport=false
|
||||
from .fields import StockField
|
||||
172
server/src/uds/core/types/rest/stock/fields.py
Normal file
172
server/src/uds/core/types/rest/stock/fields.py
Normal file
@@ -0,0 +1,172 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (c) 2025 Virtual Cable S.L.U.
|
||||
# All rights reserved.
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without modification,
|
||||
# are permitted provided that the following conditions are met:
|
||||
#
|
||||
# * Redistributions of source code must retain the above copyright notice,
|
||||
# this list of conditions and the following disclaimer.
|
||||
# * Redistributions in binary form must reproduce the above copyright notice,
|
||||
# this list of conditions and the following disclaimer in the documentation
|
||||
# and/or other materials provided with the distribution.
|
||||
# * Neither the name of Virtual Cable S.L.U. nor the names of its contributors
|
||||
# may be used to endorse or promote products derived from this software
|
||||
# without specific prior written permission.
|
||||
#
|
||||
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
"""
|
||||
Author: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
"""
|
||||
import copy
|
||||
import typing
|
||||
import enum
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
# Avoid circular import by importing ui here insetad of at the top
|
||||
from ... import ui
|
||||
|
||||
|
||||
class StockField(enum.StrEnum):
|
||||
"""
|
||||
This class contains the static fields that are common to all models.
|
||||
It is used to define the fields that are common to all models in the system.
|
||||
"""
|
||||
|
||||
TAGS = 'tags'
|
||||
NAME = 'name'
|
||||
COMMENTS = 'comments'
|
||||
PRIORITY = 'priority'
|
||||
LABEL = 'small_name'
|
||||
NETWORKS = 'networks'
|
||||
|
||||
def get_fields(self) -> list['ui.GuiElement']:
|
||||
"""
|
||||
Returns the GUI elements for the field.
|
||||
"""
|
||||
from uds.models import Network # Import here to avoid circular import
|
||||
|
||||
# Get a copy to ensure we do not modify the original
|
||||
field_gui = [copy.copy(i) for i in _STATIC_FLDS[self]]
|
||||
|
||||
# Special cases, as network choices are dynamic
|
||||
if self.value == self.NETWORKS:
|
||||
field_gui[0].gui.choices = sorted(
|
||||
[ui.ChoiceItem(id=x.uuid, text=x.name) for x in Network.objects.all()],
|
||||
key=lambda x: x.text.lower(),
|
||||
)
|
||||
|
||||
return field_gui
|
||||
|
||||
|
||||
# Note tha Table Builder will update the order, but keep the order here for, maybe, compatibility with older code
|
||||
# Eventullay, should be removed
|
||||
_STATIC_FLDS: typing.Final[dict[StockField, list['ui.GuiElement']]] = {
|
||||
StockField.TAGS: [
|
||||
ui.GuiElement(
|
||||
name='tags',
|
||||
gui=ui.FieldInfo(
|
||||
label=_('Tags'),
|
||||
type=ui.FieldType.TAGLIST,
|
||||
tooltip=_('Tags for this element'),
|
||||
order=0 - 110,
|
||||
),
|
||||
)
|
||||
],
|
||||
StockField.NAME: [
|
||||
ui.GuiElement(
|
||||
name='name',
|
||||
gui=ui.FieldInfo(
|
||||
type=ui.FieldType.TEXT,
|
||||
required=True,
|
||||
label=_('Name'),
|
||||
length=128,
|
||||
tooltip=_('Name of this element'),
|
||||
order=0 - 100,
|
||||
),
|
||||
)
|
||||
],
|
||||
StockField.COMMENTS: [
|
||||
ui.GuiElement(
|
||||
name='comments',
|
||||
gui=ui.FieldInfo(
|
||||
label=_('Comments'),
|
||||
type=ui.FieldType.TEXT,
|
||||
lines=3,
|
||||
tooltip=_('Comments for this element'),
|
||||
length=256,
|
||||
order=0 - 90,
|
||||
),
|
||||
)
|
||||
],
|
||||
StockField.PRIORITY: [
|
||||
ui.GuiElement(
|
||||
name='priority',
|
||||
gui=ui.FieldInfo(
|
||||
label=_('Priority'),
|
||||
type=ui.FieldType.NUMERIC,
|
||||
required=True,
|
||||
default=1,
|
||||
length=4,
|
||||
tooltip=_('Selects the priority of this element (lower number means higher priority)'),
|
||||
order=0 - 80,
|
||||
),
|
||||
)
|
||||
],
|
||||
StockField.LABEL: [
|
||||
ui.GuiElement(
|
||||
name='small_name',
|
||||
gui=ui.FieldInfo(
|
||||
label=_('Label'),
|
||||
type=ui.FieldType.TEXT,
|
||||
required=True,
|
||||
length=128,
|
||||
tooltip=_('Label for this element'),
|
||||
order=0 - 70,
|
||||
),
|
||||
)
|
||||
],
|
||||
StockField.NETWORKS: [
|
||||
ui.GuiElement(
|
||||
name='networks',
|
||||
gui=ui.FieldInfo(
|
||||
label=_('Networks'),
|
||||
type=ui.FieldType.MULTICHOICE,
|
||||
tooltip=_('Networks associated. If No network selected, will mean "all networks"'),
|
||||
choices=[], # Will be filled dynamically
|
||||
order=101,
|
||||
tab=ui.Tab.ADVANCED,
|
||||
),
|
||||
),
|
||||
ui.GuiElement(
|
||||
name='net_filtering',
|
||||
gui=ui.FieldInfo(
|
||||
label=_('Network Filtering'),
|
||||
type=ui.FieldType.CHOICE, # Type of network filtering
|
||||
default='n',
|
||||
choices=[
|
||||
ui.ChoiceItem(id='n', text= _('No filtering')),
|
||||
ui.ChoiceItem(id='a', text= _('Allow selected networks')),
|
||||
ui.ChoiceItem(id='d', text= _('Deny selected networks')),
|
||||
],
|
||||
tooltip=_(
|
||||
'Type of network filtering. Use "Disabled" to disable origin check, "Allow" to only enable for selected networks or "Deny" to deny from selected networks'
|
||||
),
|
||||
order=100, # At end
|
||||
tab=ui.Tab.ADVANCED,
|
||||
),
|
||||
)
|
||||
],
|
||||
}
|
||||
@@ -142,6 +142,7 @@ class ServerStatsWeights:
|
||||
cpu: float = 0.3
|
||||
memory: float = 0.6
|
||||
users: float = 0.1
|
||||
max_users: int = 100 # Max users to consider in load calculation
|
||||
|
||||
def normalize(self) -> 'ServerStatsWeights':
|
||||
total = self.cpu + self.memory + self.users
|
||||
@@ -155,6 +156,7 @@ class ServerStatsWeights:
|
||||
'cpu': self.cpu,
|
||||
'memory': self.memory,
|
||||
'users': self.users,
|
||||
'max_users': self.max_users,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
@@ -163,6 +165,7 @@ class ServerStatsWeights:
|
||||
data.get('cpu', 0.3),
|
||||
data.get('memory', 0.6),
|
||||
data.get('users', 0.1),
|
||||
int(data.get('max_users', 100)),
|
||||
).normalize()
|
||||
|
||||
|
||||
@@ -210,7 +213,7 @@ class ServerStats:
|
||||
w = (
|
||||
weights.cpu * self.cpuused
|
||||
+ weights.memory * (self.memused / (self.memtotal or 1))
|
||||
+ weights.users * (min(1.0, self.current_users / 100.0))
|
||||
+ weights.users * (min(1.0, self.current_users / weights.max_users))
|
||||
)
|
||||
|
||||
return min(max(0.0, w), 1.0)
|
||||
|
||||
@@ -37,7 +37,8 @@ import collections.abc
|
||||
from django.utils.translation import gettext_noop
|
||||
|
||||
# Old Field name type
|
||||
OldFieldNameType = typing.Union[str,list[str],None]
|
||||
OldFieldNameType = typing.Union[str, list[str], None]
|
||||
|
||||
|
||||
class Tab(enum.StrEnum):
|
||||
ADVANCED = gettext_noop('Advanced')
|
||||
@@ -79,6 +80,7 @@ class FieldType(enum.StrEnum):
|
||||
IMAGECHOICE = 'imgchoice'
|
||||
DATE = 'date'
|
||||
INFO = 'internal-info'
|
||||
TAGLIST = 'taglist'
|
||||
|
||||
@staticmethod
|
||||
def from_str(value: str) -> 'FieldType':
|
||||
@@ -129,10 +131,20 @@ class Filler(typing.TypedDict):
|
||||
# Choices
|
||||
|
||||
|
||||
class ChoiceItem(typing.TypedDict):
|
||||
id: 'str'
|
||||
@dataclasses.dataclass
|
||||
class ChoiceItem:
|
||||
id: 'str|int'
|
||||
text: str
|
||||
img: typing.NotRequired[str] # Only for IMAGECHOICE
|
||||
img: str | None = None # Only for IMAGECHOICE
|
||||
|
||||
def as_dict(self) -> dict[str, typing.Any]:
|
||||
data = {
|
||||
'id': self.id,
|
||||
'text': self.text,
|
||||
}
|
||||
if self.img:
|
||||
data['img'] = self.img
|
||||
return data
|
||||
|
||||
|
||||
ChoicesType = typing.Union[
|
||||
@@ -149,57 +161,35 @@ class FieldInfo:
|
||||
type: FieldType
|
||||
field_name: str = ''
|
||||
old_field_name: OldFieldNameType = None
|
||||
readonly: typing.Optional[bool] = None
|
||||
value: typing.Union[collections.abc.Callable[[], typing.Any], typing.Any] = None
|
||||
default: typing.Optional[typing.Union[collections.abc.Callable[[], str], str]] = None
|
||||
required: typing.Optional[bool] = None
|
||||
length: typing.Optional[int] = None
|
||||
lines: typing.Optional[int] = None
|
||||
pattern: typing.Union[FieldPatternType, 'typing.Pattern[str]'] = FieldPatternType.NONE
|
||||
tab: typing.Union[Tab, str, None] = None
|
||||
choices: typing.Optional[ChoicesType] = None
|
||||
min_value: typing.Optional[int] = None
|
||||
max_value: typing.Optional[int] = None
|
||||
fills: typing.Optional[Filler] = None
|
||||
rows: typing.Optional[int] = None
|
||||
readonly: bool | None = None
|
||||
value: collections.abc.Callable[[], typing.Any] | typing.Any | None = None
|
||||
default: collections.abc.Callable[[], str | int | bool] | str | int | bool | None = None
|
||||
required: bool | None = None
|
||||
length: int | None = None
|
||||
lines: int | None = None
|
||||
pattern: 'FieldPatternType | str | None' = None
|
||||
tab: Tab | str | None = None
|
||||
choices: ChoicesType | None = None
|
||||
min_value: int | None = None
|
||||
max_value: int | None = None
|
||||
fills: Filler | None = None
|
||||
rows: int | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, typing.Any]:
|
||||
"""Returns a dict with all fields that are not None"""
|
||||
return {k: v for k, v in dataclasses.asdict(self).items() if v is not None}
|
||||
|
||||
|
||||
class GuiElement(typing.TypedDict):
|
||||
@dataclasses.dataclass
|
||||
class GuiElement:
|
||||
name: str
|
||||
gui: dict[str, list[dict[str, typing.Any]]]
|
||||
value: typing.Any
|
||||
|
||||
|
||||
# Row styles
|
||||
@dataclasses.dataclass
|
||||
class RowStyleInfo:
|
||||
prefix: str
|
||||
field: str
|
||||
gui: FieldInfo
|
||||
value: typing.Any | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, typing.Any]:
|
||||
"""Returns a dict with all fields that are not None"""
|
||||
return dataclasses.asdict(self)
|
||||
|
||||
@staticmethod
|
||||
def null() -> 'RowStyleInfo':
|
||||
return RowStyleInfo('', '')
|
||||
|
||||
# Table information
|
||||
@dataclasses.dataclass
|
||||
class TableInfo:
|
||||
fields: list[FieldInfo]
|
||||
row_style: RowStyleInfo
|
||||
title: str
|
||||
subtitle: typing.Optional[str] = None
|
||||
|
||||
def as_dict(self) -> dict[str, typing.Any]:
|
||||
"""Returns a dict with all fields that are not None"""
|
||||
return dataclasses.asdict(self)
|
||||
|
||||
@staticmethod
|
||||
def null() -> 'TableInfo':
|
||||
return TableInfo([], RowStyleInfo.null(), '')
|
||||
return {
|
||||
'name': self.name,
|
||||
'gui': self.gui.as_dict(),
|
||||
'value': self.value,
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ import abc
|
||||
from django.conf import settings
|
||||
from django.utils.translation import gettext
|
||||
from django.utils.functional import Promise # To recognize lazy translations
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from uds.core import consts, exceptions, types
|
||||
from uds.core.managers.crypto import UDSK, CryptoManager
|
||||
@@ -56,12 +56,13 @@ from uds.core.util import modfinder, serializer, validators, ensure
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# To simplify choice parameters declaration of fields
|
||||
_ChoicesParamType: typing.TypeAlias = typing.Union[
|
||||
collections.abc.Callable[[], list['types.ui.ChoiceItem']],
|
||||
collections.abc.Iterable[str | types.ui.ChoiceItem],
|
||||
dict[str, str],
|
||||
None,
|
||||
]
|
||||
_ChoicesParamType: typing.TypeAlias = collections.abc.Iterable[types.ui.ChoiceItem]|collections.abc.Callable[[], list['types.ui.ChoiceItem']]|None
|
||||
# typing.Union[
|
||||
# collections.abc.Callable[[], list['types.ui.ChoiceItem']],
|
||||
# collections.abc.Iterable[str | types.ui.ChoiceItem],
|
||||
# dict[str, str],
|
||||
# None,
|
||||
# ]
|
||||
|
||||
|
||||
class gui:
|
||||
@@ -132,22 +133,19 @@ class gui:
|
||||
"""
|
||||
if not isinstance(text, (str, Promise)):
|
||||
text = str(text)
|
||||
return {
|
||||
'id': str(id_),
|
||||
'text': typing.cast(str, text),
|
||||
} # Cast to avoid mypy error, Promise is at all effects a str
|
||||
return types.ui.ChoiceItem(id=id_, text=typing.cast(str, text))
|
||||
|
||||
@staticmethod
|
||||
def choice_image(id_: typing.Union[str, int], text: str, img: str) -> types.ui.ChoiceItem:
|
||||
"""
|
||||
Helper method to create a single choice item with image.
|
||||
"""
|
||||
return {'id': str(id_), 'text': str(text), 'img': img}
|
||||
return types.ui.ChoiceItem(id=id_, text=str(text), img=img)
|
||||
|
||||
# Helpers
|
||||
@staticmethod
|
||||
def as_choices(
|
||||
vals: _ChoicesParamType,
|
||||
vals: _ChoicesParamType|dict[str, str]|str|collections.abc.Iterable[str|types.ui.ChoiceItem]|None = None,
|
||||
) -> typing.Union[collections.abc.Callable[[], list['types.ui.ChoiceItem']], list['types.ui.ChoiceItem']]:
|
||||
"""
|
||||
Helper to convert from array of strings (or dictionaries) to the same dict used in choice,
|
||||
@@ -160,14 +158,10 @@ class gui:
|
||||
if callable(vals):
|
||||
return vals
|
||||
|
||||
# Helper to convert an item to a dict
|
||||
def _choice_from_value(val: typing.Union[str, types.ui.ChoiceItem]) -> 'types.ui.ChoiceItem':
|
||||
if isinstance(val, dict):
|
||||
if 'id' not in val or 'text' not in val:
|
||||
raise ValueError(f'Invalid choice dict: {val}')
|
||||
return gui.choice_item(val['id'], val['text'])
|
||||
# If val is not a dict, and it has not 'id' and 'text', raise an exception
|
||||
return gui.choice_item(val, str(val))
|
||||
def _choice_from_value(val: str | types.ui.ChoiceItem) -> 'types.ui.ChoiceItem':
|
||||
if isinstance(val, str):
|
||||
return gui.choice_item(val, val)
|
||||
return val
|
||||
|
||||
# If is a dict
|
||||
if isinstance(vals, dict):
|
||||
@@ -188,9 +182,9 @@ class gui:
|
||||
key: typing.Optional[collections.abc.Callable[[types.ui.ChoiceItem], typing.Any]] = None,
|
||||
) -> list[types.ui.ChoiceItem]:
|
||||
if by_id:
|
||||
key = lambda item: item['id']
|
||||
key = lambda item: item.id
|
||||
elif key is None:
|
||||
key = lambda item: item['text'].lower()
|
||||
key = lambda item: item.text.casefold()
|
||||
else:
|
||||
key = key
|
||||
return sorted(choices, key=key, reverse=reverse)
|
||||
@@ -325,7 +319,7 @@ class gui:
|
||||
value=value,
|
||||
tab=tab,
|
||||
)
|
||||
|
||||
|
||||
@property
|
||||
def field_name(self) -> str:
|
||||
"""
|
||||
@@ -389,22 +383,29 @@ class gui:
|
||||
"""
|
||||
self._field_info.value = value
|
||||
|
||||
def gui_description(self) -> dict[str, typing.Any]:
|
||||
def gui_description(self) -> types.ui.FieldInfo:
|
||||
"""
|
||||
Returns the dictionary with the description of this item.
|
||||
We copy it, cause we need to translate the label and tooltip fields
|
||||
and don't want to
|
||||
alter original values.
|
||||
"""
|
||||
data = self._field_info.as_dict()
|
||||
for i in ('value', 'old_field_name'):
|
||||
if i in data:
|
||||
del data[i] # We don't want to send some values on gui_description
|
||||
data['label'] = gettext(data['label']) if data['label'] else ''
|
||||
data['tooltip'] = gettext(data['tooltip']) if data['tooltip'] else ''
|
||||
if 'tab' in data:
|
||||
data['tab'] = gettext(data['tab']) # Translates tab name
|
||||
data['default'] = self.default # We need to translate default value
|
||||
data = copy.copy(self._field_info)
|
||||
data.value = data.old_field_name = None # We don't want to send some values on gui_description
|
||||
data.label = gettext(data.label) if data.label else ''
|
||||
# Translate label and tooltip
|
||||
data.tooltip = gettext(data.tooltip) if data.tooltip else ''
|
||||
|
||||
# And, if tab is set, translate it too
|
||||
if data.tab:
|
||||
data.tab = gettext(data.tab) # Translates tab name
|
||||
|
||||
# Choices can be a callback, resolve
|
||||
if callable(data.choices):
|
||||
data.choices = data.choices()
|
||||
|
||||
data.default = self.default
|
||||
|
||||
return data
|
||||
|
||||
@property
|
||||
@@ -649,7 +650,7 @@ class gui:
|
||||
self.field_type = types.ui.FieldType.TEXT_AUTOCOMPLETE
|
||||
self._field_info.choices = gui.as_choices(choices or [])
|
||||
|
||||
def set_choices(self, values: collections.abc.Iterable[typing.Union[str, types.ui.ChoiceItem]]) -> None:
|
||||
def set_choices(self, values: collections.abc.Iterable[types.ui.ChoiceItem]) -> None:
|
||||
"""
|
||||
Set the values for this choice field
|
||||
"""
|
||||
@@ -765,7 +766,7 @@ class gui:
|
||||
def as_datetime(self) -> datetime.datetime:
|
||||
"""Alias for "value" property, but as datetime.datetime"""
|
||||
# Convert date to datetime
|
||||
return datetime.datetime.combine(self.as_date(), datetime.datetime.min.time())
|
||||
return timezone.make_aware(datetime.datetime.combine(self.as_date(), datetime.datetime.min.time()))
|
||||
|
||||
def as_timestamp(self) -> int:
|
||||
"""Alias for "value" property, but as timestamp"""
|
||||
@@ -799,11 +800,11 @@ class gui:
|
||||
def value(self, value: datetime.date | str) -> None:
|
||||
self._set_value(value)
|
||||
|
||||
def gui_description(self) -> dict[str, typing.Any]:
|
||||
def gui_description(self) -> types.ui.FieldInfo:
|
||||
fldgui = super().gui_description()
|
||||
# Convert if needed value and default to string (YYYY-MM-DD)
|
||||
if 'default' in fldgui:
|
||||
fldgui['default'] = str(fldgui['default'])
|
||||
if fldgui.default is not None:
|
||||
fldgui.default = str(fldgui.default)
|
||||
return fldgui
|
||||
|
||||
class PasswordField(InputField):
|
||||
@@ -1133,7 +1134,7 @@ class gui:
|
||||
if fills['callback_name'] not in gui.callbacks:
|
||||
gui.callbacks[fills['callback_name']] = fnc
|
||||
|
||||
def set_choices(self, values: collections.abc.Iterable[typing.Union[str, types.ui.ChoiceItem]]) -> None:
|
||||
def set_choices(self, values: collections.abc.Iterable[types.ui.ChoiceItem]) -> None:
|
||||
"""
|
||||
Set the values for this choice field
|
||||
"""
|
||||
@@ -1185,7 +1186,7 @@ class gui:
|
||||
|
||||
self._field_info.choices = gui.as_choices(choices or [])
|
||||
|
||||
def set_choices(self, values: collections.abc.Iterable[typing.Union[str, types.ui.ChoiceItem]]) -> None:
|
||||
def set_choices(self, values: collections.abc.Iterable[types.ui.ChoiceItem]) -> None:
|
||||
"""
|
||||
Set the values for this choice field
|
||||
"""
|
||||
@@ -1275,7 +1276,7 @@ class gui:
|
||||
self._field_info.choices = gui.as_choices(choices or [])
|
||||
|
||||
def set_choices(
|
||||
self, choices: collections.abc.Iterable[typing.Union[str, types.ui.ChoiceItem]]
|
||||
self, choices: collections.abc.Iterable[types.ui.ChoiceItem]
|
||||
) -> None:
|
||||
"""
|
||||
Set the values for this choice field
|
||||
@@ -1523,6 +1524,17 @@ class UserInterface(metaclass=UserInterfaceType):
|
||||
of this posibility in a near version...
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def describe_fields(cls: type[typing.Self]) -> list[types.ui.GuiElement]:
|
||||
return [
|
||||
types.ui.GuiElement(
|
||||
name=key,
|
||||
gui=val.gui_description(),
|
||||
value=val.value if val.is_type(types.ui.FieldType.HIDDEN) else None,
|
||||
)
|
||||
for key, val in cls._gui_fields_template.items()
|
||||
]
|
||||
|
||||
def get_fields_as_dict(self) -> gui.ValuesDictType:
|
||||
"""
|
||||
Returns own data needed for user interaction as a dict of key-names ->
|
||||
@@ -1636,6 +1648,11 @@ class UserInterface(metaclass=UserInterfaceType):
|
||||
# Dict of translations from old_field_name to field_name
|
||||
field_names_translations: dict[str, str] = self._get_fieldname_translations()
|
||||
|
||||
# Allowed conversions of type
|
||||
VALID_CONVERSIONS: typing.Final[dict[types.ui.FieldType, list[types.ui.FieldType]]] = {
|
||||
types.ui.FieldType.TEXT: [types.ui.FieldType.PASSWORD]
|
||||
}
|
||||
|
||||
# Set all values to defaults ones
|
||||
for field_name, field in self._all_serializable_fields():
|
||||
if field.is_type(types.ui.FieldType.HIDDEN) and field.is_serializable() is False:
|
||||
@@ -1653,17 +1670,20 @@ class UserInterface(metaclass=UserInterfaceType):
|
||||
if internal_field_type not in FIELD_DECODERS:
|
||||
logger.warning('Field %s has no decoder', field_name)
|
||||
continue
|
||||
|
||||
|
||||
if field_type != internal_field_type.name:
|
||||
# Especial case for text fields converted to password fields
|
||||
if not (internal_field_type == types.ui.FieldType.PASSWORD and field_type == types.ui.FieldType.TEXT.name):
|
||||
logger.warning(
|
||||
'Field %s has different type than expected: %s != %s',
|
||||
field_name,
|
||||
field_type,
|
||||
internal_field_type.name,
|
||||
)
|
||||
continue
|
||||
if valids_for_field := VALID_CONVERSIONS.get(internal_field_type):
|
||||
if field_type not in [v.name for v in valids_for_field]:
|
||||
# If the field type is not valid for the internal field type, we log a warning
|
||||
# and do not include this field in the form
|
||||
logger.warning(
|
||||
'Field %s has different type than expected: %s != %s. Not included in form',
|
||||
field_name,
|
||||
field_type,
|
||||
internal_field_type.name,
|
||||
)
|
||||
continue
|
||||
|
||||
self._gui[field_name].value = FIELD_DECODERS[internal_field_type](field_value)
|
||||
|
||||
return False
|
||||
@@ -1744,11 +1764,11 @@ class UserInterface(metaclass=UserInterfaceType):
|
||||
for key, val in self._gui.items():
|
||||
# Only add "value" for hidden fields on gui description. Rest of fields will be filled by client
|
||||
res.append(
|
||||
{
|
||||
'name': key,
|
||||
'gui': val.gui_description(),
|
||||
'value': val.value if val.is_type(types.ui.FieldType.HIDDEN) else None,
|
||||
}
|
||||
types.ui.GuiElement(
|
||||
name=key,
|
||||
gui=val.gui_description(),
|
||||
value=val.value if val.is_type(types.ui.FieldType.HIDDEN) else None,
|
||||
)
|
||||
)
|
||||
# logger.debug('theGui description: %s', res)
|
||||
return res
|
||||
@@ -1791,12 +1811,13 @@ def password_compat_field_decoder(value: str) -> str:
|
||||
"""
|
||||
Compatibility function to decode text fields converted to password fields
|
||||
"""
|
||||
try:
|
||||
try:
|
||||
value = CryptoManager.manager().aes_decrypt(value.encode('utf8'), UDSK, True).decode()
|
||||
except Exception:
|
||||
pass
|
||||
return value
|
||||
|
||||
|
||||
# Dictionaries used to encode/decode fields to be stored on database
|
||||
FIELDS_ENCODERS: typing.Final[
|
||||
collections.abc.Mapping[
|
||||
|
||||
459
server/src/uds/core/util/api.py
Normal file
459
server/src/uds/core/util/api.py
Normal file
@@ -0,0 +1,459 @@
|
||||
import typing
|
||||
import itertools
|
||||
import collections.abc
|
||||
import logging
|
||||
import dataclasses
|
||||
import datetime
|
||||
import enum
|
||||
import functools
|
||||
import types as py_types
|
||||
|
||||
from uds.core import types
|
||||
from uds.core.types.rest.api import SchemaProperty
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from uds.REST import model
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _resolve_forwardref(
|
||||
ref: typing.Any, globalns: dict[str, typing.Any] | None = None, localns: dict[str, typing.Any] | None = None
|
||||
):
|
||||
if isinstance(ref, typing.ForwardRef):
|
||||
# if not already evaluated, raise an exception
|
||||
if not ref.__forward_evaluated__:
|
||||
return None
|
||||
return ref.__forward_value__
|
||||
return ref
|
||||
|
||||
|
||||
def get_generic_types(
|
||||
cls: 'type[model.ModelHandler[typing.Any] | model.DetailHandler[typing.Any]]',
|
||||
) -> list[type[types.rest.BaseRestItem]]:
|
||||
"""
|
||||
Get the generic types of a model handler or detail handler class.
|
||||
|
||||
Args:
|
||||
cls: The class to inspect. (Must be subclass of ModelHandler or DetailHandler)
|
||||
|
||||
Note: Normally, for our models, will be or an empty list, or a list with just one element
|
||||
that is a subclass of BaseRestItem.
|
||||
Examples:
|
||||
class Test(ModelHandler[TheType]):
|
||||
...
|
||||
if Test is resolvable and TheType is also resolvable, will return
|
||||
[TheType], else will return []
|
||||
We use the "list" version just in case, in a future, we have other kind of constructions
|
||||
with several elements.
|
||||
"""
|
||||
base_types: list[type[types.rest.BaseRestItem]] = list(
|
||||
filter(
|
||||
lambda x: issubclass(x, types.rest.BaseRestItem), # pyright: ignore[reportUnnecessaryIsInstance]
|
||||
itertools.chain.from_iterable(
|
||||
map(
|
||||
lambda x: [
|
||||
# Filter out non resolvable forward references of the ARGS, protect against failures
|
||||
typing.cast(type[typing.Any], _resolve_forwardref(xx))
|
||||
for xx in typing.get_args(x)
|
||||
if _resolve_forwardref(xx) is not None
|
||||
],
|
||||
[
|
||||
# Filter out non resolvable forward references of the TYPE, protect against failures
|
||||
typing.cast(type[typing.Any], _resolve_forwardref(base))
|
||||
for base in filter(
|
||||
lambda x: _resolve_forwardref(x) is not None,
|
||||
[base for base in getattr(cls, '__orig_bases__', [])],
|
||||
)
|
||||
],
|
||||
)
|
||||
),
|
||||
)
|
||||
)
|
||||
return base_types
|
||||
|
||||
|
||||
def get_component_from_type(
|
||||
cls: 'type[model.ModelHandler[typing.Any] | model.DetailHandler[typing.Any]]',
|
||||
) -> types.rest.api.Components:
|
||||
logger.debug('Getting components from type %s', cls)
|
||||
base_types = get_generic_types(cls)
|
||||
|
||||
all_components = types.rest.api.Components()
|
||||
|
||||
for base_type in base_types:
|
||||
logger.debug('Processing base %s for components %s', base_type, base_type.__bases__)
|
||||
components = base_type.api_components()
|
||||
|
||||
# A reference
|
||||
item_name = base_type.__name__
|
||||
# For item schema in components
|
||||
item_schema = next(filter(lambda x: x[0] == item_name, components.schemas.items()), (None, None))[1]
|
||||
|
||||
is_managed_object = issubclass(base_type, types.rest.ManagedObjectItem)
|
||||
possible_types = cls.possible_types()
|
||||
|
||||
refs: list[str] = []
|
||||
mappings: list[tuple[str, str]] = []
|
||||
for type_ in possible_types:
|
||||
type_schema = types.rest.api.Schema(
|
||||
type='object',
|
||||
required=[],
|
||||
description=type_.__doc__ or None,
|
||||
)
|
||||
for field in type_.describe_fields():
|
||||
schema_property = types.rest.api.SchemaProperty.from_field_desc(field)
|
||||
if schema_property is None:
|
||||
continue # Skip fields that don't have a schema property
|
||||
type_schema.properties[field.name] = schema_property
|
||||
if field.gui.required is True:
|
||||
type_schema.required.append(field.name)
|
||||
|
||||
ref = f'#/components/schemas/{type_.mod_type()}'
|
||||
refs.append(ref)
|
||||
mappings.append((f'{type_.mod_type()}', ref))
|
||||
|
||||
components.schemas[type_.mod_type()] = type_schema
|
||||
|
||||
if is_managed_object and isinstance(item_schema, types.rest.api.Schema):
|
||||
# item_schema.discriminator = types.rest.api.Discriminator(propertyName='type')
|
||||
instance_name = f'{item_name}Instance'
|
||||
item_schema.properties['instance'] = types.rest.api.SchemaProperty(
|
||||
type=f'#/components/schemas/{instance_name}'
|
||||
)
|
||||
instance_comps = types.rest.api.Components(
|
||||
schemas={instance_name: types.rest.api.RelatedSchema(property='type', mappings=mappings)}
|
||||
)
|
||||
all_components = all_components.union(instance_comps)
|
||||
|
||||
# Store it
|
||||
all_components = all_components.union(components)
|
||||
|
||||
return all_components
|
||||
|
||||
|
||||
@dataclasses.dataclass(slots=True)
|
||||
class OpenApiTypeInfo:
|
||||
type: str
|
||||
format: str | None = None
|
||||
ref: bool = False
|
||||
items: str | None = None # Type of items in array
|
||||
|
||||
def as_dict(self) -> dict[str, typing.Any]:
|
||||
dct: dict[str, typing.Any] = {'type': self.type}
|
||||
if self.format:
|
||||
dct['format'] = self.format
|
||||
if self.items:
|
||||
dct['items'] = {'type': self.items}
|
||||
return dct
|
||||
|
||||
|
||||
class OpenApiType(enum.Enum):
|
||||
OBJECT = OpenApiTypeInfo(type='object')
|
||||
INTEGER = OpenApiTypeInfo(type='integer', format='int64')
|
||||
STRING = OpenApiTypeInfo(type='string')
|
||||
NUMBER = OpenApiTypeInfo(type='number')
|
||||
BOOLEAN = OpenApiTypeInfo(type='boolean')
|
||||
NULL = OpenApiTypeInfo(type='null')
|
||||
DATE_TIME = OpenApiTypeInfo(type='string', format='date-time')
|
||||
DATE = OpenApiTypeInfo(type='string', format='date')
|
||||
LIST_STR = OpenApiTypeInfo(type='array', items='string')
|
||||
LIST_INT = OpenApiTypeInfo(type='array', items='integer')
|
||||
|
||||
|
||||
_OPENAPI_TYPE_MAP: typing.Final[dict[typing.Any, OpenApiType]] = {
|
||||
int: OpenApiType.INTEGER,
|
||||
str: OpenApiType.STRING,
|
||||
float: OpenApiType.NUMBER,
|
||||
bool: OpenApiType.BOOLEAN,
|
||||
type(None): OpenApiType.NULL,
|
||||
datetime.datetime: OpenApiType.DATE_TIME,
|
||||
datetime.date: OpenApiType.DATE,
|
||||
list[str]: OpenApiType.LIST_STR,
|
||||
list[int]: OpenApiType.LIST_INT,
|
||||
}
|
||||
|
||||
|
||||
def python_type_to_openapi(
|
||||
py_type: typing.Any, description: str | None = None
|
||||
) -> 'types.rest.api.SchemaProperty':
|
||||
"""
|
||||
Convert a Python type to an OpenAPI 3.1 schema property.
|
||||
"""
|
||||
|
||||
# Partial to add description to schema property if provided
|
||||
schema_prop = functools.partial(types.rest.api.SchemaProperty, description=description)
|
||||
|
||||
|
||||
origin = typing.get_origin(py_type)
|
||||
args = typing.get_args(py_type)
|
||||
|
||||
# list[...] → array
|
||||
if origin is list:
|
||||
item_type = args[0] if args else typing.Any
|
||||
return schema_prop(type='array', items=python_type_to_openapi(item_type))
|
||||
|
||||
# dict[...] → object
|
||||
elif origin is dict:
|
||||
value_type = args[1] if len(args) == 2 else typing.Any
|
||||
return schema_prop(
|
||||
type='object', additionalProperties=python_type_to_openapi(value_type)
|
||||
)
|
||||
|
||||
# Union[...] → oneOf
|
||||
# Except if one of them is None, in which case, we must extract it from the list
|
||||
# and create {'type': xxx, 'nullable': true}
|
||||
elif origin in {py_types.UnionType, typing.Union}:
|
||||
# Optional[X] is Union[X, None]
|
||||
# Note: the casting is because we use "is not", and cannot ad inner types
|
||||
one_of: list[SchemaProperty] = [
|
||||
python_type_to_openapi(arg)
|
||||
for arg in args
|
||||
if arg is not None
|
||||
and typing.get_origin(arg) is not typing.cast(typing.Any, collections.abc.Callable)
|
||||
]
|
||||
# Remove repeated
|
||||
one_of = list({item.type: item for item in one_of}.values())
|
||||
# if only 1, return it directly
|
||||
if len(one_of) == 1:
|
||||
return one_of[0]
|
||||
|
||||
return schema_prop(
|
||||
type='not_used',
|
||||
one_of=one_of,
|
||||
)
|
||||
|
||||
elif origin is typing.Annotated:
|
||||
return python_type_to_openapi(args[0])
|
||||
|
||||
# Literal[...] → enum
|
||||
elif origin is typing.Literal:
|
||||
literal_type = typing.cast(type[typing.Any], type(args[0]) if args else str)
|
||||
return schema_prop(
|
||||
type=_OPENAPI_TYPE_MAP.get(literal_type, OpenApiType.STRING).value.type, enum=list(args)
|
||||
)
|
||||
|
||||
# Enum classes
|
||||
# First, IntEnum --> int
|
||||
elif isinstance(py_type, type) and issubclass(py_type, enum.IntEnum):
|
||||
return schema_prop(type='integer', enum=[e.value for e in py_type])
|
||||
|
||||
# Now, StrEnum --> string
|
||||
elif isinstance(py_type, type) and issubclass(py_type, enum.StrEnum):
|
||||
return schema_prop(type='string', enum=[e.value for e in py_type])
|
||||
|
||||
# Rest of cases --> enum with first item type setting the type for the field
|
||||
elif isinstance(py_type, type) and issubclass(py_type, enum.Enum):
|
||||
try:
|
||||
sample = next(iter(py_type))
|
||||
value_type = typing.cast(type[typing.Any], type(sample.value))
|
||||
openapi_type = _OPENAPI_TYPE_MAP.get(value_type, OpenApiType.STRING)
|
||||
return schema_prop(type=openapi_type.value.type, enum=[e.value for e in py_type])
|
||||
except StopIteration:
|
||||
return schema_prop(type='string')
|
||||
elif isinstance(py_type, type) and dataclasses.is_dataclass(py_type):
|
||||
return schema_prop(type=f'#/components/schemas/{py_type.__name__}')
|
||||
|
||||
# Simple types
|
||||
oa_type = _OPENAPI_TYPE_MAP.get(py_type, OpenApiType.OBJECT)
|
||||
return schema_prop(type=oa_type.value.type, format=oa_type.value.format)
|
||||
|
||||
|
||||
def api_components(
|
||||
dataclass: typing.Type[typing.Any], *, removable_fields: list[str] | None = None
|
||||
) -> 'types.rest.api.Components':
|
||||
from uds.core.util import api as api_util # Avoid circular import
|
||||
|
||||
# If not dataclass, raise a ValueError
|
||||
if not dataclasses.is_dataclass(dataclass):
|
||||
raise ValueError('Expected a dataclass')
|
||||
|
||||
our_removables: set[str] = set()
|
||||
|
||||
child_removables: dict[str, list[str]] = {}
|
||||
for rem_fld in removable_fields or []:
|
||||
if '.' in rem_fld:
|
||||
child_name, field = rem_fld.split('.', 1)
|
||||
if child_name not in child_removables:
|
||||
child_removables[child_name] = []
|
||||
|
||||
child_removables[child_name].append(field)
|
||||
else:
|
||||
our_removables.add(rem_fld)
|
||||
|
||||
components = types.rest.api.Components()
|
||||
schema = types.rest.api.Schema(type='object', properties={}, description=None)
|
||||
# type_hints = typing.get_type_hints(dataclass)
|
||||
|
||||
for field in dataclasses.fields(dataclass):
|
||||
if field.name in our_removables:
|
||||
continue
|
||||
|
||||
description = field.metadata.get('description')
|
||||
|
||||
# Check the type, can be a primitive or a complex type
|
||||
# complexes types accepted are list and dict currently
|
||||
field_type = field.type # type_hints.get(field.name)
|
||||
if not field_type:
|
||||
raise Exception(f'Field {field.name} has no type hint')
|
||||
|
||||
args = typing.get_args(field_type)
|
||||
|
||||
if args and dataclasses.is_dataclass(args[0]):
|
||||
# If it's a reference to a dataclass, include the dataclass definition
|
||||
# care with circular references. Not checked right now, data is our,
|
||||
# No problem should arise..
|
||||
components = components | api_components(
|
||||
typing.cast(type[typing.Any], args[0]), removable_fields=child_removables.get(field.name, [])
|
||||
)
|
||||
|
||||
# If it is a dataclass, get its API components
|
||||
if dataclasses.is_dataclass(field_type):
|
||||
components = components | api_components(
|
||||
typing.cast(type[typing.Any], field_type),
|
||||
removable_fields=child_removables.get(field.name, []),
|
||||
)
|
||||
|
||||
schema_prop = api_util.python_type_to_openapi(field_type, description=description)
|
||||
|
||||
schema.properties[field.name] = schema_prop
|
||||
if field.default is dataclasses.MISSING and field.default_factory is dataclasses.MISSING:
|
||||
schema.required.append(field.name)
|
||||
|
||||
components.schemas[dataclass.__name__] = schema
|
||||
return components
|
||||
|
||||
|
||||
def gen_response(
|
||||
type: str,
|
||||
single: bool = True,
|
||||
delete: bool = False,
|
||||
with_403: bool = True,
|
||||
) -> dict[str, types.rest.api.Response]:
|
||||
data: dict[str, types.rest.api.Response]
|
||||
|
||||
if not single:
|
||||
data = {
|
||||
'200': types.rest.api.Response(
|
||||
description=f'Successfully retrieved all {type} items',
|
||||
content=types.rest.api.Content(
|
||||
media_type='application/json',
|
||||
schema=types.rest.api.SchemaProperty(
|
||||
type='array',
|
||||
items=types.rest.api.SchemaProperty(
|
||||
type=f'#/components/schemas/{type}',
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
else:
|
||||
data = {
|
||||
'200': types.rest.api.Response(
|
||||
description=f'Successfully {"retrieved" if not delete else "deleted"} {type} item',
|
||||
content=types.rest.api.Content(
|
||||
media_type='application/json',
|
||||
schema=types.rest.api.SchemaProperty(
|
||||
type=f'#/components/schemas/{type}',
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if single:
|
||||
data['404'] = types.rest.api.Response(
|
||||
description=f'{type} item not found',
|
||||
content=types.rest.api.Content(
|
||||
media_type='application/json',
|
||||
schema=types.rest.api.SchemaProperty(
|
||||
type='object',
|
||||
properties={
|
||||
'detail': types.rest.api.SchemaProperty(
|
||||
type='string',
|
||||
)
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
if with_403:
|
||||
data['403'] = types.rest.api.Response(
|
||||
description='Forbidden. You do not have permission to access this resource with your current role.',
|
||||
content=types.rest.api.Content(
|
||||
media_type='application/json',
|
||||
schema=types.rest.api.SchemaProperty(
|
||||
type='object',
|
||||
properties={
|
||||
'detail': types.rest.api.SchemaProperty(
|
||||
type='string',
|
||||
)
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def gen_request_body(type: str, create: bool = True) -> types.rest.api.RequestBody:
|
||||
return types.rest.api.RequestBody(
|
||||
description=f'{"New" if create else "Updated"} {type} item{"s" if not create else ""} to create',
|
||||
required=True,
|
||||
content=types.rest.api.Content(
|
||||
media_type='application/json',
|
||||
schema=types.rest.api.SchemaProperty(
|
||||
type=f'#/components/schemas/{type}',
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def gen_odata_parameters() -> list[types.rest.api.Parameter]:
|
||||
return [
|
||||
types.rest.api.Parameter(
|
||||
name='$filter',
|
||||
in_='query',
|
||||
required=False,
|
||||
description='Filter items by property values (e.g., $filter=property eq value)',
|
||||
schema=types.rest.api.Schema(type='string'),
|
||||
),
|
||||
types.rest.api.Parameter(
|
||||
name='$select',
|
||||
in_='query',
|
||||
required=False,
|
||||
description='Select properties to be returned',
|
||||
schema=types.rest.api.Schema(type='string'),
|
||||
),
|
||||
types.rest.api.Parameter(
|
||||
name='$orderby',
|
||||
in_='query',
|
||||
required=False,
|
||||
description='Order items by property values (e.g., $orderby=property desc)',
|
||||
schema=types.rest.api.Schema(type='string'),
|
||||
),
|
||||
types.rest.api.Parameter(
|
||||
name='$top',
|
||||
in_='query',
|
||||
required=False,
|
||||
description='Show only the first N items',
|
||||
schema=types.rest.api.Schema(type='integer', format='int32', minimum=1),
|
||||
),
|
||||
types.rest.api.Parameter(
|
||||
name='$skip',
|
||||
in_='query',
|
||||
required=False,
|
||||
description='Skip the first N items',
|
||||
schema=types.rest.api.Schema(type='integer', format='int32', minimum=0),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def gen_uuid_parameters(with_odata: bool) -> list[types.rest.api.Parameter]:
|
||||
return [
|
||||
types.rest.api.Parameter(
|
||||
name='uuid',
|
||||
in_='path',
|
||||
required=True,
|
||||
description='The UUID of the item',
|
||||
schema=types.rest.api.Schema(type='string', format='uuid'),
|
||||
)
|
||||
] + (gen_odata_parameters() if with_odata else [])
|
||||
@@ -66,7 +66,9 @@ class Cache:
|
||||
_serializer: typing.ClassVar[collections.abc.Callable[[typing.Any], str]] = _basic_serialize
|
||||
_deserializer: typing.ClassVar[collections.abc.Callable[[str], typing.Any]] = _basic_deserialize
|
||||
|
||||
def __init__(self, owner: typing.Union[str, bytes], default_timeout: int = consts.cache.DEFAULT_CACHE_TIMEOUT) -> None:
|
||||
def __init__(
|
||||
self, owner: typing.Union[str, bytes], default_timeout: int = consts.cache.DEFAULT_CACHE_TIMEOUT
|
||||
) -> None:
|
||||
self._owner = owner.decode('utf-8') if isinstance(owner, bytes) else owner
|
||||
self._timeout = default_timeout
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ import logging
|
||||
import bitarray
|
||||
|
||||
from django.core.cache import caches
|
||||
from django.utils import timezone
|
||||
|
||||
from uds.core.util.model import sql_now
|
||||
|
||||
@@ -77,12 +78,14 @@ class CalendarChecker:
|
||||
data_date = dtime.date()
|
||||
|
||||
start = datetime.datetime.combine(data_date, datetime.datetime.min.time())
|
||||
start = timezone.make_aware(start)
|
||||
end = datetime.datetime.combine(data_date, datetime.datetime.max.time())
|
||||
end = timezone.make_aware(end)
|
||||
|
||||
for rule in self.calendar.rules.all():
|
||||
rr = rule.as_rrule()
|
||||
|
||||
r_end = datetime.datetime.combine(rule.end, datetime.datetime.max.time()) if rule.end else None
|
||||
r_end = timezone.make_aware(datetime.datetime.combine(rule.end, datetime.datetime.max.time())) if rule.end else None
|
||||
|
||||
duration_in_minutes = rule.duration_as_minutes
|
||||
frequency_in_minutes = rule.frequency_as_minutes
|
||||
|
||||
@@ -4,8 +4,10 @@ import socket
|
||||
import typing
|
||||
|
||||
from django.db import transaction, OperationalError
|
||||
from django.utils import timezone
|
||||
|
||||
from uds import models
|
||||
from uds.core import consts
|
||||
from uds.core.util.iface import get_first_iface
|
||||
from uds.core.util.model import sql_now, get_my_ip_from_db
|
||||
|
||||
@@ -20,7 +22,7 @@ class UDSClusterNode(typing.NamedTuple):
|
||||
hostname: str
|
||||
ip: str
|
||||
last_seen: datetime.datetime
|
||||
mac: str = '00:00:00:00:00:00'
|
||||
mac: str = consts.NULL_MAC
|
||||
|
||||
def as_dict(self) -> dict[str, str]:
|
||||
"""
|
||||
@@ -44,7 +46,7 @@ def store_cluster_info() -> None:
|
||||
"""
|
||||
iface = get_first_iface()
|
||||
ip = iface.ip if iface else get_my_ip_from_db()
|
||||
mac = iface.mac if iface else '00:00:00:00:00:00'
|
||||
mac = iface.mac if iface else consts.NULL_MAC
|
||||
|
||||
try:
|
||||
hostname = socket.getfqdn() + '|' + ip
|
||||
@@ -81,8 +83,8 @@ def enumerate_cluster_nodes() -> list[UDSClusterNode]:
|
||||
UDSClusterNode(
|
||||
hostname=prop.key.split('|')[0],
|
||||
ip=prop.key.split('|')[1],
|
||||
last_seen=datetime.datetime.fromisoformat(prop.value['last_seen']),
|
||||
mac=prop.value.get('mac', '00:00:00:00:00:00'),
|
||||
last_seen=timezone.make_aware(datetime.datetime.fromisoformat(prop.value['last_seen'])),
|
||||
mac=prop.value.get('mac', consts.NULL_MAC),
|
||||
)
|
||||
for prop in properties
|
||||
if 'last_seen' in prop.value and '|' in prop.key
|
||||
|
||||
@@ -349,7 +349,7 @@ class Config:
|
||||
@staticmethod
|
||||
def get_config_values(
|
||||
include_passwords: bool = False,
|
||||
) -> collections.abc.Mapping[str, collections.abc.Mapping[str, collections.abc.Mapping[str, typing.Any]]]:
|
||||
) -> dict[str, dict[str, dict[str, typing.Any]]]:
|
||||
"""
|
||||
Returns a dictionary with all config values
|
||||
"""
|
||||
@@ -701,7 +701,7 @@ class GlobalConfig:
|
||||
# Site display name & copyright info
|
||||
SITE_NAME: Config.Value = Config.section(Config.SectionType.CUSTOM).value(
|
||||
'Site name',
|
||||
'UDS Enterprise',
|
||||
'UDS',
|
||||
type=Config.FieldType.TEXT,
|
||||
help=_('Site display name'),
|
||||
)
|
||||
|
||||
@@ -45,7 +45,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# FT = typing.TypeVar('FT', bound=collections.abc.Callable[..., typing.Any])
|
||||
P = typing.ParamSpec('P')
|
||||
R = typing.TypeVar('R')
|
||||
R = typing.TypeVar('R', bound=typing.Any, covariant=True) # R is covariant, so we can return a subclass of R
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
@@ -147,16 +147,16 @@ class _HasConnect(typing.Protocol):
|
||||
|
||||
# Keep this, but mypy does not likes it... it's perfect with pyright
|
||||
# We use pyright for type checking, so we will use this
|
||||
HasConnect = typing.TypeVar('HasConnect', bound=_HasConnect)
|
||||
HAS_CONNECT = typing.TypeVar('HAS_CONNECT', bound=_HasConnect)
|
||||
|
||||
|
||||
def ensure_connected(
|
||||
func: collections.abc.Callable[typing.Concatenate[HasConnect, P], R],
|
||||
) -> collections.abc.Callable[typing.Concatenate[HasConnect, P], R]:
|
||||
func: collections.abc.Callable[typing.Concatenate[HAS_CONNECT, P], R],
|
||||
) -> collections.abc.Callable[typing.Concatenate[HAS_CONNECT, P], R]:
|
||||
"""This decorator calls "connect" method of the class of the wrapped object"""
|
||||
|
||||
@functools.wraps(func)
|
||||
def connect_and_execute(obj: HasConnect, /, *args: P.args, **kwargs: P.kwargs) -> R:
|
||||
def connect_and_execute(obj: HAS_CONNECT, /, *args: P.args, **kwargs: P.kwargs) -> R:
|
||||
# self = typing.cast(_HasConnect, args[0])
|
||||
obj.connect()
|
||||
return func(obj, *args, **kwargs)
|
||||
@@ -177,15 +177,14 @@ def ensure_connected(
|
||||
# Now, we could use this by creating two decorators, one for the class methods and one for the functions
|
||||
# But the inheritance problem will still be there, so we will keep the current implementation
|
||||
|
||||
|
||||
# Decorator for caching
|
||||
# This decorator will cache the result of the function for a given time, and given parameters
|
||||
def cached(
|
||||
prefix: typing.Optional[str] = None,
|
||||
timeout: typing.Union[collections.abc.Callable[[], int], int] = -1,
|
||||
args: typing.Optional[typing.Union[collections.abc.Iterable[int], int]] = None,
|
||||
kwargs: typing.Optional[typing.Union[collections.abc.Iterable[str], str]] = None,
|
||||
key_helper: typing.Optional[collections.abc.Callable[[typing.Any], str]] = None,
|
||||
prefix: str | None = None,
|
||||
timeout: collections.abc.Callable[[], int] | int = -1,
|
||||
args: collections.abc.Iterable[int] | int | None = None,
|
||||
kwargs: collections.abc.Iterable[str] | str | None = None,
|
||||
key_helper: collections.abc.Callable[[typing.Any], str] | None = None,
|
||||
) -> collections.abc.Callable[[collections.abc.Callable[P, R]], collections.abc.Callable[P, R]]:
|
||||
"""
|
||||
Decorator that gives us a "quick & clean" caching feature on the database.
|
||||
@@ -289,6 +288,9 @@ def cached(
|
||||
data: typing.Any = None
|
||||
# If misses is 0, we are starting, so we will not try to get from cache
|
||||
if not kwargs.get('force', False) and effective_timeout > 0 and misses > 0:
|
||||
if 'force' in kwargs:
|
||||
# Remove force key
|
||||
del kwargs['force']
|
||||
data = cache.get(cache_key, default=consts.cache.CACHE_NOT_FOUND)
|
||||
if data is not consts.cache.CACHE_NOT_FOUND:
|
||||
hits += 1
|
||||
@@ -296,10 +298,6 @@ def cached(
|
||||
|
||||
misses += 1
|
||||
|
||||
if 'force' in kwargs:
|
||||
# Remove force key
|
||||
del kwargs['force']
|
||||
|
||||
# Execute the function outside the DB transaction
|
||||
t = time.thread_time_ns()
|
||||
data = fnc(*args, **kwargs) # pyright: ignore # For some reason, pyright does not like this line
|
||||
@@ -340,8 +338,8 @@ def threaded(func: collections.abc.Callable[P, None]) -> collections.abc.Callabl
|
||||
|
||||
|
||||
def blocker(
|
||||
request_attr: typing.Optional[str] = None,
|
||||
max_failures: typing.Optional[int] = None,
|
||||
request_attr: str | None = None,
|
||||
max_failures: int | None = None,
|
||||
ignore_block_config: bool = False,
|
||||
) -> collections.abc.Callable[[collections.abc.Callable[P, R]], collections.abc.Callable[P, R]]:
|
||||
"""
|
||||
@@ -375,14 +373,14 @@ def blocker(
|
||||
except uds.core.exceptions.rest.BlockAccess:
|
||||
raise exceptions.rest.AccessDenied()
|
||||
|
||||
request: typing.Any = getattr(args[0], request_attr or '_request', None)
|
||||
req: typing.Any | None = getattr(args[0], request_attr or '_request', None)
|
||||
|
||||
# No request object, so we can't block
|
||||
if request is None or getattr(request, 'ip', None) is None:
|
||||
logger.debug('No request object, so we can\'t block: (value is %s)', request)
|
||||
if req is None or getattr(req, 'ip', None) is None:
|
||||
logger.debug('No request object, so we can\'t block: (value is %s)', req)
|
||||
return f(*args, **kwargs)
|
||||
|
||||
request = typing.cast(types.requests.ExtendedHttpRequest, request)
|
||||
request = typing.cast(types.requests.ExtendedHttpRequest, req)
|
||||
|
||||
ip = request.ip
|
||||
|
||||
@@ -412,7 +410,7 @@ def blocker(
|
||||
|
||||
|
||||
def profiler(
|
||||
log_file: typing.Optional[str] = None,
|
||||
log_file: str | None = None,
|
||||
) -> collections.abc.Callable[[collections.abc.Callable[P, R]], collections.abc.Callable[P, R]]:
|
||||
"""
|
||||
Decorator that will profile the wrapped function and log the results to the provided file
|
||||
@@ -452,7 +450,7 @@ def retry_on_exception(
|
||||
retries: int,
|
||||
*,
|
||||
wait_seconds: float = 2,
|
||||
retryable_exceptions: typing.Optional[typing.List[typing.Type[Exception]]] = None,
|
||||
retryable_exceptions: list[type[Exception]] | None = None,
|
||||
do_log: bool = False,
|
||||
) -> collections.abc.Callable[[collections.abc.Callable[P, R]], collections.abc.Callable[P, R]]:
|
||||
to_retry = retryable_exceptions or [Exception]
|
||||
|
||||
@@ -498,8 +498,8 @@ def put_back_to_cache_field(
|
||||
label=_('Put back to cache'),
|
||||
tooltip=_('On machine releasy by logout, put it back to cache instead of deleting if possible.'),
|
||||
choices=[
|
||||
{'id': 'no', 'text': _('No. Never put it back to cache')},
|
||||
{'id': 'yes', 'text': _('Yes, try to put it back to cache')},
|
||||
types.ui.ChoiceItem(id='no', text=_('No. Never put it back to cache')),
|
||||
types.ui.ChoiceItem(id='yes', text=_('Yes, try to put it back to cache')),
|
||||
],
|
||||
tab=tab,
|
||||
)
|
||||
|
||||
@@ -495,11 +495,11 @@ else:
|
||||
|
||||
class FuseContext(ctypes.Structure):
|
||||
_fields_ = [
|
||||
('fuse', ctypes.c_voidp), # type: ignore
|
||||
('fuse', ctypes.c_voidp),
|
||||
('uid', c_uid_t),
|
||||
('gid', c_gid_t),
|
||||
('pid', c_pid_t),
|
||||
('private_data', ctypes.c_voidp), # type: ignore
|
||||
('private_data', ctypes.c_voidp),
|
||||
]
|
||||
|
||||
|
||||
@@ -521,7 +521,7 @@ class FuseOperations(ctypes.Structure):
|
||||
ctypes.c_size_t,
|
||||
),
|
||||
),
|
||||
('getdir', ctypes.c_voidp), # type: ignore # Deprecated, use readdir
|
||||
('getdir', ctypes.c_voidp), # Deprecated, use readdir
|
||||
('mknod', ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_char_p, c_mode_t, c_dev_t)),
|
||||
('mkdir', ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_char_p, c_mode_t)),
|
||||
('unlink', ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_char_p)),
|
||||
@@ -532,7 +532,7 @@ class FuseOperations(ctypes.Structure):
|
||||
('chmod', ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_char_p, c_mode_t)),
|
||||
('chown', ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_char_p, c_uid_t, c_gid_t)),
|
||||
('truncate', ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_char_p, c_off_t)),
|
||||
('utime', ctypes.c_voidp), # type: ignore # Deprecated, use utimens
|
||||
('utime', ctypes.c_voidp), # Deprecated, use utimens
|
||||
(
|
||||
'open',
|
||||
ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_char_p, ctypes.POINTER(fuse_file_info)),
|
||||
@@ -604,10 +604,10 @@ class FuseOperations(ctypes.Structure):
|
||||
ctypes.CFUNCTYPE(
|
||||
ctypes.c_int,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_voidp, # type: ignore
|
||||
ctypes.c_voidp,
|
||||
ctypes.CFUNCTYPE(
|
||||
ctypes.c_int,
|
||||
ctypes.c_voidp, # type: ignore
|
||||
ctypes.c_voidp,
|
||||
ctypes.c_char_p,
|
||||
ctypes.POINTER(c_stat),
|
||||
c_off_t,
|
||||
@@ -629,8 +629,8 @@ class FuseOperations(ctypes.Structure):
|
||||
ctypes.POINTER(fuse_file_info),
|
||||
),
|
||||
),
|
||||
('init', ctypes.CFUNCTYPE(ctypes.c_voidp, ctypes.c_voidp)), # type: ignore
|
||||
('destroy', ctypes.CFUNCTYPE(ctypes.c_voidp, ctypes.c_voidp)), # type: ignore
|
||||
('init', ctypes.CFUNCTYPE(ctypes.c_voidp, ctypes.c_voidp)),
|
||||
('destroy', ctypes.CFUNCTYPE(ctypes.c_voidp, ctypes.c_voidp)),
|
||||
('access', ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_char_p, ctypes.c_int)),
|
||||
(
|
||||
'create',
|
||||
@@ -656,7 +656,7 @@ class FuseOperations(ctypes.Structure):
|
||||
ctypes.c_char_p,
|
||||
ctypes.POINTER(fuse_file_info),
|
||||
ctypes.c_int,
|
||||
ctypes.c_voidp, # type: ignore
|
||||
ctypes.c_voidp,
|
||||
),
|
||||
),
|
||||
(
|
||||
@@ -798,7 +798,7 @@ class FUSE:
|
||||
continue
|
||||
|
||||
if hasattr(typing.cast(typing.Any, prototype), 'argtypes'):
|
||||
val = prototype(partial(FUSE._wrapper, getattr(self, name))) # type: ignore
|
||||
val = prototype(partial(FUSE._wrapper, getattr(self, name)))
|
||||
|
||||
setattr(fuse_ops, name, val)
|
||||
|
||||
@@ -846,14 +846,14 @@ class FUSE:
|
||||
return func(*args, **kwargs) or 0
|
||||
|
||||
except OSError as e:
|
||||
if e.errno > 0: # pyright: ignore
|
||||
if e.errno and e.errno > 0:
|
||||
logger.debug(
|
||||
"FUSE operation %s raised a %s, returning errno %s.",
|
||||
func.__name__,
|
||||
type(e),
|
||||
e.errno,
|
||||
)
|
||||
return -e.errno # pyright: ignore
|
||||
return -e.errno
|
||||
logger.error(
|
||||
"FUSE operation %s raised an OSError with negative " "errno %s, returning errno.EINVAL.",
|
||||
func.__name__,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user