1
0
mirror of https://github.com/dkmstr/openuds.git synced 2025-10-06 11:33:43 +03:00

5 Commits

Author SHA1 Message Date
aschumann-virtualcable
cdd5f3c1d1 Merge remote-tracking branch 'origin/v4.0' into dev/andres/v4.0 2025-10-02 11:17:39 +02:00
aschumann-virtualcable
2731013710 fix v4.0 actor 2025-09-25 19:18:53 +02:00
aschumann-virtualcable
39aa842d7f Merge branch 'dev/andres/v4.0' of github.com:VirtualCable/openuds into dev/andres/v4.0 2025-09-25 19:10:05 +02:00
Adolfo Gómez García
99b33d0603 Reset actor 2025-09-25 19:02:22 +02:00
aschumann-virtualcable
398ec1aac0 Merge remote-tracking branch 'origin/v4.0' into dev/andres/v4.0 2025-09-25 12:13:11 +02:00
215 changed files with 5013 additions and 10266 deletions

View File

@@ -1,63 +0,0 @@
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

View File

@@ -1,6 +1,6 @@
BSD 3-Clause License
Copyright (c) 2022-2024, Virtualcable S.L.U.
Copyright (c) 2022-2024, Virtual Cable S.L.U.
All rights reserved.
Redistribution and use in source and binary forms, with or without

View File

@@ -13,7 +13,5 @@ Please feel free to contribute to this project.
Notes
=====
* 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.
* 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)

View File

@@ -1 +1 @@
5.0.0
4.0.0

2
actor

Submodule actor updated: 1b723fc3b4...04ce3fc2d1

2
client

Submodule client updated: 517f8935a2...5b044bca34

View File

@@ -1,28 +0,0 @@
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()

View File

@@ -1,7 +1,7 @@
[mypy]
#plugins =
# mypy_django_plugin.main
python_version = 3.12
python_version = 3.11
# 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 =

View File

@@ -11,11 +11,4 @@ python_classes =
filterwarnings =
error
ignore:The --rsyncdir command line argument and rsyncdirs config variable are deprecated.:DeprecationWarning
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
ignore::matplotlib._api.deprecation.MatplotlibDeprecationWarning:pydev

View File

@@ -1,13 +1,6 @@
# Broker (and common)
# Latest versions should work fine with master branch
Django>5.2
pytest
pytest-django
lark
ldap3
aiosmtpd
pillow
cairosvg
Django>5.0
bitarray
numpy
html5lib
@@ -36,6 +29,7 @@ XenAPI
PyJWT
pylibmc
gunicorn
python-dateutil
pywinrm
pywinrm[credssp]
whitenoise
@@ -43,6 +37,7 @@ setproctitle
openpyxl
boto3
uvicorn[standard]
numpy
pandas
xxhash
psutil
@@ -52,6 +47,7 @@ qrcode
qrcode[pil]
art
# For tunnel
dnspython
aiohttp
uvloop
argon2-cffi

View File

@@ -32,6 +32,7 @@
Author: Adolfo Gómez, dkmaster at dkmon dot com
'''
import typing
import collections.abc
import asyncio
import aiohttp
import enum
@@ -150,6 +151,8 @@ 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)

View File

@@ -14,7 +14,6 @@ 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 = (
@@ -33,7 +32,7 @@ DATABASES = {
},
'NAME': 'dbuds', # Or path to database file if using sqlite3.
'USER': 'dbuds', # Not used with sqlite3.
'PASSWORD': 'PASSWORD', # Not used with sqlite3.
'PASSWORD': 'PASSWOR', # 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
@@ -60,8 +59,7 @@ DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
# system time zone.
# TIME_SECTION_START
USE_TZ = True
TIME_ZONE = 'UTC'
TIME_ZONE = 'Europe/Madrid'
# TIME_SECTION_END
# Override for gettext so we can use the same syntax as in django
@@ -99,8 +97,6 @@ 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 = ''
@@ -154,7 +150,7 @@ CACHES = {
# }
'memory': {
'BACKEND': 'django.core.cache.backends.memcached.PyLibMCCache',
'LOCATION': '127.0.0.1:11211',
'LOCATION': 'db.dkmon.com:11211',
},
}
@@ -198,7 +194,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:

View File

@@ -47,7 +47,7 @@ from uds.core.util.model import sql_stamp_seconds
from . import processors, log
from .handlers import Handler
from . import model as rest_model
from .model import DetailHandler
# Not imported at runtime, just for type checking
if typing.TYPE_CHECKING:
@@ -58,46 +58,81 @@ 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
root_node: typing.ClassVar[types.rest.HandlerNode] = types.rest.HandlerNode('', None, None, {})
services: typing.ClassVar[HandlerNode] = HandlerNode('', None, {})
@method_decorator(csrf_exempt)
def dispatch(self, request: 'http.request.HttpRequest', path: str) -> 'http.HttpResponse':
def dispatch(
self, request: 'http.request.HttpRequest', *args: typing.Any, **kwargs: typing.Any
) -> '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('/')
# path = kwargs['arguments']
# del kwargs['arguments']
path: list[str] = kwargs['arguments'].split('/')
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]
handler_node = Dispatcher.root_node.find_path(path)
if not handler_node:
return http.HttpResponseNotFound('Service not found', content_type="text/plain")
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
logger.debug("REST request: %s (%s)", handler_node, handler_node.full_path())
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)
# 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]] = handler_node.handler
cls: typing.Optional[type[Handler]] = service.handler
if not cls:
return http.HttpResponseNotFound('Method not found', content_type="text/plain")
@@ -111,50 +146,42 @@ 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 = path[len(node_full_path) :].split('/')[1:] # First element is always empty, so we skip it
args = tuple(path)
handler: typing.Optional[Handler] = None
try:
handler = cls(
request,
node_full_path,
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',
)
logger.debug('Path: %s', full_path)
logger.debug('Error: %s', e)
log.log_operation(handler, 400, types.log.LogLevel.ERROR)
return http.HttpResponseBadRequest(
f'Invalid parameters invoking {handler_node.full_path()}: {e}',
f'Invalid parameters invoking {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=b'{"error": "Invalid method"}', content_type="application/json"
)
return http.HttpResponseNotAllowed(allowed_methods, content_type="text/plain")
except exceptions.rest.AccessDenied:
log.log_operation(handler, 403, types.log.LogLevel.ERROR)
return http.HttpResponseForbidden(b'{"error": "Access denied"}', content_type="application/json")
return http.HttpResponseForbidden('access denied', content_type="text/plain")
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, handler_node.full_path())
return http.HttpResponseServerError(
b'{"error": "Unexpected error"}', content_type="application/json"
)
logger.debug('Getting attribute %s for %s', http_method, full_path)
return http.HttpResponseServerError('Unexcepected error', content_type="text/plain")
# Invokes the handler's operation, add headers to response and returns
try:
@@ -172,7 +199,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()
@@ -183,35 +210,33 @@ 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
# 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(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(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(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")
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(f'{{"error": "{e}"}}'.encode(), content_type="application/json")
return http.HttpResponseServerError(str(e), content_type="text/plain")
except exceptions.rest.NotSupportedError as e:
log.log_operation(handler, 501, types.log.LogLevel.ERROR)
return http.HttpResponseBadRequest(str(e), content_type="text/plain")
except exceptions.rest.AccessDenied as e:
log.log_operation(handler, 403, types.log.LogLevel.ERROR)
return http.HttpResponseForbidden(str(e), content_type="text/plain")
except exceptions.rest.NotFound as e:
log.log_operation(handler, 404, types.log.LogLevel.ERROR)
return http.HttpResponseNotFound(str(e), content_type="text/plain")
except exceptions.rest.HandlerError as e:
log.log_operation(handler, 500, types.log.LogLevel.ERROR)
return http.HttpResponseBadRequest(f'{{"error": "{e}"}}'.encode(), content_type="application/json")
return http.HttpResponseBadRequest(str(e), content_type="text/plain")
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', handler_node.full_path())
logger.error('Exception processing request: %s', full_path)
for i in trace_back.splitlines():
logger.error('* %s', i)
return http.HttpResponseServerError(f'{{"error": "{e}"}}'.encode(), content_type="application/json")
return http.HttpResponseServerError(str(e), content_type="text/plain")
@staticmethod
def register_handler(type_: type[Handler]) -> None:
@@ -219,26 +244,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.root_node # Root path
service_node = Dispatcher.services # 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] = types.rest.HandlerNode(k, None, service_node, {})
service_node.children[intern_k] = HandlerNode(k, None, {})
service_node = service_node.children[intern_k]
else:
logger.info('Path: /%s', name)
if name not in service_node.children:
service_node.children[name] = types.rest.HandlerNode(name, None, service_node, {})
service_node.children[name] = HandlerNode(name, None, {})
service_node.children[name] = dataclasses.replace(service_node.children[name], handler=type_)
@@ -254,7 +279,11 @@ class Dispatcher(View):
module_name = __name__[: __name__.rfind('.')]
def checker(x: type[Handler]) -> bool:
return not issubclass(x, rest_model.DetailHandler) and not x.__subclasses__()
# 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__()
# Register all subclasses of Handler
modfinder.dynamically_load_and_register_packages(
@@ -265,7 +294,5 @@ class Dispatcher(View):
package_name='methods',
)
logger.info('REST Handlers initialized')
Dispatcher.initialize()

View File

@@ -29,21 +29,17 @@
"""
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, exceptions
from uds.core import consts, types
from uds.core.util.config import GlobalConfig
from uds.core.auths.auth import root_user
from uds.core.util import net, query_db_filter, query_filter
from uds.core.util import net
from uds.models import Authenticator, User
from uds.core.managers.crypto import CryptoManager
@@ -56,23 +52,30 @@ if typing.TYPE_CHECKING:
logger = logging.getLogger(__name__)
T = typing.TypeVar('T')
class Handler(abc.ABC):
class Handler:
"""
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
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()
# 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'
_request: 'ExtendedHttpRequestWithUser' # It's a modified HttpRequest
_path: str
@@ -82,13 +85,11 @@ class Handler(abc.ABC):
] # 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]
_headers: dict[
str, str
] # Note: These are "output" headers, not input headers (input headers can be retrieved from request)
_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)
_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
@@ -101,16 +102,25 @@ class Handler(abc.ABC):
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.ROLE.needs_authentication:
if self.authenticated: # Only retrieve auth related data on authenticated handlers
try:
self._auth_token = self._request.headers.get(consts.auth.AUTH_TOKEN_HEADER, '')
self._session = SessionStore(session_key=self._auth_token)
@@ -123,14 +133,16 @@ class Handler(abc.ABC):
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
@@ -138,8 +150,6 @@ class Handler(abc.ABC):
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)
@@ -149,34 +159,22 @@ class Handler(abc.ABC):
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 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:
def add_header(self, header: str, value: str) -> None:
"""
Inserts a new header inside the headers list
:param header: name of header to insert
:param value: value of header
"""
self._headers[header] = str(value)
self._headers[header] = value
def delete_header(self, header: str) -> None:
"""
@@ -209,10 +207,6 @@ class Handler(abc.ABC):
"""
return self._args
@property
def odata(self) -> 'types.rest.api.ODataParams':
return self._odata
@property
def session(self) -> 'SessionStore':
if self._session is None:
@@ -234,6 +228,8 @@ class Handler(abc.ABC):
password: str,
locale: str,
platform: str,
is_admin: bool,
staff_member: bool,
scrambler: str,
) -> None:
"""
@@ -245,10 +241,11 @@ class Handler(abc.ABC):
: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.manager().symmetric_encrypt(password, scrambler), 'base64'
).decode()
passwd = codecs.encode(CryptoManager().symmetric_encrypt(password, scrambler), 'base64').decode()
session['REST'] = {
'auth': id_auth,
@@ -256,6 +253,8 @@ class Handler(abc.ABC):
'password': passwd,
'locale': locale,
'platform': platform,
'is_admin': is_admin,
'staff_member': staff_member,
}
def gen_auth_token(
@@ -265,6 +264,8 @@ class Handler(abc.ABC):
password: str,
locale: str,
platform: str,
is_admin: bool,
staf_member: bool,
scrambler: str,
) -> str:
"""
@@ -284,6 +285,8 @@ class Handler(abc.ABC):
password,
locale,
platform,
is_admin,
staf_member,
scrambler,
)
session.save()
@@ -390,67 +393,3 @@ class Handler(abc.ABC):
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 {}

View File

@@ -30,110 +30,68 @@
"""
@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, ui as ui_utils
from uds.core.util import permissions, ensure
from uds.models import Account
from .accountsusage import AccountsUsage
from django.db import models
if typing.TYPE_CHECKING:
from django.db.models import Model
logger = logging.getLogger(__name__)
# Enclosed methods under /item path
@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]):
class Accounts(ModelHandler):
"""
Processes REST requests about accounts
"""
MODEL = Account
DETAIL = {'usage': AccountsUsage}
model = Account
detail = {'usage': AccountsUsage}
CUSTOM_METHODS = [
types.rest.ModelCustomMethod('clear', True),
types.rest.ModelCustomMethod('timemark', True),
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}},
]
FIELDS_TO_SAVE = ['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()
)
# 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:
def item_as_dict(self, item: 'Model') -> types.rest.ItemDictType:
item = ensure.is_instance(item, Account)
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),
)
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),
}
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 get_gui(self, type_: str) -> list[typing.Any]:
return self.add_default_fields([], ['name', 'comments', 'tags'])
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
"""
def timemark(self, item: 'Model') -> typing.Any:
item = ensure.is_instance(item, Account)
item.time_mark = timezone.localtime()
item.time_mark = datetime.datetime.now()
item.save()
return ''
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
"""
def clear(self, item: 'Model') -> typing.Any:
item = ensure.is_instance(item, Account)
self.check_access(item, uds.core.types.permissions.PermissionType.MANAGEMENT)
self.ensure_has_access(item, uds.core.types.permissions.PermissionType.MANAGEMENT)
return item.usages.filter(user_service=None).delete()

View File

@@ -30,95 +30,78 @@
"""
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.types.rest import TableInfo
from uds.core.util import ensure, permissions, ui as ui_utils
from uds.core.util import ensure, permissions
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__)
@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
class AccountsUsage(DetailHandler): # pylint: disable=too-many-public-methods
"""
Detail handler for Services, whose parent is a Provider
"""
@staticmethod
def usage_to_dict(item: 'AccountUsage', perm: int) -> AccountItem:
def usage_to_dict(item: 'AccountUsage', perm: int) -> dict[str, typing.Any]:
"""
Convert an account usage to a dictionary
:param item: Account usage item (db)
:param perm: permission
"""
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,
)
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,
}
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ItemsResult[AccountItem]:
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
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 self.filter_queryset(parent.usages.all())]
return [AccountsUsage.usage_to_dict(k, perm) for k in 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 exceptions.rest.NotFound(_('Account usage not found: {}').format(item)) from None
raise self.invalid_item_response()
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_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 save_item(self, parent: 'Model', item: typing.Optional[str]) -> AccountItem:
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:
raise exceptions.rest.RequestError('Accounts usage cannot be edited')
def delete_item(self, parent: 'Model', item: str) -> None:
@@ -128,5 +111,12 @@ class AccountsUsage(DetailHandler[AccountItem]): # pylint: disable=too-many-pub
usage = parent.usages.get(uuid=process_uuid(item))
usage.delete()
except Exception:
logger.error('Error deleting account usage %s from %s', item, parent)
raise exceptions.rest.NotFound(_('Account usage not found: {}').format(item)) from None
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')

View File

@@ -30,88 +30,68 @@
"""
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, ui as ui_utils
from uds.core.util import ensure
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}
class ActorTokens(ModelHandler[ActorTokenItem]):
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')}},
]
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:
def item_as_dict(self, item: 'Model') -> dict[str, typing.Any]:
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 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,
)
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,
}
def delete(self) -> str:
"""
@@ -120,13 +100,13 @@ class ActorTokens(ModelHandler[ActorTokenItem]):
if len(self._args) != 1:
raise RequestError('Delete need one and only one argument')
self.check_access(
self.MODEL(), permissions.PermissionType.ALL, root=True
self.ensure_has_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

View File

@@ -28,6 +28,7 @@
"""
Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
import enum
import functools
import logging
import time
@@ -63,6 +64,16 @@ 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]:
"""
@@ -134,9 +145,8 @@ def clear_failed_ip_counter(request: 'ExtendedHttpRequest') -> None:
class ActorV3Action(Handler):
ROLE = consts.UserRole.ANONYMOUS
PATH = 'actor/v3'
NAME = 'actorv3'
authenticated = False # Actor requests are not authenticated normally
path = 'actor/v3'
@staticmethod
def actor_result(result: typing.Any = None, **kwargs: typing.Any) -> dict[str, typing.Any]:
@@ -187,7 +197,7 @@ class ActorV3Action(Handler):
raise exceptions.rest.AccessDenied('Access denied')
# Some helpers
def notify_service(self, action: types.rest.actor.NotifyActionType) -> None:
def notify_service(self, action: NotifyActionType) -> None:
"""
Notifies the Service (not userservice) that an action has been performed
@@ -217,17 +227,17 @@ class ActorV3Action(Handler):
is_remote = self._params.get('session_type', '')[:4] in ('xrdp', 'RDP-')
# Must be valid
if action in (types.rest.actor.NotifyActionType.LOGIN, types.rest.actor.NotifyActionType.LOGOUT):
if action in (NotifyActionType.LOGIN, 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 == types.rest.actor.NotifyActionType.LOGIN:
if action == NotifyActionType.LOGIN:
# Try to guess if this is a remote session
service.process_login(service_id, remote_login=is_remote)
elif action == types.rest.actor.NotifyActionType.LOGOUT:
elif action == NotifyActionType.LOGOUT:
service.process_logout(service_id, remote_login=is_remote)
elif action == types.rest.actor.NotifyActionType.DATA:
elif action == NotifyActionType.DATA:
service.notify_data(service_id, self._params['data'])
else:
raise Exception('Invalid action')
@@ -244,7 +254,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.
@@ -281,9 +291,10 @@ class Register(ActorV3Action):
"""
ROLE = consts.UserRole.STAFF
authenticated = True
needs_staff = True
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...
@@ -357,7 +368,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]:
"""
@@ -496,7 +507,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]:
"""
@@ -556,7 +567,7 @@ class IpChange(BaseReadyChange):
Processses IP Change.
"""
NAME = 'ipchange'
name = 'ipchange'
class Ready(BaseReadyChange):
@@ -564,7 +575,7 @@ class Ready(BaseReadyChange):
Notifies the user service is ready
"""
NAME = 'ready'
name = 'ready'
def action(self) -> dict[str, typing.Any]:
"""
@@ -584,7 +595,7 @@ class Ready(BaseReadyChange):
# 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)
@@ -597,7 +608,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)
@@ -613,7 +624,7 @@ class Login(ActorV3Action):
Notifies user logged id
"""
NAME = 'login'
name = 'login'
# payload received
# {
@@ -662,7 +673,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=types.rest.actor.NotifyActionType.LOGIN)
self.notify_service(action=NotifyActionType.LOGIN)
return ActorV3Action.actor_result(
{
@@ -681,7 +692,7 @@ class Logout(ActorV3Action):
Notifies user logged out
"""
NAME = 'logout'
name = 'logout'
@staticmethod
def process_logout(userservice: UserService, username: str, session_id: str) -> None:
@@ -715,7 +726,7 @@ class Logout(ActorV3Action):
except Exception:
if is_managed:
raise
self.notify_service(types.rest.actor.NotifyActionType.LOGOUT) # Logout notification
self.notify_service(NotifyActionType.LOGOUT) # Logout notification
# Result is that we have not processed the logout in fact, but notified the service
return ActorV3Action.actor_result('notified')
@@ -727,7 +738,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)
@@ -752,49 +763,27 @@ 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)
if len(self._args) > 1:
raise exceptions.rest.RequestError('Invalid request')
kind = self._args[0] if len(self._args) == 1 else 'server'
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...
try:
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')
return ActorV3Action.actor_result(TicketStore.get(self._params['ticket'], invalidate=True))
except TicketStore.DoesNotExist:
return ActorV3Action.actor_result(error='Invalid ticket')
class Unmanaged(ActorV3Action):
NAME = 'unmanaged'
name = 'unmanaged'
def action(self) -> dict[str, typing.Any]:
"""
@@ -880,7 +869,7 @@ class Unmanaged(ActorV3Action):
class Notify(ActorV3Action):
NAME = 'notify'
name = 'notify'
def post(self) -> dict[str, typing.Any]:
# Raplaces original post (non existent here)
@@ -889,7 +878,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 = types.rest.actor.NotifyActionType(self._params['action'])
action = NotifyActionType(self._params['action'])
_token = self._params['token'] # Just to check it exists
except Exception as e:
# Requested login, logout or whatever
@@ -898,11 +887,11 @@ class Notify(ActorV3Action):
try:
# Check block manually
check_ip_is_blocked(self._request) # pylint: disable=protected-access
if action == types.rest.actor.NotifyActionType.LOGIN:
if action == NotifyActionType.LOGIN:
Login.action(typing.cast(Login, self))
elif action == types.rest.actor.NotifyActionType.LOGOUT:
elif action == NotifyActionType.LOGOUT:
Logout.action(typing.cast(Logout, self))
elif action == types.rest.actor.NotifyActionType.DATA:
elif action == NotifyActionType.DATA:
self.notify_service(action)
return ActorV3Action.actor_result('ok')

View File

@@ -31,19 +31,18 @@
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, gettext_lazy as _
from django.db import models
from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
from uds.core import auths, consts, exceptions, types, ui
from uds.core import auths, consts, exceptions, types
from uds.core.environment import Environment
from uds.core.util import ensure, permissions, ui as ui_utils
from uds.core.ui import gui
from uds.core.util import ensure, permissions
from uds.core.util.model import process_uuid
from uds.models import MFA, Authenticator, Network, Tag
from uds.REST.model import ModelHandler
@@ -51,89 +50,45 @@ 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[AuthenticatorItem]):
ITEM_TYPE = AuthenticatorItem
MODEL = Authenticator
class Authenticators(ModelHandler):
model = Authenticator
# Custom get method "search" that requires authenticator id
CUSTOM_METHODS = [types.rest.ModelCustomMethod('search', True)]
DETAIL = {'users': Users, 'groups': Groups}
FIELDS_TO_SAVE = ['name', 'comments', 'tags', 'priority', 'small_name', 'mfa_id:_', 'state']
custom_methods = [('search', True)]
detail = {'users': Users, 'groups': Groups}
save_fields = ['name', 'comments', 'tags', 'priority', 'small_name', 'mfa_id:_', 'state']
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()
)
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}},
]
# 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]]:
def enum_types(self) -> collections.abc.Iterable[type[auths.Authenticator]]:
return auths.factory().providers().values()
@classmethod
def extra_type_info(
cls: type[typing.Self], type_: type['Module']
) -> typing.Optional[AuthenticatorTypeInfo]:
def type_info(self, type_: type['Module']) -> typing.Optional[types.rest.AuthenticatorTypeInfo]:
if issubclass(type_, auths.Authenticator):
return AuthenticatorTypeInfo(
return types.rest.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,
@@ -143,82 +98,95 @@ class Authenticators(ModelHandler[AuthenticatorItem]):
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_identifier(),
mfa_supported=type_.provides_mfa(),
)
# Not of my type
return None
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
def get_gui(self, type_: str) -> list[typing.Any]:
try:
auth_type = auths.factory().lookup(for_type)
auth_type = auths.factory().lookup(type_)
if auth_type:
# Create a new instance of the authenticator to access to its GUI
with Environment.temporary_environment() as env:
# If supports mfa, add MFA provider selector field
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)
)
.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'),
)
field = self.add_default_fields(
auth_instance.gui_description(),
['name', 'comments', 'tags', 'priority', 'small_name', 'networks'],
)
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()]
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,
},
)
return gui.build()
return field
raise Exception() # Not found
except Exception as e:
logger.info('Authenticator type not found: %s', e)
raise exceptions.rest.NotFound('Authenticator type not found') from 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
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
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:
def post_save(self, item: 'Model') -> None:
item = ensure.is_instance(item, Authenticator)
try:
networks = self._params['networks']
@@ -231,17 +199,13 @@ class Authenticators(ModelHandler[AuthenticatorItem]):
item.networks.set(Network.objects.filter(uuid__in=networks))
# Custom "search" method
def search(self, item: 'models.Model') -> list[types.auth.SearchResultItem.ItemDict]:
"""
API:
Search for users or groups in this authenticator
"""
def search(self, item: 'Model') -> list[types.rest.ItemDictType]:
item = ensure.is_instance(item, Authenticator)
self.check_access(item, types.permissions.PermissionType.READ)
self.ensure_has_access(item, types.permissions.PermissionType.READ)
try:
type_ = self._params['type']
if type_ not in ('user', 'group'):
raise exceptions.rest.RequestError(_('Invalid type: {}').format(type_))
raise self.invalid_request_response()
term = self._params['term']
@@ -263,7 +227,7 @@ class Authenticators(ModelHandler[AuthenticatorItem]):
)
)
if search_supported is False:
raise exceptions.rest.NotSupportedError(_('Search not supported'))
raise self.not_supported_response()
if type_ == 'user':
iterable = auth.search_users(term)
@@ -273,15 +237,13 @@ class Authenticators(ModelHandler[AuthenticatorItem]):
return [i.as_dict() for i in itertools.islice(iterable, limit)]
except Exception as e:
logger.exception('Too many results: %s', e)
return [
types.auth.SearchResultItem(id=_('Too many results...'), name=_('Refine your query')).as_dict()
]
return [{'id': _('Too many results...'), 'name': _('Refine your query')}]
# self.invalidResponseException('{}'.format(e))
def test(self, type_: str) -> typing.Any:
auth_type = auths.factory().lookup(type_)
if not auth_type:
raise exceptions.rest.RequestError(_('Invalid type: {}').format(type_))
raise self.invalid_request_response(f'Invalid type: {type_}')
dct = self._params.copy()
dct['_request'] = self._request
@@ -308,9 +270,11 @@ class Authenticators(ModelHandler[AuthenticatorItem]):
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 exceptions.rest.RequestError(_('Label must contain only letters, numbers, or symbols: - : .'))
raise self.invalid_request_response(
_('Label must contain only letters, numbers, or symbols: - : .')
)
def delete_item(self, item: 'models.Model') -> None:
def delete_item(self, item: 'Model') -> None:
# For every user, remove assigned services (mark them for removal)
item = ensure.is_instance(item, Authenticator)

View File

@@ -35,7 +35,7 @@ import typing
from django.core.cache import caches
from uds.core import exceptions, consts
from uds.core import exceptions
from uds.core.util.cache import Cache as UCache
from uds.REST import Handler
@@ -44,7 +44,8 @@ logger = logging.getLogger(__name__)
# Enclosed methods under /cache path
class Cache(Handler):
ROLE = consts.UserRole.ADMIN
authenticated = True
needs_admin = True
def get(self) -> typing.Any:
"""

View File

@@ -30,98 +30,85 @@
"""
Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
import dataclasses
import datetime
import logging
import typing
from django.db import IntegrityError, models
from django.db import IntegrityError
from django.utils.translation import gettext as _
from django.utils import timezone
from uds.core import exceptions, types
from uds.core.util import ensure, permissions, ui as ui_utils
from uds.core import exceptions
from uds.core.util import ensure, permissions
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__)
@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
class CalendarRules(DetailHandler): # pylint: disable=too-many-public-methods
"""
Detail handler for Services, whose parent is a Provider
"""
@staticmethod
def rule_as_dict(item: CalendarRule, perm: int) -> CalendarRuleItem:
def rule_as_dict(item: CalendarRule, perm: int) -> dict[str, typing.Any]:
"""
Convert a calrule db item to a dict for a rest response
:param item: Rule item (db)
:param perm: Permission of the object
"""
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,
)
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,
}
def get_items(
self, parent: 'models.Model', item: typing.Optional[str]
) -> types.rest.ItemsResult[CalendarRuleItem]:
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> typing.Any:
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 self.filter_queryset(parent.rules.all())]
return [CalendarRules.rule_as_dict(k, perm) for k in 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 exceptions.rest.RequestError(f'Error retrieving calendar rule: {e}') from e
raise self.invalid_item_response() from e
def get_table(self, parent: 'models.Model') -> types.rest.TableInfo:
def get_fields(self, parent: 'Model') -> list[typing.Any]:
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()
)
def save_item(self, parent: 'models.Model', item: typing.Optional[str]) -> typing.Any:
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:
parent = ensure.is_instance(parent, Calendar)
# Extract item db fields
@@ -141,12 +128,12 @@ class CalendarRules(DetailHandler[CalendarRuleItem]): # pylint: disable=too-man
)
if int(fields['interval']) < 1:
raise exceptions.rest.RequestError('Repeat must be greater than zero')
raise self.invalid_item_response('Repeat must be greater than zero')
# Convert timestamps to datetimes
fields['start'] = timezone.make_aware(datetime.datetime.fromtimestamp(fields['start']))
fields['start'] = datetime.datetime.fromtimestamp(fields['start'])
if fields['end'] is not None:
fields['end'] = timezone.make_aware(datetime.datetime.fromtimestamp(fields['end']))
fields['end'] = datetime.datetime.fromtimestamp(fields['end'])
calendar_rule: CalendarRule
try:
@@ -158,14 +145,14 @@ class CalendarRules(DetailHandler[CalendarRuleItem]): # pylint: disable=too-man
calendar_rule.save()
return {'id': calendar_rule.uuid}
except CalendarRule.DoesNotExist:
raise exceptions.rest.NotFound(_('Calendar rule not found: {}').format(item)) from None
raise self.invalid_item_response() 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 exceptions.rest.RequestError(f'incorrect invocation to PUT: {e}') from e
raise self.invalid_request_response(f'incorrect invocation to PUT: {e}') from e
def delete_item(self, parent: 'models.Model', item: str) -> None:
def delete_item(self, parent: 'Model', item: str) -> None:
parent = ensure.is_instance(parent, Calendar)
logger.debug('Deleting rule %s from %s', item, parent)
try:
@@ -173,8 +160,13 @@ class CalendarRules(DetailHandler[CalendarRuleItem]): # pylint: disable=too-man
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.error('Error deleting calendar rule %s from %s', item, parent)
raise exceptions.rest.RequestError(f'Error deleting calendar rule: {e}') from 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')

View File

@@ -30,84 +30,66 @@
"""
@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, ui as ui_utils
from uds.core.util import permissions, ensure
from uds.REST.model import ModelHandler
from .calendarrules import CalendarRules
if typing.TYPE_CHECKING:
from django.db.models import Model
logger = logging.getLogger(__name__)
@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
# Enclosed methods under /item path
class Calendars(ModelHandler[CalendarItem]):
class Calendars(ModelHandler):
"""
Processes REST requests about calendars
"""
MODEL = Calendar
DETAIL = {'rules': CalendarRules}
model = Calendar
detail = {'rules': CalendarRules}
FIELDS_TO_SAVE = ['name', 'comments', 'tags']
save_fields = ['name', 'comments', 'tags']
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()
)
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}},
]
# 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:
def item_as_dict(self, item: 'Model') -> dict[str, typing.Any]:
item = ensure.is_instance(item, Calendar)
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),
)
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),
}
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()
)
def get_gui(self, type_: str) -> list[typing.Any]:
return self.add_default_fields([], ['name', 'comments', 'tags'])

View File

@@ -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_args
from uds.core.util.rest.tools import match
from uds.models import TicketStore, User
from uds.REST import Handler
@@ -58,7 +58,7 @@ class Client(Handler):
Processes Client requests
"""
ROLE = consts.UserRole.ANONYMOUS
authenticated = False # Client requests are not authenticated
@staticmethod
def result(
@@ -130,7 +130,7 @@ class Client(Handler):
try:
data: dict[str, typing.Any] = TicketStore.get(ticket)
except TicketStore.DoesNotExist:
except TicketStore.InvalidTicket:
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.DoesNotExist:
except TicketStore.InvalidTicket:
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_args(
return match(
self._args,
_error, # In case of error, raises RequestError
((), _noargs), # No args, return version

View File

@@ -33,7 +33,6 @@ 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
@@ -43,15 +42,10 @@ logger = logging.getLogger(__name__)
# Enclosed methods under /config path
class Config(Handler):
"""
API:
Get or update UDS configuration
"""
ROLE = consts.UserRole.ADMIN
needs_admin = True # By default, staff is lower level needed
def get(self) -> typing.Any:
return self.filter_data(CfgConfig.get_config_values(self.is_admin()))
return 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():
@@ -66,11 +60,5 @@ 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'

View File

@@ -30,16 +30,15 @@
"""
Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
import datetime
import logging
import typing
from django.utils import timezone
from uds.core import exceptions, types, consts
from uds.core import exceptions, types
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_args
from uds.core.util.rest.tools import match
from uds.REST import Handler
from uds.web.util import services
@@ -52,7 +51,9 @@ class Connection(Handler):
Processes actor requests
"""
ROLE = consts.UserRole.USER
authenticated = True # Actor requests are not authenticated
needs_admin = False
needs_staff = False
@staticmethod
def result(
@@ -68,7 +69,7 @@ class Connection(Handler):
:return: A dictionary, suitable for response to Caller
"""
result = result if result is not None else ''
res = {'result': result, 'date': timezone.localtime()}
res = {'result': result, 'date': datetime.datetime.now()}
if error:
if isinstance(error, int):
error = types.errors.Error.from_int(error).message
@@ -86,7 +87,7 @@ class Connection(Handler):
# Ensure user is present on request, used by web views methods
self._request.user = self._user
return Connection.result(result=self.filter_data(services.get_services_info_dict(self._request)))
return Connection.result(result=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')
@@ -178,7 +179,7 @@ class Connection(Handler):
def error() -> dict[str, typing.Any]:
raise exceptions.rest.RequestError('Invalid Request')
return match_args(
return match(
self._args,
error,
((), self.service_list),

View File

@@ -32,7 +32,7 @@ Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
import logging
from uds.core import exceptions, types, consts
from uds.core import exceptions, types
from uds.core.ui import gui
from uds.REST import Handler
@@ -42,13 +42,9 @@ logger = logging.getLogger(__name__)
class Callback(Handler):
"""
API:
Executes a callback from the GUI. Internal use, not intended to be called from outside.
"""
PATH = 'gui'
ROLE = consts.UserRole.STAFF
path = 'gui'
authenticated = True
needs_staff = True
def get(self) -> types.ui.CallbackResultType:
if len(self._args) != 1:

View File

@@ -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 _
from django.db import models
from django.utils.translation import gettext_lazy as _, gettext
from uds.models import Image
from uds.core import types
from uds.core.util import ensure, ui as ui_utils
from uds.core.util import ensure
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
@dataclasses.dataclass
class ImageItem(types.rest.BaseRestItem):
id: str
name: str
data: str = ''
size: str = ''
thumb: str = ''
class Images(ModelHandler[ImageItem]):
class Images(ModelHandler):
"""
Handles the gallery REST interface
"""
PATH = 'gallery'
MODEL = Image
FIELDS_TO_SAVE = ['name', 'data']
path = 'gallery'
model = Image
save_fields = ['name', 'data']
TABLE = (
ui_utils.TableBuilder(_('Image Gallery'))
.image('thumb', _('Image'), width='96px')
.text_column('name', _('Name'))
.text_column('size', _('Size'))
.build()
)
table_title = _('Image Gallery')
table_fields = [
{
'thumb': {
'title': _('Image'),
'visible': True,
'type': 'image',
'width': '96px',
}
},
{'name': {'title': _('Name')}},
{'size': {'title': _('Size')}},
]
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: 'models.Model') -> None:
def post_save(self, item: '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()
# 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 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
},
)
def get_item_summary(self, item: 'models.Model') -> ImageItem:
def item_as_dict(self, item: 'Model') -> dict[str, typing.Any]:
item = ensure.is_instance(item, Image)
return ImageItem(
id=item.uuid,
size='{}x{}, {} bytes (thumb {} bytes)'.format(
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(
item.width, item.height, len(item.data), len(item.thumb)
),
name=item.name,
thumb=item.thumb64,
)
'name': item.name,
'thumb': item.thumb64,
}

View File

@@ -55,8 +55,8 @@ class Login(Handler):
Responsible of user authentication
"""
PATH = 'auth'
ROLE = consts.UserRole.ANONYMOUS
path = 'auth'
authenticated = False # Public method
@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, scrambler)
self.gen_auth_token(-1, username, password, locale, platform, True, True, scrambler)
return Login.result(result='ok', token=self.get_auth_token())
return Login.result(error='Invalid credentials')
@@ -188,6 +188,8 @@ class Login(Handler):
password,
locale,
platform,
auth_result.user.is_admin,
auth_result.user.staff_member,
scrambler,
),
scrambler=scrambler,
@@ -205,8 +207,8 @@ class Logout(Handler):
Responsible of user de-authentication
"""
PATH = 'auth'
ROLE = consts.UserRole.USER # Must be logged in to logout :)
path = 'auth'
authenticated = True # By default, all handlers needs authentication
def get(self) -> typing.Any:
# Remove auth token
@@ -218,8 +220,8 @@ class Logout(Handler):
class Auths(Handler):
PATH = 'auth'
ROLE = consts.UserRole.ANONYMOUS
path = 'auth'
authenticated = False # By default, all handlers needs authentication
def auths(self) -> collections.abc.Iterable[dict[str, typing.Any]]:
all_param: bool = self._params.get('all', 'false').lower() == 'true'

View File

@@ -30,17 +30,16 @@
"""
Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
import dataclasses
import logging
import typing
from django.utils.translation import gettext, gettext_lazy as _
from django.db import models
from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
from uds.core import types, exceptions
from uds.core.consts.images import DEFAULT_THUMB_BASE64
from uds.core import ui
from uds.core.util import ensure, permissions, ui as ui_utils
from uds.core.ui import gui
from uds.core.util import ensure, permissions
from uds.core.util.model import process_uuid
from uds.core.types.states import State
from uds.models import Image, MetaPool, ServicePoolGroup
@@ -50,47 +49,26 @@ 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__)
@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]):
class MetaPools(ModelHandler):
"""
Handles Services Pools REST requests
"""
MODEL = MetaPool
DETAIL = {
model = MetaPool
detail = {
'pools': MetaServicesPool,
'services': MetaAssignedService,
'groups': Groups,
'access': AccessCalendars,
}
FIELDS_TO_SAVE = [
save_fields = [
'name',
'short_name',
'comments',
@@ -104,40 +82,35 @@ class MetaPools(ModelHandler[MetaPoolItem]):
'transport_grouping',
]
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),
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}},
]
# Rest api related information to complete the auto-generated API
REST_API_INFO = types.rest.api.RestApiInfo(
typed=types.rest.api.RestApiInfoGuiType.SINGLE_TYPE,
)
custom_methods = [('setFallbackAccess', True), ('getFallbackAccess', True)]
def get_item(self, item: 'models.Model') -> MetaPoolItem:
def item_as_dict(self, item: 'Model') -> dict[str, typing.Any]:
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
@@ -158,93 +131,126 @@ class MetaPools(ModelHandler[MetaPoolItem]):
(i.pool.userServices.filter(state=State.PREPARING).count()) for i in all_pools
)
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),
)
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
# Gui related
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
def get_gui(self, type_: str) -> list[typing.Any]:
local_gui = self.add_default_fields([], ['name', 'comments', 'tags'])
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()
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()
],
tooltip=gettext(
'Service pool High Availability policy. If enabled and a pool fails, it will be restarted in another pool. Enable with care!'
'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!.'
),
)
.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()
'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()
],
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()
)
'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
def pre_save(self, fields: dict[str, typing.Any]) -> None:
# logger.debug(self._params)
@@ -278,17 +284,13 @@ class MetaPools(ModelHandler[MetaPoolItem]):
logger.debug('Fields: %s', fields)
def delete_item(self, item: 'models.Model') -> None:
def delete_item(self, item: 'Model') -> None:
item = ensure.is_instance(item, MetaPool)
item.delete()
# Set fallback status
def set_fallback_access(self, item: MetaPool) -> typing.Any:
"""
API:
Sets the fallback access for a metapool
"""
self.check_access(item, types.permissions.PermissionType.MANAGEMENT)
self.ensure_has_access(item, types.permissions.PermissionType.MANAGEMENT)
fallback = self._params.get('fallbackAccess', 'ALLOW')
logger.debug('Setting fallback of %s to %s', item.name, fallback)

View File

@@ -29,92 +29,69 @@
"""
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 exceptions, types
from uds.core import 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, ui as ui_utils
from uds.core.util import log, ensure
from uds.REST.model import DetailHandler
from .user_services import AssignedUserService, UserServiceItem
from .user_services import AssignedService
from django.db.models import Model
if typing.TYPE_CHECKING:
from django.db.models import Model
logger = logging.getLogger(__name__)
@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]):
class MetaServicesPool(DetailHandler):
"""
Processes the transports detail requests of a Service Pool
"""
@staticmethod
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 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 get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ItemsResult['MetaItem']:
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
parent = ensure.is_instance(parent, models.MetaPool)
try:
if not item:
return [MetaServicesPool.as_dict(i) for i in self.filter_queryset(parent.members.all())]
return [MetaServicesPool.as_dict(i) for i in parent.members.all()]
i = parent.members.get(uuid=process_uuid(item))
return MetaServicesPool.as_dict(i)
except models.MetaPoolMember.DoesNotExist:
raise exceptions.rest.NotFound(_('Meta pool member not found: {}').format(item)) from None
except Exception as e:
except Exception:
logger.exception('err: %s', item)
raise exceptions.rest.RequestError(f'Error retrieving meta pool member: {e}') from e
raise self.invalid_item_response()
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 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 save_item(self, parent: 'Model', item: typing.Optional[str]) -> typing.Any:
parent = ensure.is_instance(parent, models.MetaPool)
@@ -128,13 +105,13 @@ class MetaServicesPool(DetailHandler[MetaItem]):
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,
@@ -145,6 +122,7 @@ class MetaServicesPool(DetailHandler[MetaItem]):
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]))
@@ -155,7 +133,7 @@ class MetaServicesPool(DetailHandler[MetaItem]):
log.log(parent, types.log.LogLevel.INFO, log_str, types.log.LogSource.ADMIN)
class MetaAssignedService(DetailHandler[UserServiceItem]):
class MetaAssignedService(DetailHandler):
"""
Rest handler for Assigned Services, wich parent is Service
"""
@@ -165,10 +143,10 @@ class MetaAssignedService(DetailHandler[UserServiceItem]):
meta_pool: 'models.MetaPool',
item: 'models.UserService',
props: typing.Optional[dict[str, typing.Any]],
) -> 'UserServiceItem':
element = AssignedUserService.userservice_item(item, props, False)
element.pool_id = item.deployed_service.uuid
element.pool_name = item.deployed_service.name
) -> 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
return element
def _get_assigned_userservice(self, metapool: models.MetaPool, userservice_id: str) -> models.UserService:
@@ -182,21 +160,17 @@ class MetaAssignedService(DetailHandler[UserServiceItem]):
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:
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
raise self.invalid_item_response()
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ItemsResult[UserServiceItem]:
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
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 self.filter_queryset(parent.members.filter(enabled=True)):
for m in parent.members.filter(enabled=True):
properties: dict[str, typing.Any] = {
k: v
for k, v in models.Properties.objects.filter(
@@ -229,40 +203,47 @@ class MetaAssignedService(DetailHandler[UserServiceItem]):
).values_list('key', 'value')
},
)
except Exception as e:
except Exception:
logger.exception('get_items')
raise exceptions.rest.RequestError(f'Error retrieving meta pool member: {e}') from e
raise self.invalid_item_response()
def get_table(self, parent: 'Model') -> TableInfo:
def get_title(self, parent: 'Model') -> str:
parent = ensure.is_instance(parent, models.MetaPool)
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()
)
return _('Assigned services')
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:
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
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()
def delete_item(self, parent: 'Model', item: str) -> None:
parent = ensure.is_instance(parent, models.MetaPool)
@@ -275,18 +256,16 @@ class MetaAssignedService(DetailHandler[UserServiceItem]):
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 exceptions.rest.RequestError(_('Item already being removed'))
raise self.invalid_item_response(_('Item already being removed'))
else:
raise exceptions.rest.RequestError(_('Item is not removable'))
raise self.invalid_item_response(_('Item is not removable'))
log.log(parent, types.log.LogLevel.INFO, log_str, types.log.LogSource.ADMIN)
@@ -294,16 +273,14 @@ class MetaAssignedService(DetailHandler[UserServiceItem]):
def save_item(self, parent: 'Model', item: typing.Optional[str]) -> typing.Any:
parent = ensure.is_instance(parent, models.MetaPool)
if item is None:
raise exceptions.rest.RequestError(_('Invalid item specified'))
raise self.invalid_item_response()
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
@@ -314,7 +291,7 @@ class MetaAssignedService(DetailHandler[UserServiceItem]):
.count()
> 0
):
raise exceptions.rest.RequestError(
raise self.invalid_response_response(
'There is already another user service assigned to {}'.format(user.pretty_name)
)
@@ -323,5 +300,5 @@ class MetaAssignedService(DetailHandler[UserServiceItem]):
# Log change
log.log(parent, types.log.LogLevel.INFO, log_str, types.log.LogSource.ADMIN)
return {'id': userservice.uuid}

View File

@@ -30,109 +30,91 @@
'''
@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 exceptions, mfas, types
from uds.core import 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
@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
class MFA(ModelHandler):
model = models.MFA
save_fields = ['name', 'comments', 'tags', 'remember_device', 'validity']
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}},
]
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]]:
def enum_types(self) -> collections.abc.Iterable[type[mfas.MFA]]:
return mfas.factory().providers().values()
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
mfa_type = mfas.factory().lookup(for_type)
def get_gui(self, type_: str) -> list[typing.Any]:
mfa_type = mfas.factory().lookup(type_)
if not mfa_type:
raise exceptions.rest.NotFound(_('MFA type not found: {}').format(for_type))
raise self.invalid_item_response()
# Create a temporal instance to get the gui
with Environment.temporary_environment() as env:
mfa = mfa_type(env, None)
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()
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,
},
)
def get_item(self, item: 'Model') -> MFAItem:
return local_gui
def item_as_dict(self, item: 'Model') -> dict[str, typing.Any]:
item = ensure.is_instance(item, models.MFA)
type_ = item.get_type()
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),
)
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),
}

