1
0
mirror of https://github.com/dkmstr/openuds.git synced 2025-10-09 23:33:47 +03:00

52 Commits

Author SHA1 Message Date
Adolfo Gómez García
844060addb Added info on modurls fail loading 2025-10-09 17:27:02 +02:00
Adolfo Gómez García
cd60b398a9 Fixed sample settings and added more info to log on case of SAML failure 2025-10-03 18:03:50 +02:00
Adolfo Gómez García
979f992b6d Update RDP and X2GO tunnel scripts and signatures with new configurations 2025-10-01 18:47:43 +02:00
Adolfo Gómez García
83689dddaa Merge remote-tracking branch 'origin/dev/andres/v4.0' into v4.0 2025-09-25 19:19:19 +02:00
Adolfo Gómez García
037b4abad1 Add enumerate_servers method to filter out servers in maintenance mode 2025-09-23 19:26:52 +02:00
Adolfo Gómez García
839e4c6b1d Fix access check in ServersServers class to use parent object instead of item 2025-09-23 19:10:49 +02:00
aschumann-virtualcable
2fd157e463 Merge remote-tracking branch 'origin/v4.0' into dev/andres/v4.0 2025-09-22 17:36:09 +02:00
Adolfo Gómez García
7e51c1fd93 Handle InvalidServiceException in enable_service function and log a warning 2025-09-22 16:53:45 +02:00
Adolfo Gómez García
b6af59cc44 Refactor MAC address handling in IPMachinesUserService to use constant for unknown MACs 2025-09-22 16:27:57 +02:00
Adolfo Gómez García
50072e948e Fix token assignment in IPMachinesService to use the correct attribute 2025-09-22 16:22:25 +02:00
Adolfo Gómez García
c5e0d0721f Add max_users attribute to ServerStatsWeights for load calculation 2025-09-19 16:03:16 +02:00
aschumann-virtualcable
afbd4c5355 Fixes incorrect parameter usage for macOS RDP connections
Updates logic to use the correct macOS-specific custom parameters
instead of Linux parameters when generating RDP connection settings.
Adds type ignore comments to improve compatibility with type checkers
and prevent related runtime issues.
2025-09-19 13:54:36 +02:00
aschumann-virtualcable
e4377b83e4 Corrects Mac RDP file usage and field mapping
Aligns Mac-specific RDP file logic to use the appropriate configuration and updates legacy field naming for better clarity and migration. Ensures Mac connections consistently respect intended custom parameter and file options, reducing potential confusion with Linux settings.
2025-09-18 13:59:48 +02:00
Adolfo Gómez García
bf97c6f2dc Reduce default max items to 200 and enhance service pool export logic to include additional user services, while adding server group serialization to the tree command output. 2025-09-17 17:57:41 +02:00
aschumann-virtualcable
6763de2bab Merge remote-tracking branch 'origin/v4.0' into dev/andres/v4.0 2025-09-17 12:35:22 +02:00
Adolfo Gómez García
b4ca743d7c Refactor server management to use atomic transactions for server updates and improve code formatting in service pool model
Fixed is_usable on ServicePool to include locked as a velid is_usable state
2025-09-16 17:45:35 +02:00
Adolfo Gómez García
20f7ae7fcd Update script and preload links in admin index.html with new integrity and timestamp values 2025-09-16 17:34:38 +02:00
Adolfo Gómez García
8aac4f9aa5 Improve error handling in UserServiceManager for non-active elements and task errors 2025-09-16 16:00:37 +02:00
aschumann-virtualcable
f494c706fc Updates RDP signature files for macOS with new parameters 2025-09-15 12:34:00 +02:00
aschumann-virtualcable
76b488dc1d Extends RDP custom parameter support for macOS clients
Unifies logic for applying custom RDP parameters to macOS alongside Windows and Linux, improving compatibility and flexibility for connecting from Apple platforms.

Refactors script handling to better support Thincast and MSRDC clients on macOS, allowing password injection into RDP files and debugging RDP file content. Adds consistent type hints to suppress type checking warnings in subprocess and file operations.

Enhances tunnel scripts to properly apply RDP file logic for Thincast and improves debugging output.

No issue reference provided.
2025-09-15 11:23:47 +02:00
aschumann-virtualcable
826cc7aed8 Add macOS support for RDP file usage in Thincast connections
Adds macOS RDP file support for Thincast connections

Introduces a configurable option to use RDP files for Thincast and xfreerdp on macOS, enabling seamless file-based connections. Updates logic to open Thincast with the RDP file when the option is enabled, improving compatibility and user experience for macOS users.
2025-09-12 15:38:14 +02:00
aschumann-virtualcable
4da15d66fe Improves Thincast client detection and launch on macOS
Switches Thincast detection from file to directory check to match macOS app bundle structure.

Updates Thincast launch logic to use the 'open' command with appropriate arguments, improving compatibility and reliability.

Removes unused code for opening .rdp files with Thincast and applies consistent resolution handling.

Ensures signature files are updated accordingly.
2025-09-12 12:09:54 +02:00
aschumann-virtualcable
79495fc3b1 Enables Thincast support for RDP transport on macOS
Uncomments and activates logic for launching Thincast client,
allowing users to initiate RDP sessions via Thincast.

Updates the related signature file for integrity validation.
2025-09-12 11:24:31 +02:00
Adolfo Gómez García
f438a9241e Remove unused import of DynamicUserService in Xen service module 2025-09-11 17:12:34 +02:00
aschumann-virtualcable
e37b345aff Adds support for RDP file custom params on Linux
Enables the use of Windows custom parameters in RDP file generation when specified for Linux targets, aligning Linux behavior with Windows.

Improves flexibility for custom connection settings across platforms.
2025-09-11 13:15:07 +02:00
aschumann-virtualcable
ce1330066f Enhance XFREERDP and Thincast support to conditionally use RDP files, improving parameter handling and logging.
Improves RDP client handling with conditional file usage

Allows XFREERDP and Thincast to use RDP files when provided, enhancing parameter management and execution flexibility.
Refines logging for better traceability of client launch logic.
2025-09-10 19:22:37 +02:00
aschumann-virtualcable
20e86cd8c7 Refactor Thincast support: rename lnx_thincast_rdp_file to lnx_use_rdp_file, update related logic in RDPTransport and BaseRDPTransport, and enhance RDP file handling in direct.py and tunnel.py.
Refactors Thincast RDP file support for Linux clients

Renames and consolidates configuration for using RDP files with Thincast and xfreerdp, streamlines related logic, and enhances RDP file handling in Linux scripts. Improves clarity, maintainability, and user experience for Linux RDP connections.
2025-09-10 18:33:59 +02:00
Adolfo Gómez García
dc52e37abc Fix proxy handling in secure_requests_session to check for None instead of truthiness 2025-09-09 21:40:17 +02:00
Adolfo Gómez García
69fae6a1a6 Refactor access denial handling in blocker decorator and update frequency for DeployedServiceInfoItemsCleaner 2025-09-09 18:58:13 +02:00
Adolfo Gómez García
7c14923afe Add string representation method to Environment class 2025-09-09 18:07:02 +02:00
Adolfo Gómez García
9e66583b4e Enhance MAC address handling in Proxmox and Xen services; add maintenance command for cleaning unused MACs 2025-09-09 16:58:49 +02:00
aschumann-virtualcable
34676c817f Enhance Thincast support by updating RDPTransport to conditionally handle 'as_file' and improve logging in direct.py for better debugging. 2025-09-09 11:16:52 +02:00
aschumann-virtualcable
d17224c9cb Merge branch 'dev/andres/v4.0' of github.com:VirtualCable/openuds into dev/andres/v4.0 2025-09-09 10:43:02 +02:00
aschumann-virtualcable
b57b00f3fc Add lnx_thincast_rdp_file field to RDPTransport and BaseRDPTransport for Thincast support 2025-09-09 10:42:40 +02:00
aschumann-virtualcable
f82041da1e Add debug logging for Thincast RDP file processing and update signatures 2025-09-08 13:10:04 +02:00
aschumann-virtualcable
03a837f865 Add Thincast support and improve logging in RDP scripts 2025-09-08 13:08:12 +02:00
Adolfo Gómez García
473dc2577f Add MAC Address field to ServersTokens API response 2025-09-05 16:42:31 +02:00
Adolfo Gómez García
49dfaf3709 Add NO_MORE_MACS constant and update error handling in DynamicUserService and UniqueMacGenerator 2025-09-04 22:01:18 +02:00
Adolfo Gómez García
f5afb79a2b Limit length of server group name and comments in migrate function for consistency 2025-09-04 20:18:43 +02:00
Adolfo Gómez García
bd26fb38d9 Add error logging for unavailable IDs in UniqueGenerator and UniqueMacGenerator 2025-09-04 19:20:35 +02:00
aschumann-virtualcable
95f0b0ab26 Update tunnel.py.signature with new signature data 2025-09-04 11:59:05 +02:00
aschumann-virtualcable
28433fc33e Add support for Thincast in RDP scripts and improve executable search logic 2025-09-04 11:55:41 +02:00
aschumann-virtualcable
fc4e7414df Update subproject commits for actor and client modules 2025-09-04 11:26:01 +02:00
aschumann-virtualcable
e61cb1f855 Add logging for client discovery in RDP scripts 2025-09-04 11:25:24 +02:00
Adolfo Gómez García
689214cf84 Refactor code formatting in ServerManager and Server classes for improved readability 2025-09-04 02:05:41 +02:00
Adolfo Gómez García
d268478767 Add server stats weights handling and update load calculation 2025-09-04 02:04:17 +02:00
Adolfo Gómez García
4a5ad5dc09 Update max user limit in ServerStats comment from 1000 to 100 for accuracy 2025-09-03 16:06:41 +02:00
Adolfo Gómez García
7365ee8cc6 Fix CryptoManager call in generate_uuid function to use manager method 2025-09-03 15:44:54 +02:00
Adolfo Gómez García
5a93aa15e8 Add HA group handling for Proxmox version 9 and update tests accordingly 2025-08-25 17:29:08 +02:00
aschumann-virtualcable
1fddc17b75 initial dev enviroment 2025-08-21 18:04:11 +02:00
Adolfo Gómez García
ca540d7725 Improve logging message in RadiusClient and change secret field to PasswordField in RadiusOTP for better security 2025-08-21 16:30:39 +02:00
Adolfo Gómez García
fe11b485ed Rename _ensure_local_db_exists method to ensure_local_db_exists for clarity 2025-08-19 17:16:38 +02:00
165 changed files with 5134 additions and 8508 deletions

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
client