View File

@@ -30,83 +30,85 @@
"""
@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, ui as ui_utils
from uds.core.util import permissions, ensure
from ..model import ModelHandler
if typing.TYPE_CHECKING:
from django.db.models import Model
logger = logging.getLogger(__name__)
# Enclosed methods under /item path
@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]):
class Networks(ModelHandler):
"""
Processes REST requests about networks
Implements specific handling for network related requests using GUI
"""
MODEL = Network
FIELDS_TO_SAVE = ['name', 'net_string', 'tags']
model = Network
save_fields = ['name', 'net_string', 'tags']
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()
)
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}},
]
# 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...)'
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...'
),
)
.build()
'type': types.ui.FieldType.TEXT,
'order': 100, # At end
},
)
def get_item(self, item: 'Model') -> NetworkItem:
def item_as_dict(self, item: 'Model') -> dict[str, typing.Any]:
item = ensure.is_instance(item, Network)
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),
)
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),
}

View File

@@ -30,46 +30,33 @@
'''
@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 exceptions, messaging, types
from uds.core import 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
@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 = [
class Notifiers(ModelHandler):
path = 'messaging'
model = Notifier
save_fields = [
'name',
'comments',
'level',
@@ -77,69 +64,66 @@ class Notifiers(ModelHandler[NotifierItem]):
'enabled',
]
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()
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}},
]
# 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]]:
def enum_types(self) -> collections.abc.Iterable[type[messaging.Notifier]]:
return messaging.factory().providers().values()
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
notifier_type = messaging.factory().lookup(for_type)
def get_gui(self, type_: str) -> list[typing.Any]:
notifier_type = messaging.factory().lookup(type_)
if not notifier_type:
raise exceptions.rest.NotFound(_('Notifier type not found: {}').format(for_type))
raise self.invalid_item_response()
with Environment.temporary_environment() as env:
notifier = notifier_type(env, None)
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()
local_gui = self.add_default_fields(
notifier.gui_description(), ['name', 'comments', 'tags']
)
def get_item(self, item: 'Model') -> NotifierItem:
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]:
item = ensure.is_instance(item, Notifier)
type_ = item.get_type()
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),
)
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),
}

View File

@@ -30,23 +30,21 @@
"""
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 exceptions, types, consts
from uds.core.types.rest import TableInfo
from uds.core.util import log, ensure, ui as ui_utils
from uds.core import types, consts
from uds.core.util import log, ensure
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__)
@@ -54,50 +52,38 @@ ALLOW = 'ALLOW'
DENY = 'DENY'
@dataclasses.dataclass
class AccessCalendarItem(types.rest.BaseRestItem):
id: str
calendar_id: str
calendar: str
access: str
priority: int
class AccessCalendars(DetailHandler[AccessCalendarItem]):
class AccessCalendars(DetailHandler):
@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 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,
}
def get_items(
self, parent: 'Model', item: typing.Optional[str]
) -> types.rest.ItemsResult[AccessCalendarItem]:
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
# 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_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
return [AccessCalendars.as_dict(i) for i in parent.calendarAccess.all()]
return AccessCalendars.as_dict(parent.calendarAccess.get(uuid=process_uuid(item)))
except Exception as e:
logger.exception('err: %s', item)
raise exceptions.rest.RequestError(f'Error retrieving access calendar: {e}') from e
raise self.invalid_item_response() from e
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 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 save_item(self, parent: 'Model', item: typing.Optional[str]) -> typing.Any:
parent = typing.cast(typing.Union['models.ServicePool', 'models.MetaPool'], parent)
@@ -105,20 +91,12 @@ class AccessCalendars(DetailHandler[AccessCalendarItem]):
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:
logger.error('Error saving calendar access: %s', e)
raise exceptions.rest.RequestError(_('Invalid parameters on request')) from e
raise self.invalid_request_response(_('Invalid parameters on request')) from e
priority = int(self._params['priority'])
if uuid is not None:
@@ -136,7 +114,7 @@ class AccessCalendars(DetailHandler[AccessCalendarItem]):
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:
@@ -148,76 +126,64 @@ class AccessCalendars(DetailHandler[AccessCalendarItem]):
log.log(parent, types.log.LogLevel.INFO, log_str, types.log.LogSource.ADMIN)
@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]):
class ActionsCalendars(DetailHandler):
"""
Processes the transports detail requests of a Service Pool
"""
CUSTOM_METHODS = [
custom_methods = [
'execute',
]
@staticmethod
def as_dict(item: 'models.CalendarAction') -> ActionCalendarItem:
def as_dict(item: 'models.CalendarAction') -> dict[str, typing.Any]:
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 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,
)
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,
}
def get_items(
self, parent: 'Model', item: typing.Optional[str]
) -> types.rest.ItemsResult[ActionCalendarItem]:
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
parent = ensure.is_instance(parent, models.ServicePool)
try:
if item is None:
return [ActionsCalendars.as_dict(i) for i in self.filter_queryset(parent.calendaraction_set.all())]
return [ActionsCalendars.as_dict(i) for i in 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:
logger.error('Error retrieving scheduled action %s: %s', item, e)
raise exceptions.rest.RequestError(f'Error retrieving scheduled action: {e}') from e
raise self.invalid_item_response() from e
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 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 save_item(self, parent: 'Model', item: typing.Optional[str]) -> typing.Any:
parent = ensure.is_instance(parent, models.ServicePool)
@@ -227,7 +193,7 @@ class ActionsCalendars(DetailHandler[ActionCalendarItem]):
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 exceptions.rest.RequestError(_('Invalid action: {}').format(action))
raise self.invalid_request_response()
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'])
@@ -259,7 +225,7 @@ class ActionsCalendars(DetailHandler[ActionCalendarItem]):
)
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:
@@ -281,7 +247,7 @@ class ActionsCalendars(DetailHandler[ActionCalendarItem]):
logger.debug('Launching action')
uuid = process_uuid(item)
calendar_action: models.CalendarAction = models.CalendarAction.objects.get(uuid=uuid)
self.check_access(calendar_action, types.permissions.PermissionType.MANAGEMENT)
self.ensure_has_access(calendar_action, types.permissions.PermissionType.MANAGEMENT)
log_str = (
f'Launched scheduled action "{calendar_action.calendar.name},'

View File

@@ -31,75 +31,55 @@
Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
import collections.abc
import dataclasses
import logging
import typing
from django.db.models import Model
from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
from django.utils.translation import gettext, 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
@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
class OsManagers(ModelHandler):
model = OSManager
save_fields = ['name', 'comments', 'tags']
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}},
]
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=[
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': [
type_.services_types
], # A list for backward compatibility. TODO: To be removed when admin interface is changed
comments=item.comments,
permission=permissions.effective_permissions(self._user, item),
item=item,
)
# Fill type and type_name
return ret_value
'comments': osm.comments,
'permission': permissions.effective_permissions(self._user, osm),
}
def get_item(self, item: 'Model') -> OsManagerItem:
def item_as_dict(self, item: 'Model') -> types.rest.ItemDictType:
item = ensure.is_instance(item, OSManager)
return self.os_manager_as_dict(item)
@@ -112,26 +92,22 @@ class OsManagers(ModelHandler[OsManagerItem]):
)
# Types related
@classmethod
def possible_types(cls: type[typing.Self]) -> collections.abc.Iterable[type[osmanagers.OSManager]]:
def enum_types(self) -> collections.abc.Iterable[type[osmanagers.OSManager]]:
return osmanagers.factory().providers().values()
# Gui related
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
def get_gui(self, type_: str) -> list[typing.Any]:
try:
osmanager_type = osmanagers.factory().lookup(for_type)
osmanager_type = osmanagers.factory().lookup(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 (
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()
return self.add_default_fields(
osmanager.gui_description(),
['name', 'comments', 'tags'],
)
except:
raise exceptions.rest.NotFound(_('OS Manager type not found: {}').format(for_type))
raise exceptions.rest.NotFound('type not found')

View File

@@ -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 consts, exceptions
from uds.core import exceptions
from uds.core.util import permissions
from uds.core.util.rest.tools import match_args
from uds.core.util.rest.tools import match
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
"""
ROLE = consts.UserRole.ADMIN
needs_admin = True
@staticmethod
def get_class(class_name: str) -> type['Model']:
@@ -72,6 +72,7 @@ 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:
@@ -94,19 +95,21 @@ 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 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,
}
)
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,
}
)
return sorted(res, key=lambda v: v['auth_name'] + v['entity_name'])
@@ -115,10 +118,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')
@@ -133,17 +136,11 @@ 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')
@@ -172,10 +169,33 @@ 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_args(
self._args,
return match(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')

View File

@@ -31,17 +31,16 @@
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 _
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, ui as ui_utils
from uds.core.util import ensure, permissions
from uds.core.types.states import State
from uds.models import Provider, Service, UserService
from uds.REST.model import ModelHandler
@@ -51,87 +50,67 @@ from .services_usage import ServicesUsage
logger = logging.getLogger(__name__)
# Helper class for Provider offers
@dataclasses.dataclass
class OfferItem(types.rest.BaseRestItem):
name: str
type: str
description: str
icon: str
if typing.TYPE_CHECKING:
from django.db.models import Model
@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
class Providers(ModelHandler):
"""
Providers REST handler
"""
model = Provider
detail = {'services': DetailServices, 'usage': ServicesUsage}
class Providers(ModelHandler[ProviderItem]):
custom_methods = [('allservices', False), ('service', False), ('maintenance', True)]
MODEL = Provider
DETAIL = {'services': DetailServices, 'usage': ServicesUsage}
save_fields = ['name', 'comments', 'tags']
CUSTOM_METHODS = [
types.rest.ModelCustomMethod('allservices', False),
types.rest.ModelCustomMethod('service', False),
types.rest.ModelCustomMethod('maintenance', True),
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}},
]
# 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')
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:
def item_as_dict(self, item: 'Model') -> types.rest.ItemDictType:
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: list[OfferItem] = [
OfferItem(
name=gettext(t.mod_name()),
type=t.mod_type(),
description=gettext(t.description()),
icon=t.icon64().replace('\n', ''),
)
offers = [
{
'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 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)
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)
.exclude(state__in=(State.REMOVED, State.ERROR))
.count(),
maintenance_mode=item.maintenance_mode,
offers=offers,
comments=item.comments,
permission=permissions.effective_permissions(self._user, item),
item=item,
)
'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),
}
def validate_delete(self, item: 'Model') -> None:
item = ensure.is_instance(item, Provider)
@@ -139,27 +118,19 @@ class Providers(ModelHandler[ProviderItem]):
raise exceptions.rest.RequestError(gettext('Can\'t delete providers with services'))
# Types related
@classmethod
def possible_types(cls: type[typing.Self]) -> collections.abc.Iterable[type[services.ServiceProvider]]:
def enum_types(self) -> collections.abc.Iterable[type[services.ServiceProvider]]:
return services.factory().providers().values()
# Gui related
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
provider_type = services.factory().lookup(for_type)
def get_gui(self, type_: str) -> list[typing.Any]:
provider_type = services.factory().lookup(type_)
if provider_type:
with Environment.temporary_environment() as env:
provider = provider_type(env, None)
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()
return self.add_default_fields(provider.gui_description(), ['name', 'comments', 'tags'])
raise exceptions.rest.NotFound('Type not found!')
def allservices(self) -> typing.Generator[types.rest.BaseRestItem, None, None]:
def allservices(self) -> typing.Generator[types.rest.ItemDictType, None, None]:
"""
Custom method that returns "all existing services", no mater who's his daddy :)
"""
@@ -167,33 +138,33 @@ class Providers(ModelHandler[ProviderItem]):
try:
perm = permissions.effective_permissions(self._user, s)
if perm >= uds.core.types.permissions.PermissionType.READ:
yield DetailServices.service_item(s, perm, True)
yield DetailServices.service_to_dict(s, perm, True)
except Exception:
logger.exception('Passed service cause type is unknown')
def service(self) -> types.rest.BaseRestItem:
def service(self) -> types.rest.ItemDictType:
"""
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.check_access(service.provider, uds.core.types.permissions.PermissionType.READ)
self.ensure_has_access(service.provider, uds.core.types.permissions.PermissionType.READ)
perm = self.get_permissions(service.provider)
return DetailServices.service_item(service, perm, True)
return DetailServices.service_to_dict(service, perm, True)
except Exception:
# logger.exception('Exception')
return types.rest.BaseRestItem()
return {}
def maintenance(self, item: 'Model') -> types.rest.BaseRestItem:
def maintenance(self, item: 'Model') -> types.rest.ItemDictType:
"""
Custom method that swaps maintenance mode state for a provider
:param item:
"""
item = ensure.is_instance(item, Provider)
self.check_access(item, uds.core.types.permissions.PermissionType.MANAGEMENT)
self.ensure_has_access(item, uds.core.types.permissions.PermissionType.MANAGEMENT)
item.maintenance_mode = not item.maintenance_mode
item.save()
return self.get_item(item)
return self.item_as_dict(item)
def test(self, type_: str) -> str:
from uds.core.environment import Environment
@@ -207,8 +178,7 @@ class Providers(ModelHandler[ProviderItem]):
with Environment.temporary_environment() as temp_environment:
logger.debug('spType: %s', provider_type)
# On 5.0 onwards, instance comes inside "instance" key
dct = self._params.copy()['instance']
dct = self._params.copy()
dct['_request'] = self._request
test_result = provider_type.test(temp_environment, dct)
return 'ok' if test_result.success else test_result.error

View File

@@ -30,15 +30,13 @@
"""
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 exceptions, types, consts
from uds.core.util.rest.tools import match_args
from uds.core.util import ui as ui_utils
from uds.core import types, consts
from uds.core.util.rest.tools import match
from uds.REST import model
from uds import reports
@@ -60,42 +58,25 @@ 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[ReportItem]):
class Reports(model.BaseModelHandler):
"""
Processes reports requests
"""
ROLE = consts.UserRole.ADMIN
needs_admin = True # By default, staff is lower level needed
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()
)
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')
# 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':
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:
@@ -104,7 +85,7 @@ class Reports(model.BaseModelHandler[ReportItem]):
break
if not found:
raise exceptions.rest.NotFound(f'Report not found: {uuid}') from None
raise self.invalid_request_response('Invalid report uuid!')
return found
@@ -112,19 +93,21 @@ class Reports(model.BaseModelHandler[ReportItem]):
logger.debug('method GET for %s, %s', self.__class__.__name__, self._args)
def error() -> typing.NoReturn:
raise exceptions.rest.RequestError('Invalid report uuid!')
raise self.invalid_request_response()
def report_gui(report_id: str) -> typing.Any:
return self.get_gui(report_id)
return match_args(
return match(
self._args,
error,
((), lambda: list(self.filter_data(self.get_items()))),
((), lambda: list(self.get_items())),
((consts.rest.OVERVIEW,), lambda: list(self.get_items())),
(
(consts.rest.TABLEINFO,),
lambda: self.TABLE.as_dict(),
lambda: self.process_table_fields(
str(self.table_title), self.table_fields, self.table_row_style
),
),
((consts.rest.GUI, '<report>'), report_gui),
)
@@ -141,7 +124,7 @@ class Reports(model.BaseModelHandler[ReportItem]):
)
if len(self._args) != 1:
raise exceptions.rest.RequestError('Invalid report uuid!')
raise self.invalid_request_response()
report = self._locate_report(self._args[0], self._params)
@@ -159,21 +142,23 @@ class Reports(model.BaseModelHandler[ReportItem]):
return data
except Exception as e:
logger.exception('Generating report')
raise exceptions.rest.RequestError(str(e)) from e
raise self.invalid_request_response(str(e))
# Gui related
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)
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'])
# Returns the list of
def get_items(self, *args: typing.Any, **kwargs: typing.Any) -> typing.Generator[ReportItem, None, None]:
def get_items(
self, *args: typing.Any, **kwargs: typing.Any
) -> typing.Generator[types.rest.ItemDictType, None, None]:
for i in reports.available_reports:
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(),
)
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(),
}

View File

@@ -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.NULL_MAC)
mac = self._params.get('mac', consts.MAC_UNKNOWN)
data = self._params.get('data', None)
subtype = self._params.get('subtype', '')
os = self._params.get('os', types.os.KnownOS.UNKNOWN.os_name()).lower()
@@ -138,34 +138,17 @@ class ServerRegisterBase(Handler):
class ServerRegister(ServerRegisterBase):
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'),
}
)
})
needs_staff = True
path = 'servers'
name = 'register'
# REST handlers for server actions
class ServerTest(Handler):
ROLE = consts.UserRole.ANONYMOUS
authenticated = False # Test is not authenticated, the auth is the token to test itself
PATH = 'servers'
NAME = 'test'
path = 'servers'
name = 'test'
@decorators.blocker()
def post(self) -> collections.abc.MutableMapping[str, typing.Any]:
@@ -189,9 +172,9 @@ class ServerEvent(Handler):
* log
"""
ROLE = consts.UserRole.ANONYMOUS
PATH = 'servers'
NAME = 'event'
authenticated = False # Actor requests are not authenticated normally
path = 'servers'
name = 'event'
def get_user_service(self) -> models.UserService:
'''

View File

@@ -29,83 +29,65 @@
"""
Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
import dataclasses
import datetime
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 django.db import transaction
from uds import models
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 import consts, types, ui
from uds.core.util import net, permissions, ensure
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[TokenItem]):
class ServersTokens(ModelHandler):
# servers/groups/[id]/servers
MODEL = models.Server
EXCLUDE = {
model = models.Server
model_exclude = {
'type__in': [
types.servers.ServerType.ACTOR,
types.servers.ServerType.UNMANAGED,
]
}
PATH = 'servers'
NAME = 'tokens'
path = 'servers'
name = 'tokens'
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()
)
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')}},
]
def get_item(self, item: 'Model') -> TokenItem:
def item_as_dict(self, item: 'Model') -> dict[str, typing.Any]:
item = typing.cast('models.Server', item) # We will receive for sure
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,
)
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,
}
def delete(self) -> str:
"""
@@ -114,134 +96,157 @@ class ServersTokens(ModelHandler[TokenItem]):
if len(self._args) != 1:
raise RequestError('Delete need one and only one argument')
self.check_access(
self.MODEL(), types.permissions.PermissionType.ALL, root=True
self.ensure_has_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
@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
# REST API For servers (except tunnel servers nor actors)
class ServersServers(DetailHandler[ServerItem]):
class ServersServers(DetailHandler):
custom_methods = ['maintenance', 'importcsv']
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]:
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
parent = typing.cast('models.ServerGroup', parent) # We will receive for sure
try:
if item is None:
q = self.filter_queryset(parent.servers.all())
q = parent.servers.all()
else:
q = parent.servers.filter(uuid=process_uuid(item))
res: list[ServerItem] = []
res: types.rest.ItemListType = []
i = None
for i in q:
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,
)
)
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)
if item is None:
return res
if not i:
raise exceptions.rest.NotFound(f'Server not found: {item}')
raise Exception('Item not found')
return res[0]
except exceptions.rest.HandlerError:
raise
except Exception:
logger.exception('Error getting server')
raise exceptions.rest.ResponseError(_('Error getting server')) from None
except Exception as e:
logger.exception('REST servers')
raise self.invalid_item_response() from e
def get_table(self, parent: 'Model') -> TableInfo:
def get_title(self, parent: 'Model') -> str:
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'))
try:
return (_('Servers of {0}')).format(parent.name)
except Exception:
return str(_('Servers'))
def get_fields(self, parent: 'Model') -> list[typing.Any]:
parent = ensure.is_instance(parent, models.ServerGroup)
return (
table_info.dict_column(
name='maintenance_mode',
title=_('State'),
dct={True: _('Maintenance'), False: _('Normal')},
[
{
'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')}},
]
)
.row_style(prefix='row-maintenance-', field='maintenance_mode')
.build()
+ [
{
'maintenance_mode': {
'title': _('State'),
'type': 'dict',
'dict': {True: _('Maintenance'), False: _('Normal')},
}
},
]
)
def get_gui(self, parent: 'Model', for_type: str) -> list[types.ui.GuiElement]:
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]:
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 (
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()
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='server',
label=gettext('Server'),
tooltip=gettext('Server to include on group'),
default='',
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,
},
],
)
.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)
@@ -251,10 +256,10 @@ class ServersServers(DetailHandler[ServerItem]):
if item is None:
# Create new, depending on server type
if parent.type == types.servers.ServerType.UNMANAGED:
# Ensure mac is empty or valid
# Ensure mac is emty or valid
mac = self._params['mac'].strip().upper()
if mac and not net.is_valid_mac(mac):
raise exceptions.rest.RequestError(_('Invalid MAC address'))
raise self.invalid_request_response('Invalid MAC address')
# Create a new one, and add it to group
server = models.Server.objects.create(
register_username=self._user.pretty_name,
@@ -277,20 +282,16 @@ class ServersServers(DetailHandler[ServerItem]):
# 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 exceptions.rest.RequestError('Invalid server type') from None
raise self.invalid_request_response() from None
parent.servers.add(server)
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
except Exception:
raise self.invalid_item_response() 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 exceptions.rest.RequestError('Invalid MAC address')
raise self.invalid_request_response('Invalid MAC address')
try:
models.Server.objects.filter(uuid=process_uuid(item)).update(
# Update register info also on update
@@ -301,20 +302,20 @@ class ServersServers(DetailHandler[ServerItem]):
mac=mac,
stamp=sql_now(), # Modified now
)
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
except Exception:
raise self.invalid_item_response() from None
else:
# Remove current server and add the new one in a single transaction
try:
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
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
return {'id': item}
def delete_item(self, parent: 'Model', item: str) -> None:
@@ -326,11 +327,8 @@ class ServersServers(DetailHandler[ServerItem]):
server.delete() # and delete server
else:
parent.servers.remove(server) # Just remove reference
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
except Exception:
raise self.invalid_item_response() from None
# Custom methods
def maintenance(self, parent: 'Model', id: str) -> typing.Any:
@@ -340,7 +338,7 @@ class ServersServers(DetailHandler[ServerItem]):
:param item:
"""
item = models.Server.objects.get(uuid=process_uuid(id))
self.check_access(item, types.permissions.PermissionType.MANAGEMENT)
self.ensure_has_access(parent, types.permissions.PermissionType.MANAGEMENT)
item.maintenance_mode = not item.maintenance_mode
item.save()
return 'ok'
@@ -365,11 +363,11 @@ class ServersServers(DetailHandler[ServerItem]):
continue
hostname = row[0].strip()
ip = ''
mac = consts.NULL_MAC
mac = consts.MAC_UNKNOWN
if len(row) > 1:
ip = row[1].strip()
if len(row) > 2:
mac = row[2].strip().upper().strip() or consts.NULL_MAC
mac = row[2].strip().upper().strip() or consts.MAC_UNKNOWN
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
@@ -417,88 +415,71 @@ class ServersServers(DetailHandler[ServerItem]):
return import_errors
@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 = {
class ServersGroups(ModelHandler):
custom_methods = [('stats', True)]
model = models.ServerGroup
model_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'
FIELDS_TO_SAVE = ['name', 'comments', 'type', 'tags'] # Subtype is appended on pre_save
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}},
]
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(
def get_types(
self, *args: typing.Any, **kwargs: typing.Any
) -> typing.Generator[types.rest.TypeInfo, None, None]:
) -> typing.Generator[types.rest.TypeInfoDict, None, None]:
for i in types.servers.ServerSubtype.manager().enum():
yield types.rest.TypeInfo(
v = 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, 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]
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]
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 (
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()
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,
},
],
)
def pre_save(self, fields: dict[str, typing.Any]) -> None:
@@ -508,27 +489,27 @@ class ServersGroups(ModelHandler[GroupItem]):
fields['subtype'] = subtype
return super().pre_save(fields)
def get_item(self, item: 'Model') -> GroupItem:
def item_as_dict(self, item: 'Model') -> dict[str, typing.Any]:
item = ensure.is_instance(item, models.ServerGroup)
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),
)
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),
}
def delete_item(self, item: 'Model') -> None:
item = ensure.is_instance(item, models.ServerGroup)
"""
Processes a DELETE request
"""
self.check_access(
self.MODEL(), permissions.PermissionType.ALL, root=True
self.ensure_has_access(
self.model(), permissions.PermissionType.ALL, root=True
) # Must have write permissions to delete
try:
@@ -537,7 +518,7 @@ class ServersGroups(ModelHandler[GroupItem]):
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:
@@ -552,7 +533,7 @@ class ServersGroups(ModelHandler[GroupItem]):
'server': {
'id': s[1].uuid,
'hostname': s[1].hostname,
'mac': s[1].mac if s[1].mac != consts.NULL_MAC else '',
'mac': s[1].mac if s[1].mac != consts.MAC_UNKNOWN else '',
'ip': s[1].ip,
'load': s[0].load(weights=item.weights) if s[0] else 0,
'weights': item.weights.as_dict(),

View File

@@ -30,148 +30,106 @@
"""
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, module, services
from uds.core import exceptions, types
import uds.core.types.permissions
from uds.core.types.rest import TableInfo
from uds.core.util import log, permissions, ensure, ui as ui_utils
from uds.core.util import log, permissions, ensure
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 import ui
from uds.core.ui import gui
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__)
@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
class Services(DetailHandler): # pylint: disable=too-many-public-methods
"""
Detail handler for Services, whose parent is a Provider
"""
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,
)
custom_methods = ['servicepools']
@staticmethod
def service_info(item: models.Service) -> ServiceInfo:
def service_info(item: models.Service) -> dict[str, typing.Any]:
info = item.get_type()
overrided_fields = info.overrided_pools_fields or {}
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(),
)
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(),
}
@staticmethod
def service_item(item: models.Service, perm: int, full: bool = False) -> ServiceItem:
def service_to_dict(item: models.Service, perm: int, full: bool = False) -> types.rest.ItemDictType:
"""
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
"""
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)
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)
.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,
item=item,
)
'max_services_count_type': str(item.max_services_count_type),
'maintenance_mode': item.provider.maintenance_mode,
'permission': perm,
}
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.ItemsResult[ServiceItem]:
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
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_item(k, perm) for k in self.filter_queryset(parent.services.all())]
return [Services.service_to_dict(k, perm) for k in parent.services.all()]
k = parent.services.get(uuid=process_uuid(item))
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
val = Services.service_to_dict(k, perm, full=True)
return self.fill_instance_fields(k, val)
except Exception as e:
logger.error('Error getting services for %s: %s', parent, e)
raise exceptions.rest.ResponseError(_('Error getting services')) from None
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')
def _delete_incomplete_service(self, service: models.Service) -> None:
"""
@@ -183,7 +141,7 @@ class Services(DetailHandler[ServiceItem]): # pylint: disable=too-many-public-m
except Exception: # nosec: This is a delete, we don't care about exceptions
pass
def save_item(self, parent: 'Model', item: typing.Optional[str]) -> ServiceItem:
def save_item(self, parent: 'Model', item: typing.Optional[str]) -> typing.Any:
parent = ensure.is_instance(parent, models.Provider)
# Extract item db fields
# We need this fields for all
@@ -230,25 +188,22 @@ class Services(DetailHandler[ServiceItem]): # pylint: disable=too-many-public-m
service.data = service_instance.serialize()
service.save()
return Services.service_item(
service, permissions.effective_permissions(self._user, service), full=True
)
return {'id': service.uuid}
except models.Service.DoesNotExist:
raise exceptions.rest.NotFound('Service not found') from None
raise self.invalid_item_response() 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.ValidationError('Input error: {0}'.format(e)) from e
raise exceptions.rest.RequestError(_('Input error: {0}'.format(e))) from e
except Exception as e:
if not item and service:
self._delete_incomplete_service(service)
@@ -262,99 +217,110 @@ class Services(DetailHandler[ServiceItem]): # pylint: disable=too-many-public-m
if service.deployedServices.count() == 0:
service.delete()
return
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
except Exception:
logger.exception('Deleting service')
raise self.invalid_item_response() from None
raise exceptions.rest.RequestError('Item has associated deployed services')
def get_table(self, parent: 'Model') -> TableInfo:
def get_title(self, parent: 'Model') -> str:
parent = ensure.is_instance(parent, models.Provider)
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'),
},
)
.text_column(name='tags', title=_('Tags'), visible=False)
.row_style(prefix='row-maintenance-', field='maintenance_mode')
.build()
)
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')},
},
},
{'tags': {'title': _('tags'), 'visible': False}},
]
def get_types(
self, parent: 'Model', for_type: typing.Optional[str]
) -> collections.abc.Iterable[types.rest.TypeInfoDict]:
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.TypeInfo] = []
offers: list[types.rest.TypeInfoDict] = []
if for_type is None:
offers = [type(self).as_typeinfo(t) for t in parent.get_type().get_provided_services()]
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()
]
else:
for t in parent.get_type().get_provided_services():
if for_type == t.mod_type():
offers = [type(self).as_typeinfo(t)]
offers = [
{
'name': _(t.mod_name()),
'type': t.mod_type(),
'description': _(t.description()),
'icon': t.icon64().replace('\n', ''),
}
]
break
if not offers:
raise exceptions.rest.NotFound('type not found')
return offers
return offers # Default is that details do not have types
@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]:
def get_gui(self, parent: 'Model', for_type: str) -> collections.abc.Iterable[typing.Any]:
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 exceptions.rest.RequestError(f'Gui for type "{for_type}" not found')
raise self.invalid_item_response(f'Gui for {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
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')
),
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')),
],
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())
'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,
},
)
return [field_gui for field_gui in gui.build() if field_gui.name not in overrided_fields]
# 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
except Exception as e:
logger.exception('get_gui')
@@ -366,32 +332,29 @@ class Services(DetailHandler[ServiceItem]): # pylint: disable=too-many-public-m
service = parent.services.get(uuid=process_uuid(item))
logger.debug('Getting logs for %s', item)
return log.get_logs(service)
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
except Exception:
raise self.invalid_item_response() from None
def servicepools(self, parent: 'Model', item: str) -> list[ServicePoolResumeItem]:
def servicepools(self, parent: 'Model', item: str) -> types.rest.ManyItemsDictType:
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: list[ServicePoolResumeItem] = []
res: types.rest.ItemListType = []
for i in service.deployedServices.all():
try:
self.check_access(
self.ensure_has_access(
i, uds.core.types.permissions.PermissionType.READ
) # Ensures access before listing...
res.append(
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(
{
'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

View File

@@ -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.util import ensure, ui as ui_utils
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.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
@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()
class ServicesPoolGroups(ModelHandler):
"""
Handles the gallery REST interface
"""
# needs_admin = True
class ServicesPoolGroups(ModelHandler[ServicePoolGroupItem]):
path = 'gallery'
model = ServicePoolGroup
save_fields = ['name', 'comments', 'image_id', 'priority']
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,
)
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')}},
]
def pre_save(self, fields: dict[str, typing.Any]) -> None:
img_id = fields['image_id']
@@ -91,33 +91,47 @@ class ServicesPoolGroups(ModelHandler[ServicePoolGroupItem]):
logger.exception('At image recovering')
# Gui related
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()
)
def get_gui(self, type_: str) -> list[typing.Any]:
local_gui = self.add_default_fields([], ['name', 'comments', 'priority'])
def get_item(self, item: 'Model') -> ServicePoolGroupItem:
item = ensure.is_instance(item, ServicePoolGroup)
return ServicePoolGroupItem(
id=item.uuid,
name=item.name,
comments=item.comments,
priority=item.priority,
image_id=item.image.uuid if item.image else None,
)
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)
def get_item_summary(self, item: 'Model') -> ServicePoolGroupItem:
return local_gui
def item_as_dict(self, item: 'Model') -> dict[str, typing.Any]:
item = ensure.is_instance(item, ServicePoolGroup)
return ServicePoolGroupItem(
id=item.uuid,
priority=item.priority,
name=item.name,
comments=item.comments,
thumb=item.thumb64,
)
return {
'id': item.uuid,
'priority': item.priority,
'name': item.name,
'comments': item.comments,
'image_id': item.image.uuid if item.image else None,
}
def item_as_dict_overview(
self, item: 'Model'
) -> dict[str, typing.Any]:
item = ensure.is_instance(item, ServicePoolGroup)
return {
'id': item.uuid,
'priority': item.priority,
'name': item.name,
'comments': item.comments,
'thumb': item.thumb64,
}

View File

@@ -30,19 +30,19 @@
"""
Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
import dataclasses
import datetime
import logging
import typing
from django.utils.translation import gettext, gettext_lazy as _
from django.db.models import Model, Count, Q
from django.db.models import Count, Q
from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
from uds.core import types, exceptions, consts
from uds.core.managers.userservice import UserServiceManager
from uds.core import ui
from uds.core.ui import gui
from uds.core.consts.images import DEFAULT_THUMB_BASE64
from uds.core.util import log, permissions, ensure, ui as ui_utils
from uds.core.util import log, permissions, ensure
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,64 +50,23 @@ 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, ServiceInfo
from .user_services import AssignedUserService, CachedService, Changelog, Groups, Publications, Transports
from .services import Services
from .user_services import AssignedService, CachedService, Changelog, Groups, Publications, Transports
if typing.TYPE_CHECKING:
from django.db.models import Model
logger = logging.getLogger(__name__)
@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]):
class ServicesPools(ModelHandler):
"""
Handles Services Pools REST requests
"""
MODEL = ServicePool
DETAIL = {
'services': AssignedUserService,
model = ServicePool
detail = {
'services': AssignedService,
'cache': CachedService,
'servers': CachedService, # Alias for cache, but will change in a future release
'groups': Groups,
@@ -118,7 +77,7 @@ class ServicesPools(ModelHandler[ServicePoolItem]):
'actions': ActionsCalendars,
}
FIELDS_TO_SAVE = [
save_fields = [
'name',
'short_name',
'comments',
@@ -143,41 +102,36 @@ class ServicesPools(ModelHandler[ServicePoolItem]):
'state:_', # Optional field, defaults to Nothing (to apply default or existing value)
]
EXCLUDED_FIELDS = ['osmanager_id', 'service_id']
remove_fields = ['osmanager_id', 'service_id']
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 = [
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),
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')
# Rest api related information to complete the auto-generated API
REST_API_INFO = types.rest.api.RestApiInfo(
typed=types.rest.api.RestApiInfoGuiType.SINGLE_TYPE,
)
custom_methods = [
('set_fallback_access', True),
('get_fallback_access', True),
('actions_list', True),
('list_assignables', True),
('create_from_assignable', True),
('add_log', True),
]
def get_items(
self, *args: typing.Any, **kwargs: typing.Any
) -> typing.Generator[ServicePoolItem, None, None]:
) -> typing.Generator[types.rest.ItemDictType, 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(
@@ -224,7 +178,7 @@ class ServicesPools(ModelHandler[ServicePoolItem]):
# 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 get_item(self, item: 'Model') -> ServicePoolItem:
def item_as_dict(self, item: 'Model') -> dict[str, typing.Any]:
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)
@@ -245,76 +199,78 @@ class ServicesPools(ModelHandler[ServicePoolItem]):
# 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: 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
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,
}
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
# 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
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.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) + '%'
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
return val
# Gui related
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
def get_gui(self, type_: str) -> list[typing.Any]:
# 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:
@@ -322,148 +278,202 @@ class ServicesPools(ModelHandler[ServicePoolItem]):
gettext('Create at least a service before creating a new service pool')
)
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()]
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()]
),
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.'
'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'
),
)
.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'
),
)
.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(
'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.'
),
)
.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()
'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(
'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'
),
'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)
return g
# pylint: disable=too-many-statements
def pre_save(self, fields: dict[str, typing.Any]) -> None:
# logger.debug(self._params)
@@ -495,9 +505,7 @@ class ServicesPools(ModelHandler[ServicePoolItem]):
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']
@@ -528,7 +536,7 @@ class ServicesPools(ModelHandler[ServicePoolItem]):
# 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 service requires an OS Manager')) from e
raise exceptions.rest.RequestError(gettext('This parameters provided are not valid')) from e
# If max < initial or cache_1 or cache_l2
fields['max_srvs'] = max(
@@ -542,36 +550,36 @@ class ServicesPools(ModelHandler[ServicePoolItem]):
# *** ACCOUNT ***
account_id = fields['account_id']
fields['account_id'] = None
logger.debug('Account id: %s', account_id)
if account_id != '-1':
if account_id and account_id != '-1':
logger.debug('Account id: %s', account_id)
try:
fields['account_id'] = Account.objects.get(uuid=process_uuid(account_id)).id
except Exception:
logger.exception('Getting account ID')
logger.warning('Getting account ID: %s %s', account_id)
# **** IMAGE ***
image_id = fields['image_id']
fields['image_id'] = None
logger.debug('Image id: %s', image_id)
try:
if image_id != '-1':
if image_id and image_id != '-1':
logger.debug('Image id: %s', image_id)
try:
image = Image.objects.get(uuid=process_uuid(image_id))
fields['image_id'] = image.id
except Exception:
logger.exception('At image recovering')
except Exception:
logger.warning('At image recovering: %s', image_id)
# Servicepool Group
pool_group_id = fields['pool_group_id']
del fields['pool_group_id']
fields['servicesPoolGroup_id'] = None
logger.debug('pool_group_id: %s', pool_group_id)
try:
if pool_group_id != '-1':
if pool_group_id and pool_group_id != '-1':
logger.debug('pool_group_id: %s', pool_group_id)
try:
spgrp = ServicePoolGroup.objects.get(uuid=process_uuid(pool_group_id))
fields['servicesPoolGroup_id'] = spgrp.id
except Exception:
logger.exception('At service pool group recovering')
except Exception:
logger.warning('At service pool group recovering: %s', pool_group_id)
except (exceptions.rest.RequestError, exceptions.rest.ResponseError):
raise
@@ -606,7 +614,7 @@ class ServicesPools(ModelHandler[ServicePoolItem]):
# Set fallback status
def set_fallback_access(self, item: 'Model') -> typing.Any:
item = ensure.is_instance(item, ServicePool)
self.check_access(item, types.permissions.PermissionType.MANAGEMENT)
self.ensure_has_access(item, types.permissions.PermissionType.MANAGEMENT)
fallback = self._params.get('fallbackAccess', self.params.get('fallback', None))
if fallback:
@@ -675,7 +683,7 @@ class ServicesPools(ModelHandler[ServicePoolItem]):
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:
raise exceptions.rest.RequestError('Invalid parameters')
return self.invalid_request_response('Invalid parameters')
logger.debug('Creating from assignable: %s', self._params)
UserServiceManager.manager().create_from_assignable(
@@ -689,10 +697,10 @@ class ServicesPools(ModelHandler[ServicePoolItem]):
def add_log(self, item: 'Model') -> typing.Any:
item = ensure.is_instance(item, ServicePool)
if 'message' not in self._params:
raise exceptions.rest.RequestError('Invalid parameters')
return self.invalid_request_response('Invalid parameters')
if 'level' not in self._params:
raise exceptions.rest.RequestError('Invalid parameters')
return self.invalid_request_response('Invalid parameters')
log.log(
item,
level=types.log.LogLevel.from_str(self._params['level']),
@@ -700,3 +708,4 @@ class ServicesPools(ModelHandler[ServicePoolItem]):
source=types.log.LogSource.REST,
log_name=self._params.get('log_name', None),
)

View File

@@ -31,53 +31,32 @@
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 django.db.models import Model
from uds.core import exceptions, types
from uds.core import 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, ui as ui_utils
from uds.core.util import ensure
# Not imported at runtime, just for type checking
if typing.TYPE_CHECKING:
from django.db.models import Model
logger = logging.getLogger(__name__)
@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]):
class ServicesUsage(DetailHandler):
"""
Rest handler for Assigned Services, which parent is Service
"""
@staticmethod
def item_as_dict(item: UserService) -> ServicesUsageItem:
def item_as_dict(item: UserService) -> dict[str, typing.Any]:
"""
Converts an assigned/cached service db item to a dictionary for REST response
:param item: item to convert
@@ -93,32 +72,30 @@ class ServicesUsage(DetailHandler[ServicesUsageItem]):
owner = item.user.pretty_name
owner_info = {'auth_id': item.user.manager.uuid, 'user_id': item.user.uuid}
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,
)
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,
}
def get_items(
self, parent: 'Model', item: typing.Optional[str]
) -> types.rest.ItemsResult[ServicesUsageItem]:
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
parent = ensure.is_instance(parent, Provider)
try:
if item is None:
userservices_query = self.filter_queryset(
UserService.objects.filter(deployed_service__service__provider=parent)
userservices_query = UserService.objects.filter(
deployed_service__service__provider=parent
)
else:
userservices_query = UserService.objects.filter(
@@ -132,26 +109,29 @@ class ServicesUsage(DetailHandler[ServicesUsageItem]):
.prefetch_related('deployed_service', 'deployed_service__service', 'user', 'user__manager')
]
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
except Exception:
logger.exception('get_items')
raise self.invalid_item_response()
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 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 delete_item(self, parent: 'Model', item: str) -> None:
parent = ensure.is_instance(parent, Provider)
@@ -160,11 +140,8 @@ class ServicesUsage(DetailHandler[ServicesUsageItem]):
userservice = UserService.objects.get(
uuid=process_uuid(item), deployed_service__service__provider=parent
)
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
except Exception:
raise self.invalid_item_response()
logger.debug('Deleting user service')
if userservice.state in (State.USABLE, State.REMOVING):
@@ -172,6 +149,6 @@ class ServicesUsage(DetailHandler[ServicesUsageItem]):
elif userservice.state == State.PREPARING:
userservice.cancel()
elif userservice.state == State.REMOVABLE:
raise exceptions.rest.ResponseError(_('Item already being removed')) from None
raise self.invalid_item_response(_('Item already being removed'))
else:
raise exceptions.rest.ResponseError(_('Item is not removable')) from None
raise self.invalid_item_response(_('Item is not removable'))

View File

@@ -34,9 +34,7 @@ import logging
import datetime
import typing
from django.utils import timezone
from uds.core import types, consts
from uds.core import types
from uds.REST import Handler
from uds import models
from uds.core.util.stats import counters
@@ -46,7 +44,13 @@ logger = logging.getLogger(__name__)
# Enclosed methods under /cache path
class Stats(Handler):
ROLE = consts.UserRole.ADMIN
authenticated = True
needs_admin = True
help_paths = [
('', 'Returns the last day usage statistics for all authenticators'),
]
help_text = 'Provides access to usage statistics'
def _usage_stats(self, since: datetime.datetime) -> dict[str, list[dict[str, typing.Any]]]:
"""
@@ -134,4 +138,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(timezone.localtime() - datetime.timedelta(days=1))
return self._usage_stats(datetime.datetime.now() - datetime.timedelta(days=1))

View File

@@ -37,10 +37,8 @@ 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, consts
from uds.core import exceptions, types
from uds.core.util import permissions
from uds.core.util.cache import Cache
from uds.core.util.model import process_uuid, sql_now
@@ -50,6 +48,8 @@ from uds.REST import Handler
logger = logging.getLogger(__name__)
if typing.TYPE_CHECKING:
from django.db.models import Model
cache = Cache('StatsDispatcher')
@@ -66,7 +66,9 @@ 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)
@@ -85,7 +87,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 = [
{
@@ -105,7 +107,8 @@ 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(
@@ -140,7 +143,21 @@ class System(Handler):
}
"""
ROLE = consts.UserRole.STAFF
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'
def get(self) -> typing.Any:
logger.debug('args: %s', self._args)
@@ -149,16 +166,14 @@ 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()
@@ -173,7 +188,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,

View File

@@ -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 consts, exceptions
from uds.core import exceptions
logger = logging.getLogger(__name__)
@@ -89,14 +89,14 @@ class Tickets(Handler):
- servicePool has these groups in it's allowed list
"""
ROLE = consts.UserRole.ADMIN
needs_admin = True # By default, staff is lower level needed
@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': timezone.localtime()}
res = {'result': result, 'date': datetime.datetime.now()}
if error is not None:
res['error'] = error
return res

View File

@@ -30,49 +30,31 @@
'''
@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, 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 consts, exceptions, transports, types, ui
from uds.core import consts, transports, types, ui
from uds.core.environment import Environment
from uds.core.util import ensure, permissions, ui as ui_utils
from uds.core.util import ensure, permissions
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
@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 = [
class Transports(ModelHandler):
model = Transport
save_fields = [
'name',
'comments',
'tags',
@@ -82,97 +64,112 @@ class Transports(ModelHandler[TransportItem]):
'label',
]
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()
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}},
]
# 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]]:
def enum_types(self) -> collections.abc.Iterable[type[transports.Transport]]:
return transports.factory().providers().values()
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
transport_type = transports.factory().lookup(for_type)
def get_gui(self, type_: str) -> list[typing.Any]:
transport_type = transports.factory().lookup(type_)
if not transport_type:
raise exceptions.rest.NotFound(_('Transport type not found: {}').format(for_type))
raise self.invalid_item_response()
with Environment.temporary_environment() as env:
transport = transport_type(env, None)
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=[
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': [
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
],
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()
'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,
},
)
def get_item(self, item: 'Model') -> TransportItem:
return field
def item_as_dict(self, item: 'Model') -> dict[str, typing.Any]:
item = ensure.is_instance(item, Transport)
type_ = item.get_type()
pools = list(item.deployedServices.all().values_list('uuid', flat=True))
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,
)
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),
}
def pre_save(self, fields: dict[str, typing.Any]) -> None:
fields['allowed_oss'] = ','.join(fields['allowed_oss'])
@@ -180,7 +177,7 @@ class Transports(ModelHandler[TransportItem]):
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 exceptions.rest.ValidationError(
raise self.invalid_request_response(
gettext('Label must contain only letters, numbers, ":" and "-"')
)

View File

@@ -34,7 +34,7 @@ import logging
import typing
from uds import models
from uds.core import consts, exceptions, types
from uds.core import 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
"""
ROLE = consts.UserRole.ANONYMOUS
PATH = 'tunnel'
NAME = 'ticket'
authenticated = False # Client requests are not authenticated
path = 'tunnel'
name = 'ticket'
def get(self) -> collections.abc.MutableMapping[str, typing.Any]:
"""
@@ -148,13 +148,12 @@ class TunnelTicket(Handler):
class TunnelRegister(ServerRegisterBase):
ROLE = consts.UserRole.ADMIN
PATH = 'tunnel'
NAME = 'register'
needs_admin = True
path = 'tunnel'
name = 'register'
# Just a compatibility method for old tunnel servers
def post(self) -> dict[str, typing.Any]:
def post(self) -> collections.abc.MutableMapping[str, typing.Any]:
self._params['type'] = types.servers.ServerType.TUNNEL
self._params['os'] = self._params.get(
'os', types.os.KnownOS.LINUX.os_name()

View File

@@ -29,88 +29,87 @@
"""
Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
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 _
import uds.core.types.permissions
from uds.core import exceptions, types, consts
from uds.core.types.rest import TableInfo
from uds.core.util import permissions, validators, ensure, ui as ui_utils
from uds.core.util import permissions, validators, ensure
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__)
@dataclasses.dataclass
class TunnelServerItem(types.rest.BaseRestItem):
id: str
hostname: str
ip: str
mac: str
maintenance: bool
class TunnelServers(DetailHandler):
# tunnels/[id]/servers
custom_methods = ['maintenance']
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]:
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
parent = ensure.is_instance(parent, models.ServerGroup)
try:
multi = False
if item is None:
multi = True
q = self.filter_queryset(parent.servers.all())
q = parent.servers.all().order_by('hostname')
else:
q = parent.servers.filter(uuid=process_uuid(item))
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
]
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)
if multi:
return res
if not res:
raise exceptions.rest.NotFound(f'Tunnel server {item} not found')
if not i:
raise Exception('Item not found')
return res[0]
except exceptions.rest.HandlerError:
raise
except Exception as e:
logger.error('Error getting tunnel servers for %s: %s', parent, e)
raise exceptions.rest.ResponseError(_('Error getting tunnel servers')) from e
logger.exception('REST groups')
raise self.invalid_item_response() from e
def get_table(self, parent: 'Model') -> TableInfo:
def get_title(self, parent: 'Model') -> str:
parent = ensure.is_instance(parent, models.ServerGroup)
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()
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')
# Cannot save a tunnel server, it's not editable...
@@ -118,107 +117,88 @@ class TunnelServers(DetailHandler[TunnelServerItem]):
parent = ensure.is_instance(parent, models.ServerGroup)
try:
parent.servers.remove(models.Server.objects.get(uuid=process_uuid(item)))
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
except Exception:
raise self.invalid_item_response() 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.check_access(item, uds.core.types.permissions.PermissionType.MANAGEMENT)
self.ensure_has_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[TunnelItem]):
PATH = 'tunnels'
NAME = 'tunnels'
MODEL = models.ServerGroup
FILTER = {'type': types.servers.ServerType.TUNNEL}
CUSTOM_METHODS = [
class Tunnels(ModelHandler):
path = 'tunnels'
name = 'tunnels'
model = models.ServerGroup
model_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}
FIELDS_TO_SAVE = ['name', 'comments', 'host:', 'port:0']
detail = {'servers': TunnelServers}
save_fields = ['name', 'comments', 'host:', 'port:0']
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()
)
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}},
]
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 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
},
],
)
def get_item(self, item: 'Model') -> TunnelItem:
def item_as_dict(self, item: 'Model') -> dict[str, typing.Any]:
item = ensure.is_instance(item, models.ServerGroup)
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),
)
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),
}
def pre_save(self, fields: dict[str, typing.Any]) -> None:
fields['type'] = types.servers.ServerType.TUNNEL.value
@@ -236,24 +216,21 @@ class Tunnels(ModelHandler[TunnelItem]):
def assign(self, parent: 'Model') -> typing.Any:
parent = ensure.is_instance(parent, models.ServerGroup)
self.check_access(parent, uds.core.types.permissions.PermissionType.MANAGEMENT)
self.ensure_has_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 exceptions.rest.RequestError('No server specified')
raise self.invalid_item_response('No server specified')
try:
server = models.Server.objects.get(uuid=process_uuid(item))
self.check_access(server, uds.core.types.permissions.PermissionType.READ)
self.ensure_has_access(server, uds.core.types.permissions.PermissionType.READ)
parent.servers.add(server)
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
except Exception:
raise self.invalid_item_response() from None
return 'ok'

View File

@@ -31,100 +31,71 @@
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, ui as ui_utils
from uds.core.util import ensure, log, permissions
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__)
@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]):
class AssignedService(DetailHandler):
"""
Rest handler for Assigned Services, wich parent is Service
"""
CUSTOM_METHODS = ['reset']
custom_methods = [
'reset',
]
custom_methods = ['reset']
@staticmethod
def userservice_item(
def item_as_dict(
item: models.UserService,
props: typing.Optional[dict[str, typing.Any]] = None,
is_cache: bool = False,
) -> 'UserServiceItem':
) -> dict[str, typing.Any]:
"""
Converts an assigned/cached service db item to a dictionary for REST response
Args:
item: item to convert
props: properties to include
is_cache: If item is from cache or not
:param item: item to convert
:param is_cache: If item is from cache or not
"""
if props is None:
props = dict(item.properties)
val = UserServiceItem(
id=item.uuid,
pool_id=item.deployed_service.uuid,
unique_id=item.unique_id,
friendly_name=item.friendly_name,
state=(
val = {
'id': item.uuid,
'id_deployed_service': 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 str(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 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 = ''
@@ -136,18 +107,19 @@ class AssignedUserService(DetailHandler[UserServiceItem]):
'user_id': item.user.uuid,
}
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
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,
}
)
return val
def get_items(
self, parent: 'Model', item: typing.Optional[str]
) -> types.rest.ItemsResult['UserServiceItem']:
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
parent = ensure.is_instance(parent, models.ServicePool)
try:
@@ -155,21 +127,19 @@ class AssignedUserService(DetailHandler[UserServiceItem]):
# 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 self.filter_queryset(
models.Properties.objects.filter(
owner_type='userservice',
owner_id__in=parent.assigned_user_services().values_list('uuid', flat=True),
)
for id, key, value in 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 [
AssignedUserService.userservice_item(k, properties.get(k.uuid, {}))
AssignedService.item_as_dict(k, properties.get(k.uuid, {}))
for k in parent.assigned_user_services()
.all()
.prefetch_related('deployed_service', 'publication', 'user')
]
return AssignedUserService.userservice_item(
return AssignedService.item_as_dict(
parent.assigned_user_services().get(process_uuid(uuid=process_uuid(item))),
props={
k: v
@@ -179,30 +149,48 @@ class AssignedUserService(DetailHandler[UserServiceItem]):
},
)
except Exception as e:
logger.error('Error getting user service %s: %s', item, e)
raise exceptions.rest.ResponseError(_('Error getting user service')) from e
logger.exception('get_items')
raise self.invalid_item_response() from e
def get_table(self, parent: 'Model') -> types.rest.TableInfo:
def get_title(self, parent: 'Model') -> str:
return _('Assigned services')
def get_fields(self, parent: 'Model') -> list[typing.Any]:
parent = ensure.is_instance(parent, models.ServicePool)
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'))
# Revision is only shown if publication type is not None
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()
[
{'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')}},
]
)
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.ServicePool)
@@ -210,11 +198,8 @@ class AssignedUserService(DetailHandler[UserServiceItem]):
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:
logger.error('Error getting user service logs for %s: %s', item, e)
raise exceptions.rest.ResponseError(_('Error getting user service logs')) from e
raise self.invalid_item_response() 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:
@@ -225,8 +210,8 @@ class AssignedUserService(DetailHandler[UserServiceItem]):
else:
userservice = parent.assigned_user_services().get(uuid=process_uuid(item))
except Exception as e:
logger.error('Error deleting user service %s from %s: %s', item, parent, e)
raise exceptions.rest.ResponseError(_('Error deleting user service')) from None
logger.exception('delete_item')
raise self.invalid_item_response() from e
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}'
@@ -238,9 +223,9 @@ class AssignedUserService(DetailHandler[UserServiceItem]):
elif userservice.state == State.PREPARING:
userservice.cancel()
elif userservice.state == State.REMOVABLE:
raise exceptions.rest.RequestError(_('Item already being removed')) from None
raise self.invalid_item_response(_('Item already being removed'))
else:
raise exceptions.rest.RequestError(_('Item is not removable')) from None
raise self.invalid_item_response(_('Item is not removable'))
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)
@@ -249,7 +234,7 @@ class AssignedUserService(DetailHandler[UserServiceItem]):
def save_item(self, parent: 'Model', item: typing.Optional[str]) -> typing.Any:
parent = ensure.is_instance(parent, models.ServicePool)
if not item:
raise exceptions.rest.RequestError('Only modify is allowed')
raise self.invalid_item_response('Only modify is allowed')
fields = self.fields_from_params(['auth_id:_', 'user_id:_', 'ip:_'])
userservice = parent.userServices.get(uuid=process_uuid(item))
@@ -266,7 +251,7 @@ class AssignedUserService(DetailHandler[UserServiceItem]):
.count()
> 0
):
raise exceptions.rest.RequestError(
raise self.invalid_response_response(
f'There is already another user service assigned to {user.pretty_name}'
)
@@ -276,7 +261,7 @@ class AssignedUserService(DetailHandler[UserServiceItem]):
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 exceptions.rest.RequestError('Invalid fields')
raise self.invalid_item_response('Invalid fields')
# Log change
log.log(parent, types.log.LogLevel.INFO, log_string, types.log.LogSource.ADMIN)
@@ -289,51 +274,50 @@ class AssignedUserService(DetailHandler[UserServiceItem]):
UserServiceManager.manager().reset(userservice)
class CachedService(AssignedUserService):
class CachedService(AssignedService):
"""
Rest handler for Cached Services, which parent is ServicePool
"""
CUSTOM_METHODS = [] # Remove custom methods from assigned services
custom_methods: typing.ClassVar[list[str]] = [] # Remove custom methods from assigned services
def get_items(
self, parent: 'Model', item: typing.Optional[str]
) -> types.rest.ItemsResult['UserServiceItem']:
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
parent = ensure.is_instance(parent, models.ServicePool)
try:
if not item:
return [
AssignedUserService.userservice_item(k, is_cache=True)
for k in self.filter_queryset(parent.cached_users_services().all()).prefetch_related(
'deployed_service', 'publication'
)
AssignedService.item_as_dict(k, is_cache=True)
for k in parent.cached_users_services()
.all()
.prefetch_related('deployed_service', 'publication')
]
cached_userservice: models.UserService = parent.cached_users_services().get(uuid=process_uuid(item))
return AssignedUserService.userservice_item(cached_userservice, is_cache=True)
except models.UserService.DoesNotExist:
raise exceptions.rest.NotFound(_('User service not found')) from None
return AssignedService.item_as_dict(cached_userservice, is_cache=True)
except Exception as e:
logger.error('Error getting user service %s: %s', item, e)
raise exceptions.rest.ResponseError(_('Error getting user service')) from e
logger.exception('get_items')
raise self.invalid_item_response() from e
def get_table(self, parent: 'Model') -> types.rest.TableInfo:
def get_title(self, parent: 'Model') -> str:
return _('Cached services')
def get_fields(self, parent: 'Model') -> list[typing.Any]:
parent = ensure.is_instance(parent, models.ServicePool)
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())
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 []
)
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)
@@ -344,57 +328,63 @@ class CachedService(AssignedUserService):
userservice = parent.cached_users_services().get(uuid=process_uuid(item))
logger.debug('Getting logs for %s', item)
return log.get_logs(userservice)
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
except Exception:
raise self.invalid_item_response() from None
@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]):
class Groups(DetailHandler):
"""
Processes the groups detail requests of a Service Pool
"""
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> list['GroupItem']:
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
parent = typing.cast(typing.Union['models.ServicePool', 'models.MetaPool'], parent)
return [
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())
)
{
'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())
]
def get_table(self, parent: 'Model') -> TableInfo:
def get_title(self, parent: 'Model') -> str:
parent = typing.cast(typing.Union['models.ServicePool', 'models.MetaPool'], parent)
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()
)
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')
def save_item(self, parent: 'Model', item: typing.Optional[str]) -> typing.Any:
parent = typing.cast(typing.Union['models.ServicePool', 'models.MetaPool'], parent)
@@ -422,46 +412,44 @@ class Groups(DetailHandler[GroupItem]):
)
@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]):
class Transports(DetailHandler):
"""
Processes the transports detail requests of a Service Pool
"""
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> list['TransportItem']:
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
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 [
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())
{
'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)
]
def get_table(self, parent: 'Model') -> TableInfo:
def get_title(self, parent: 'Model') -> str:
parent = ensure.is_instance(parent, models.ServicePool)
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()
)
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')}},
]
def save_item(self, parent: 'Model', item: typing.Optional[str]) -> typing.Any:
parent = ensure.is_instance(parent, models.ServicePool)
@@ -488,22 +476,12 @@ class Transports(DetailHandler[TransportItem]):
)
@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]):
class Publications(DetailHandler):
"""
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:
"""
@@ -518,7 +496,7 @@ class Publications(DetailHandler[PublicationItem]):
is False
):
logger.debug('Management Permission failed for user %s', self._user)
raise exceptions.rest.AccessDenied(_('Access denied to publish service pool')) from None
raise self.access_denied_response()
logger.debug('Custom "publish" invoked for %s', parent)
parent.publish(change_log) # Can raise exceptions that will be processed on response
@@ -545,7 +523,7 @@ class Publications(DetailHandler[PublicationItem]):
is False
):
logger.debug('Management Permission failed for user %s', self._user)
raise exceptions.rest.AccessDenied(_('Access denied to cancel service pool publication')) from None
raise self.access_denied_response()
try:
ds = models.ServicePoolPublication.objects.get(uuid=process_uuid(uuid))
@@ -562,60 +540,65 @@ class Publications(DetailHandler[PublicationItem]):
return self.success()
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> list['PublicationItem']:
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
parent = ensure.is_instance(parent, models.ServicePool)
return [
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())
{
'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()
]
def get_table(self, parent: 'Model') -> TableInfo:
def get_title(self, parent: 'Model') -> str:
parent = ensure.is_instance(parent, models.ServicePool)
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()
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')
@dataclasses.dataclass
class ChangelogItem(types.rest.BaseRestItem):
revision: int
stamp: datetime.datetime
log: str
class Changelog(DetailHandler[ChangelogItem]):
class Changelog(DetailHandler):
"""
Processes the transports detail requests of a Service Pool
"""
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> list['ChangelogItem']:
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
parent = ensure.is_instance(parent, models.ServicePool)
return [
ChangelogItem(
revision=i.revision,
stamp=i.stamp,
log=i.log,
)
for i in self.filter_queryset(parent.changelog.all())
{
'revision': i.revision,
'stamp': i.stamp,
'log': i.log,
}
for i in parent.changelog.all()
]
def get_table(self, parent: 'Model') -> types.rest.TableInfo:
def get_title(self, parent: 'Model') -> str:
parent = ensure.is_instance(parent, models.ServicePool)
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()
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')}},
]

View File

@@ -29,29 +29,31 @@
"""
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, ui as ui_utils
from uds.core.util import log, ensure
from uds.core.util.model import process_uuid, sql_stamp_seconds
from uds.models import Authenticator, User, Group, ServicePool, UserService
from uds.models import Authenticator, User, Group, ServicePool
from uds.core.managers.crypto import CryptoManager
from uds.core import consts, exceptions, types
from uds.REST.model import DetailHandler
from .user_services import AssignedUserService, UserServiceItem
from .user_services import AssignedService
if typing.TYPE_CHECKING:
from django.db.models import Model
from uds.models import UserService
logger = logging.getLogger(__name__)
@@ -75,24 +77,8 @@ def get_service_pools_for_groups(
yield servicepool
@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 = [
class Users(DetailHandler):
custom_methods = [
'services_pools',
'user_services',
'clean_related',
@@ -100,67 +86,116 @@ class Users(DetailHandler[UserItem]):
'enable_client_logging',
]
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ItemsResult[UserItem]:
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> typing.Any:
parent = ensure.is_instance(parent, Authenticator)
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(),
)
# 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
logger.debug(item)
# Extract authenticator
try:
if item is None: # All users
return [as_user_item(i) for i in self.filter_queryset(parent.users.all())]
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
u = parent.users.get(uuid__iexact=process_uuid(item))
res = as_user_item(u)
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')
)
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:
logger.error('Error getting user %s: %s', item, e)
raise exceptions.rest.ResponseError(_('Error getting user')) from e
# User not found
raise self.invalid_item_response() from e
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')}
def get_title(self, parent: 'Model') -> str:
try:
return _('Users of {0}').format(
Authenticator.objects.get(uuid=process_uuid(self._kwargs['parent_id'])).name
)
.datetime_column(name='last_access', title=_('Last access'))
.row_style(prefix='row-state-', field='state')
).build()
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')
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 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
except Exception:
raise self.invalid_item_response() from None
return log.get_logs(user)
@@ -212,21 +247,21 @@ class Users(DetailHandler[UserItem]):
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 exceptions.rest.NotFound(_('User not found')) from None
raise self.invalid_item_response() 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:
except exceptions.rest.RequestError: # pylint: disable=try-except-raise
raise # Re-raise
except Exception as e:
logger.error('Error saving user %s: %s', item, e)
raise exceptions.rest.ResponseError(_('Error saving user')) from e
logger.exception('Saving user')
raise self.invalid_request_response() from e
def delete_item(self, parent: 'Model', item: str) -> None:
parent = ensure.is_instance(parent, Authenticator)
@@ -237,7 +272,7 @@ class Users(DetailHandler[UserItem]):
'Removal of user %s denied due to insufficients rights',
user.pretty_name,
)
raise exceptions.rest.AccessDenied(
raise self.invalid_item_response(
f'Removal of user {user.pretty_name} denied due to insufficients rights'
)
@@ -255,17 +290,11 @@ class Users(DetailHandler[UserItem]):
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 exceptions.rest.ResponseError(_('Error removing user')) from e
raise self.invalid_item_response() 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))
@@ -286,21 +315,19 @@ class Users(DetailHandler[UserItem]):
return res
def user_services(self, parent: 'Authenticator', item: str) -> list[UserServiceItem]:
def user_services(self, parent: 'Authenticator', item: str) -> list[dict[str, typing.Any]]:
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)
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)
]
return res
def clean_related(self, parent: 'Authenticator', item: str) -> dict[str, str]:
uuid = process_uuid(item)
@@ -334,97 +361,101 @@ class Users(DetailHandler[UserItem]):
return {'status': 'ok'}
@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()
class Groups(DetailHandler):
custom_methods = ['services_pools', 'users']
class Groups(DetailHandler[GroupItem]):
CUSTOM_METHODS = ['services_pools', 'users']
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ItemsResult['GroupItem']:
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
parent = ensure.is_instance(parent, Authenticator)
try:
multi = False
if item is None:
multi = True
q = self.filter_queryset(parent.groups.all())
q = parent.groups.all().order_by('name')
else:
q = parent.groups.filter(uuid=process_uuid(item))
res: list[GroupItem] = []
res: list[dict[str, typing.Any]] = []
i = None
for i in q:
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,
)
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,
}
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 exceptions.rest.NotFound(_('Group not found')) from None
raise Exception('Item not found')
# Add pools field if 1 item only
res[0].pools = [v.uuid for v in get_service_pools_for_groups([i])]
return res[0]
except exceptions.rest.HandlerError:
raise # Re-raise
result = res[0]
result['pools'] = [v.uuid for v in get_service_pools_for_groups([i])]
return result
except Exception as e:
logger.error('Group item not found: %s.%s: %s', parent.name, item, e)
raise exceptions.rest.ResponseError(_('Error getting group')) from e
raise self.invalid_item_response() from e
def get_table(self, parent: 'Model') -> types.rest.TableInfo:
def get_title(self, parent: 'Model') -> str:
parent = ensure.is_instance(parent, Authenticator)
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()
try:
return _('Groups of {0}').format(parent.name)
except Exception:
return _('Current groups')
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(
self, parent: 'Model', for_type: typing.Optional[str]
) -> collections.abc.Iterable[types.rest.TypeInfo]:
) -> collections.abc.Iterable[types.rest.TypeInfoDict]:
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.TypeInfo] = [
types.rest.TypeInfo(
name=v['name'],
type=k,
description=v['description'],
icon='',
)
types_list: list[types.rest.TypeInfoDict] = [
{
'name': v['name'],
'type': k,
'description': v['description'],
'icon': '',
}
for k, v in types_dict.items()
]
if not for_type:
if for_type is None:
return types_list
try:
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
return [next(filter(lambda x: x['type'] == for_type, types_list))]
except Exception:
raise self.invalid_request_response() from None
def save_item(self, parent: 'Model', item: typing.Optional[str]) -> typing.Any:
parent = ensure.is_instance(parent, Authenticator)
@@ -482,7 +513,7 @@ class Groups(DetailHandler[GroupItem]):
group.save()
return {'id': group.uuid}
except Group.DoesNotExist:
raise exceptions.rest.NotFound(_('Group not found')) from None
raise self.invalid_item_response() 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:
@@ -490,8 +521,8 @@ class Groups(DetailHandler[GroupItem]):
except exceptions.rest.RequestError: # pylint: disable=try-except-raise
raise # Re-raise
except Exception as e:
logger.error('Error saving group %s: %s', item, e)
raise exceptions.rest.ResponseError(_('Error saving group')) from e
logger.exception('Saving group')
raise self.invalid_request_response() from e
def delete_item(self, parent: 'Model', item: str) -> None:
parent = ensure.is_instance(parent, Authenticator)
@@ -499,13 +530,10 @@ class Groups(DetailHandler[GroupItem]):
group = parent.groups.get(uuid=item)
group.delete()
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
except Exception:
raise self.invalid_item_response() from None
def services_pools(self, parent: 'Model', item: str) -> list[collections.abc.Mapping[str, typing.Any]]:
def servicesPools(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))

View File

@@ -40,8 +40,8 @@ logger = logging.getLogger(__name__)
class UDSVersion(Handler):
ROLE = consts.UserRole.ANONYMOUS
NAME = 'version'
authenticated = False # Version requests are public
name = 'version'
def get(self) -> collections.abc.MutableMapping[str, typing.Any]:
return {'version': consts.system.VERSION, 'build': consts.system.VERSION_STAMP}

View File

@@ -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 .master import ModelHandler
from .model import ModelHandler

View File

@@ -29,8 +29,9 @@
"""
Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
# pylint: disable=too-many-public-methods
import abc
import inspect
import logging
import typing
@@ -40,10 +41,9 @@ 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.util import permissions
from uds.core.module import Module
# from uds.models import ManagedObjectModel
from uds.core.util import permissions
from uds.models import ManagedObjectModel, Network
from ..handlers import Handler
@@ -55,48 +55,247 @@ logger = logging.getLogger(__name__)
# pylint: disable=unused-argument
class BaseModelHandler(Handler, abc.ABC, typing.Generic[types.rest.T_Item]):
class BaseModelHandler(Handler):
"""
Base Handler for Master & Detail Handlers
"""
def check_access(
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(
self,
obj: models.Model,
permission: 'types.permissions.PermissionType',
root: bool = False,
) -> None:
if not permissions.has_access(self._user, obj, permission, root):
raise exceptions.rest.AccessDenied('Access denied')
raise self.access_denied_response()
def get_permissions(self, obj: models.Model, root: bool = False) -> int:
return permissions.effective_permissions(self._user, obj, root)
@classmethod
def extra_type_info(cls: type[typing.Self], type_: type['Module']) -> types.rest.ExtraTypeInfo | None:
def type_info(self, type_: type['Module']) -> typing.Optional[types.rest.ExtraTypeInfo]:
"""
Returns info about the type
In fact, right now, it returns an empty dict, that will be extended by typeAsDict
"""
return None
@typing.final
@classmethod
def as_typeinfo(cls: type[typing.Self], type_: type['Module']) -> types.rest.TypeInfo:
def type_as_dict(self, type_: type['Module']) -> types.rest.TypeInfoDict:
"""
Returns a dictionary describing the type (the name, the icon, description, etc...)
"""
return types.rest.TypeInfo(
res = types.rest.TypeInfo(
name=_(type_.mod_name()),
type=type_.mod_type(),
description=_(type_.description()),
icon=type_.icon64().replace('\n', ''),
extra=cls.extra_type_info(type_),
extra=self.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
@@ -104,10 +303,9 @@ class BaseModelHandler(Handler, abc.ABC, typing.Generic[types.rest.T_Item]):
:return: A dictionary containing all required fields
"""
args: dict[str, str] = {}
default: str | None = None
default: typing.Optional[str]
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
@@ -116,15 +314,14 @@ class BaseModelHandler(Handler, abc.ABC, typing.Generic[types.rest.T_Item]):
if default == '_' and k not in self._params:
continue
args[k] = self._params.get(k, default)
else: # Required field, with a possible default on defaults dict
if key not in self._params:
if defaults and key in defaults:
else:
try:
args[key] = self._params[key]
except KeyError:
if defaults is not None and key in defaults:
args[key] = defaults[key]
else:
raise exceptions.rest.RequestError(f'needed parameter not found in data {key}')
else:
# Set the value
args[key] = self._params[key]
raise
# del self._params[key]
except KeyError as e:
@@ -132,58 +329,63 @@ class BaseModelHandler(Handler, abc.ABC, typing.Generic[types.rest.T_Item]):
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 a simple OK response
Utility method to be invoked for simple methods that returns nothing in fact
"""
logger.debug('Returning success on %s %s', self.__class__, self._args)
return consts.OK
def test(self, type_: str) -> str:
def test(self, type_: str) -> str: # pylint: disable=unused-argument
"""
Invokes a test for an item
"""
logger.debug('Called base test for %s --> %s', self.__class__.__name__, self._params)
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 {}
raise self.invalid_method_response()

View File

@@ -38,18 +38,18 @@ import collections.abc
from django.db import models
from django.utils.translation import gettext as _
from uds.core import consts, exceptions, types, module
from uds.core import consts
from uds.core import types
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 uds.REST.model.base import BaseModelHandler
from uds.REST.utils import camel_and_snake_case_from
from .base import BaseModelHandler
from ..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 uds.REST.model.master import ModelHandler
from .model 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[types.rest.T_Item]):
class DetailHandler(BaseModelHandler):
"""
Detail handler (for relations such as provider-->services, authenticators-->users,groups, deployed services-->cache,assigned, groups, transports
Urls recognized for GET are:
@@ -79,24 +79,22 @@ class DetailHandler(BaseModelHandler[types.rest.T_Item]):
Also accepts GET methods for "custom" methods
"""
CUSTOM_METHODS: typing.ClassVar[list[str]] = []
_parent: typing.Optional[
'ModelHandler[types.rest.T_Item]'
] # Parent handler, that is the ModelHandler that contains this detail
custom_methods: typing.ClassVar[list[str]] = []
_parent: typing.Optional['ModelHandler']
_path: str
_params: typing.Any # _params is deserialized object from request
_args: list[str]
_parent_item: models.Model # Parent item, that is the parent model element
_kwargs: dict[str, typing.Any]
_user: 'User'
def __init__(
self,
parent_handler: 'ModelHandler[types.rest.T_Item]',
parent_handler: 'ModelHandler',
path: str,
params: typing.Any,
*args: str,
user: 'User',
parent_item: models.Model,
**kwargs: typing.Any,
) -> None:
"""
Detail Handlers in fact "disabled" handler most initialization, that is no needed because
@@ -108,10 +106,8 @@ class DetailHandler(BaseModelHandler[types.rest.T_Item]):
self._path = path
self._params = params
self._args = list(args)
self._parent_item = parent_item
self._kwargs = kwargs
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:
"""
@@ -120,7 +116,7 @@ class DetailHandler(BaseModelHandler[types.rest.T_Item]):
: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)
@@ -140,7 +136,7 @@ class DetailHandler(BaseModelHandler[types.rest.T_Item]):
logger.debug('Detail args for GET: %s', self._args)
num_args = len(self._args)
parent: models.Model = self._parent_item
parent: models.Model = self._kwargs['parent']
if num_args == 0:
return self.get_items(parent, None)
@@ -150,40 +146,41 @@ class DetailHandler(BaseModelHandler[types.rest.T_Item]):
if r is not consts.rest.NOT_FOUND:
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
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
# Not understood, fallback, maybe the derived class can understand it
return self.fallback_get()
@@ -196,7 +193,7 @@ class DetailHandler(BaseModelHandler[types.rest.T_Item]):
"""
logger.debug('Detail args for PUT: %s, %s', self._args, self._params)
parent: models.Model = self._parent_item
parent: models.Model = self._kwargs['parent']
# if has custom methods, look for if this request matches any of them
if len(self._args) > 1:
@@ -209,7 +206,7 @@ class DetailHandler(BaseModelHandler[types.rest.T_Item]):
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 exceptions.rest.RequestError('Invalid PUT request') from None
raise self.invalid_request_response()
logger.debug('Invoking proper saving detail item %s', item)
return rest_result(self.save_item(parent, item))
@@ -220,7 +217,7 @@ class DetailHandler(BaseModelHandler[types.rest.T_Item]):
Post can be used for, for example, testing.
Right now is an invalid method for Detail elements
"""
raise exceptions.rest.RequestError('This method does not accepts POST') from None
raise self.invalid_request_response('This method does not accepts POST')
def delete(self) -> typing.Any:
"""
@@ -229,10 +226,10 @@ class DetailHandler(BaseModelHandler[types.rest.T_Item]):
"""
logger.debug('Detail args for DELETE: %s', self._args)
parent = self._parent_item
parent = self._kwargs['parent']
if len(self._args) != 1:
raise exceptions.rest.RequestError('Invalid DELETE request') from None
raise self.invalid_request_response()
self.delete_item(parent, self._args[0])
@@ -243,13 +240,11 @@ class DetailHandler(BaseModelHandler[types.rest.T_Item]):
Invoked if default get can't process request.
Here derived classes can process "non default" (and so, not understood) GET constructions
"""
raise exceptions.rest.RequestError('Invalid GET request') from None
raise self.invalid_request_response('Fallback invoked')
# Override this to provide functionality
# Default (as sample) get_items
def get_items(
self, parent: models.Model, item: typing.Optional[str]
) -> types.rest.ItemsResult[types.rest.T_Item]:
def get_items(self, parent: models.Model, item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
"""
This MUST be overridden by derived classes
Excepts to return a list of dictionaries or a single dictionary, depending on "item" param
@@ -262,7 +257,7 @@ class DetailHandler(BaseModelHandler[types.rest.T_Item]):
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]) -> types.rest.T_Item:
def save_item(self, parent: models.Model, item: typing.Optional[str]) -> typing.Any:
"""
Invoked for a valid "put" operation
If this method is not overridden, the detail class will not have "Save/modify" operations.
@@ -272,7 +267,7 @@ class DetailHandler(BaseModelHandler[types.rest.T_Item]):
:return: Normally "success" is expected, but can throw any "exception"
"""
logger.debug('Default save_item handler caller for %s', self._path)
raise exceptions.rest.RequestError('Invalid PUT request') from None
raise self.invalid_request_response()
# Default delete
def delete_item(self, parent: models.Model, item: str) -> None:
@@ -283,17 +278,41 @@ class DetailHandler(BaseModelHandler[types.rest.T_Item]):
:param item: Item id (uuid)
:return: Normally "success" is expected, but can throw any "exception"
"""
raise exceptions.rest.InvalidMethodError('Object does not support delete')
raise self.invalid_request_response()
def get_table(self, parent: models.Model) -> types.rest.TableInfo:
# A detail handler must also return title & fields for tables
def get_title(self, parent: models.Model) -> str: # pylint: disable=no-self-use
"""
Returns the table info for this detail, that is the title, fields and row style
A "generic" title for a view based on this detail.
If not overridden, defaults to ''
:param parent: Parent object
:return: TableInfo object with title, fields and row style
:return: Expected to return an string that is the "title".
"""
return types.rest.TableInfo.null()
return ''
def get_gui(self, parent: models.Model, for_type: str) -> list[types.ui.GuiElement]:
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]:
"""
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
@@ -303,21 +322,21 @@ class DetailHandler(BaseModelHandler[types.rest.T_Item]):
for_type (str): Type of object needing gui
Return:
list[types.ui.GuiElement]: A list of gui fields
collections.abc.Iterable[typing.Any]: 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) -> list[types.ui.GuiElement]:
return sorted(self.get_gui(parent, for_type), key=lambda f: f.gui.order)
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 enum_types(
def get_types(
self, parent: models.Model, for_type: typing.Optional[str]
) -> collections.abc.Iterable[types.rest.TypeInfo]:
) -> collections.abc.Iterable[types.rest.TypeInfoDict]:
"""
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
@@ -328,15 +347,6 @@ class DetailHandler(BaseModelHandler[types.rest.T_Item]):
"""
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
@@ -344,21 +354,4 @@ class DetailHandler(BaseModelHandler[types.rest.T_Item]):
:param item:
:return: a list of log elements (normally got using "uds.core.util.log.get_logs" method)
"""
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)
raise self.invalid_method_response()