Submodule client updated: 4dfb56c5a1...5b044bca34

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,9 +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
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,8 +1,6 @@
# Broker (and common)
# Latest versions should work fine with master branch
Django>5.2
pillow
cairosvg
Django>5.0
bitarray
numpy
html5lib

View File

@@ -14,6 +14,7 @@ BASE_DIR = '/'.join(
) # If used 'relpath' instead of abspath, returns path of "enterprise" instead of "openuds"
DEBUG = True
PROFILING = False
# USE_X_FORWARDED_HOST = True
SECURE_PROXY_SSL_HEADER = (
@@ -194,7 +195,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,48 +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
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")
@@ -112,35 +145,29 @@ class Dispatcher(View):
# ensure method is recognized
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:
@@ -153,7 +180,7 @@ class Dispatcher(View):
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())
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
@@ -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,8 +210,12 @@ 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.RequestError as e:
log.log_operation(handler, 400, types.log.LogLevel.ERROR)
return http.HttpResponseBadRequest(str(e), content_type="text/plain")
except exceptions.rest.ResponseError as e:
log.log_operation(handler, 500, types.log.LogLevel.ERROR)
return http.HttpResponseServerError(str(e), content_type="text/plain")
except exceptions.rest.NotSupportedError as e:
log.log_operation(handler, 501, types.log.LogLevel.ERROR)
return http.HttpResponseBadRequest(str(e), content_type="text/plain")
@@ -194,12 +225,6 @@ class Dispatcher(View):
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.RequestError as e:
log.log_operation(handler, 400, types.log.LogLevel.ERROR)
return http.HttpResponseBadRequest(str(e), content_type="text/plain")
except exceptions.rest.ResponseError as e:
log.log_operation(handler, 500, types.log.LogLevel.ERROR)
return http.HttpResponseServerError(str(e), content_type="text/plain")
except exceptions.rest.HandlerError as e:
log.log_operation(handler, 500, types.log.LogLevel.ERROR)
return http.HttpResponseBadRequest(str(e), content_type="text/plain")
@@ -207,7 +232,7 @@ class Dispatcher(View):
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)
@@ -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(
@@ -264,8 +293,6 @@ class Dispatcher(View):
checker=checker,
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,21 +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
# 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
@@ -80,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
@@ -99,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)
@@ -121,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
@@ -136,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)
@@ -147,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:
"""
@@ -207,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:
@@ -232,6 +228,8 @@ class Handler(abc.ABC):
password: str,
locale: str,
platform: str,
is_admin: bool,
staff_member: bool,
scrambler: str,
) -> None:
"""
@@ -243,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,
@@ -254,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(
@@ -263,6 +264,8 @@ class Handler(abc.ABC):
password: str,
locale: str,
platform: str,
is_admin: bool,
staf_member: bool,
scrambler: str,
) -> str:
"""
@@ -282,6 +285,8 @@ class Handler(abc.ABC):
password,
locale,
platform,
is_admin,
staf_member,
scrambler,
)
session.save()
@@ -388,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]) -> dict[str, types.rest.api.PathItem]:
"""
Returns the API operations that should be registered
"""
return {}

View File

@@ -30,7 +30,6 @@
"""
@Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
import dataclasses
import datetime
import logging
import typing
@@ -40,93 +39,59 @@ from django.utils.translation import gettext_lazy as _
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()
)
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 = 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,7 +763,7 @@ 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)
@@ -772,7 +783,7 @@ class Ticket(ActorV3Action):
class Unmanaged(ActorV3Action):
NAME = 'unmanaged'
name = 'unmanaged'
def action(self) -> dict[str, typing.Any]:
"""
@@ -858,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)
@@ -867,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
@@ -876,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,84 +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}},
]
@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,
@@ -138,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=[
field = self.add_default_fields(
auth_instance.gui_description(),
['name', 'comments', 'tags', 'priority', 'small_name', 'networks'],
)
self.add_field(
field,
{
'name': 'state',
'value': consts.auth.VISIBLE,
'choices': [
{'id': consts.auth.VISIBLE, 'text': _('Visible')},
{'id': consts.auth.HIDDEN, 'text': _('Hidden')},
{'id': consts.auth.DISABLED, 'text': _('Disabled')},
],
label=gettext('Access'),
)
)
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()]
'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']
@@ -226,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']
@@ -258,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)
@@ -268,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
@@ -303,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,97 +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 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=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
@@ -140,7 +128,7 @@ 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'] = datetime.datetime.fromtimestamp(fields['start'])
@@ -157,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:
@@ -172,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,79 +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__)
# Enclosed methods under /item path
@dataclasses.dataclass
class CalendarItem(types.rest.BaseRestItem):
id: str
name: str
tags: list[str]
comments: str
modified: datetime.datetime
number_rules: int
number_access: int
number_actions: int
permission: types.permissions.PermissionType
class Calendars(ModelHandler[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}},
]
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(
@@ -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

@@ -34,11 +34,11 @@ import datetime
import logging
import typing
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
@@ -51,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(
@@ -85,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')
@@ -177,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,82 +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
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,35 +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}},
]
def get_item(self, item: 'models.Model') -> MetaPoolItem:
custom_methods = [('setFallbackAccess', True), ('getFallbackAccess', True)]
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
@@ -153,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)
@@ -273,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,104 +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()
)
@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,77 +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}},
]
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,64 +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}},
]
@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,70 +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()
)
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)
@@ -107,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,83 +50,67 @@ from .services_usage import ServicesUsage
logger = logging.getLogger(__name__)
if typing.TYPE_CHECKING:
from django.db.models import Model
# Helper class for Provider offers
@dataclasses.dataclass
class OfferItem(types.rest.BaseRestItem):
name: str
type: str
description: str
icon: str
class Providers(ModelHandler):
"""
Providers REST handler
"""
model = Provider
detail = {'services': DetailServices, 'usage': ServicesUsage}
@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
custom_methods = [('allservices', False), ('service', False), ('maintenance', True)]
save_fields = ['name', 'comments', 'tags']
class Providers(ModelHandler[ProviderItem]):
table_title = _('Service providers')
MODEL = Provider
DETAIL = {'services': DetailServices, 'usage': ServicesUsage}
CUSTOM_METHODS = [
types.rest.ModelCustomMethod('allservices', False),
types.rest.ModelCustomMethod('service', False),
types.rest.ModelCustomMethod('maintenance', True),
# 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()
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)
@@ -135,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 :)
"""
@@ -163,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

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,37 +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')
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:
@@ -99,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
@@ -107,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),
)
@@ -136,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)
@@ -154,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)
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