View File

@@ -1,205 +0,0 @@
# -*- 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

View File

@@ -1,205 +0,0 @@
# -*- 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

View File

@@ -33,33 +33,29 @@ 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, api as api_utils
from uds.core.util import log, permissions
from uds.models import ManagedObjectModel, Tag, TaggingMixin
from uds.REST.model.base import BaseModelHandler
from uds.REST.utils import camel_and_snake_case_from
from .base import BaseModelHandler
from ..utils import camel_and_snake_case_from
# Not imported at runtime, just for type checking
if typing.TYPE_CHECKING:
from uds.REST.model.detail import DetailHandler
from .detail import DetailHandler
logger = logging.getLogger(__name__)
T = typing.TypeVar('T', bound=models.Model)
class ModelHandler(BaseModelHandler[types.rest.T_Item], abc.ABC):
class ModelHandler(BaseModelHandler):
"""
Basic Handler for a model
Basically we will need same operations for all models, so we can
@@ -76,62 +72,90 @@ class ModelHandler(BaseModelHandler[types.rest.T_Item], abc.ABC):
"""
# Authentication related
ROLE = consts.UserRole.STAFF
authenticated = True
needs_staff = True
# 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)
FILTER: 'typing.ClassVar[typing.Optional[collections.abc.Mapping[str, typing.Any]]]' = None
model_filter: 'typing.ClassVar[typing.Optional[collections.abc.Mapping[str, typing.Any]]]' = None
# Same, but for exclude
EXCLUDE: 'typing.ClassVar[typing.Optional[collections.abc.Mapping[str, typing.Any]]]' = None
model_exclude: 'typing.ClassVar[typing.Optional[collections.abc.Mapping[str, typing.Any]]]' = None
# By default, filter is empty
fltr: typing.Optional[str] = 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[types.rest.ModelCustomMethod]] = []
custom_methods: typing.ClassVar[list[tuple[str, bool]]] = (
[]
) # If this model respond to "custom" methods, we will declare them here
# If this model has details, which ones
# Dictionary containing detail routing
DETAIL: typing.ClassVar[typing.Optional[dict[str, type['DetailHandler[typing.Any]']]]] = None
detail: typing.ClassVar[typing.Optional[dict[str, type['DetailHandler']]]] = (
None # Dictionary containing detail routing
)
# 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
FIELDS_TO_SAVE: typing.ClassVar[list[str]] = []
save_fields: typing.ClassVar[list[str]] = []
# Put removable fields before updating
EXCLUDED_FIELDS: typing.ClassVar[list[str]] = []
remove_fields: typing.ClassVar[list[str]] = []
# Table info needed fields and title
TABLE: typing.ClassVar[types.rest.TableInfo] = types.rest.TableInfo.null()
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] = ''
# 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 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
def enum_types(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 enum_types(self) -> typing.Generator[types.rest.TypeInfo, None, None]:
for type_ in self.possible_types():
yield type(self).as_typeinfo(type_)
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 get_type(self, type_: str) -> types.rest.TypeInfo:
for v in self.enum_types():
if v.type == type_:
return v
def get_type(self, type_: str) -> types.rest.TypeInfoDict:
found = None
for v in self.get_types():
if v['type'] == type_:
found = v
break
raise exceptions.rest.NotFound('type not found')
if found is None:
raise exceptions.rest.NotFound('type not found')
logger.debug('Found type %s', found)
return found
# log related
def get_logs(self, item: models.Model) -> list[dict[typing.Any, typing.Any]]:
self.check_access(item, types.permissions.PermissionType.READ)
self.ensure_has_access(item, types.permissions.PermissionType.READ)
try:
return log.get_logs(item)
except Exception as e:
@@ -139,13 +163,10 @@ class ModelHandler(BaseModelHandler[types.rest.T_Item], abc.ABC):
return []
# gui related
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
def get_gui(self, type_: str) -> list[typing.Any]:
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:
@@ -171,7 +192,7 @@ class ModelHandler(BaseModelHandler[types.rest.T_Item], abc.ABC):
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'):
@@ -185,71 +206,61 @@ class ModelHandler(BaseModelHandler[types.rest.T_Item], abc.ABC):
self._user,
required_permission,
)
raise exceptions.rest.AccessDenied()
raise self.access_denied_response()
if not self.DETAIL:
raise exceptions.rest.NotFound('Detail not found')
if not self.detail:
raise self.invalid_request_response()
# 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=item, user=self._user)
detail_handler = handler_type(self, path, self._params, *args, parent=item, user=self._user)
method = getattr(detail_handler, self._operation)
return method()
except self.MODEL.DoesNotExist:
raise exceptions.rest.NotFound('Item not found on model {self.MODEL.__name__}')
except self.model.DoesNotExist:
raise self.invalid_item_response()
except (KeyError, AttributeError) as e:
raise exceptions.rest.InvalidMethodError(f'Invalid method {self._operation}') from e
raise self.invalid_method_response() from e
except exceptions.rest.HandlerError:
raise
except Exception as e:
logger.error('Exception processing detail: %s', 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)
raise self.invalid_request_response() from e
def get_items(
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
self, *args: typing.Any, **kwargs: typing.Any
) -> typing.Generator[types.rest.ItemDictType, None, None]:
if 'overview' in kwargs:
overview = kwargs['overview']
del kwargs['overview']
else:
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)
overview = True
qs = self.filter_queryset(qs)
if 'prefetch' in kwargs:
prefetch = kwargs['prefetch']
logger.debug('Prefetching %s', prefetch)
del kwargs['prefetch']
else:
prefetch = []
for item in 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:
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,
@@ -259,12 +270,24 @@ class ModelHandler(BaseModelHandler[types.rest.T_Item], abc.ABC):
is False
):
continue
yield self.get_item_summary(item) if overview else self.get_item(item)
if overview:
yield self.item_as_dict_overview(item)
else:
res = self.item_as_dict(item)
self.fill_instance_fields(item, res)
yield res
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)
@@ -272,10 +295,10 @@ class ModelHandler(BaseModelHandler[types.rest.T_Item], abc.ABC):
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.name)
if number_of_args > 1 and cm.needs_parent: # Method needs parent (existing item)
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)
if self._args[1] in (camel_case_name, snake_case_name):
item = None
# Check if operation method exists
@@ -283,9 +306,9 @@ class ModelHandler(BaseModelHandler[types.rest.T_Item], abc.ABC):
try:
if not operation:
raise Exception() # Operation not found
item = self.MODEL.objects.get(uuid__iexact=self._args[0])
except self.MODEL.DoesNotExist:
raise exceptions.rest.NotFound('Item not found') from None
item = self.model.objects.get(uuid__iexact=self._args[0])
except self.model.DoesNotExist:
raise self.invalid_item_response()
except Exception as e:
logger.error(
'Invalid custom method exception %s/%s/%s: %s',
@@ -294,63 +317,74 @@ class ModelHandler(BaseModelHandler[types.rest.T_Item], abc.ABC):
self._params,
e,
)
raise exceptions.rest.ResponseError(
f'Error processing custom method: {self.__class__.__name__}/{self._args}'
) from e
raise self.invalid_method_response()
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 exceptions.rest.InvalidMethodError(f'Invalid method {self._operation}') from None
raise self.invalid_method_response()
return operation()
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
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('')
if self.DETAIL is not None:
return self.process_detail()
# get item ID
try:
item = self.model.objects.get(uuid__iexact=self._args[0].lower())
raise exceptions.rest.RequestError('Invalid request') from None
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
def post(self) -> typing.Any:
"""
@@ -362,7 +396,7 @@ class ModelHandler(BaseModelHandler[types.rest.T_Item], abc.ABC):
if self._args[0] == 'test':
return self.test(self._args[1])
raise exceptions.rest.InvalidMethodError(f'Invalid method {self._operation}') from None
raise self.invalid_method_response() # Will not return
def put(self) -> typing.Any:
"""
@@ -376,21 +410,21 @@ class ModelHandler(BaseModelHandler[types.rest.T_Item], abc.ABC):
delete_on_error = False
if len(self._args) > 1: # Detail (1 arg means ID, more means detail/ID)?
if len(self._args) > 1: # Detail?
return self.process_detail()
# Here, self.model() indicates an "django model object with default params"
self.check_access(
self.MODEL(), types.permissions.PermissionType.ALL, root=True
self.ensure_has_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.FIELDS_TO_SAVE)
args = self.fields_from_params(self.save_fields)
logger.debug('Args: %s', args)
self.pre_save(args)
# If tags is in save fields, treat it "specially"
if 'tags' in self.FIELDS_TO_SAVE:
if 'tags' in self.save_fields:
tags = args['tags']
del args['tags']
else:
@@ -399,12 +433,12 @@ class ModelHandler(BaseModelHandler[types.rest.T_Item], abc.ABC):
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.EXCLUDED_FIELDS:
item = self.model.objects.get(uuid__iexact=self._args[0].lower())
for v in self.remove_fields:
if v in args:
del args[v]
# Upadte fields from args
@@ -430,14 +464,12 @@ class ModelHandler(BaseModelHandler[types.rest.T_Item], abc.ABC):
data_type: typing.Optional[str] = self._params.get('data_type', self._params.get('type'))
if data_type:
item.data_type = data_type
# 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.data = item.get_instance(self._params).serialize()
item.save()
res = self.get_item(item)
res = self.item_as_dict(item)
self.fill_instance_fields(item, res)
except Exception:
logger.exception('Exception on put')
if delete_on_error:
@@ -446,9 +478,9 @@ class ModelHandler(BaseModelHandler[types.rest.T_Item], abc.ABC):
self.post_save(item)
return res.as_dict()
return res
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
@@ -471,15 +503,15 @@ class ModelHandler(BaseModelHandler[types.rest.T_Item], abc.ABC):
if len(self._args) != 1:
raise exceptions.rest.RequestError('Delete need one and only one argument')
self.check_access(
self.MODEL(), types.permissions.PermissionType.ALL, root=True
self.ensure_has_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
@@ -489,18 +521,3 @@ class ModelHandler(BaseModelHandler[types.rest.T_Item], abc.ABC):
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)

View File

@@ -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, types
from uds.core import consts
from .utils import to_incremental_json
@@ -65,14 +65,9 @@ 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]:
"""
@@ -110,63 +105,38 @@ class ContentProcessor:
yield self.render(obj).encode('utf8')
@staticmethod
def process_for_render(
obj: typing.Any,
data_transformer: collections.abc.Callable[[dict[str, typing.Any]], dict[str, typing.Any]],
) -> typing.Any:
def process_for_render(obj: typing.Any) -> typing.Any:
"""
Helper for renderers. Alters some types so they can be serialized correctly (as we want them to be)
"""
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 obj is None or isinstance(obj, (bool, int, float, str)):
return obj
case DjangoPromise():
return str(obj) # This is for translations
if isinstance(obj, DjangoPromise):
return str(obj) # This is for translations
case bytes():
return obj.decode('utf-8')
if isinstance(obj, dict):
return {
k: ContentProcessor.process_for_render(v)
for k, v in typing.cast(dict[str, typing.Any], obj).items()
}
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, bytes):
return obj.decode('utf-8')
case datetime.datetime():
return int(obj.timestamp())
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.date():
return '{}-{:02d}-{:02d}'.format(obj.year, obj.month, obj.day)
if isinstance(obj, (datetime.datetime,)): # Datetime as timestamp
return int(time.mktime(obj.timetuple()))
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
if isinstance(obj, (datetime.date,)): # Date as string
return '{}-{:02d}-{:02d}'.format(obj.year, obj.month, obj.day)
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)
return str(obj)
class MarshallerProcessor(ContentProcessor):
@@ -199,11 +169,7 @@ class MarshallerProcessor(ContentProcessor):
raise ParametersException(str(e))
def render(self, obj: typing.Any) -> str:
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))
return self.marshaller.dumps(ContentProcessor.process_for_render(obj))
# ---------------

View File

@@ -34,6 +34,7 @@ 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
@@ -45,7 +46,7 @@ if typing.TYPE_CHECKING:
from django.http import HttpRequest
@weblogin_required(role=consts.UserRole.ADMIN)
@weblogin_required(admin=True)
def index(request: 'HttpRequest') -> HttpResponse:
# Gets csrf token
csrf_token = csrf.get_token(request)
@@ -56,14 +57,21 @@ def index(request: 'HttpRequest') -> HttpResponse:
{'csrf_field': consts.auth.CSRF_FIELD, 'csrf_token': csrf_token},
)
# 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")
# 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')

View File

@@ -119,8 +119,19 @@ class OAuth2Authenticator(auths.Authenticator):
required=True,
default='code',
choices=[
gui.choice_item(v, v.as_text)
{'id': v, 'text': 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,
)

View File

@@ -163,7 +163,7 @@ class RegexLdap(auths.Authenticator):
# Label for password field
label_password = _("Password")
_connection: typing.Optional['ldaputil.LDAPConnection'] = None
_connection: typing.Optional['ldaputil.LDAPObject'] = 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.LDAPConnection':
def _stablish_connection(self) -> 'ldaputil.LDAPObject':
"""
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.LDAPConnection':
def _stablish_connection_as(self, username: str, password: str) -> 'ldaputil.LDAPObject':
return ldaputil.connection(
username,
password,

View File

@@ -657,10 +657,7 @@ 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:
logger.debug('Errors processing SAML response: %s (%s)', errors, auth.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()})')
raise exceptions.auth.AuthenticatorException('SAML response error: ' + str(errors))
if not auth.is_authenticated():
raise exceptions.auth.AuthenticatorException(gettext('SAML response not authenticated'))