@@ -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,92 +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)
.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')}},
]
# table_title = _('Registered Servers')
# xtable_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'}},
# ]
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:
"""
@@ -123,129 +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']
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.MAC_UNKNOWN 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)
@@ -255,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,
@@ -281,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
@@ -305,19 +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:
@@ -329,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:
@@ -343,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'
@@ -420,83 +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()
)
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:
@@ -506,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:
@@ -535,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 +535,8 @@ class ServersGroups(ModelHandler[GroupItem]):
'hostname': s[1].hostname,
'mac': s[1].mac if s[1].mac != consts.MAC_UNKNOWN else '',
'ip': s[1].ip,
'load': s[0].load() if s[0] else 0,
'load': s[0].load(weights=item.weights) if s[0] else 0,
'weights': item.weights.as_dict(),
},
}
for s in ServerManager.manager().get_server_stats(item.servers.all())

View File

@@ -30,143 +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']
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:
"""
@@ -178,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
@@ -225,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)
@@ -257,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')
@@ -361,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,49 +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()
)
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']
@@ -86,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,36 +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()
)
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')
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),
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(
@@ -219,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)
@@ -240,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:
@@ -317,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)
@@ -490,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']
@@ -523,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(
@@ -537,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
@@ -601,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:
@@ -670,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(
@@ -684,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']),
@@ -695,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,7 +34,7 @@ import logging
import datetime
import typing
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
@@ -44,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]]]:
"""

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

@@ -40,7 +40,7 @@ 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,7 +89,7 @@ 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]:

View File

@@ -30,48 +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',
@@ -81,92 +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}},
]
@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'])
@@ -174,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,84 +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[TunnelServerItem]):
class TunnelServers(DetailHandler):
# tunnels/[id]/servers
CUSTOM_METHODS = ['maintenance']
custom_methods = ['maintenance']
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.MAC_UNKNOWN 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...
@@ -114,100 +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}},
]
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
@@ -225,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'
)
@@ -257,13 +292,9 @@ class Users(DetailHandler[UserItem]):
user.delete()
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))
@@ -284,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)
@@ -332,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)
@@ -480,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:
@@ -488,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)
@@ -497,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

@@ -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,41 +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]) -> 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)
raise self.invalid_method_response()

View File

@@ -38,9 +38,9 @@ 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 .base import BaseModelHandler
@@ -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 [consts.rest.LOG, for_type]:
return self.get_logs(parent, for_type)
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,17 +322,18 @@ 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
@@ -327,10 +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]]:
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
@@ -338,19 +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]) -> dict[str, types.rest.api.PathItem]:
"""
Returns the API operations that should be registered
"""
return {}
raise self.invalid_method_response()

View File

@@ -33,18 +33,16 @@ 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 .base import BaseModelHandler
@@ -56,9 +54,8 @@ if typing.TYPE_CHECKING:
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
@@ -75,23 +72,27 @@ 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
# 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
DETAIL: typing.ClassVar[typing.Optional[dict[str, type['DetailHandler[typing.Any]']]]] = (
detail: typing.ClassVar[typing.Optional[dict[str, type['DetailHandler']]]] = (
None # Dictionary containing detail routing
)
# Fields that are going to be saved directly
@@ -99,41 +100,62 @@ class ModelHandler(BaseModelHandler[types.rest.T_Item], abc.ABC):
# * 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(
def get_types(
self, *args: typing.Any, **kwargs: typing.Any
) -> typing.Generator[types.rest.TypeInfo, None, None]:
for type_ in self.possible_types():
yield type(self).as_typeinfo(type_)
) -> 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:
@@ -141,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:
@@ -173,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'):
@@ -187,74 +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,
@@ -264,7 +270,12 @@ 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))
@@ -284,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
@@ -295,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',
@@ -306,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:
"""
@@ -374,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:
"""
@@ -392,17 +414,17 @@ class ModelHandler(BaseModelHandler[types.rest.T_Item], abc.ABC):
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:
@@ -411,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
@@ -442,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:
@@ -458,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
@@ -483,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
@@ -501,45 +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]) -> dict[str, types.rest.api.PathItem]:
"""
Returns the API operations that should be registered
"""
# 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
return {
'': types.rest.api.PathItem(
get=types.rest.api.Operation(
summary=f'Get all {cls.MODEL.__name__} items',
description=f'Retrieve a list of all {cls.MODEL.__name__} items',
parameters=[],
responses={},
)
)
}

View File

@@ -40,7 +40,7 @@ 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,47 +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)
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(time.mktime(obj.timetuple()))
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 _:
return str(obj)
if isinstance(obj, (datetime.date,)): # Date as string
return '{}-{:02d}-{:02d}'.format(obj.year, obj.month, obj.day)
return str(obj)
class MarshallerProcessor(ContentProcessor):
@@ -183,10 +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

@@ -197,7 +197,7 @@ class RadiusClient:
if i.startswith(groupclass_prefix)
]
else:
logger.info('No "Class (25)" attribute found')
logger.info('No "Class (25)" attribute found: %s', reply)
return ([], '', b'')
# ...and mfa code

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,7 +657,10 @@ class SAMLAuthenticator(auths.Authenticator):
raise exceptions.auth.AuthenticatorException(gettext('Error processing SAML response: ') + str(e))
errors = typing.cast(list[str], auth.get_errors())
if errors:
raise exceptions.auth.AuthenticatorException('SAML response error: ' + str(errors))
logger.debug('Errors processing SAML response: %s (%s)', errors, auth.get_last_error_reason()) # pyright: ignore reportUnknownVariableType
logger.debug('post_data: %s', req['post_data'])
logger.info('Response XML: %s', auth.get_last_response_xml()) # pyright: ignore reportUnknownVariableType
raise exceptions.auth.AuthenticatorException(f'SAML response error: {errors} ({auth.get_last_error_reason()})')
if not auth.is_authenticated():
raise exceptions.auth.AuthenticatorException(gettext('SAML response not authenticated'))

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,13 +31,10 @@
Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
# pyright: reportUnusedImport=false
import enum
import time
import typing
from datetime import datetime
from django.utils.translation import gettext as _
from . import actor, auth, cache, calendar, images, net, os, system, ticket, rest, services, transports, ui
# Date related constants
@@ -77,60 +74,5 @@ UNLIMITED: typing.Final[int] = -1
# Constant marking no more names available
NO_MORE_NAMES: typing.Final[str] = 'NO-NAME-ERROR'
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

@@ -215,6 +215,9 @@ class Environment:
def __exit__(self, exc_type: typing.Any, exc_value: typing.Any, traceback: typing.Any) -> None:
if self._key == TEST_ENV or (self._key.startswith('#_#') and self._key.endswith('#^#')):
self.clean_related_data()
def __str__(self) -> str:
return f'Environment: {self._key}'
class Environmentable:

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

@@ -51,7 +51,7 @@ class NotificationsManager(metaclass=singleton.Singleton):
_initialized: bool = False
def _ensure_local_db_exists(self) -> bool:
def ensure_local_db_exists(self) -> bool:
if not apps.ready:
return False
@@ -85,7 +85,7 @@ class NotificationsManager(metaclass=singleton.Singleton):
from uds.models.notifications import Notification # pylint: disable=import-outside-toplevel
# Due to use of local db, we must ensure that it exists (and cannot do it on ready)
if self._ensure_local_db_exists() is False:
if self.ensure_local_db_exists() is False:
return # Not initialized apps yet, so we cannot do anything
# logger.debug(

View File

@@ -119,9 +119,9 @@ class ServerManager(metaclass=singleton.Singleton):
Returns:
An iterator of servers with activity in the last last_activity_delta time
"""
op = operator.gt if with_activity else operator.le
activity_limit = model_utils.sql_now() - last_activity_delta
# Get all servers with activity in the last 10 minutes
for server in server_group.servers.filter(maintenance_mode=False):
@@ -181,7 +181,7 @@ class ServerManager(metaclass=singleton.Singleton):
weight_threshold_f = weight_threshold / 100
def _real_weight(stats: 'types.servers.ServerStats') -> float:
stats_weight = stats.load()
stats_weight = stats.load(weights=server_group.weights)
if weight_threshold == 0:
return stats_weight
@@ -545,7 +545,12 @@ class ServerManager(metaclass=singleton.Singleton):
# Get the stats for all servers, but in parallel
server_stats = self.get_server_stats(fltrs)
# Sort by load, lower first (lower is better)
return [s[1] for s in sorted(server_stats, key=lambda x: x[0].load() if x[0] else 999999999)]
return [
s[1]
for s in sorted(
server_stats, key=lambda x: x[0].load(weights=server_group.weights) if x[0] else 999999999
)
]
def perform_maintenance(self, server_group: 'models.ServerGroup') -> None:
"""Realizes maintenance on server group

View File

@@ -489,7 +489,13 @@ class UserServiceManager(metaclass=singleton.Singleton):
operations_logger.info('Removing userservice %a', userservice.name)
if userservice.is_usable() is False and State.from_str(userservice.state).is_removable() is False:
if not forced:
raise OperationException(_('Can\'t remove a non active element') + ': ' + userservice.name + ', ' + userservice.state)
raise OperationException(
_('Can\'t remove a non active element')
+ ': '
+ userservice.name
+ ', '
+ userservice.state
)
userservice.set_state(State.REMOVING)
logger.debug("***** The state now is %s *****", State.from_str(userservice.state).localized)
userservice.set_in_use(False) # For accounting, ensure that it is not in use right now
@@ -772,6 +778,11 @@ class UserServiceManager(metaclass=singleton.Singleton):
logger.warning('Could not check readyness of %s: %s', user_service, e)
return False
if state == types.states.TaskState.ERROR:
user_service.update_data(userservice_instance)
user_service.set_state(State.ERROR)
raise InvalidServiceException('Service missing or in error state')
logger.debug('State: %s', state)
if state == types.states.TaskState.FINISHED:

View File

@@ -33,6 +33,7 @@ import time
import logging
import typing
from uds.core.managers.notifications import NotificationsManager
from uds.core.managers.task import BaseThread
from uds.models import Notifier, Notification
@@ -43,13 +44,12 @@ from .config import DO_NOT_REPEAT
logger = logging.getLogger(__name__)
# Note that this thread will be running on the scheduler process
class MessageProcessorThread(BaseThread):
_keep_running: bool = True
_cached_providers: typing.Optional[
list[tuple[int, NotificationProviderModule]]
]
_cached_providers: typing.Optional[list[tuple[int, NotificationProviderModule]]]
_cached_stamp: float
def __init__(self) -> None:
@@ -73,12 +73,14 @@ class MessageProcessorThread(BaseThread):
return self._cached_providers
def run(self) -> None:
while NotificationsManager.manager().ensure_local_db_exists() is False:
logger.info('Waiting for local notifications database to be ready...')
time.sleep(1)
while self._keep_running:
# Locate all notifications from "persistent" and try to process them
# If no notification can be fully resolved, it will be kept in the database
not_before = sql_now() - datetime.timedelta(
seconds=DO_NOT_REPEAT.as_int()
)
not_before = sql_now() - datetime.timedelta(seconds=DO_NOT_REPEAT.as_int())
for n in Notification.get_persistent_queryset().all():
# If there are any other notification simmilar to this on default db, skip it
# Simmilar means that group, identificator and message are already been logged less than DO_NOT_REPEAT seconds ago
@@ -119,7 +121,7 @@ class MessageProcessorThread(BaseThread):
# logger.warning(
# 'Could not save notification %s to main DB, trying notificators',
# n,
#)
# )
if notify:
for p in (i[1] for i in self.providers if i[0] >= n.level):

View File

@@ -566,6 +566,14 @@ class DynamicUserService(services.UserService, autoserializable.AutoSerializable
If you override this method, you should take care yourself of removing duplicated machines
(maybe only calling "super().op_initialize()" method)
"""
# By default, should return a VALID username and unique_id
# Note that valid is anything different from consts.NO_MORE_NAMES or consts.NO_MORE_MACS
if self.get_name() == consts.NO_MORE_NAMES:
self.error('No more names available') # Will mark as error and check will note it
return
if self.get_unique_id() == consts.NO_MORE_MACS:
self.error('No more MACs available') # Will mark as error and check will note it
return
self.remove_duplicates()
@abc.abstractmethod

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
@@ -200,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