View File

@@ -33,13 +33,14 @@ import logging
import typing
import collections.abc
from uds.core.util import ldaputil
import ldap # pyright: ignore # Needed to import ldap.filter without errors
import ldap.filter
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 fields, ldaputil, validators, auth as auth_utils
from uds.core.util import ensure, fields, ldaputil, validators, auth as auth_utils
# Not imported at runtime, just for type checking
if typing.TYPE_CHECKING:
@@ -182,7 +183,7 @@ class SimpleLDAPAuthenticator(auths.Authenticator):
# Label for password field
label_password = _("Password")
_connection: typing.Optional['ldaputil.LDAPConnection'] = None
_connection: typing.Optional['ldaputil.LDAPObject'] = None
def initialize(self, values: typing.Optional[dict[str, typing.Any]]) -> None:
if values:
@@ -231,54 +232,51 @@ 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.LDAPConnection':
def _get_connection(self) -> 'ldaputil.LDAPObject':
"""
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
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
"""
if self._connection is None:
if self._connection is None: # We are not connected
self._connection = ldaputil.connection(
username=self.username.as_str(),
passwd=self.password.as_str(),
host=self.host.as_str(),
self.username.as_str(),
self.password.as_str(),
self.host.as_str(),
port=self.port.as_int(),
use_ssl=self.use_ssl.as_bool(),
ssl=self.use_ssl.as_bool(),
timeout=self.timeout.as_int(),
debug=False,
verify_ssl=self.verify_ssl.as_bool(),
certificate_data=self.certificate.as_str(),
certificate=self.certificate.as_str(),
)
return self._connection
def _connect_as(self, username: str, password: str) -> typing.Any:
return ldaputil.connection(
username=username,
passwd=password,
host=self.host.as_str(),
username,
password,
self.host.as_str(),
port=self.port.as_int(),
use_ssl=self.use_ssl.as_bool(),
ssl=self.use_ssl.as_bool(),
timeout=self.timeout.as_int(),
debug=False,
verify_ssl=self.verify_ssl.as_bool(),
certificate_data=self.certificate.as_str(),
certificate=self.certificate.as_str(),
)
def _get_user(self, username: str) -> typing.Optional[ldaputil.LDAPResultType]:
"""
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
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
"""
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(),
@@ -291,11 +289,13 @@ class SimpleLDAPAuthenticator(auths.Authenticator):
def _get_group(self, groupname: str) -> typing.Optional[ldaputil.LDAPResultType]:
"""
Searches for the groupname and returns its LDAP entry.
Searchs 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, a dictionary of LDAP entry attributes if found.
typing.Optional[ldaputil.LDAPResultType]: None if groupname is not found, an dictionary of LDAP entry attributes if found.
"""
return ldaputil.first(
con=self._get_connection(),
@@ -309,14 +309,17 @@ class SimpleLDAPAuthenticator(auths.Authenticator):
def _get_groups(self, user: ldaputil.LDAPResultType) -> list[str]:
"""
Searches for the groups the user belongs to and returns a list of group names.
Searchs 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(),
@@ -328,17 +331,19 @@ 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 _get_groups')
logger.exception('Exception at __getGroups')
return []
def _get_user_realname(self, user: ldaputil.LDAPResultType) -> str:
"""
Tries to extract the real name for this user. Will return all attributes (joined)
specified in username_attr (comma separated).
"""
'''
Tries to extract the real name for this user. Will return all atttributes (joint)
specified in _userNameAttr (comma separated).
'''
return ' '.join(auth_utils.process_regex_field(self.username_attr.value, user))
def authenticate(
@@ -348,26 +353,41 @@ class SimpleLDAPAuthenticator(auths.Authenticator):
groups_manager: 'auths.GroupsManager',
request: 'ExtendedHttpRequest',
) -> types.auth.AuthenticationResult:
"""
Authenticates the user using ldaputil.
"""
'''
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
'''
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:
self._connect_as(user['dn'], credentials)
# Let's see first if it credentials are fine
self._connect_as(user['dn'], credentials) # Will raise an exception if it can't connect
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
@@ -450,104 +470,120 @@ class SimpleLDAPAuthenticator(auths.Authenticator):
except Exception as e:
return types.core.TestResult(False, str(e))
# Test base search
try:
next(ldaputil.as_dict(
con,
self.ldap_base.as_str(),
'(objectClass=*)',
limit=1,
scope=ldaputil.SCOPE_BASE,
))
con.search_s( # pyright: ignore reportGeneralTypeIssues
base=self.ldap_base.as_str(),
scope=ldaputil.SCOPE_BASE, # pyright: ignore reportGeneralTypeIssues
)
except Exception:
return types.core.TestResult(False, _('Ldap search base is incorrect'))
# Test user class
try:
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
# 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
# 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
# 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
# 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
# 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 (
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
):
if self.member_attr.as_str() in r:
found = True
raise Exception(_('Ldap user class seems to be incorrect (no user found by that class)'))
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)'))
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)')
)
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)')
)
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)'
)
)
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
break
if not found:
return types.core.TestResult(False, _('Can\'t locate any group with the membership attribute specified'))
if ok is False:
raise Exception(_('Can\'t locate any group with the membership attribute specified'))
except Exception as e:
return types.core.TestResult(False, str(e))

View File

@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
#
# Copyright (c) 2012-2025 Virtual Cable S.L.U.
# Copyright (c) 2012-2021 Virtual Cable S.L.U.
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without modification,
@@ -124,14 +124,17 @@ def root_user() -> models.User:
# Decorator to make easier protect pages that needs to be logged in
def weblogin_required(
role: typing.Optional[consts.UserRole] = None,
admin: typing.Union[bool, typing.Literal['admin']] = False,
) -> 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:
role (str, optional): If set, needs this role. Defaults to None.
admin (bool, optional): If True, needs admin or staff. Is it's "admin" literal, needs admin . Defaults to False (any user).
Returns:
collections.abc.Callable[[collections.abc.Callable[..., HttpResponse]], collections.abc.Callable[..., HttpResponse]]: Decorator
@@ -139,11 +142,10 @@ 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(
@@ -156,8 +158,8 @@ def weblogin_required(
if not request.user or not request.authorized:
return weblogout(request)
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):
if admin in (True, 'admin'):
if request.user.is_staff() is False or (admin == 'admin' and not request.user.is_admin):
return HttpResponseForbidden(_('Forbidden'))
return view_func(request, *args, **kwargs)
@@ -178,7 +180,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
@@ -418,7 +420,7 @@ def weblogin(
request.session[consts.auth.SESSION_USER_KEY] = user.id
request.session[consts.auth.SESSION_PASS_KEY] = codecs.encode(
CryptoManager.manager().symmetric_encrypt(password, cookie), "base64"
CryptoManager().symmetric_encrypt(password, cookie), "base64"
).decode() # as str
# Ensures that this user will have access through REST api if logged in through web interface
@@ -430,6 +432,8 @@ def weblogin(
password,
get_language() or '',
request.os.os.name,
user.is_admin,
user.staff_member,
cookie,
)
return True

View File

@@ -330,7 +330,7 @@ class Authenticator(Module):
return ''
@classmethod
def provides_mfa_identifier(cls: typing.Type['Authenticator']) -> bool:
def provides_mfa(cls: typing.Type['Authenticator']) -> bool:
"""
Returns if this authenticator provides a MFA identifier
"""

View File

@@ -31,21 +31,18 @@
Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
# pyright: reportUnusedImport=false
import enum
import time
import typing
import datetime
from django.utils.translation import gettext as _
from datetime import datetime
from . import actor, auth, cache, calendar, images, net, os, system, ticket, rest, services, transports, ui
# Date related constants
NEVER: typing.Final[datetime.datetime] = datetime.datetime(1972, 7, 1, tzinfo=datetime.timezone.utc)
NEVER: typing.Final[datetime] = datetime(1972, 7, 1)
NEVER_UNIX: typing.Final[int] = int(time.mktime(NEVER.timetuple()))
# Null mac address "magic" value
NULL_MAC: typing.Final[str] = '00:00:00:00:00:00'
# Unknown mac address "magic" value
MAC_UNKNOWN: 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"
@@ -77,62 +74,5 @@ UNLIMITED: typing.Final[int] = -1
# Constant marking no more names available
NO_MORE_NAMES: typing.Final[str] = 'NO-NAME-ERROR'
# 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
# For convenience, same as MAC_UNKNOWN, but different meaning
NO_MORE_MACS: typing.Final[str] = MAC_UNKNOWN

View File

@@ -39,8 +39,6 @@ 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
# 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
# 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) -
SCRAMBLER_LENGTH: typing.Final[int] = 32 # Scrambler length

View File

@@ -31,83 +31,50 @@ 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(RequestError):
class NotSupportedError(HandlerError):
"""
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

View File

@@ -33,20 +33,13 @@ 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
@@ -56,11 +49,9 @@ 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, aead
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from django.conf import settings
from django.utils import timezone
from uds.core.util import singleton
@@ -315,12 +306,10 @@ 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 a non-deterministic uuid (preferably uuid7 if available, else uuid4)
If obj is None, returns an uuid based on a 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())
if obj is None:
obj = self.random_string()
elif isinstance(obj, bytes):
obj = obj.decode('utf8') # To string
else:
@@ -329,32 +318,9 @@ class CryptoManager(metaclass=singleton.Singleton):
except Exception:
obj = str(hash(obj)) # Get hash of object
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()
return str(
uuid.uuid5(self._namespace, obj)
).lower() # I believe uuid returns a lowercase uuid always, but in case... :)
def random_string(self, length: int = 40, digits: bool = True, punctuation: bool = False) -> str:
base = (
@@ -366,7 +332,7 @@ class CryptoManager(metaclass=singleton.Singleton):
def unique(self) -> str:
return hashlib.sha3_256(
(self.random_string(24, True) + timezone.localtime().strftime('%H%M%S%f')).encode()
(self.random_string(24, True) + datetime.datetime.now().strftime('%H%M%S%f')).encode()
).hexdigest()
def sha(self, value: typing.Union[str, bytes]) -> str:

View File

@@ -38,7 +38,6 @@ 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
@@ -62,7 +61,7 @@ class ServerManager(metaclass=singleton.Singleton):
BASE_PROPERTY_NAME: typing.Final[str] = 'sm_usr_'
# Singleton, can initialize here
last_counters_clean: datetime.datetime = timezone.localtime() # This is local to server, so it's ok
last_counters_clean: datetime.datetime = datetime.datetime.now() # This is local to server, so it's ok
@staticmethod
def manager() -> 'ServerManager':
@@ -72,8 +71,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 timezone.localtime() - self.last_counters_clean > self.MAX_COUNTERS_AGE:
self.last_counters_clean = timezone.localtime()
if datetime.datetime.now() - self.last_counters_clean > self.MAX_COUNTERS_AGE:
self.last_counters_clean = datetime.datetime.now()
storage.clear()
yield storage

View File

@@ -35,8 +35,6 @@ 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
@@ -185,7 +183,6 @@ 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:
@@ -193,7 +190,6 @@ 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:

View File

@@ -487,12 +487,6 @@ 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(
@@ -1081,7 +1075,7 @@ class UserServiceManager(metaclass=singleton.Singleton):
)
self.notify_preconnect(
userservice,
transport_instance.get_connection_info(userservice, user, '', for_notify=True),
transport_instance.get_connection_info(userservice, user, ''),
)
trace_logger.info(
'READY on service "%s" for user "%s" with transport "%s" (ip:%s)',

View File

@@ -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=timezone.localtime()),
_('Printed in {now:%Y, %b %d} at {now:%H:%M}').format(now=datetime.datetime.now()),
)
)

View File

@@ -77,7 +77,7 @@ class Transport(Module):
own_link: bool = False
# Protocol "type". This is not mandatory, but will help
PROTOCOL: typing.ClassVar[types.transports.Protocol] = types.transports.Protocol.NONE
protocol: 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 is_protocol_supported(cls, protocol: typing.Union[collections.abc.Iterable[str], str]) -> bool:
def supports_protocol(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.is_protocol_supported(v):
if cls.supports_protocol(v):
return True
return False
@@ -175,8 +175,6 @@ 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.
@@ -202,7 +200,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

View File

@@ -41,6 +41,7 @@ from . import (
permissions,
pools,
requests,
rest,
servers,
services,
states,
@@ -50,7 +51,6 @@ from . import (
core,
log,
net,
rest,
)
# Log is not imported here, as it is a special case with lots of dependencies

View File

@@ -119,12 +119,8 @@ class LoginResult:
@dataclasses.dataclass
class SearchResultItem:
class ItemDict(typing.TypedDict):
id: str
name: str
id: str
name: str
def as_dict(self) -> 'SearchResultItem.ItemDict':
return typing.cast(SearchResultItem.ItemDict, dataclasses.asdict(self))
def as_dict(self) -> typing.Dict[str, str]:
return dataclasses.asdict(self)

View File

@@ -34,7 +34,9 @@ import typing
import dataclasses
# Module values type
ValuesType = dict[str, typing.Any] | None
ValuesType = typing.Optional[
dict[str, typing.Any]
]
# Module Test Result type

View File

@@ -0,0 +1,110 @@
# -*- 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]

View File

@@ -1,391 +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
"""
# 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}'

View File

@@ -1,42 +0,0 @@
# -*- 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]

View File

@@ -1,454 +0,0 @@
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}

View File

@@ -1,33 +0,0 @@
# -*- 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

View File

@@ -1,172 +0,0 @@
# -*- 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,
),
)
],
}

View File

@@ -37,8 +37,7 @@ 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')
@@ -80,7 +79,6 @@ class FieldType(enum.StrEnum):
IMAGECHOICE = 'imgchoice'
DATE = 'date'
INFO = 'internal-info'
TAGLIST = 'taglist'
@staticmethod
def from_str(value: str) -> 'FieldType':
@@ -131,20 +129,10 @@ class Filler(typing.TypedDict):
# Choices
@dataclasses.dataclass
class ChoiceItem:
class ChoiceItem(typing.TypedDict):
id: 'str'
text: str
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
img: typing.NotRequired[str] # Only for IMAGECHOICE
ChoicesType = typing.Union[
@@ -161,35 +149,57 @@ class FieldInfo:
type: FieldType
field_name: str = ''
old_field_name: OldFieldNameType = 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
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
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}
@dataclasses.dataclass
class GuiElement:
class GuiElement(typing.TypedDict):
name: str
gui: FieldInfo
value: typing.Any | None = None
gui: dict[str, list[dict[str, typing.Any]]]
value: typing.Any
# Row styles
@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 {
'name': self.name,
'gui': self.gui.as_dict(),
'value': self.value,
}
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(), '')

View File

@@ -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,13 +56,12 @@ from uds.core.util import modfinder, serializer, validators, ensure
logger = logging.getLogger(__name__)
# To simplify choice parameters declaration of fields
_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,
# ]
_ChoicesParamType: typing.TypeAlias = typing.Union[
collections.abc.Callable[[], list['types.ui.ChoiceItem']],
collections.abc.Iterable[str | types.ui.ChoiceItem],
dict[str, str],
None,
]
class gui:
@@ -133,19 +132,22 @@ class gui:
"""
if not isinstance(text, (str, Promise)):
text = str(text)
return types.ui.ChoiceItem(id=str(id_), text=typing.cast(str, text))
return {
'id': str(id_),
'text': typing.cast(str, text),
} # Cast to avoid mypy error, Promise is at all effects a str
@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 types.ui.ChoiceItem(id=str(id_), text=str(text), img=img)
return {'id': str(id_), 'text': str(text), 'img': img}
# Helpers
@staticmethod
def as_choices(
vals: _ChoicesParamType|dict[str, str]|str|collections.abc.Iterable[str|types.ui.ChoiceItem]|None = None,
vals: _ChoicesParamType,
) -> 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,
@@ -158,10 +160,14 @@ class gui:
if callable(vals):
return vals
def _choice_from_value(val: str | types.ui.ChoiceItem) -> 'types.ui.ChoiceItem':
if isinstance(val, str):
return gui.choice_item(val, val)
return val
# 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))
# If is a dict
if isinstance(vals, dict):
@@ -182,9 +188,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.casefold()
key = lambda item: item['text'].lower()
else:
key = key
return sorted(choices, key=key, reverse=reverse)
@@ -319,7 +325,7 @@ class gui:
value=value,
tab=tab,
)
@property
def field_name(self) -> str:
"""
@@ -383,29 +389,22 @@ class gui:
"""
self._field_info.value = value
def gui_description(self) -> types.ui.FieldInfo:
def gui_description(self) -> dict[str, typing.Any]:
"""
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 = 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
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
return data
@property
@@ -650,7 +649,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[types.ui.ChoiceItem]) -> None:
def set_choices(self, values: collections.abc.Iterable[typing.Union[str, types.ui.ChoiceItem]]) -> None:
"""
Set the values for this choice field
"""
@@ -766,7 +765,7 @@ class gui:
def as_datetime(self) -> datetime.datetime:
"""Alias for "value" property, but as datetime.datetime"""
# Convert date to datetime
return timezone.make_aware(datetime.datetime.combine(self.as_date(), datetime.datetime.min.time()))
return datetime.datetime.combine(self.as_date(), datetime.datetime.min.time())
def as_timestamp(self) -> int:
"""Alias for "value" property, but as timestamp"""
@@ -800,11 +799,11 @@ class gui:
def value(self, value: datetime.date | str) -> None:
self._set_value(value)
def gui_description(self) -> types.ui.FieldInfo:
def gui_description(self) -> dict[str, typing.Any]:
fldgui = super().gui_description()
# Convert if needed value and default to string (YYYY-MM-DD)
if fldgui.default is not None:
fldgui.default = str(fldgui.default)
if 'default' in fldgui:
fldgui['default'] = str(fldgui['default'])
return fldgui
class PasswordField(InputField):
@@ -1134,7 +1133,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[types.ui.ChoiceItem]) -> None:
def set_choices(self, values: collections.abc.Iterable[typing.Union[str, types.ui.ChoiceItem]]) -> None:
"""
Set the values for this choice field
"""
@@ -1186,7 +1185,7 @@ class gui:
self._field_info.choices = gui.as_choices(choices or [])
def set_choices(self, values: collections.abc.Iterable[types.ui.ChoiceItem]) -> None:
def set_choices(self, values: collections.abc.Iterable[typing.Union[str, types.ui.ChoiceItem]]) -> None:
"""
Set the values for this choice field
"""
@@ -1276,7 +1275,7 @@ class gui:
self._field_info.choices = gui.as_choices(choices or [])
def set_choices(
self, choices: collections.abc.Iterable[types.ui.ChoiceItem]
self, choices: collections.abc.Iterable[typing.Union[str, types.ui.ChoiceItem]]
) -> None:
"""
Set the values for this choice field
@@ -1524,17 +1523,6 @@ 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 ->
@@ -1648,11 +1636,6 @@ 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:
@@ -1670,20 +1653,17 @@ 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:
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
# 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
self._gui[field_name].value = FIELD_DECODERS[internal_field_type](field_value)
return False
@@ -1764,11 +1744,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(
types.ui.GuiElement(
name=key,
gui=val.gui_description(),
value=val.value if val.is_type(types.ui.FieldType.HIDDEN) else None,
)
{
'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
@@ -1811,13 +1791,12 @@ 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[

View File

@@ -1,459 +0,0 @@
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 [])

View File

@@ -66,9 +66,7 @@ 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

View File

@@ -39,7 +39,6 @@ import logging
import bitarray
from django.core.cache import caches
from django.utils import timezone
from uds.core.util.model import sql_now
@@ -78,14 +77,12 @@ 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 = timezone.make_aware(datetime.datetime.combine(rule.end, datetime.datetime.max.time())) if rule.end else None
r_end = 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

View File

@@ -4,10 +4,8 @@ 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
@@ -22,7 +20,7 @@ class UDSClusterNode(typing.NamedTuple):
hostname: str
ip: str
last_seen: datetime.datetime
mac: str = consts.NULL_MAC
mac: str = '00:00:00:00:00:00'
def as_dict(self) -> dict[str, str]:
"""
@@ -46,7 +44,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 consts.NULL_MAC
mac = iface.mac if iface else '00:00:00:00:00:00'
try:
hostname = socket.getfqdn() + '|' + ip
@@ -83,8 +81,8 @@ def enumerate_cluster_nodes() -> list[UDSClusterNode]:
UDSClusterNode(
hostname=prop.key.split('|')[0],
ip=prop.key.split('|')[1],
last_seen=timezone.make_aware(datetime.datetime.fromisoformat(prop.value['last_seen'])),
mac=prop.value.get('mac', consts.NULL_MAC),
last_seen=datetime.datetime.fromisoformat(prop.value['last_seen']),
mac=prop.value.get('mac', '00:00:00:00:00:00'),
)
for prop in properties
if 'last_seen' in prop.value and '|' in prop.key

View File

@@ -349,7 +349,7 @@ class Config:
@staticmethod
def get_config_values(
include_passwords: bool = False,
) -> dict[str, dict[str, dict[str, typing.Any]]]:
) -> collections.abc.Mapping[str, collections.abc.Mapping[str, collections.abc.Mapping[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',
'UDS Enterprise',
type=Config.FieldType.TEXT,
help=_('Site display name'),
)

View File

@@ -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', bound=typing.Any, covariant=True) # R is covariant, so we can return a subclass of R
R = typing.TypeVar('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
HAS_CONNECT = typing.TypeVar('HAS_CONNECT', bound=_HasConnect)
HasConnect = typing.TypeVar('HasConnect', bound=_HasConnect)
def ensure_connected(
func: collections.abc.Callable[typing.Concatenate[HAS_CONNECT, P], R],
) -> collections.abc.Callable[typing.Concatenate[HAS_CONNECT, P], R]:
func: collections.abc.Callable[typing.Concatenate[HasConnect, P], R],
) -> collections.abc.Callable[typing.Concatenate[HasConnect, P], R]:
"""This decorator calls "connect" method of the class of the wrapped object"""
@functools.wraps(func)
def connect_and_execute(obj: HAS_CONNECT, /, *args: P.args, **kwargs: P.kwargs) -> R:
def connect_and_execute(obj: HasConnect, /, *args: P.args, **kwargs: P.kwargs) -> R:
# self = typing.cast(_HasConnect, args[0])
obj.connect()
return func(obj, *args, **kwargs)
@@ -177,14 +177,15 @@ 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: 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,
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,
) -> 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.
@@ -288,9 +289,6 @@ 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
@@ -298,6 +296,10 @@ 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
@@ -338,8 +340,8 @@ def threaded(func: collections.abc.Callable[P, None]) -> collections.abc.Callabl
def blocker(
request_attr: str | None = None,
max_failures: int | None = None,
request_attr: typing.Optional[str] = None,
max_failures: typing.Optional[int] = None,
ignore_block_config: bool = False,
) -> collections.abc.Callable[[collections.abc.Callable[P, R]], collections.abc.Callable[P, R]]:
"""
@@ -373,14 +375,14 @@ def blocker(
except uds.core.exceptions.rest.BlockAccess:
raise exceptions.rest.AccessDenied()
req: typing.Any | None = getattr(args[0], request_attr or '_request', None)
request: typing.Any = getattr(args[0], request_attr or '_request', None)
# No request object, so we can't block
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)
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)
return f(*args, **kwargs)
request = typing.cast(types.requests.ExtendedHttpRequest, req)
request = typing.cast(types.requests.ExtendedHttpRequest, request)
ip = request.ip
@@ -410,7 +412,7 @@ def blocker(
def profiler(
log_file: str | None = None,
log_file: typing.Optional[str] = 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
@@ -450,7 +452,7 @@ def retry_on_exception(
retries: int,
*,
wait_seconds: float = 2,
retryable_exceptions: list[type[Exception]] | None = None,
retryable_exceptions: typing.Optional[typing.List[typing.Type[Exception]]] = None,
do_log: bool = False,
) -> collections.abc.Callable[[collections.abc.Callable[P, R]], collections.abc.Callable[P, R]]:
to_retry = retryable_exceptions or [Exception]

View File

@@ -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=[
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')),
{'id': 'no', 'text': _('No. Never put it back to cache')},
{'id': 'yes', 'text': _('Yes, try to put it back to cache')},
],
tab=tab,
)

View File

@@ -495,11 +495,11 @@ else:
class FuseContext(ctypes.Structure):
_fields_ = [
('fuse', ctypes.c_voidp),
('fuse', ctypes.c_voidp), # type: ignore
('uid', c_uid_t),
('gid', c_gid_t),
('pid', c_pid_t),
('private_data', ctypes.c_voidp),
('private_data', ctypes.c_voidp), # type: ignore
]
@@ -521,7 +521,7 @@ class FuseOperations(ctypes.Structure):
ctypes.c_size_t,
),
),
('getdir', ctypes.c_voidp), # Deprecated, use readdir
('getdir', ctypes.c_voidp), # type: ignore # 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), # Deprecated, use utimens
('utime', ctypes.c_voidp), # type: ignore # 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,
ctypes.c_voidp, # type: ignore
ctypes.CFUNCTYPE(
ctypes.c_int,
ctypes.c_voidp,
ctypes.c_voidp, # type: ignore
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)),
('destroy', ctypes.CFUNCTYPE(ctypes.c_voidp, ctypes.c_voidp)),
('init', ctypes.CFUNCTYPE(ctypes.c_voidp, ctypes.c_voidp)), # type: ignore
('destroy', ctypes.CFUNCTYPE(ctypes.c_voidp, ctypes.c_voidp)), # type: ignore
('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,
ctypes.c_voidp, # type: ignore
),
),
(
@@ -798,7 +798,7 @@ class FUSE:
continue
if hasattr(typing.cast(typing.Any, prototype), 'argtypes'):
val = prototype(partial(FUSE._wrapper, getattr(self, name)))
val = prototype(partial(FUSE._wrapper, getattr(self, name))) # type: ignore
setattr(fuse_ops, name, val)
@@ -846,14 +846,14 @@ class FUSE:
return func(*args, **kwargs) or 0
except OSError as e:
if e.errno and e.errno > 0:
if e.errno > 0: # pyright: ignore
logger.debug(
"FUSE operation %s raised a %s, returning errno %s.",
func.__name__,
type(e),
e.errno,
)
return -e.errno
return -e.errno # pyright: ignore
logger.error(
"FUSE operation %s raised an OSError with negative " "errno %s, returning errno.EINVAL.",
func.__name__,

View File

@@ -35,7 +35,7 @@ import struct
import array
import typing
from uds.core import consts, types
from uds.core import types
def list_ifaces() -> typing.Iterator[types.net.Iface]:
@@ -102,7 +102,7 @@ def list_ifaces() -> typing.Iterator[types.net.Iface]:
for ifname in _list_ifaces():
ip, mac = _get_iface_ip_addr(ifname), _get_iface_mac_addr(ifname)
if (
mac != consts.NULL_MAC and mac and ip and ip.startswith('169.254') is False
mac != '00:00:00:00:00:00' and mac and ip and ip.startswith('169.254') is False
): # Skips local interfaces & interfaces with no dhcp IPs
yield types.net.Iface(name=ifname, mac=mac, ip=ip)

View File

@@ -1,64 +1,89 @@
# pyright: reportUnknownMemberType=false
# pylint: disable=no-member
#
# Copyright (c) 2016-2021 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
Converted to ldap3 by GitHub Copilot
"""
import logging
import typing
import collections.abc
import ssl
import tempfile
import os.path
# For pyasn1 compatibility of ldap3
# This is a workaround for the deprecation warning of pyasn1 when used by ldap3
# It is not recommended to ignore warnings :)
import warnings
warnings.filterwarnings("ignore", module='pyasn1', category=DeprecationWarning)
import ldap.filter
from ldap3 import (
Server,
Connection,
Tls,
ALL,
SUBTREE,
BASE,
LEVEL,
ALL_ATTRIBUTES,
SIMPLE,
MODIFY_ADD as LDAP_MODIFY_ADD,
MODIFY_DELETE as LDAP_MODIFY_DELETE,
MODIFY_REPLACE as LDAP_MODIFY_REPLACE,
MODIFY_INCREMENT as LDAP_MODIFY_INCREMENT,
# Import for local use, and reexport
from ldap import (
SCOPE_BASE as S_BASE, # pyright: ignore
SCOPE_SUBTREE as S_SUBTREE, # pyright: ignore
SCOPE_ONELEVEL as S_ONELEVEL, # pyright: ignore
ALREADY_EXISTS as S_ALREADY_EX, # pyright: ignore
# SCOPE_SUBORDINATE, # pyright: ignore
)
# Reexporting, so we can use them as ldaputil.SCOPE_BASE, etc...
# This allows us to replace this in a future with another ldap library if needed
SCOPE_BASE: int = S_BASE # pyright: ignore
SCOPE_SUBTREE: int = S_SUBTREE # pyright: ignore
SCOPE_ONELEVEL: int = S_ONELEVEL # pyright: ignore
ALREADY_EXISTS: int = S_ALREADY_EX # pyright: ignore
from django.utils.translation import gettext as _
from django.conf import settings
# So it is avaliable for importers
from ldap.ldapobject import LDAPObject as S_LDAPObject # pyright: ignore
# Reexporting, so we can use them as ldaputil.LDAPObject, etc...
# This allows us to replace this in a future with another ldap library if needed
LDAPObject: typing.TypeAlias = S_LDAPObject
from uds.core.util import utils
logger = logging.getLogger(__name__)
# Re-export with our nomenclature
SCOPE_BASE = BASE
SCOPE_SUBTREE = SUBTREE
SCOPE_ONELEVEL = LEVEL
# Also for modify operations
MODIFY_ADD = LDAP_MODIFY_ADD
MODIFY_DELETE = LDAP_MODIFY_DELETE
MODIFY_REPLACE = LDAP_MODIFY_REPLACE
MODIFY_INCREMENT = LDAP_MODIFY_INCREMENT
LDAPResultType = collections.abc.MutableMapping[str, typing.Any]
LDAPSearchResultType = typing.Optional[list[dict[str, typing.Any]]]
LDAPSearchResultType = typing.Optional[list[tuple[typing.Optional[str], dict[str, typing.Any]]]]
LDAPConnection: typing.TypeAlias = Connection
# About ldap filters: (just for reference)
# https://ldap.com/ldap-filters/
class LDAPError(Exception):
@staticmethod
def reraise(e: typing.Any) -> typing.NoReturn:
_str = _('Connection error: ')
_str += str(e)
if hasattr(e, 'message') and isinstance(getattr(e, 'message'), dict):
_str += f'{getattr(e, "message").get("info", "")}, {e.message.get("desc", "")}'
else:
_str += str(e)
raise LDAPError(_str) from e
@@ -66,235 +91,246 @@ def escape(value: str) -> str:
"""
Escape filter chars for ldap search filter
"""
# ldap3 does not provide a direct escape, but this is a safe replacement
return (
value.replace('\\', '\\5c')
.replace('*', '\\2a')
.replace('(', '\\28')
.replace(')', '\\29')
.replace('\0', '\\00')
)
return ldap.filter.escape_filter_chars(value) # pyright: ignore reportGeneralTypeIssues
def connection(
username: str,
passwd: str,
passwd: typing.Union[str, bytes],
host: str,
*,
port: int = -1,
read_only: bool = True, # Most times we want read-only connections, so default to True
use_ssl: bool = False,
ssl: bool = False,
timeout: int = 3,
debug: bool = False,
verify_ssl: bool = False,
certificate_data: typing.Optional[str] = None, # Content of the certificate, not the file itself
) -> 'LDAPConnection':
certificate: typing.Optional[str] = None, # Content of the certificate, not the file itself
) -> 'LDAPObject':
"""
Tries to connect to ldap using ldap3. If username is None, it tries to connect using user provided credentials.
Tries to connect to ldap. If username is None, it tries to connect using user provided credentials.
Args:
username (str): Username to use for connection
passwd (typing.Union[str, bytes]): Password to use for connection
host (str): Host to connect to
port (int, optional): Port to connect to. Defaults to -1.
ssl (bool, optional): If connection is ssl. Defaults to False.
timeout (int, optional): Timeout for connection. Defaults to 3 seconds.
debug (bool, optional): If debug is enabled. Defaults to False.
verify_ssl (bool, optional): If ssl certificate must be verified. Defaults to False.
certificate (typing.Optional[str], optional): Certificate to use for connection. Defaults to None. (only if ssl and verify_ssl are True)
returns:
LDAPObject: Connection object
Raises:
LDAPError: If connection could not be established
@raise exception: If connection could not be established
"""
logger.debug('Login in to %s as user %s', host, username)
password = passwd.encode('utf-8') if isinstance(passwd, str) else passwd
if port == -1:
port = 636 if use_ssl else 389
tls = None
if use_ssl:
# Use ldap3's own constants for validate and version, not ssl module
tls_validate = ssl.CERT_REQUIRED if verify_ssl else ssl.CERT_NONE
if hasattr(settings, 'SECURE_MIN_TLS_VERSION') and settings.SECURE_MIN_TLS_VERSION:
# format is "1.0, 1.1, 1.2 or 1.3", convert to ssl.TLSVersion.TLSv1_0, ssl.TLSVersion.TLSv1_1, ssl.TLSVersion.TLSv1_2 or ssl.TLSVersion.TLSv1_3
tls_version = getattr(ssl.TLSVersion, 'TLSv' + settings.SECURE_MIN_TLS_VERSION.replace('.', '_'))
else:
tls_version = ssl.TLSVersion.TLSv1_2
if hasattr(settings, 'SECURE_CIPHERS') and settings.SECURE_CIPHERS:
cipher = settings.SECURE_CIPHERS
else:
cipher = None
tls = Tls(
ca_certs_data=certificate_data,
validate=tls_validate,
version=tls_version,
ciphers=cipher,
)
server = Server(
host,
port=port,
use_ssl=use_ssl,
get_info=ALL,
tls=tls,
)
l: 'LDAPObject'
try:
conn = Connection(
server,
user=username,
password=passwd,
read_only=read_only,
authentication=SIMPLE,
receive_timeout=timeout,
)
conn.open()
if not conn.bind():
logger.error('Could not bind to LDAP server %s as user %s', host, username)
raise LDAPError(_('Could not bind to LDAP server: {host}').format(host=host))
if debug:
ldap.set_option(ldap.OPT_DEBUG_LEVEL, 8191) # pyright: ignore
schema = 'ldaps' if ssl else 'ldap'
if port == -1:
port = 636 if ssl else 389
uri = f'{schema}://{host}:{port}'
logger.debug('Ldap uri: %s', uri)
l = ldap.initialize(uri=uri) # pyright: ignore
l.set_option(ldap.OPT_REFERRALS, 0) # pyright: ignore
l.set_option(ldap.OPT_TIMEOUT, int(timeout)) # pyright: ignore
l.network_timeout = int(timeout)
l.protocol_version = ldap.VERSION3 # pyright: ignore
certificate = (certificate or '').strip()
if ssl:
cipher_suite = getattr(settings, 'LDAP_CIPHER_SUITE', 'PFS')
if certificate and verify_ssl: # If not verify_ssl, we don't need the certificate
# Create a semi-temporary ca file, with the content of the certificate
# The name is from the host, so we can ovwerwrite it if needed
cert_filename = os.path.join(tempfile.gettempdir(), f'ldap-cert-{host}.pem')
with open(cert_filename, 'w') as f:
f.write(certificate)
l.set_option(ldap.OPT_X_TLS_CACERTFILE, cert_filename) # pyright: ignore
# If enforced on settings, do no change it here
if not getattr(settings, 'LDAP_CIPHER_SUITE', None):
cipher_suite = 'PFS'
if not verify_ssl:
l.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER) # pyright: ignore
# Disable TLS1 and TLS1.1
# 0x304 = TLS1.3, 0x303 = TLS1.2, 0x302 = TLS1.1, 0x301 = TLS1.0, but use ldap module constants
# Ensure that libldap is compiled with TLS1.3 support
min_tls_version = getattr(settings, 'SECURE_MIN_TLS_VERSION', '1.2')
if hasattr(ldap, 'OPT_X_TLS_PROTOCOL_TLS1_3'):
tls_version: typing.Any = { # for pyright to ignore
'1.2': ldap.OPT_X_TLS_PROTOCOL_TLS1_2, # pyright: ignore
'1.3': ldap.OPT_X_TLS_PROTOCOL_TLS1_3, # pyright: ignore
}.get(
min_tls_version, ldap.OPT_X_TLS_PROTOCOL_TLS1_2 # pyright: ignore
)
l.set_option(ldap.OPT_X_TLS_PROTOCOL_MIN, tls_version) # pyright: ignore
# Cipher suites are from GNU TLS, not OpenSSL
# https://gnutls.org/manual/html_node/Priority-Strings.html for more info
# i.e.:
# * NORMAL
# * NORMAL:-VERS-TLS-ALL:+VERS-TLS1.2:+VERS-TLS1.3
# * PFS
# * SECURE256
#
# Note: Your distro could have compiled libldap with OpenSSL, so this will not work
# You can simply use OpenSSL cipher suites, but you will need to test them
try:
l.set_option(ldap.OPT_X_TLS_CIPHER_SUITE, cipher_suite) # pyright: ignore
l.set_option(ldap.OPT_X_TLS_NEWCTX, 0) # pyright: ignore
except Exception:
logger.info('Cipher suite %s not supported by libldap', cipher_suite)
l.simple_bind_s(who=username, cred=password) # pyright: ignore reportGeneralTypeIssues
logger.debug('Connection was successful')
return conn
return l
except ldap.SERVER_DOWN as e: # pyright: ignore
raise LDAPError(_('Can\'t contact LDAP server') + f': {e}') from e
except ldap.LDAPError as e: # pyright: ignore
LDAPError.reraise(e)
except Exception as e:
logger.exception('Exception connection:')
raise LDAPError(str(e)) from e
raise LDAPError(_('Unknown error'))
def as_dict(
con: Connection,
con: 'LDAPObject',
base: str,
ldap_filter: str,
*,
attributes: typing.Optional[collections.abc.Iterable[str]] = None,
limit: int = 100,
scope: typing.Any = SCOPE_SUBTREE,
) -> typing.Generator[LDAPResultType, None, None]:
"""
Makes a search on LDAP, returns a generator with the results, where each result is a dictionary where values are always a list of strings
Makes a search on LDAP, adjusting string to required type (ascii on python2, str on python3).
returns an generator with the results, where each result is a dictionary where it values are always a list of strings
"""
logger.debug('Filter: %s, attr list: %s', ldap_filter, attributes)
attr_list = list(attributes) if attributes else ALL_ATTRIBUTES
if attributes:
attributes = list(attributes) # Ensures iterable is a list
res: LDAPSearchResultType = None
try:
con.search(
search_base=base,
search_filter=ldap_filter,
search_scope=scope,
attributes=attr_list,
size_limit=limit,
# On python2, attrs and search string is str (not unicode), in 3, str (not bytes)
res = con.search_ext_s( # pyright: ignore reportGeneralTypeIssues
base,
scope=scope,
filterstr=ldap_filter,
attrlist=attributes,
sizelimit=limit,
)
for entry in typing.cast(typing.Any, con.entries):
dct = utils.CaseInsensitiveDict[list[str]]()
for attr in attr_list:
dct[attr] = entry[attr].values if attr in entry else ['']
dct['dn'] = entry.entry_dn
yield dct
except ldap.LDAPError as e: # pyright: ignore
LDAPError.reraise(e)
except Exception as e:
logger.exception('Exception in search:')
logger.exception('Exception connection:')
raise LDAPError(str(e)) from e
logger.debug(
'Result of search %s on %s: %s', ldap_filter, base, res
) # pyright: ignore reportGeneralTypeIssues
if res is not None:
for r in res:
if r[0] is None:
continue # Skip None entities
# Convert back attritutes to test_type ONLY on python2
dct: dict[str, typing.Any] = (
utils.CaseInsensitiveDict[list[str]]((k, ['']) for k in attributes)
if attributes is not None
else utils.CaseInsensitiveDict[list[str]]()
)
# Convert back result fields to str
for k, v in r[1].items():
dct[k] = list(i.decode('utf8', errors='replace') for i in v)
dct.update({'dn': r[0]})
yield dct
def first(
con: Connection,
con: 'LDAPObject',
base: str,
object_class: str,
field: str,
value: str,
*,
attributes: typing.Optional[collections.abc.Iterable[str]] = None,
max_entries: int = 50,
) -> typing.Optional[LDAPResultType]:
"""
Searchs for the username and returns its LDAP entry
Args:
con (LDAPObject): Connection to LDAP
base (str): Base to search
object_class (str): Object class to search
field (str): Field to search
value (str): Value to search
attributes (typing.Optional[collections.abc.Iterable[str]], optional): Attributes to return. Defaults to None.
max_entries (int, optional): Max entries to return. Defaults to 50.
Returns:
typing.Optional[LDAPResultType]: Result of the search
"""
value = escape(value)
attr_list = [field] + list(attributes) if attributes else [field]
value = ldap.filter.escape_filter_chars(value) # pyright: ignore reportGeneralTypeIssues
attributes = [field] + list(attributes) if attributes else []
ldap_filter = f'(&(objectClass={object_class})({field}={value}))'
try:
gen = as_dict(con, base, ldap_filter, attributes=attr_list, limit=max_entries)
obj = next(gen)
obj = next(as_dict(con, base, ldap_filter, attributes, max_entries))
except StopIteration:
return None
return None # None found
obj['_id'] = value
return obj
def add(
con: Connection,
dn: str,
*,
attributes: dict[str, list[bytes | str]],
) -> bool:
"""
Adds a new LDAP entry.
Args:
con: LDAP connection
dn: Distinguished Name of the entry to add
attributes: Dictionary of attributes, e.g. { 'objectClass': ['user'], ... }
Returns:
True if the operation was successful, raises LDAPError otherwise
"""
try:
result = typing.cast(typing.Any, con.add(dn, attributes))
if not result:
raise LDAPError(f'Add operation failed: {con.result}')
return True
except Exception as e:
logger.exception('Exception in add:')
raise LDAPError(str(e)) from e
# Recursive delete
def recursive_delete(con: 'LDAPObject', base_dn: str) -> None:
search: LDAPSearchResultType = con.search_s(base_dn, SCOPE_ONELEVEL) # pyright: ignore reportGeneralTypeIssues
if search:
for found in search:
# recursive_delete(conn, dn)
# RIGHT NOW IS NOT RECURSIVE, JUST 1 LEVEL BELOW!!!
con.delete_s(found[0]) # pyright: ignore reportGeneralTypeIssues
con.delete_s(base_dn) # pyright: ignore reportGeneralTypeIssues
def delete(con: Connection, dn: str, *, depth: int = 1) -> None:
def get_root_dse(con: 'LDAPObject') -> typing.Optional[LDAPResultType]:
"""
Deletes an LDAP entry and its children up to a certain depth.
Args:
con: LDAP connection
dn: Distinguished Name of the entry to delete
depth: How many levels to delete (1=only direct children, 2=children and grandchildren, <1=all levels)
Returns:
None. Raises LDAPError on failure.
Gets the root DSE of the LDAP server
@param cont: Connection to LDAP server
@return: None if root DSE is not found, an dictionary of LDAP entry attributes if found (all in unicode on py2, str on py3).
"""
try:
con.search(dn, '(objectClass=*)', search_scope=SCOPE_ONELEVEL, attributes=['dn'])
for entry in typing.cast(list[typing.Any], con.entries):
child_dn: str = entry.entry_dn
delete(con, child_dn, depth=depth - 1)
result = typing.cast(typing.Any, con.delete(child_dn))
if not result:
raise LDAPError(f'Delete operation failed: {con.result}')
result = typing.cast(typing.Any, con.delete(dn))
if not result:
raise LDAPError(f'Delete operation failed: {con.result}')
except Exception as e:
logger.exception('Exception in delete:')
raise LDAPError(str(e)) from e
def recursive_delete(con: Connection, base_dn: str) -> None:
"""
Deletes all direct children and the entry itself (one level deep, for compatibility).
"""
delete(con, base_dn, depth=1)
def modify(
con: Connection,
dn: str,
changes: dict[str, list[tuple[str, list[bytes | str]]]],
*,
controls: typing.Any = None,
) -> bool:
"""
Performs a modify operation on the LDAP entry.
Args:
con: LDAP connection
dn: Distinguished Name of the entry to modify
changes: Dictionary of changes, e.g. { 'member': [(MODIFY_ADD, [b'userdn'])] }
controls: Optional controls
Returns:
True if the operation was successful, raises LDAPError otherwise
"""
try:
result = typing.cast(typing.Any, con.modify(dn, changes, controls=controls))
if not result:
raise LDAPError(f'Modify operation failed: {con.result}')
return True
except Exception as e:
logger.exception('Exception in modify:')
raise LDAPError(str(e)) from e
def get_root_dse(con: Connection) -> typing.Optional[LDAPResultType]:
con.search('', '(objectClass=*)', search_scope=SCOPE_BASE)
if con.entries:
entry = typing.cast(typing.Any, con.entries[0])
dct: dict[str, typing.Any] = {attr: entry[attr].values for attr in entry.entry_attributes}
dct['dn'] = entry.entry_dn
return dct
return None
return next(
as_dict(
con=con,
base='',
ldap_filter='(objectClass=*)',
scope=SCOPE_BASE,
)
)

View File

@@ -1,344 +0,0 @@
# pylint: disable=no-member
#
# Copyright (c) 2016-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.
# TO BE REMOVED IN FUTURE VERSIONS, USE ldaputil.py INSTEAD
# Just keep here for a case of emergency :)
"""
Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
import logging
import typing
import collections.abc
import tempfile
import os.path
import ldap.filter
# Import for local use, and reexport
from ldap import (
SCOPE_BASE as S_BASE, # pyright: ignore
SCOPE_SUBTREE as S_SUBTREE, # pyright: ignore
SCOPE_ONELEVEL as S_ONELEVEL, # pyright: ignore
ALREADY_EXISTS as S_ALREADY_EX, # pyright: ignore
# SCOPE_SUBORDINATE, # pyright: ignore
)
# Reexporting, so we can use them as ldaputil.SCOPE_BASE, etc...
# This allows us to replace this in a future with another ldap library if needed
SCOPE_BASE: int = S_BASE # pyright: ignore
SCOPE_SUBTREE: int = S_SUBTREE # pyright: ignore
SCOPE_ONELEVEL: int = S_ONELEVEL # pyright: ignore
ALREADY_EXISTS: int = S_ALREADY_EX # pyright: ignore
from django.utils.translation import gettext as _
from django.conf import settings
# So it is avaliable for importers
from ldap.ldapobject import LDAPObject as S_LDAPObject # pyright: ignore
# Reexporting, so we can use them as ldaputil.LDAPObject, etc...
# This allows us to replace this in a future with another ldap library if needed
LDAPObject: typing.TypeAlias = S_LDAPObject
from uds.core.util import utils
logger = logging.getLogger(__name__)
LDAPResultType = collections.abc.MutableMapping[str, typing.Any]
LDAPSearchResultType = typing.Optional[list[tuple[typing.Optional[str], dict[str, typing.Any]]]]
# About ldap filters: (just for reference)
# https://ldap.com/ldap-filters/
class LDAPError(Exception):
@staticmethod
def reraise(e: typing.Any) -> typing.NoReturn:
_str = _('Connection error: ')
if hasattr(e, 'message') and isinstance(getattr(e, 'message'), dict):
_str += f'{getattr(e, "message").get("info", "")}, {e.message.get("desc", "")}'
else:
_str += str(e)
raise LDAPError(_str) from e
def escape(value: str) -> str:
"""
Escape filter chars for ldap search filter
"""
return ldap.filter.escape_filter_chars(value) # pyright: ignore reportGeneralTypeIssues
def connection(
username: str,
passwd: typing.Union[str, bytes],
host: str,
*,
port: int = -1,
ssl: bool = False,
timeout: int = 3,
debug: bool = False,
verify_ssl: bool = False,
certificate: typing.Optional[str] = None, # Content of the certificate, not the file itself
) -> 'LDAPObject':
"""
Tries to connect to ldap. If username is None, it tries to connect using user provided credentials.
Args:
username (str): Username to use for connection
passwd (typing.Union[str, bytes]): Password to use for connection
host (str): Host to connect to
port (int, optional): Port to connect to. Defaults to -1.
ssl (bool, optional): If connection is ssl. Defaults to False.
timeout (int, optional): Timeout for connection. Defaults to 3 seconds.
debug (bool, optional): If debug is enabled. Defaults to False.
verify_ssl (bool, optional): If ssl certificate must be verified. Defaults to False.
certificate (typing.Optional[str], optional): Certificate to use for connection. Defaults to None. (only if ssl and verify_ssl are True)
returns:
LDAPObject: Connection object
Raises:
LDAPError: If connection could not be established
@raise exception: If connection could not be established
"""
logger.debug('Login in to %s as user %s', host, username)
password = passwd.encode('utf-8') if isinstance(passwd, str) else passwd
l: 'LDAPObject'
try:
if debug:
ldap.set_option(ldap.OPT_DEBUG_LEVEL, 8191) # pyright: ignore
schema = 'ldaps' if ssl else 'ldap'
if port == -1:
port = 636 if ssl else 389
uri = f'{schema}://{host}:{port}'
logger.debug('Ldap uri: %s', uri)
l = ldap.initialize(uri=uri) # pyright: ignore
l.set_option(ldap.OPT_REFERRALS, 0) # pyright: ignore
l.set_option(ldap.OPT_TIMEOUT, int(timeout)) # pyright: ignore
l.network_timeout = int(timeout)
l.protocol_version = ldap.VERSION3 # pyright: ignore
certificate = (certificate or '').strip()
if ssl:
cipher_suite = getattr(settings, 'LDAP_CIPHER_SUITE', 'PFS')
if certificate and verify_ssl: # If not verify_ssl, we don't need the certificate
# Create a semi-temporary ca file, with the content of the certificate
# The name is from the host, so we can ovwerwrite it if needed
cert_filename = os.path.join(tempfile.gettempdir(), f'ldap-cert-{host}.pem')
with open(cert_filename, 'w') as f:
f.write(certificate)
l.set_option(ldap.OPT_X_TLS_CACERTFILE, cert_filename) # pyright: ignore
# If enforced on settings, do no change it here
if not getattr(settings, 'LDAP_CIPHER_SUITE', None):
cipher_suite = 'PFS'
if not verify_ssl:
l.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER) # pyright: ignore
# Disable TLS1 and TLS1.1
# 0x304 = TLS1.3, 0x303 = TLS1.2, 0x302 = TLS1.1, 0x301 = TLS1.0, but use ldap module constants
# Ensure that libldap is compiled with TLS1.3 support
min_tls_version = getattr(settings, 'SECURE_MIN_TLS_VERSION', '1.2')
if hasattr(ldap, 'OPT_X_TLS_PROTOCOL_TLS1_3'):
tls_version = typing.cast(
typing.Any,
{ # for pyright to ignore
'1.2': ldap.OPT_X_TLS_PROTOCOL_TLS1_2, # pyright: ignore
'1.3': ldap.OPT_X_TLS_PROTOCOL_TLS1_3, # pyright: ignore
},
).get(
min_tls_version, ldap.OPT_X_TLS_PROTOCOL_TLS1_2 # pyright: ignore
)
l.set_option(ldap.OPT_X_TLS_PROTOCOL_MIN, tls_version) # pyright: ignore
# Cipher suites are from GNU TLS, not OpenSSL
# https://gnutls.org/manual/html_node/Priority-Strings.html for more info
# i.e.:
# * NORMAL
# * NORMAL:-VERS-TLS-ALL:+VERS-TLS1.2:+VERS-TLS1.3
# * PFS
# * SECURE256
#
# Note: Your distro could have compiled libldap with OpenSSL, so this will not work
# You can simply use OpenSSL cipher suites, but you will need to test them
try:
l.set_option(ldap.OPT_X_TLS_CIPHER_SUITE, cipher_suite) # pyright: ignore
l.set_option(ldap.OPT_X_TLS_NEWCTX, 0) # pyright: ignore
except Exception:
logger.info('Cipher suite %s not supported by libldap', cipher_suite)
l.simple_bind_s(who=username, cred=password) # pyright: ignore reportGeneralTypeIssues
logger.debug('Connection was successful')
return l
except ldap.SERVER_DOWN as e: # pyright: ignore
raise LDAPError(_('Can\'t contact LDAP server') + f': {e}') from e
except ldap.LDAPError as e: # pyright: ignore
LDAPError.reraise(e)
except Exception as e:
logger.exception('Exception connection:')
raise LDAPError(str(e)) from e
raise LDAPError(_('Unknown error'))
def as_dict(
con: 'LDAPObject',
base: str,
ldap_filter: str,
attributes: typing.Optional[collections.abc.Iterable[str]] = None,
limit: int = 100,
scope: typing.Any = SCOPE_SUBTREE,
) -> typing.Generator[LDAPResultType, None, None]:
"""
Makes a search on LDAP, adjusting string to required type (ascii on python2, str on python3).
returns an generator with the results, where each result is a dictionary where it values are always a list of strings
"""
logger.debug('Filter: %s, attr list: %s', ldap_filter, attributes)
if attributes:
attributes = list(attributes) # Ensures iterable is a list
res: LDAPSearchResultType = None
try:
# On python2, attrs and search string is str (not unicode), in 3, str (not bytes)
res = con.search_ext_s( # pyright: ignore reportGeneralTypeIssues
base,
scope=scope,
filterstr=ldap_filter,
attrlist=attributes,
sizelimit=limit,
)
except ldap.LDAPError as e: # pyright: ignore
LDAPError.reraise(e)
except Exception as e:
logger.exception('Exception connection:')
raise LDAPError(str(e)) from e
logger.debug(
'Result of search %s on %s: %s', ldap_filter, base, res
) # pyright: ignore reportGeneralTypeIssues
if res is not None:
for r in res:
if r[0] is None:
continue # Skip None entities
# Convert back attritutes to test_type ONLY on python2
dct: dict[str, typing.Any] = (
utils.CaseInsensitiveDict[list[str]]((k, ['']) for k in attributes)
if attributes is not None
else utils.CaseInsensitiveDict[list[str]]()
)
# Convert back result fields to str
for k, v in r[1].items():
dct[k] = list(i.decode('utf8', errors='replace') for i in v)
dct.update(typing.cast(dict[str, typing.Any], {'dn': r[0]}))
yield dct
def first(
con: 'LDAPObject',
base: str,
object_class: str,
field: str,
value: str,
attributes: typing.Optional[collections.abc.Iterable[str]] = None,
max_entries: int = 50,
) -> typing.Optional[LDAPResultType]:
"""
Searchs for the username and returns its LDAP entry
Args:
con (LDAPObject): Connection to LDAP
base (str): Base to search
object_class (str): Object class to search
field (str): Field to search
value (str): Value to search
attributes (typing.Optional[collections.abc.Iterable[str]], optional): Attributes to return. Defaults to None.
max_entries (int, optional): Max entries to return. Defaults to 50.
Returns:
typing.Optional[LDAPResultType]: Result of the search
"""
value = ldap.filter.escape_filter_chars(value) # pyright: ignore reportGeneralTypeIssues
attributes = [field] + list(attributes) if attributes else []
ldap_filter = f'(&(objectClass={object_class})({field}={value}))'
try:
obj = next(as_dict(con, base, ldap_filter, attributes, max_entries))
except StopIteration:
return None # None found
obj['_id'] = value
return obj
# Recursive delete
def recursive_delete(con: 'LDAPObject', base_dn: str) -> None:
search: LDAPSearchResultType = con.search_s( # pyright: ignore reportGeneralTypeIssues
base_dn, SCOPE_ONELEVEL
)
if search:
for found in search:
# recursive_delete(conn, dn)
# RIGHT NOW IS NOT RECURSIVE, JUST 1 LEVEL BELOW!!!
con.delete_s(found[0]) # pyright: ignore reportGeneralTypeIssues
con.delete_s(base_dn) # pyright: ignore reportGeneralTypeIssues
def get_root_dse(con: 'LDAPObject') -> typing.Optional[LDAPResultType]:
"""
Gets the root DSE of the LDAP server
@param cont: Connection to LDAP server
@return: None if root DSE is not found, an dictionary of LDAP entry attributes if found (all in unicode on py2, str on py3).
"""
return next(
as_dict(
con=con,
base='',
ldap_filter='(objectClass=*)',
scope=SCOPE_BASE,
)
)

View File

@@ -32,29 +32,28 @@ Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
import typing
import logging
import threading
from threading import Lock
import datetime
import time
from time import mktime
from django.db import connection
from django.utils import timezone
from uds.core import consts
from uds.core.managers.crypto import CryptoManager
logger = logging.getLogger(__name__)
CACHE_TIME_TIMEOUT: typing.Final[int] = 60 # Every 60 second, refresh the time from database (to avoid drifts)
CACHE_TIME_TIMEOUT = 60 # Every 60 second, refresh the time from database (to avoid drifts)
# pylint: disable=too-few-public-methods
class TimeTrack:
"""
Reduces the queries to database to get the current time
keeping it cached for CACHE_TIME_TIMEOUT seconds (and adjusting it based on local time)
"""
lock: typing.ClassVar[threading.Lock] = threading.Lock()
lock: typing.ClassVar[Lock] = Lock()
last_check: typing.ClassVar[datetime.datetime] = consts.NEVER
cached_time: typing.ClassVar[datetime.datetime] = consts.NEVER
hits: typing.ClassVar[int] = 0
@@ -81,20 +80,17 @@ class TimeTrack:
else 'SELECT CURRENT_TIMESTAMP'
)
cursor.execute(sentence)
dt = (cursor.fetchone() or [timezone.localtime()])[0]
date = (cursor.fetchone() or [datetime.datetime.now()])[0]
else:
dt = (
timezone.localtime()
date = (
datetime.datetime.now()
) # If not know how to get database datetime, returns local datetime (this is fine for sqlite, which is local)
if timezone.is_naive(dt):
dt = timezone.make_aware(dt)
return dt
return date
@staticmethod
def sql_now() -> datetime.datetime:
now = timezone.localtime()
now = datetime.datetime.now()
with TimeTrack.lock:
diff = now - TimeTrack.last_check
# If in last_check is in the future, or more than CACHE_TIME_TIMEOUT seconds ago, we need to refresh
@@ -108,7 +104,6 @@ class TimeTrack:
the_time = TimeTrack.cached_time + (now - TimeTrack.last_check)
# Keep only cent of second precision
the_time = the_time.replace(microsecond=int(the_time.microsecond / 10000) * 10000)
return the_time
@@ -125,7 +120,7 @@ def sql_stamp_seconds() -> int:
Returns:
int: Unix timestamp
"""
return int(time.mktime(sql_now().timetuple()))
return int(mktime(sql_now().timetuple()))
def sql_stamp() -> float:
@@ -134,14 +129,14 @@ def sql_stamp() -> float:
Returns:
float: Unix timestamp
"""
return float(time.mktime(sql_now().timetuple())) + sql_now().microsecond / 1000000.0
return float(mktime(sql_now().timetuple())) + sql_now().microsecond / 1000000.0
def generate_uuid() -> str:
def generate_uuid(obj: typing.Any = None) -> str:
"""
Generates a ramdom uuid for models default
"""
return CryptoManager.manager().uuid().lower()
return CryptoManager.manager().uuid(obj=obj).lower()
def process_uuid(uuid: str) -> str:
@@ -172,9 +167,9 @@ def get_my_ip_from_db() -> str:
with connection.cursor() as cursor:
cursor.execute(query)
result_row = cursor.fetchone()
if result_row:
result = result_row[0] if isinstance(result_row[0], str) else result_row[0].decode('utf8')
result = cursor.fetchone()
if result:
result = result[0] if isinstance(result[0], str) else result[0].decode('utf8')
return result.split(':')[0]
except Exception as e:

View File

@@ -1,273 +0,0 @@
# 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: reportUnknownMemberType=false
import typing
import re
import contextvars
import logging
import hashlib
import lark
from django.db.models import Q, F, QuerySet, Value, Func
from django.db.models.functions import (
Lower,
Upper,
Length,
ExtractYear,
ExtractMonth,
ExtractDay,
Concat,
Substr,
)
logger = logging.getLogger(__name__)
from .query_filter import _QUERY_GRAMMAR, _FUNCTIONS_PARAMS_NUM
_DB_QUERY_PARSER_VAR: typing.Final[contextvars.ContextVar[lark.Lark]] = contextvars.ContextVar(
"db_query_parser"
)
_REMOVE_QUOTES_RE: typing.Final[typing.Pattern[str]] = re.compile(r"^(['\"])(.*)\1$")
class FieldName(str):
"""Marker class to distinguish field names from string literals."""
pass
class AnnotatedField(str):
"""Represents an annotated field name from a function."""
pass
_UNARY_FUNCTIONS: typing.Final[dict[str, typing.Callable[[F], typing.Any]]] = {
'tolower': Lower,
'toupper': Upper,
'trim': lambda arg: Func(arg, function='TRIM'),
'length': Length,
'year': ExtractYear,
'month': ExtractMonth,
'day': ExtractDay,
'floor': lambda arg: Func(arg, function='FLOOR'),
'ceiling': lambda arg: Func(arg, function='CEIL'),
'round': lambda arg: Func(arg, function='ROUND'),
}
class DjangoQueryTransformer(lark.Transformer[typing.Any, Q | AnnotatedField]):
def __init__(self):
super().__init__()
self.annotations: dict[str, typing.Any] = {}
@lark.visitors.v_args(inline=True)
def value(self, arg: lark.Token | str | int | float) -> typing.Any:
if isinstance(arg, lark.Token):
match arg.type:
case 'ESCAPED_STRING':
match = _REMOVE_QUOTES_RE.match(arg.value)
return match.group(2) if match else arg.value
case 'NUMBER':
return float(arg.value) if '.' in arg.value else int(arg.value)
case 'BOOLEAN':
return arg.value.lower() == 'true'
case 'CNAME':
return F(arg.value)
case _:
raise ValueError(f"Unexpected token type: {arg.type}")
return arg
@lark.visitors.v_args(inline=True)
def true(self) -> Q:
return Q(pk__isnull=False)
@lark.visitors.v_args(inline=True)
def false(self) -> Q:
return ~Q(pk__isnull=False)
@lark.visitors.v_args(inline=True)
def field(self, arg: lark.Token) -> FieldName:
return FieldName(arg.value)
@lark.visitors.v_args(inline=True)
def binary_expr(self, left: typing.Any, op: typing.Any, right: typing.Any) -> Q:
if isinstance(right, FieldName):
right = F(right)
if isinstance(left, (FieldName, AnnotatedField)):
field_name = str(left)
elif isinstance(left, F):
field_name = left.name
else:
raise ValueError(f"Left side of binary expression must be a field name or annotated field")
logger.debug("Binary expr: field=%s, op=%s, value=%s", field_name, op, right)
match op:
case 'eq':
return Q(**{field_name: right})
case 'ne':
return ~Q(**{field_name: right})
case 'gt':
return Q(**{f"{field_name}__gt": right})
case 'lt':
return Q(**{f"{field_name}__lt": right})
case 'ge':
return Q(**{f"{field_name}__gte": right})
case 'le':
return Q(**{f"{field_name}__lte": right})
case _:
raise ValueError(f"Unknown operator: {op}")
@lark.visitors.v_args(inline=True)
def logical_and(self, left: Q, right: Q) -> Q:
return left & right
@lark.visitors.v_args(inline=True)
def logical_or(self, left: Q, right: Q) -> Q:
return left | right
@lark.visitors.v_args(inline=True)
def unary_not(self, expr: Q) -> Q:
return ~expr
@lark.visitors.v_args(inline=True)
def paren_expr(self, expr: Q) -> Q:
return expr
@lark.visitors.v_args()
def func_call(self, args: list[typing.Any]) -> Q | AnnotatedField:
func_token = typing.cast(lark.Token, args[0])
func_name = typing.cast(str, func_token.value).lower()
func_args = args[1:]
if func_name not in _FUNCTIONS_PARAMS_NUM:
raise ValueError(f"Unknown function: {func_name}")
if func_name in ('substringof', 'startswith', 'endswith'):
if len(func_args) != 2:
raise ValueError(f"{func_name} requires 2 arguments")
field, value = func_args
if not isinstance(field, str):
raise ValueError(f"Field name must be a string")
if isinstance(value, F):
raise ValueError(f"Function '{func_name}' does not support field-to-field comparison")
match func_name:
case 'substringof':
return Q(**{f"{field}__icontains": value})
case 'startswith':
return Q(**{f"{field}__istartswith": value})
case 'endswith':
return Q(**{f"{field}__iendswith": value})
if func_name in _UNARY_FUNCTIONS:
if len(func_args) != 1:
raise ValueError(f"{func_name} requires 1 argument")
field = func_args[0]
if not isinstance(field, FieldName):
raise ValueError(f"{func_name} requires a field name")
alias = DjangoQueryTransformer._make_alias(func_name, [field])
self.annotations[alias] = _UNARY_FUNCTIONS[func_name](F(field))
return AnnotatedField(alias)
if func_name == 'concat':
if len(func_args) < 2:
raise ValueError("concat requires at least 2 arguments")
concat_args = [F(arg) if isinstance(arg, FieldName) else Value(arg) for arg in func_args]
alias = DjangoQueryTransformer._make_alias(func_name, func_args)
self.annotations[alias] = Concat(*concat_args)
return AnnotatedField(alias)
elif func_name == 'substring':
# 2 or 3 args
if len(func_args) not in (2, 3):
raise ValueError(f"{func_name} requires 2 or 3 arguments")
substr_args: list[typing.Any] = []
if not isinstance(func_args[0], FieldName):
raise ValueError(f"{func_name} requires a field name as the first argument")
substr_args.append(str(func_args[0]))
if not isinstance(func_args[1], int):
raise ValueError(f"{func_name} requires an integer as the second argument")
substr_args.append(func_args[1] + 1) # Django's Substr is 1-based index
if len(func_args) == 3:
if not isinstance(func_args[2], int):
raise ValueError(f"{func_name} requires an integer as the third argument")
substr_args.append(func_args[2])
alias = DjangoQueryTransformer._make_alias(func_name, func_args)
self.annotations[alias] = Substr(*substr_args)
return AnnotatedField(alias)
raise ValueError(f"Function {func_name} not supported in Django Q")
@staticmethod
def _make_alias(func_name: str, args: list[typing.Any]) -> str:
raw = f"{func_name}:{','.join(str(a) for a in args)}"
digest = hashlib.sha256(raw.encode('utf-8')).hexdigest()[:10]
return f"{func_name}_{digest}"
def get_parser() -> lark.Lark:
try:
return _DB_QUERY_PARSER_VAR.get()
except LookupError:
parser = lark.Lark(_QUERY_GRAMMAR, parser="lalr")
_DB_QUERY_PARSER_VAR.set(parser)
return parser
T = typing.TypeVar('T', bound=typing.Any)
def exec_query(query: str, qs: QuerySet[T]) -> QuerySet[T]:
try:
parser = get_parser()
tree = parser.parse(query)
transformer = DjangoQueryTransformer()
q_obj = transformer.transform(tree)
if not isinstance(q_obj, Q):
raise ValueError("Query must result in a filterable expression")
if transformer.annotations:
qs = qs.annotate(**transformer.annotations)
logger.info(
"Executing query: %s -> %s (%s) %s",
query,
q_obj,
transformer.annotations,
qs.query if isinstance(typing.cast(typing.Any, qs), QuerySet) else qs,
)
return qs.filter(q_obj)
except lark.exceptions.LarkError as e:
raise ValueError(f"Error processing query: {e}") from None

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