@@ -30,7 +30,6 @@
Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
# pyright: reportUnusedImport=false
from . import rest
from . import (
auth,
calendar,
@@ -42,6 +41,7 @@ from . import (
permissions,
pools,
requests,
rest,
servers,
services,
states,

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

@@ -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,386 +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_uti # Avoid circular import
return api_uti.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__)
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
type: str
description: str
icon: str
group: typing.Optional[str] = None
extra: 'ExtraTypeInfo|None' = None
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,321 +0,0 @@
import typing
import dataclasses
from uds.core import exceptions
if typing.TYPE_CHECKING:
from uds.core.types import ui
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
# Info general
@dataclasses.dataclass
class Info:
title: str
version: str
description: str | None = None
def as_dict(self) -> dict[str, typing.Any]:
return as_dict_without_none(dataclasses.asdict(self))
# Parameter
@dataclasses.dataclass
class Parameter:
name: str
in_: str
required: bool
schema: dict[str, typing.Any]
description: str | None = None
style: str | None = None
explode: bool | None = None
name: str
in_: str # 'query', 'path', 'header', etc.
required: bool
schema: dict[str, typing.Any]
description: str | None = None
def as_dict(self) -> dict[str, typing.Any]:
return as_dict_without_none(dataclasses.asdict(self))
# Request body
@dataclasses.dataclass
class RequestBody:
required: bool
content: dict[str, typing.Any] # e.g. {'application/json': {'schema': {...}}}
def as_dict(self) -> dict[str, typing.Any]:
return as_dict_without_none(dataclasses.asdict(self))
# Response
@dataclasses.dataclass
class Response:
description: str
content: dict[str, typing.Any] | None = None
def as_dict(self) -> dict[str, typing.Any]:
return as_dict_without_none(dataclasses.asdict(self))
# 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])
tags: list[str] = dataclasses.field(default_factory=list[str])
def as_dict(self) -> dict[str, typing.Any]:
return 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,
}
)
# 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
@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'].get('tooltip', '')}'
return schema
def as_dict(self) -> dict[str, typing.Any]:
val = as_dict_without_none(dataclasses.asdict(self))
# 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 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']
return as_dict_without_none(val)
# Schema
@dataclasses.dataclass
class Schema:
type: str
properties: dict[str, SchemaProperty] = dataclasses.field(default_factory=dict[str, SchemaProperty])
required: list[str] = dataclasses.field(default_factory=list[str])
description: str | None = None
# For use on generating schemas
def as_dict(self) -> dict[str, typing.Any]:
return as_dict_without_none(
{
'type': self.type,
'properties': {k: v.as_dict() for k, v in self.properties.items()},
'required': self.required,
'description': self.description,
}
)
# Componentes
@dataclasses.dataclass
class Components:
schemas: dict[str, Schema] = dataclasses.field(default_factory=dict[str, Schema])
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()},
}
)
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}
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
# 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,171 +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 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 = [i.copy() for i in _STATIC_FLDS[self]]
# Special cases, as network choices are dynamic
if self.value == self.NETWORKS:
field_gui[0]['gui']['choices'] = sorted(
[{'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: [
{
'name': 'tags',
'gui': {
'label': _('Tags'),
'type': ui.FieldType.TAGLIST,
'tooltip': _('Tags for this element'),
'order': 0 - 110,
},
}
],
StockField.NAME: [
{
'name': 'name',
'gui': {
'type': ui.FieldType.TEXT,
'required': True,
'label': _('Name'),
'length': 128,
'tooltip': _('Name of this element'),
'order': 0 - 100,
},
}
],
StockField.COMMENTS: [
{
'name': 'comments',
'gui': {
'label': _('Comments'),
'type': ui.FieldType.TEXT,
'lines': 3,
'tooltip': _('Comments for this element'),
'length': 256,
'order': 0 - 90,
},
}
],
StockField.PRIORITY: [
{
'name': 'priority',
'gui': {
'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: [
{
'name': 'small_name',
'gui': {
'label': _('Label'),
'type': ui.FieldType.TEXT,
'required': True,
'length': 128,
'tooltip': _('Label for this element'),
'order': 0 - 70,
},
}
],
StockField.NETWORKS: [
{
'name': 'networks',
'gui': {
'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,
},
},
{
'name': 'net_filtering',
'gui': {
'label': _('Network Filtering'),
'type': ui.FieldType.CHOICE, # Type of network filtering
'default': 'n',
'choices': [
{'id': 'n', 'text': _('No filtering')},
{'id': 'a', 'text': _('Allow selected networks')},
{'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

@@ -137,6 +137,38 @@ class ServerDiskInfo:
}
@dataclasses.dataclass
class ServerStatsWeights:
cpu: float = 0.3
memory: float = 0.6
users: float = 0.1
max_users: int = 100 # Max users to consider in load calculation
def normalize(self) -> 'ServerStatsWeights':
total = self.cpu + self.memory + self.users
self.cpu /= total
self.memory /= total
self.users /= total
return self
def as_dict(self) -> dict[str, float]:
return {
'cpu': self.cpu,
'memory': self.memory,
'users': self.users,
'max_users': self.max_users,
}
@staticmethod
def from_dict(data: dict[str, float]) -> 'ServerStatsWeights':
return ServerStatsWeights(
data.get('cpu', 0.3),
data.get('memory', 0.6),
data.get('users', 0.1),
int(data.get('max_users', 100)),
).normalize()
@dataclasses.dataclass
class ServerStats:
memused: int = 0 # In bytes
@@ -165,21 +197,23 @@ class ServerStats:
return self.stamp > sql_stamp() - consts.cache.DEFAULT_CACHE_TIMEOUT
def load(self, min_memory: int = 0) -> float:
def load(self, *, min_memory: int = 0, weights: ServerStatsWeights | None = None) -> float:
# Loads are calculated as:
# 30% cpu usage
# 60% memory usage
# 10% current users, with a max of 1000 users
# 10% current users, with a max of 100 users
# Loads are normalized to 0-1
# Lower weight is better
weights = (weights or ServerStatsWeights()).normalize()
if self.memtotal - self.memused < min_memory:
return 1000000000 # At the end of the list
w = (
0.3 * self.cpuused
+ 0.6 * (self.memused / (self.memtotal or 1))
+ 0.1 * (min(1.0, self.current_users / 100.0))
weights.cpu * self.cpuused
+ weights.memory * (self.memused / (self.memtotal or 1))
+ weights.users * (min(1.0, self.current_users / weights.max_users))
)
return min(max(0.0, w), 1.0)

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':
@@ -170,31 +168,38 @@ class FieldInfo:
return {k: v for k, v in dataclasses.asdict(self).items() if v is not None}
class GuiDescription(typing.TypedDict):
"""
GuiDescription is a dictionary that describes a GUI element.
It contains the name of the element, the GUI description, and the value.
"""
label: str
order: int
type: FieldType
tooltip: typing.NotRequired[str]
readonly: typing.NotRequired[bool]
default: typing.NotRequired[str | int | float | bool]
required: typing.NotRequired[bool]
length: typing.NotRequired[int]
lines: typing.NotRequired[int]
pattern: typing.NotRequired[str]
tab: typing.NotRequired[str]
choices: typing.NotRequired[list[ChoiceItem]]
min_value: typing.NotRequired[int]
max_value: typing.NotRequired[int]
fills: typing.NotRequired[Filler]
rows: typing.NotRequired[int]
class GuiElement(typing.TypedDict):
name: str
value: typing.NotRequired[typing.Any]
gui: GuiDescription
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 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

@@ -325,7 +325,7 @@ class gui:
value=value,
tab=tab,
)
@property
def field_name(self) -> str:
"""
@@ -389,7 +389,7 @@ class gui:
"""
self._field_info.value = value
def gui_description(self) -> types.ui.GuiDescription:
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
@@ -400,17 +400,12 @@ class gui:
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
# Translate label and tooltip
data['label'] = gettext(data['label']) if data['label'] else ''
data['tooltip'] = gettext(data['tooltip']) if data['tooltip'] else ''
# And, if tab is set, translate it too
if 'tab' in data:
data['tab'] = gettext(data['tab']) # Translates tab name
data['default'] = self.default
return typing.cast(types.ui.GuiDescription, data)
data['default'] = self.default # We need to translate default value
return data
@property
def default(self) -> typing.Any:
@@ -804,7 +799,7 @@ class gui:
def value(self, value: datetime.date | str) -> None:
self._set_value(value)
def gui_description(self) -> types.ui.GuiDescription:
def gui_description(self) -> dict[str, typing.Any]:
fldgui = super().gui_description()
# Convert if needed value and default to string (YYYY-MM-DD)
if 'default' in fldgui:
@@ -1527,17 +1522,6 @@ class UserInterface(metaclass=UserInterfaceType):
time, and returned data will be probable a nonsense. We will take care
of this posibility in a near version...
"""
@classmethod
def describe_fields(cls: type[typing.Self]) -> list[types.ui.GuiElement]:
return [
{
'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:
"""
@@ -1669,13 +1653,10 @@ class UserInterface(metaclass=UserInterfaceType):
if internal_field_type not in FIELD_DECODERS:
logger.warning('Field %s has no decoder', field_name)
continue
if field_type != internal_field_type.name:
# Especial case for text fields converted to password fields
if not (
internal_field_type == types.ui.FieldType.PASSWORD
and field_type == types.ui.FieldType.TEXT.name
):
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,
@@ -1810,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,228 +0,0 @@
import typing
import itertools
import collections.abc
import logging
import dataclasses
import datetime
import enum
import types as py_types
from uds.core import types, module
if typing.TYPE_CHECKING:
from uds.REST import model
from uds.core.types.rest import api
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_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: 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, 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, 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__', [])],
)
],
)
),
)
)
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()
item_schema = next(iter(components.schemas.values()))
possible_types: collections.abc.Iterable[type['module.Module']] = []
if issubclass(base_type, types.rest.ManagedObjectItem):
# Managed object item class should provide types as it has "instance" field
possible_types = cls.possible_types()
else: # BaseRestItem, does not have types as it does not have "instance" field
pass
refs: list[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'].get('required', False):
type_schema.required.append(field['name'])
refs.append(f'#/components/schemas/{type_.type_type}')
components.schemas[type_.type_type] = type_schema
if issubclass(base_type, types.rest.ManagedObjectItem):
item_schema.properties['instance'] = types.rest.api.SchemaProperty(type=refs, discriminator='type')
# Store it
all_components = all_components.union(components)
return all_components
@dataclasses.dataclass(slots=True)
class OpenApiTypeInfo:
type: str
format: str | None = None
def as_dict(self) -> dict[str, typing.Any]:
dct = {'type': self.type}
if self.format:
dct['format'] = self.format
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')
_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,
}
def python_type_to_openapi(py_type: typing.Any) -> 'api.SchemaProperty':
"""
Convert a Python type to an OpenAPI 3.1 schema property.
"""
from uds.core.types.rest import api
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 api.SchemaProperty(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 api.SchemaProperty(type='object', additionalProperties=python_type_to_openapi(value_type))
# Union[...] → oneOf
elif origin in {py_types.UnionType, typing.Union}:
# Optional[X] is Union[X, None]
oa_types = [_OPENAPI_TYPE_MAP.get(arg, OpenApiType.OBJECT) for arg in args if isinstance(arg, type)]
return api.SchemaProperty(
type=[oa_type.value.type for oa_type in oa_types],
)
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 api.SchemaProperty(
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 api.SchemaProperty(type='integer')
# Now, StrEnum --> string
elif isinstance(py_type, type) and issubclass(py_type, enum.StrEnum):
return api.SchemaProperty(type='string')
# 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 api.SchemaProperty(type=openapi_type.value.type, enum=[e.value for e in py_type])
except StopIteration:
return api.SchemaProperty(type='string')
# Simple types
oa_type = _OPENAPI_TYPE_MAP.get(py_type, OpenApiType.OBJECT)
return api.SchemaProperty(type=oa_type.value.type, format=oa_type.value.format)
def api_components(dataclass: typing.Type[typing.Any]) -> 'api.Components':
from uds.core.util import api as api_uti # Avoid circular import
from uds.core.types.rest import api
# If not dataclass, raise a ValueError
if not dataclasses.is_dataclass(dataclass):
raise ValueError('Expected a dataclass')
components = api.Components()
schema = api.Schema(type='object', properties={}, description=None)
type_hints = typing.get_type_hints(dataclass)
for field in dataclasses.fields(dataclass):
# Check the type, can be a primitive or a complex type
# complexes types accepted are list and dict currently
field_type = type_hints.get(field.name)
if not field_type:
raise Exception(f'Field {field.name} has no type hint')
# If it is a dataclass, get its API components
if dataclasses.is_dataclass(field_type):
sub_component = api_uti.api_components(typing.cast(type[typing.Any], field_type))
components = components.union(sub_component)
schema_prop = api.SchemaProperty(type=f'#/components/schemas/{next(iter(sub_component.schemas.keys()))}', description=None)
else:
schema_prop = api_uti.python_type_to_openapi(field_type)
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

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

@@ -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
"""

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]]:
"""
@@ -371,28 +373,30 @@ def blocker(
try:
return f(*args, **kwargs)
except uds.core.exceptions.rest.BlockAccess:
raise exceptions.rest.AccessDenied
raise exceptions.rest.AccessDenied()
request: 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 request is None or not isinstance(request, types.requests.ExtendedHttpRequest):
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, request)
ip = request.ip
# if ip is blocked, raise exception
failures_count: int = mycache.get(ip, 0)
if failures_count >= max_failures:
raise exceptions.rest.AccessDenied
raise exceptions.rest.AccessDenied()
try:
result = f(*args, **kwargs)
except uds.core.exceptions.rest.BlockAccess:
# Increment
mycache.put(ip, failures_count + 1, GlobalConfig.LOGIN_BLOCK.as_int())
raise exceptions.rest.AccessDenied
raise exceptions.rest.AccessDenied()
# Any other exception will be raised
except Exception:
raise
@@ -408,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
@@ -448,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

@@ -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

@@ -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,28 +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 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
@@ -120,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:
@@ -129,7 +129,7 @@ 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(obj: typing.Any = None) -> str:
@@ -167,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

@@ -76,8 +76,8 @@ def get_urlpatterns_from_modules() -> list[typing.Any]:
# Append patters from mod
for up in urlpatterns:
patterns.append(up)
except Exception:
logger.error('No patterns found in %s', module_fullname)
except Exception as e:
logger.error('No patterns found in %s (%s)', module_fullname, e)
except Exception:
logger.exception('Processing dispatchers loading')

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

View File

@@ -1,379 +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 math
import typing
import re
import contextvars
import collections.abc
import logging
import lark
logger = logging.getLogger(__name__)
_QUERY_GRAMMAR: typing.Final[
str
] = r"""?start: expr
?expr: or_expr
?or_expr: and_expr
| or_expr "or" and_expr -> logical_or
?and_expr: not_expr
| and_expr "and" not_expr -> logical_and
?not_expr: comparison
| "not" not_expr -> unary_not
?comparison: value
| value OP value -> binary_expr
| "(" expr ")" -> paren_expr
value: field | ESCAPED_STRING | NUMBER | boolean | func_call
field: NAME
func_call: NAME "(" [ value ("," value)* ] ")"
boolean: "true" -> true
| "false" -> false
OP: "eq" | "gt" | "lt" | "ne" | "ge" | "le"
ESCAPED_STRING: /'[^']*'/ | /"[^"]*"/
NAME: CNAME ("." CNAME)*
%import common.CNAME
%import common.SIGNED_NUMBER -> NUMBER
%import common.WS
%ignore WS
"""
# with open("lark1.lark", "r") as f:
# _QUERY_GRAMMAR = f.read()
# The idea is that parser returns a function that can be used to filter a list of dictionaries
# So we ensure all returned functions have the same signature and can be composed together
# Note that value can receive function or final values, as it is composed of
# terminals and
_T_Result: typing.TypeAlias = collections.abc.Callable[[typing.Any], typing.Any]
T = typing.TypeVar('T')
_QUERY_PARSER_VAR: typing.Final[contextvars.ContextVar[lark.Lark]] = contextvars.ContextVar("query_parser")
_REMOVE_QUOTES_RE: typing.Final[typing.Pattern[str]] = re.compile(r"^(['\"])(.*)\1$")
_FUNCTIONS_PARAMS_NUM: dict[str, int] = {
# Variable parameters
'substring': -1,
'concat': -1,
# 2 parametes
'substringof': 2,
'contains': 2,
'startswith': 2,
'endswith': 2,
'indexof': 2,
# 1 parameter
'tolower': 1,
'toupper': 1,
'length': 1,
'year': 1,
'month': 1,
'day': 1,
'floor': 1,
'ceiling': 1,
'round': 1,
'trim': 1,
}
# The transformer basic type is a lambda that will be evaluated "on the fly" after generating the parse tree
# This allows for dynamic filtering based on the parsed query.
class QueryTransformer(lark.Transformer[typing.Any, _T_Result]):
@lark.visitors.v_args(inline=True) # pyright: ignore
def value(self, arg: lark.Token | str | int | float) -> _T_Result:
"""
Transforms a value token into a filtering function.
Args:
arg: The value token to transform.
Returns:
A filtering function that returns the value of the token.
"""
value: typing.Any = arg
if isinstance(arg, lark.Token):
match arg.type:
case 'ESCAPED_STRING':
match = _REMOVE_QUOTES_RE.match(arg.value)
if not match:
return arg.value
value = match.group(2)
case 'NUMBER':
value = float(arg.value) if '.' in arg.value else int(arg.value)
case 'BOOLEAN':
value = typing.cast(str, arg.value).lower() == 'true'
case _:
raise ValueError(f"Unexpected token type: {arg.type}")
elif isinstance(arg, typing.Callable):
return arg
return lambda _obj: value
@lark.visitors.v_args(inline=True)
def true(self) -> _T_Result:
"""
Transforms a true token into a filtering function.
"""
return lambda obj: True
@lark.visitors.v_args(inline=True)
def false(self) -> _T_Result:
"""
Transforms a false token into a filtering function.
"""
return lambda obj: False
@lark.visitors.v_args(inline=True)
def field(self, arg: lark.Token) -> _T_Result:
"""
Transforms a field token into a filtering function.
Args:
arg: The field token to transform.
Returns:
A filtering function that returns the value of the field from the input dictionary.
"""
def getter(obj: typing.Any) -> typing.Any:
if isinstance(obj, dict):
for part in arg.value.split('.'):
obj = typing.cast(dict[str, typing.Any], obj).get(part, {})
else:
try:
for part in arg.value.split('.'):
obj = getattr(obj, part)
except AttributeError: # Nonexisting fields simple maps to empty value
return ''
return typing.cast(typing.Any, obj)
return getter
@lark.visitors.v_args(inline=True)
def binary_expr(self, left: _T_Result, op: typing.Any, right: _T_Result) -> _T_Result:
"""
Transforms a binary expression (comparison) into a filtering function.
Args:
left: The left operand as a filtering function.
op: The comparison operator.
right: The right operand as a filtering function.
Returns:
A filtering function that applies the comparison operator to the operands.
"""
def _compare(val_left: str | int | float, val_right: str | int | float) -> int:
if type(val_left) != type(val_right):
val_left = str(val_left)
val_right = str(val_right)
if typing.cast(typing.Any, val_left) < typing.cast(typing.Any, val_right):
return -1
elif typing.cast(typing.Any, val_left) > typing.cast(typing.Any, val_right):
return 1
return 0
if op == "eq":
return lambda item: _compare(left(item), right(item)) == 0
elif op == "gt":
return lambda item: _compare(left(item), right(item)) > 0
elif op == "lt":
return lambda item: _compare(left(item), right(item)) < 0
elif op == "ne":
return lambda item: _compare(left(item), right(item)) != 0
elif op == "ge":
return lambda item: _compare(left(item), right(item)) >= 0
elif op == "le":
return lambda item: _compare(left(item), right(item)) <= 0
else:
raise ValueError(f"Unknown operator: {op}")
@lark.visitors.v_args(inline=True)
def logical_and(self, left: _T_Result, right: _T_Result) -> _T_Result:
"""
Transforms a logical AND expression into a filtering function.
Args:
left: The left operand as a filtering function.
right: The right operand as a filtering function.
Returns:
A filtering function that returns True if both operands are True.
"""
return lambda item: left(item) and right(item)
@lark.visitors.v_args(inline=True)
def logical_or(self, left: _T_Result, right: _T_Result) -> _T_Result:
"""
Transforms a logical OR expression into a filtering function.
Args:
left: The left operand as a filtering function.
right: The right operand as a filtering function.
Returns:
A filtering function that returns True if either operand is True.
"""
return lambda item: left(item) or right(item)
@lark.visitors.v_args(inline=True)
def unary_not(self, expr: _T_Result) -> _T_Result:
"""
Transforms a logical NOT expression into a filtering function.
Args:
expr: The operand as a filtering function.
Returns:
A filtering function that returns the negation of the operand.
"""
return lambda item: not expr(item)
@lark.visitors.v_args(inline=True)
def paren_expr(self, expr: _T_Result) -> _T_Result:
"""
Returns the filtering function for a parenthesized expression.
Args:
expr: The filtering function inside parentheses.
Returns:
The same filtering function.
"""
return expr
@lark.visitors.v_args(inline=True)
def func_call(self, func: lark.Token, *args: _T_Result) -> _T_Result:
"""
Transforms a function call into a filtering function.
Args:
func: The function name token.
*args: Arguments as filtering functions.
Returns:
A filtering function that applies the specified function to the arguments.
"""
func_name = func.value.lower()
# If unknown function, raise an error
if func_name not in _FUNCTIONS_PARAMS_NUM:
raise ValueError(f"Unknown function: {func.value}")
if len(args) != _FUNCTIONS_PARAMS_NUM[func_name] and _FUNCTIONS_PARAMS_NUM[func_name] != -1:
raise ValueError(
f"{func_name} function requires exactly {_FUNCTIONS_PARAMS_NUM[func_name]} arguments"
)
match func_name:
case 'substringof':
return lambda obj: str(args[1](obj)).find(str(args[0](obj))) != -1
case 'contains':
return lambda obj: str(args[0](obj)).find(str(args[1](obj))) != -1
case 'substring':
if len(args) == 2:
return lambda obj: str(args[0](obj))[int(args[1](obj)) :]
elif len(args) == 3:
return lambda obj: str(args[0](obj))[int(args[1](obj)) : int(args[2](obj))]
else:
raise ValueError(f"substring function requires 2 or 3 arguments")
case 'startswith':
return lambda obj: str(args[0](obj)).startswith(str(args[1](obj)))
case 'endswith':
return lambda obj: str(args[0](obj)).endswith(str(args[1](obj)))
case 'indexof':
return lambda obj: str(args[0](obj)).find(str(args[1](obj)))
case 'concat':
return lambda obj: ''.join(str(arg(obj)) for arg in args) if args else ''
case 'length':
return lambda obj: len(str(args[0](obj)))
case 'tolower':
return lambda obj: str(args[0](obj)).lower()
case 'toupper':
return lambda obj: str(args[0](obj)).upper()
case 'year':
return lambda obj: str(args[0](obj)).split('-')[0] if isinstance(args[0](obj), str) else ''
case 'month':
return lambda obj: str(args[0](obj)).split('-')[1] if isinstance(args[0](obj), str) else ''
case 'day':
return lambda obj: str(args[0](obj)).split('-')[2] if isinstance(args[0](obj), str) else ''
case 'trim':
return lambda obj: str(args[0](obj)).strip()
case 'floor':
return lambda obj: math.floor(args[0](obj))
case 'round':
return lambda obj: round(args[0](obj))
case 'ceiling':
return lambda obj: math.ceil(args[0](obj))
case _:
# Will never reach this, as it has been already
raise ValueError(f"Unknown function: {func.value}")
def get_parser() -> lark.Lark:
"""
Returns the query parser instance, creating it if necessary.
Returns:
lark.Lark: The query parser.
"""
try:
return _QUERY_PARSER_VAR.get()
except LookupError:
parser = lark.Lark(_QUERY_GRAMMAR, parser="lalr", transformer=QueryTransformer())
_QUERY_PARSER_VAR.set(parser)
return parser
def exec_query(query: str, data: collections.abc.Iterable[T]) -> collections.abc.Iterable[T]:
"""
Filters a list of dictionaries using a query string.
Args:
data: The list of dictionaries to filter.
query: The query string to apply.
Returns:
An iterable of dictionaries that match the query.
"""
try:
filter_func = typing.cast(_T_Result, get_parser().parse(query))
return filter(filter_func, data)
except lark.exceptions.LarkError as e:
raise ValueError(f"Error processing query: {e}") from None

View File

@@ -48,7 +48,7 @@ T = typing.TypeVar('T', bound=typing.Any)
# The callback will be called with the arguments in the order they are in the tuple, so:
# callback(sample, arg_2, argument)
# And the literals will be ignored
def match_args(
def match(
arg_list: collections.abc.Iterable[str],
error: collections.abc.Callable[..., typing.Any],
*args: tuple[tuple[str, ...], collections.abc.Callable[..., T]],
@@ -67,7 +67,7 @@ def match_args(
callback(sample, arg_2, argument)
And the literals will be ignored
"""
arg_list = [i for i in arg_list] # ensure it is a list
arg_list = list(arg_list) # ensure it is a list
for pattern, function in args:
if len(arg_list) != len(pattern):
continue
@@ -96,4 +96,3 @@ def match_args(
# Invoke error callback
error()
return None # In fact, error is expected to raise an exception, so this is never reached

View File

@@ -38,6 +38,7 @@ import ssl
import typing
import datetime
import certifi
import requests
import requests.adapters
import urllib3
@@ -101,34 +102,20 @@ def create_self_signed_cert(ip: str) -> tuple[str, str, str]:
)
def create_client_sslcontext(
verify: bool = True, ca_cert_file: str | None = None, ca_cert_data: str | None = None
) -> ssl.SSLContext:
def create_client_sslcontext(verify: bool = True) -> ssl.SSLContext:
"""
Creates a SSLContext for client connections.
Args:
verify: If True, the server certificate will be verified. (Default: True)
custom_cert: If provided, this will be used as the CA_BUNDLE file or directory with certificates of trusted CAs.
Returns:
A SSLContext object.
"""
ssl_context = ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH)
ssl_context = ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH, cafile=certifi.where())
if not verify:
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.VerifyMode.CERT_NONE
else:
if ca_cert_file:
# If custom_cert is provided, use it as the CA_BUNDLE file or directory with certificates of trusted CAs.
# This is the same as requests.Session.verify
ssl_context.load_verify_locations(cafile=ca_cert_file)
elif ca_cert_data:
# If custom_cert is provided, use it as the CA_BUNDLE file or directory with certificates of trusted CAs.
# This is the same as requests.Session.verify
ssl_context.load_verify_locations(cadata=ca_cert_data)
# Disable TLS1.0 and TLS1.1, SSLv2 and SSLv3 are disabled by default
# Next line is deprecated in Python 3.7
@@ -209,7 +196,7 @@ def secure_requests_session(*, verify: 'str|bool' = True, proxies: 'dict[str, st
# See urllib3.poolmanager.SSL_KEYWORDS for all available keys.
self._ssl_context = kwargs['ssl_context'] = create_client_sslcontext(verify=verify is True)
return super().init_poolmanager(*args, **kwargs) # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
return super().init_poolmanager(*args, **kwargs) # type: ignore
def cert_verify(self, conn: typing.Any, url: typing.Any, verify: 'str|bool', cert: typing.Any) -> None:
"""Verify a SSL certificate. This method should not be called from user
@@ -230,12 +217,12 @@ def secure_requests_session(*, verify: 'str|bool' = True, proxies: 'dict[str, st
# conn_kw = conn.__dict__['conn_kw']
# conn_kw['ssl_context'] = self.ssl_context
super().cert_verify(conn, url, verify, cert) # pyright: ignore[reportUnknownMemberType]
super().cert_verify(conn, url, verify, cert) # type: ignore
session = requests.Session()
session.mount("https://", UDSHTTPAdapter())
if proxies:
if proxies is not None:
session.proxies = proxies
# Add user agent header to session

View File

@@ -73,10 +73,10 @@ def _get_prov_serv_pool_ids(provider: 'Provider') -> tuple[int, ...]:
return res
TYPE_TO_ID_RETRIEVER: typing.Final[
dict[
_id_retriever: typing.Final[
collections.abc.Mapping[
type[Model],
dict[types.stats.CounterType, collections.abc.Callable[[typing.Any], typing.Any]],
collections.abc.Mapping[types.stats.CounterType, collections.abc.Callable[[typing.Any], typing.Any]],
]
] = {
Provider: {
@@ -102,8 +102,8 @@ TYPE_TO_ID_RETRIEVER: typing.Final[
},
}
VALID_MODEL_FOR_COUNTER_TYPE_DICT: typing.Final[
dict[types.stats.CounterType, tuple[type[Model], ...]]
_valid_model_for_counterype: typing.Final[
collections.abc.Mapping[types.stats.CounterType, tuple[type[Model], ...]]
] = {
types.stats.CounterType.LOAD: (Provider,),
types.stats.CounterType.STORAGE: (Service,),
@@ -115,7 +115,7 @@ VALID_MODEL_FOR_COUNTER_TYPE_DICT: typing.Final[
types.stats.CounterType.CACHED: (ServicePool,),
}
OBJ_TYPE_FROM_MODEL_DICT: typing.Final[dict[type[Model], types.stats.CounterOwnerType]] = {
_obj_type_from_model: typing.Final[collections.abc.Mapping[type[Model], types.stats.CounterOwnerType]] = {
ServicePool: types.stats.CounterOwnerType.SERVICEPOOL,
Service: types.stats.CounterOwnerType.SERVICE,
Provider: types.stats.CounterOwnerType.PROVIDER,
@@ -139,7 +139,7 @@ def add_counter(
note: Runtime checks are done so if we try to insert an unssuported stat, this won't be inserted and it will be logged
"""
type_ = type(obj)
if type_ not in VALID_MODEL_FOR_COUNTER_TYPE_DICT.get(counter_type, ()): # pylint: disable
if type_ not in _valid_model_for_counterype.get(counter_type, ()): # pylint: disable
logger.error(
'Type %s does not accepts counter of type %s',
type_,
@@ -149,7 +149,7 @@ def add_counter(
return False
return StatsManager.manager().add_counter(
OBJ_TYPE_FROM_MODEL_DICT[type(obj)], obj.id, counter_type, value, stamp
_obj_type_from_model[type(obj)], obj.id, counter_type, value, stamp
)
@@ -182,27 +182,27 @@ def enumerate_counters(
Returns:
A generator, that contains pairs of (stamp, value) tuples
"""
obj_type = type(obj)
type_ = type(obj)
type_to_id_dct = TYPE_TO_ID_RETRIEVER.get(obj_type)
read_fnc_tbl = _id_retriever.get(type_)
if not type_to_id_dct:
logger.error('Type %s has no registered stats', obj_type)
if not read_fnc_tbl:
logger.error('Type %s has no registered stats', type_)
return
id_retriever_fnc = type_to_id_dct.get(counter_type)
fnc = read_fnc_tbl.get(counter_type)
if not id_retriever_fnc:
logger.error('Type %s has no registerd stats of type %s', obj_type, counter_type)
if not fnc:
logger.error('Type %s has no registerd stats of type %s', type_, counter_type)
return
if not all:
owner_ids = id_retriever_fnc(obj) # pyright: ignore
owner_ids = fnc(obj) # pyright: ignore
else:
owner_ids = None
for i in StatsManager.manager().enumerate_counters(
OBJ_TYPE_FROM_MODEL_DICT[type(obj)],
_obj_type_from_model[type(obj)],
counter_type,
owner_ids,
since or consts.NEVER,
@@ -227,7 +227,7 @@ def enumerate_accumulated_counters(
infer_owner_type_from: typing.Optional[CounterClass] = None,
) -> typing.Generator[AccumStat, None, None]:
if not owner_type and infer_owner_type_from:
owner_type = OBJ_TYPE_FROM_MODEL_DICT[type(infer_owner_type_from)]
owner_type = _obj_type_from_model[type(infer_owner_type_from)]
yield from StatsManager.manager().get_accumulated_counters(
interval_type=interval_type,

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,

View File

@@ -1,535 +0,0 @@
# -*- coding: utf-8 -*-
#
# Copyright (c) 2012-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 typing
from uds.core import types
from django.utils.translation import gettext
class GuiBuilder:
fields: list[types.ui.GuiElement]
order: int = 0
saved_tab: types.ui.Tab | str | None = None
def __init__(
self,
order: int = 0,
) -> None:
"""
Initializes the GuiBuilder with a starting order.
"""
self.order = order
self.fields = []
def next(self) -> int:
"""
Returns the next value of the counter.
"""
val = self.order
self.order += 1
return val
def next_tab(self) -> None:
"""
returns the next value that is divisible by 10.
"""
self.order = (self.order // 10 + 1) * 10
def make_gui(
self,
name: str,
type: types.ui.FieldType,
*,
label: str | None = None,
tab: types.ui.Tab | str | None = None,
order: int | None = None,
length: int | None = None,
min_value: int | None = None,
max_value: int | None = None,
default: str | int | bool | None = None,
required: bool | None = None,
readonly: bool | None = None,
tooltip: str | None = None,
choices: list[types.ui.ChoiceItem] | None = None,
) -> types.ui.GuiElement:
"""
Adds common fields to the given GUI element.
"""
gui_desk: types.ui.GuiDescription = {
'type': type,
'label': label or '',
'order': self.next(),
}
tab = tab or self.saved_tab
if tab:
gui_desk['tab'] = tab
if order is not None:
gui_desk['order'] = order
if length is not None:
gui_desk['length'] = length
if min_value is not None:
gui_desk['min_value'] = min_value
if max_value is not None:
gui_desk['max_value'] = max_value
if default is not None:
gui_desk['default'] = default
if required is not None:
gui_desk['required'] = required
if readonly is not None:
gui_desk['readonly'] = readonly
if choices is not None:
gui_desk['choices'] = choices
gui_desk['tooltip'] = tooltip or ''
return {
'name': name,
'gui': gui_desk,
}
def new_tab(self, tab: types.ui.Tab | str | None = None) -> typing.Self:
"""
Resets the order counter to the next tab.
"""
self.saved_tab = tab
self.next_tab()
return self
def set_order(self, order: int) -> typing.Self:
"""
Resets the order counter to the given value.
"""
self.order = order
return self
def add_fields(self, fields: list[types.ui.GuiElement], *, parent: str | None = None) -> typing.Self:
"""
Adds a list of GUI elements to the GUI.
"""
# Copy fields, deep copy to ensure not modifying the original fields
fields = [field.copy() for field in fields]
for field in fields:
# Add "parent." to the name of each field if a parent is specified
if parent:
field['name'] = f"{parent}.{field['name']}"
field['gui']['order'] = self.next()
self.fields.extend(fields)
return self
def add_stock_field(self, field: types.rest.stock.StockField) -> typing.Self:
"""
Adds a stock field set to the GUI.
"""
def update_order(gui: types.ui.GuiElement) -> types.ui.GuiElement:
gui = gui.copy()
gui['gui']['order'] = self.next()
return gui
self.fields.extend([update_order(i) for i in field.get_fields()])
return self
def add_hidden(
self,
name: str,
*,
default: str | int | bool | None = None,
readonly: bool = False,
) -> typing.Self:
"""
Creates a hidden field with the given parameters.
"""
self.fields.append(
self.make_gui(
name,
types.ui.FieldType.HIDDEN,
default=default,
readonly=readonly,
)
)
return self
def add_info(
self,
name: str,
*,
default: str | int | bool | None = None,
readonly: bool = False,
) -> typing.Self:
"""
Creates an info field with the given parameters.
"""
self.fields.append(
self.make_gui(
name,
types.ui.FieldType.INFO,
default=default,
readonly=readonly,
)
)
return self
def add_text(
self,
name: str,
label: str,
*,
tooltip: str = '',
tab: types.ui.Tab | str | None = None,
default: str | None = None,
readonly: bool = False,
length: int | None = None,
required: bool | None = None,
) -> typing.Self:
"""
Creates a text field with the given parameters.
"""
self.fields.append(
self.make_gui(
name,
types.ui.FieldType.TEXT,
label=label,
tab=tab,
default=default or '',
readonly=readonly,
length=length,
required=required,
tooltip=tooltip,
)
)
return self
def add_numeric(
self,
name: str,
label: str,
*,
tooltip: str = '',
tab: types.ui.Tab | str | None = None,
default: int | None = None,
readonly: bool = False,
min_value: int | None = None,
max_value: int | None = None,
required: bool | None = None,
) -> typing.Self:
"""
Creates a numeric field with the given parameters.
"""
self.fields.append(
self.make_gui(
name,
types.ui.FieldType.NUMERIC,
label=label,
tab=tab,
default=default or 0,
readonly=readonly,
min_value=min_value,
max_value=max_value,
tooltip=tooltip,
required=required,
)
)
return self
def add_checkbox(
self,
name: str,
label: str,
*,
tooltip: str = '',
tab: types.ui.Tab | str | None = None,
default: bool | None = None,
readonly: bool = False,
required: bool | None = None,
) -> typing.Self:
"""
Creates a checkbox field with the given parameters.
"""
self.fields.append(
self.make_gui(
name,
types.ui.FieldType.CHECKBOX,
label=label,
tab=tab,
default=default or False,
tooltip=tooltip,
readonly=readonly,
required=required,
)
)
return self
def add_choice(
self,
name: str,
label: str,
choices: list[types.ui.ChoiceItem],
*,
tooltip: str = '',
tab: types.ui.Tab | str | None = None,
default: str | None = None,
readonly: bool = False,
required: bool | None = None,
) -> typing.Self:
"""
Creates a choice field with the given parameters.
"""
self.fields.append(
self.make_gui(
name,
types.ui.FieldType.CHOICE,
label=label,
choices=choices,
tab=tab,
default=default or (choices[0]['id'] if choices else None),
readonly=readonly,
tooltip=tooltip,
required=required,
)
)
return self
def add_multichoice(
self,
name: str,
label: str,
choices: list[types.ui.ChoiceItem],
*,
tooltip: str = '',
tab: types.ui.Tab | str | None = None,
default: str | None = None,
readonly: bool = False,
required: bool | None = None,
) -> typing.Self:
"""
Creates a multichoice field with the given parameters.
"""
self.fields.append(
self.make_gui(
name,
types.ui.FieldType.MULTICHOICE,
label=label,
choices=choices,
tab=tab,
tooltip=tooltip,
default=default,
readonly=readonly,
required=required,
)
)
return self
def add_image_choice(
self,
*,
name: str | None = None,
label: str | None = None,
choices: list[types.ui.ChoiceItem] | None = None,
tooltip: str | None = None,
tab: types.ui.Tab | str | None = None,
default: str | None = None,
readonly: bool = False,
required: bool | None = None,
) -> typing.Self:
"""
Creates an image choice field with the given parameters.
"""
from uds.core import ui
from uds.core.consts.images import DEFAULT_THUMB_BASE64
from uds.models import Image
name = name or 'image_id'
label = label or gettext('Associated Image')
if tooltip is None:
tooltip = gettext('Select an image')
if choices is None:
choices = [ui.gui.choice_image(v.uuid, v.name, v.thumb64) for v in Image.objects.all()]
# Prepend ui.gui.choice_image(-1, '--------', DEFAULT_THUMB_BASE64)
choices = [ui.gui.choice_image(-1, '--------', DEFAULT_THUMB_BASE64)] + ui.gui.sorted_choices(choices)
self.fields.append(
self.make_gui(
name,
types.ui.FieldType.IMAGECHOICE,
label=label,
choices=choices,
tab=tab,
default=default,
tooltip=tooltip,
readonly=readonly,
required=required,
)
)
return self
def build(self) -> list[types.ui.GuiElement]:
return self.fields
class TableBuilder:
"""
Builds a list of table fields for REST API responses.
"""
title: str
subtitle: str | None
fields: list[types.rest.TableField]
style_info: types.rest.RowStyleInfo
def __init__(self, title: str, subtitle: str | None = None) -> None:
# TODO: USe table_name on a later iteration of the code
self.title = title
self.subtitle = subtitle
self.fields = []
self.style_info = types.rest.RowStyleInfo.null()
def _add_field(
self,
name: str,
title: str,
type: types.rest.TableFieldType = types.rest.TableFieldType.ALPHANUMERIC,
visible: bool = True,
width: str | None = None,
dct: dict[typing.Any, typing.Any] | None = None,
) -> typing.Self:
"""
Adds a field to the table fields.
"""
self.fields.append(
types.rest.TableField(
name=name,
title=title,
type=type,
visible=visible,
width=width,
dct=dct, # Dictionary for dictionary fields, if applicable
)
)
return self
# For each field type, we can add a specific method
def text_column(self, name: str, title: str, visible: bool = True, width: str | None = None) -> typing.Self:
"""
Adds a string field to the table fields.
"""
return self._add_field(name, title, types.rest.TableFieldType.ALPHANUMERIC, visible, width)
def numeric_column(
self, name: str, title: str, visible: bool = True, width: str | None = None
) -> typing.Self:
"""
Adds a number field to the table fields.
"""
return self._add_field(name, title, types.rest.TableFieldType.NUMERIC, visible, width)
def boolean(self, name: str, title: str, visible: bool = True, width: str | None = None) -> typing.Self:
"""
Adds a boolean field to the table fields.
"""
return self._add_field(name, title, types.rest.TableFieldType.BOOLEAN, visible, width)
def datetime_column(
self, name: str, title: str, visible: bool = True, width: str | None = None
) -> typing.Self:
"""
Adds a datetime field to the table fields.
"""
return self._add_field(name, title, types.rest.TableFieldType.DATETIME, visible, width)
def datetime_sec(
self, name: str, title: str, visible: bool = True, width: str | None = None
) -> typing.Self:
"""
Adds a datetime with seconds field to the table fields.
"""
return self._add_field(name, title, types.rest.TableFieldType.DATETIMESEC, visible, width)
def date(self, name: str, title: str, visible: bool = True, width: str | None = None) -> typing.Self:
"""
Adds a date field to the table fields.
"""
return self._add_field(name, title, types.rest.TableFieldType.DATE, visible, width)
def time(self, name: str, title: str, visible: bool = True, width: str | None = None) -> typing.Self:
"""
Adds a time field to the table fields.
"""
return self._add_field(name, title, types.rest.TableFieldType.TIME, visible, width)
def icon(self, name: str, title: str, visible: bool = True, width: str | None = None) -> typing.Self:
"""
Adds an icon field to the table fields.
"""
return self._add_field(name, title, types.rest.TableFieldType.ICON, visible, width)
def dict_column(
self,
name: str,
title: str,
dct: dict[typing.Any, typing.Any],
visible: bool = True,
width: str | None = None,
) -> typing.Self:
"""
Adds a dictionary field to the table fields.
"""
return self._add_field(name, title, types.rest.TableFieldType.DICTIONARY, visible, width, dct=dct)
def image(self, name: str, title: str, visible: bool = True, width: str | None = None) -> typing.Self:
"""
Adds an image field to the table fields.
"""
return self._add_field(name, title, types.rest.TableFieldType.IMAGE, visible, width)
def row_style(self, prefix: str, field: str) -> typing.Self:
"""
Sets the row style for the table fields.
"""
self.style_info = types.rest.RowStyleInfo(prefix=prefix, field=field)
return self
def build(self) -> types.rest.TableInfo:
"""
Returns the table info for the table fields.
"""
return types.rest.TableInfo(
title=self.title,
fields=self.fields,
row_style=self.style_info,
subtitle=self.subtitle,
)

View File

@@ -111,6 +111,7 @@ class UniqueGenerator:
seq = range_start
if seq > range_end:
logger.error('No more ids available in range %s - %s', range_start, range_end)
return -1 # No ids free in range
# May ocurr on some circustance that a concurrency access gives same item twice, in this case, we

View File

@@ -33,6 +33,8 @@ Author: Adolfo Gómez, dkmaster at dkmon dot com
import logging
import re
from uds.core import consts
from .unique_id_generator import UniqueGenerator
logger = logging.getLogger(__name__)
@@ -48,8 +50,9 @@ class UniqueMacGenerator(UniqueGenerator):
return int(mac.replace(':', ''), 16)
def _to_mac_addr(self, seq: int) -> str:
if seq == -1: # No mor macs available
return '00:00:00:00:00:00'
if seq == -1: # No more macs available
logger.error('No more MAC addresses available')
return consts.NO_MORE_MACS
return re.sub(r"(..)", r"\1:", f'{seq:012X}')[:-1]
# Mac Generator rewrites the signature of parent class, so we need to redefine it here

View File

@@ -0,0 +1,93 @@
# -*- coding: utf-8 -*-
#
# Copyright (c) 2012-2019 Virtual Cable S.L.
# 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 logging
import typing
from django.core.management.base import BaseCommand
from uds import models
from uds.core.util import unique_mac_generator
logger = logging.getLogger(__name__)
MIN_VERBOSITY: typing.Final[int] = 1 # Minimum verbosity to print freed macs
class Command(BaseCommand):
help = "Execute maintenance tasks for UDS broker"
def clean_unused_service_macs(self, service: models.Service) -> int:
# Get all userservices from this service, extract their "unique_id" (the mac)
# And store it in a set for later use
self.stdout.write(f'Cleaning unused macs for service {service.name} (id: {service.id})\n')
def mac_to_int(mac: str) -> int:
try:
return int(mac.replace(':', ''), 16)
except Exception:
return -1
mac_gen = unique_mac_generator.UniqueMacGenerator(f't-service-{service.id}')
used_macs = {
mac_to_int(us.unique_id) for us in models.UserService.objects.filter(deployed_service__service=service)
}
counter = 0
for seq in (
models.UniqueId.objects.filter(basename='\tmac', assigned=True, owner=f't-service-{service.id}')
.exclude(seq__in=used_macs)
.values_list('seq', flat=True)
):
counter += 1
self.stdout.write(f'Freeing mac {mac_gen._to_mac_addr(seq)} for service {service.name}\n')
mac_gen.free(mac_gen._to_mac_addr(seq))
self.stdout.write(f'Freed {counter} macs for service {service.name}\n')
logger.info('Freed %d macs for service %s', counter, service.name)
return counter
def handle(self, *args: typing.Any, **options: typing.Any) -> None:
logger.debug('Maintenance called with args: %s, options: %s', args, options)
counter = 0
for service in models.Service.objects.all():
try:
counter += self.clean_unused_service_macs(service)
except Exception as e:
logger.error('Error doing maintenance for service %s: %s', service.name, e)
self.stdout.write(f'Error doing maintenance for service {service.name}: {e}\n')
logger.info('Maintenance finished, total freed macs: %d', counter)
self.stdout.write(f'Total freed macs: {counter}\n')

View File

@@ -130,7 +130,7 @@ class Command(BaseCommand):
'--max-items',
action='store',
dest='maxitems',
default=400,
default=200,
help='Maximum elements exported for groups and user services',
)
@@ -166,7 +166,15 @@ class Command(BaseCommand):
fltr = servicepool.userServices.all()
if not options['alluserservices']:
fltr = fltr.filter(state=types.states.State.ERROR)
for item in fltr[:max_items]: # at most max_items items
fltr_list = list(fltr)[:max_items]
if len(fltr_list) < max_items:
# Append rest of userservices, if there is space
fltr_list += list(
servicepool.userServices.exclude(
pk__in=[u.pk for u in fltr_list]
)[: max_items - len(fltr_list)]
)
for item in fltr_list[:max_items]: # at most max_items items
logs = [
f'{l["date"]}: {types.log.LogLevel.from_int(l["level"])} [{l["source"]}] - {l["message"]}'
for l in log.get_logs(item)
@@ -241,8 +249,22 @@ class Command(BaseCommand):
'_': get_serialized_from_managed_object(provider),
'services': services,
}
tree[counter('PROVIDERS')] = providers
# Get server groups
server_groups: dict[str, typing.Any] = {}
for server_group in models.ServerGroup.objects.all():
servers: dict[str, typing.Any] = {}
for server in server_group.servers.all()[:max_items]: # at most max_items items
servers[server.hostname] = get_serialized_from_model(server, exclude_uuid=False)
server_groups[server_group.name] = {
'_': get_serialized_from_model(server_group, exclude_uuid=False),
'servers': servers,
}
tree[counter('SERVICES')] = {
'providers': providers,
'server_groups': server_groups
}
# authenticators
authenticators: dict[str, typing.Any] = {}
@@ -380,12 +402,12 @@ class Command(BaseCommand):
tree[counter('CONFIG')] = cfg
# Last 7 days of logs
# Last 7 days of logs or 500 entries, whichever is less
logs = [
get_serialized_from_model(log_entry)
for log_entry in models.Log.objects.filter(
created__gt=now - datetime.timedelta(days=7)
).order_by('-created')
).order_by('-created')[:500]
]
# Cluster nodes
cluster_nodes: list[dict[str, str]] = [node.as_dict() for node in cluster.enumerate_cluster_nodes()]

View File

@@ -81,7 +81,7 @@ class RadiusOTP(mfas.MFA):
tooltip=_('Radius authentication port (usually 1812)'),
required=True,
)
secret = gui.TextField(
secret = gui.PasswordField(
length=64,
label=_('Secret'),
order=3,

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