1
0
mirror of https://github.com/dkmstr/openuds.git synced 2025-10-05 07:33:41 +03:00

238 Commits

Author SHA1 Message Date
Adolfo Gómez García
386ad0001d Enhance OData support: implement filter_queryset and filter_data methods for improved data handling, update relevant methods to utilize these new filters across multiple handlers. 2025-08-19 00:49:48 +02:00
Adolfo Gómez García
e437de3855 Fix: update CryptoManager instantiation to use manager method for symmetric encryption in weblogin function 2025-08-18 17:58:39 +02:00
Adolfo Gómez García
7ffc781fca Refactor OData handling: remove filter_dict_by_keys utility, implement select_filter method in ODataParams class, and update ContentProcessor to utilize OData parameters for rendering. 2025-08-14 23:43:29 +02:00
Adolfo Gómez García
40387b03cb Enhance OData support: add ODataParams class for query parameter handling, implement filtering and pagination in ModelHandler, and introduce filter_dict_by_keys utility function. 2025-08-14 04:44:41 +02:00
Adolfo Gómez García
4e7c990340 Enhance query functionality: add support for substring, trim, floor, ceiling, and round functions; improve tests for query execution with new function cases. 2025-08-14 02:54:08 +02:00
Adolfo Gómez García
768d16b4b4 Enhance query functionality: support variable argument count for concat function, improve error handling in query execution, and add tests for concat functionality in query filters. 2025-08-14 01:06:40 +02:00
Adolfo Gómez García
5f3c6fd868 Enhance query execution functionality: add support for unary functions, improve field handling, and update tests for new query capabilities. 2025-08-14 00:11:59 +02:00
Adolfo Gómez García
9eb4c029df Refactor query filter and database query handling: update function signatures, enhance query parsing logic, and improve test coverage for query execution. 2025-08-13 22:19:55 +02:00
Adolfo Gómez García
4d6f7c1b67 Fix copyright year in query_filter.py and add detailed docstrings for filtering functions in QueryTransformer class; enhance test_query_filter.py with comprehensive test cases for indexof function. 2025-08-13 07:38:04 +02:00
Adolfo Gómez García
bc69075f6a Enhance query filter tests: rename indexof_function test for clarity and add case-insensitive variant to improve coverage. 2025-08-13 07:32:13 +02:00
Adolfo Gómez García
355362956f Refactor query filter grammar and update test cases: enhance query parsing logic, add support for new functions, and improve test descriptions for clarity. 2025-08-13 07:25:46 +02:00
Adolfo Gómez García
2454af7ec1 Refactor REST API: update path handling in Dispatcher, enhance API paths in handlers, and improve query filter functionality with comprehensive tests. 2025-08-13 01:40:29 +02:00
Adolfo Gómez García
4210fbe009 Refactor table handling: rename Table to TableInfo across multiple modules for consistency and clarity in type usage. 2025-08-12 02:01:17 +02:00
Adolfo Gómez García
fffd3621ad Refactor API component method names: rename api_component to api_components for consistency across handlers and models; add common_components method to BaseModelHandler for shared component retrieval logic. 2025-08-12 01:49:42 +02:00
Adolfo Gómez García
df057662d5 Refactor MFA handling: rename methods for clarity, update type checks, and enhance consistency across various modules. 2025-08-12 00:39:30 +02:00
Adolfo Gómez García
7ee2be5336 Refactor type handling in REST methods: update enum_types to class methods, enhance type_info usage, and improve OpenAPI documentation. 2025-08-11 19:37:05 +02:00
Adolfo Gómez García
04ae83ce21 Refactor REST API structure: remove unused documentation module, streamline handler help paths, and enhance OpenAPI type handling in utility functions. 2025-08-11 18:46:59 +02:00
Adolfo Gómez García
2a0f4fb2ba Refactor API components and handlers: rename methods for consistency, enhance component retrieval logic, and improve type handling across various modules. 2025-08-10 22:33:59 +02:00
Adolfo Gómez García
1b3e6261fb Refactor GUI field handling: update add_fields method to accept an optional parent parameter for hierarchical naming; enhance field copying and ordering logic. Updated admin to support instance fields (must migrate everything...) 2025-08-10 07:44:32 +02:00
Adolfo Gómez García
2c17f72695 Refactor imports and type hints across multiple modules
- Removed unnecessary conditional imports for Django models.
- Updated type hints from 'Model' to 'models.Model' for better clarity and consistency.
- Added missing imports for 'models' in various files to ensure proper type checking.
- Cleaned up import statements by consolidating related imports.
- Enhanced readability and maintainability of the codebase by standardizing import styles.
2025-08-10 07:13:04 +02:00
Adolfo Gómez García
07676fa0f3 Refactor model type hints and clean up unused code: update type hints for models in accounts and authenticators; remove commented-out exception helpers in base model; enhance OpenAPI type mapping in API; add test cases for managed object schemas. 2025-08-10 02:08:38 +02:00
Adolfo Gómez García
be3175be57 Refactor transport protocol attributes: rename 'protocol' to 'PROTOCOL' for consistency across transport classes; update related connection data handling 2025-08-09 23:13:56 +02:00
Adolfo Gómez García
fa2e045275 Refactor API component methods: rename enum_api_components to api_component for consistency; update related tests and API handling 2025-08-09 18:00:28 +02:00
Adolfo Gómez García
42fb3d42d8 Refactor API schema handling: rename methods for clarity, enhance type mapping, and add comprehensive tests for type conversions 2025-08-09 16:32:51 +02:00
Adolfo Gómez García
f4170ab861 Refactor Dispatcher to use root_node instead of base_handler_node for consistency; update related documentation and model handling; add new API generation tests. 2025-08-09 05:43:47 +02:00
Adolfo Gómez García
95610d4bca Merge remote-tracking branch 'origin/v4.0' 2025-08-08 18:48:10 +02:00
Adolfo Gómez García
12394c873c Rename force_new parameter to for_unique_id in multiple services for clarity and consistency
Fixed NutanixPrism getting the mac at the end of the generation
2025-08-08 18:27:30 +02:00
Adolfo Gómez García
07d03bacc8 Enhance API schema handling and improve field description methods 2025-08-08 04:32:58 +02:00
Adolfo Gómez García
15f6e889c7 Refactor REST API error handling and improve exception management
- Introduced new exception classes for better error categorization: NotFound, AccessDenied, RequestError, ResponseError, ValidationError, and InvalidMethodError.
- Updated existing REST methods to raise these new exceptions instead of generic responses, enhancing clarity and maintainability.
- Improved logging for error scenarios to provide more context during failures.
- Refactored methods across various modules (services, services_usage, transports, tunnels_management, user_services, users_groups, etc.) to utilize the new exception handling structure.
- Added OpenAPI data structures to define API specifications, including Info, Parameter, RequestBody, Response, Operation, PathItem, Schema, and Components.
- Ensured consistent error messages and response formats across the API for better client-side handling.
2025-08-08 03:56:31 +02:00
Adolfo Gómez García
5904a9c9c5 Add timeit context manager for measuring code execution time 2025-08-07 18:17:55 +02:00
Adolfo Gómez García
c73ddb37c5 Fix key name in groups tableinfo assertion for consistency 2025-08-07 06:20:49 +02:00
Adolfo Gómez García
70954a18fe Merge remote-tracking branch 'origin/v4.0' 2025-08-07 05:43:15 +02:00
Adolfo Gómez García
6ecefbabe8 Add checks for VM state and delete_vm calls in TestXenPublication and TestProxmoxPublication 2025-08-07 05:42:56 +02:00
Adolfo Gómez García
2c11d29974 Merge remote-tracking branch 'origin/v4.0' 2025-08-07 05:37:46 +02:00
Adolfo Gómez García
74908f186c Rename domain_allowed parameter to allow_domain in validate_hostname function for consistency 2025-08-07 05:37:07 +02:00
Adolfo Gómez García
01fab78ce0 Fixing up the new REST typing model 2025-08-06 18:51:35 +02:00
Adolfo Gómez García
bb81841340 roll back cache.put to cache.set, until nutanix is fully finished 2025-08-05 23:01:26 +02:00
Adolfo Gómez García
4d4d6aee6c Refactor cache usage from put() to set() across multiple modules for consistency and to address deprecation warnings 2025-08-05 22:59:41 +02:00
Adolfo Gómez García
10d2929cf4 Enhance logging configuration and add context manager for timing code execution
- Update pytest.ini to include log format and date format settings
- Rename 'new_func' to 'connect_and_execute' in ensure_connected decorator for clarity
- Modify generate_uuid function to use CryptoManager.manager() instead of CryptoManager()
- Add context manager 'timeit' for measuring execution time in helpers.py
2025-08-03 04:49:54 +02:00
Adolfo Gómez García
f8358837cd Merge remote-tracking branch 'origin/v4.0' 2025-07-31 17:59:59 +02:00
Adolfo Gómez García
102b36d090 Rename new_func to connect_and_execute for improved clarity in ensure_connected decorator 2025-07-31 17:24:11 +02:00
Adolfo Gómez García
52f8a91f75 Add docstring to validate_hostname function for improved clarity and usage 2025-07-31 17:01:30 +02:00
Adolfo Gómez García
738081bf7d Merge remote-tracking branch 'origin/v4.0' 2025-07-31 16:55:40 +02:00
Adolfo Gómez García
ad9a0c3f11 Improve logging for future job execution warnings with detailed timestamps 2025-07-31 16:54:38 +02:00
Adolfo Gómez García
1e6cca1c37 Rename item_as_dict_overview methods to get_item_summary for clarity 2025-07-31 04:04:02 +02:00
Adolfo Gómez García
7a55d284b7 Remove unused item processing logic in BaseRestItem.as_dict method 2025-07-31 04:02:07 +02:00
Adolfo Gómez García
5437cfe6de Heavy refactoring again to convert all return types to an structured inheritance scheme based on dacaclasses 2025-07-31 03:47:59 +02:00
Adolfo Gómez García
e4aff9d1e5 Add user validation in Dispatcher and fix documentation string formatting 2025-07-31 01:12:10 +02:00
Adolfo Gómez García
af3c309917 Refactor REST handler attributes and clean up unused code
- Changed attribute names from `name` to `NAME` and `path` to `PATH` in various REST handler classes for consistency.
- Removed commented-out code related to table titles and fields in multiple methods.
- Introduced a new `actor.py` file to define `NotifyActionType` enum for better organization.
- Updated references to help paths and help text in the `Stats` and `System` classes.
- Cleaned up the `Permissions` class by removing old commented-out code.
- Adjusted the `ServersManagement` and `ServicesPoolGroups` classes to use the new attribute naming convention.
2025-07-30 20:01:29 +02:00
Adolfo Gómez García
c0cdb26c48 Strong refactoring to finish TABLE builder and renaming const ClassVars to uppercase 2025-07-30 19:31:27 +02:00
Adolfo Gómez García
c5299378cf Refactor AssignedService and CachedService classes to improve field handling and structure 2025-07-30 16:19:56 +02:00
Adolfo Gómez García
fab66744ad Hide 'tags' field in ServicesPools representation 2025-07-30 16:07:22 +02:00
Adolfo Gómez García
968fca260f Refactor table representation in REST methods
- Updated various REST methods to replace `table_title` and `table_fields` with a unified `table_info` structure.
- Enhanced the `table_info` to encapsulate title, fields, and row styles in a single object, improving code clarity and maintainability.
- Adjusted the `DetailHandler` and `ModelHandler` classes to accommodate the new `table_info` structure.
- Removed deprecated methods related to table title and fields, streamlining the codebase.
- Ensured backward compatibility by maintaining the existing functionality while transitioning to the new structure.
2025-07-30 01:38:14 +02:00
Adolfo Gómez García
088ab0cdc1 Remove unused translation for "No time mark" and update script resource links in admin index.html 2025-07-30 01:38:04 +02:00
Adolfo Gómez García
10b0978fe7 Refactor CalendarRules and Calendars to utilize TableBuilder for improved structure and consistency 2025-07-30 01:37:42 +02:00
Adolfo Gómez García
e8de389830 Refactor Accounts and AccountsUsage to utilize TableBuilder and TableInfo for improved structure and consistency 2025-07-30 01:37:30 +02:00
Adolfo Gómez García
fdcebc4821 Add literals_dict method to FrequencyInfo and improve type casting in CalendarRule 2025-07-30 01:37:15 +02:00
Adolfo Gómez García
26b963374d Refactor TableBuilder to use RowStyleInfo from types.rest and improve boolean field handling 2025-07-30 01:37:01 +02:00
Adolfo Gómez García
971e51ff27 Refactor table_fields to use TableBuilder for consistency across multiple modules 2025-07-29 21:59:54 +02:00
Adolfo Gómez García
df098ddbc1 Refactor table_fields in Notifiers and Providers to use TableFieldsBuilder for improved structure and readability 2025-07-29 21:52:00 +02:00
Adolfo Gómez García
8231855bd0 Adding table_fields builder and updating all rest code to honor it 2025-07-29 21:48:01 +02:00
Adolfo Gómez García
378fecab10 Refactor type hinting in BaseModelHandler methods for improved clarity and consistency 2025-07-29 03:31:58 +02:00
Adolfo Gómez García
73fb7d831c Refactor UserService and Transport classes to improve type handling and remove unused code 2025-07-29 03:24:38 +02:00
Adolfo Gómez García
549f2cc769 Refactor type handling to use TypeInfo objects instead of dictionaries across multiple modules 2025-07-29 03:17:08 +02:00
Adolfo Gómez García
6950370c96 Refactor table description methods to return TableInfo objects instead of dicts 2025-07-29 02:33:13 +02:00
Adolfo Gómez García
53f95c6733 Refactor REST API item classes and access methods
- Changed various item classes from ItemDictType to BaseRestItem for consistency.
- Updated ManagedObjectDictType to ManagedObjectItem.
- Replaced ensure_has_access method calls with check_access for improved clarity.
- Refactored GUI composition methods to utilize the new GuiBuilder utility.
- Adjusted return types in get_items methods to use ItemsResult for better type safety.
- Removed deprecated field handling in BaseModelHandler.
- Enhanced type annotations across multiple modules for better type checking.
2025-07-29 01:44:10 +02:00
Adolfo Gómez García
a265dfb754 Update translations and HTML template for admin interface
- Added new translation strings for various UI elements and error messages in translations-fakejs.js.
- Removed duplicate and unnecessary translation entries to streamline the code.
- Updated the integrity and timestamp for module preload links in the index.html file to ensure the latest resources are loaded.
2025-07-28 22:27:47 +02:00
Adolfo Gómez García
162ae349af Add support for uploading svg images to UDS 2025-07-28 22:21:48 +02:00
Adolfo Gómez García
e0efa0a79c Refactor catched decorator to use a more generic return type 2025-07-28 17:13:46 +02:00
Adolfo Gómez García
20c923dad9 Enhance UI utility functions by adding tooltip support and refactoring GUI element creation 2025-07-28 16:56:54 +02:00
Adolfo Gómez
679fd6dd49 Update telegram.py 2025-07-27 14:22:10 +02:00
Adolfo Gómez
353288971d Update jobs.py
added header for license
2025-07-27 14:21:47 +02:00
Adolfo Gómez
95b2b2eb4e Update telegram.py
Added license header (BSD3)
2025-07-27 14:21:02 +02:00
Adolfo Gómez García
ac231f80eb Refactor get_gui method to improve clarity and utilize ui_utils for image choices 2025-07-26 23:56:23 +02:00
Adolfo Gómez García
942c3a63fe Refactor REST methods and UI components
- Introduced ServicesUsageItem class to encapsulate service usage details.
- Updated ServicesUsage to utilize ServicesUsageItem for type safety.
- Enhanced Transports and TunnelServers classes with improved GUI handling using StockField.
- Added StockField and related fields for consistent UI element definitions.
- Refactored get_gui methods to return structured GUI elements.
- Improved type hints across various methods for better clarity and type safety.
- Added utility functions for creating common UI fields.
- Updated documentation and comments for clarity and maintainability.
2025-07-26 23:52:15 +02:00
Adolfo Gómez García
ce9110b7ca Refactor GUI field handling to replace add_default_fields with default_fields for consistency 2025-07-26 02:07:58 +02:00
Adolfo Gómez García
19f7cda26c Refactor exception handling in Users class to improve clarity 2025-07-26 01:27:54 +02:00
Adolfo Gómez García
be6cfb0ec5 Refactor REST model handlers to enhance type safety and consistency
- Updated `AuthenticatorItem`, `NetworkItem`, `NotifierItem`, `AccessCalendarItem`, `ActionCalendarItem`, `OsManagerItem`, `ProviderItem`, `ReportItem`, `TokenItem`, `ServerItem`, `GroupItem`, `ServiceItem`, `ServicePoolItem`, and `TransportItem` to inherit from `ManagedObjectDictType` for improved type safety.
- Refactored `BaseModelHandler` and `ModelHandler` to use generic types for better type inference.
- Modified `DetailHandler` to extend `BaseModelHandler` with generics.
- Adjusted return types in various methods to utilize the new item types, ensuring consistent type usage across the REST API.
- Introduced `ManagedObjectDictType` to represent managed objects in the REST API, including fields for type and instance.
2025-07-26 01:21:06 +02:00
Adolfo Gómez García
d74e6daed2 Refactor model handlers to use generic ItemDictType for improved type safety and consistency 2025-07-25 22:59:33 +02:00
Adolfo Gómez García
6922d28537 Refactor REST methods to use GetItemsResult type and improve type safety
- Updated return types of get_items methods in various REST handlers to use types.rest.GetItemsResult instead of types.rest.ManyItemsDictType for better clarity and type safety.
- Introduced UserServiceItem, GroupItem, TransportItem, PublicationItem, and ChangelogItem TypedDicts to standardize item representations across different handlers.
- Refactored AssignedService to AssignedUserService and updated related methods to reflect the new naming convention.
- Enhanced as_typed_dict utility function to convert models to TypedDicts, improving type safety in REST API responses.
- Cleaned up imports and ensured consistent use of typing annotations throughout the codebase.
2025-07-25 21:33:28 +02:00
Adolfo Gómez García
3d9bc55b1d Refactor return types in AssignedService and related classes to use ItemDictType for improved type safety 2025-07-25 15:50:54 +02:00
Adolfo Gómez García
e251c69599 Merge remote-tracking branch 'origin/v4.0' 2025-07-24 23:19:01 +02:00
Adolfo Gómez García
6c315ef8a8 Add persistent database configuration for SQLite with connection options 2025-07-23 21:04:03 +02:00
Adolfo Gómez García
6f192a8f7c Improve error logging in process function to include stack trace 2025-07-23 20:13:21 +02:00
Adolfo Gómez García
890f00b111 Refactor logging in process_log function for improved readability 2025-07-23 20:12:53 +02:00
Adolfo Gómez García
3e67e3ba30 Add MAC address display to ServersServers detail response 2025-07-23 18:09:35 +02:00
Adolfo Gómez García
e5ea6d6a33 Update RDP security handling to use TLS when no credentials are provided 2025-07-22 17:05:14 +02:00
Adolfo Gómez García
3009ae601a Refactor RDP security handling to use no_credentials flag instead of force_rdp_security for improved clarity 2025-07-22 16:59:36 +02:00
Adolfo Gómez García
ed2e3c557d Enhance UDSClusterNode representation with as_dict method and update tree command to use dictionary format for cluster nodes 2025-07-22 16:02:54 +02:00
Adolfo Gómez García
21f156a930 Merge remote-tracking branch 'origin/v4.0' 2025-07-21 21:58:31 +02:00
Adolfo Gómez García
7ca96e4262 Enhance get_serialized_from_managed_object function with callback support and update system tree structure to include connectivity details, scheduled jobs, and delayed tasks 2025-07-21 21:54:16 +02:00
Adolfo Gómez García
46546a837e Add network interface utilities and update SystemInformation job frequency 2025-07-21 19:38:27 +02:00
Adolfo Gómez García
f1ffe0cb9c Add support for proxy configuration in secure_requests_session function 2025-07-21 17:32:56 +02:00
Adolfo Gómez García
355b3ca07a Merge remote-tracking branch 'origin/v4.0' 2025-07-20 21:58:06 +02:00
Adolfo Gómez García
7ce0fb0f22 Enhance logging in XenPublication and XenClient classes for better traceability; refactor variable naming for consistency 2025-07-19 02:31:34 +02:00
Adolfo Gómez García
a90d2719a3 Add SystemInformation job for periodic cluster info storage 2025-07-19 02:31:27 +02:00
Adolfo Gómez García
e8d7da650c Fix type hinting for value field in Properties model by using typing.cast 2025-07-19 02:31:08 +02:00
Adolfo Gómez García
6f4d14bdf2 Enhance TimeTrack class: fix last_check assignment logic and add get_my_ip_from_db function for retrieving server IP from the database 2025-07-19 02:30:27 +02:00
Adolfo Gómez García
9f303b8cfa Implement cluster management functionality: add UDSClusterNode class, store_cluster_info, and enumerate_cluster_nodes methods; integrate with task manager and tree commands. 2025-07-19 02:29:52 +02:00
Adolfo Gómez García
8db36ac964 Adjust delayed task execution timing and improve logging for task handling 2025-07-19 02:28:31 +02:00
Adolfo Gómez García
921abfe0bb Use sql_stamp_seconds to adjust timestamp in StatsCountersAccum 2025-07-17 15:27:00 +02:00
Adolfo Gómez García
fc964ab96d Merge remote-tracking branch 'origin/v4.0' 2025-07-17 03:30:29 +02:00
Adolfo Gómez García
15b13b00ae Refactor delayed task and scheduler logic to improve time checks; rename maintenance cleanup method for consistency 2025-07-17 03:29:15 +02:00
Adolfo Gómez García
6116a98bc6 Fix ldaputil.modify call to use constant for MODIFY_ADD 2025-07-09 04:10:38 +02:00
Adolfo Gómez García
5dd4b455f3 Add add() function to ldaputil for creating LDAP entries and enhance delete() for better error handling 2025-07-09 04:10:09 +02:00
Adolfo Gómez García
6df756e22f Refactor WinDomainOsManager to use ldaputil 2025-07-09 03:36:44 +02:00
Adolfo Gómez García
6fcc5efc3e Update connection type hints in SimpleLDAPAuthenticator to use LDAPConnection instead of LDAPObject 2025-07-09 03:36:25 +02:00
Adolfo Gómez García
2a3e90be57 Update connection type hints in RegexLdap to use LDAPConnection instead of LDAPObject 2025-07-09 03:36:15 +02:00
Adolfo Gómez García
92cb728715 Enhance ldaputil.py with additional modify functionality and improve recursive_delete documentation 2025-07-09 03:35:52 +02:00
Adolfo Gómez García
68f16c7ad3 Refactor SimpleLDAPAuthenticator to utilize ldaputil for connection handling and improve error handling in LDAP queries 2025-07-09 03:22:58 +02:00
Adolfo Gómez García
87d2c5a064 Refactor LDAP utility functions to use ldap3 library and improve SSL context handling 2025-07-09 03:18:32 +02:00
Adolfo Gómez García
441fd7b7da Comment out SECURE_MIN_TLS_VERSION in settings.py.sample. Due to the fact that ldap3 misinterprets the "version", a patch is needed for ldap3 if this is enabled. So, by default, disable it. Anyway. with SECURE_CIPHERS should be enougt :) 2025-07-09 03:18:09 +02:00
Adolfo Gómez García
f7b7aef9bb Updated actor 2025-06-27 16:26:55 +02:00
Adolfo Gómez García
3f034bb51c Merge remote-tracking branch 'origin/v4.0' 2025-06-27 16:26:45 +02:00
Adolfo Gómez García
763ca4bd07 Updated translations 2025-06-27 16:25:46 +02:00
Adolfo Gómez García
faf852cdaa updated admin interface to improve some texts 2025-06-27 15:39:28 +02:00
Adolfo Gómez García
35e4422e0e Handle MaxServicesReachedError in UserServiceManager to log error and delete assigned service 2025-06-25 18:37:02 +02:00
Adolfo Gómez García
288757c984 Refactor get_unique_id method in IPMachinesUserService to return MAC address or IP address 2025-06-25 18:03:20 +02:00
Adolfo Gómez García
9b0ea5f893 Updated actor 2025-06-25 16:50:36 +02:00
Adolfo Gómez García
af2178d338 Refactor get_unique_id method in IPMachinesUserService to return IP address instead of hashed VMID 2025-06-25 16:49:50 +02:00
Adolfo Gómez García
3ec748eba6 Enhance operation handling in DynamicUserService to check FINISH state for deferred operations 2025-06-24 13:39:58 +02:00
Adolfo Gómez García
429700d4c1 Enhance deferred operation handling in DynamicUserService for improved state management 2025-06-24 13:33:17 +02:00
Adolfo Gómez García
fb7be05ea9 Merge remote-tracking branch 'origin/v4.0' 2025-06-22 04:07:11 +02:00
Adolfo Gómez García
91184a24b0 Refactor state handling and logging in DynamicUserService for improved clarity 2025-06-22 04:04:55 +02:00
Adolfo Gómez García
f78e8c74d6 Merge remote-tracking branch 'origin/v4.0' 2025-06-21 21:39:15 +02:00
Adolfo Gómez García
8ae073bc27 Improve state update efficiency in PublicationLauncher by specifying updated fields 2025-06-21 19:17:51 +02:00
Adolfo Gómez García
d4b90c387b Refactor destroy operation handling in DynamicPublication for improved clarity 2025-06-21 16:51:17 +02:00
Adolfo Gómez García
bf1b4a3d31 Refactor type hinting in AutoSpecMethodInfo and enhance logging functions in helpers.py 2025-06-21 06:03:37 +02:00
Adolfo Gómez García
ed99412e71 fixed logging info 2025-06-20 01:32:14 +02:00
Adolfo Gómez García
d387366d15 Refactor username and domain handling in HTML5RDP and RDP transports for improved clarity and consistency 2025-06-20 01:25:25 +02:00
Adolfo Gómez García
f781a8001c Add retry_on_exception decorator for enhanced error handling 2025-06-20 01:25:10 +02:00
Adolfo Gómez García
1be33751d9 fixed tag and domain auths 2025-06-17 18:17:27 +02:00
Adolfo Gómez García
b2baf61d0e Remove exception info from IP retrieval error logging in DynamicUserService 2025-06-17 16:20:57 +02:00
Adolfo Gómez García
6d1e999b1f Refactor get_by_tag method to improve tag filtering and update uds_js to handle global login configuration 2025-06-13 17:59:32 +02:00
Adolfo Gómez García
076a300751 Fix username formatting in preconnect request to include domain from transport 2025-06-13 15:40:00 +02:00
Adolfo Gómez García
474302174b Merge remote-tracking branch 'origin/v4.0' 2025-06-12 18:33:10 +02:00
Adolfo Gómez García
43406e10f6 Refactor cookie handling and decorators for consistency and clarity 2025-06-12 18:28:48 +02:00
Adolfo Gómez García
a8ec91025c Merge remote-tracking branch 'origin/v4.0' 2025-06-10 23:39:27 +02:00
Adolfo Gómez
daf3646577 Update README.md 2025-06-04 16:42:11 +02:00
Adolfo Gómez
861fdc67a0 Merge pull request #124 from PAzter1101/fix-readme
Fix readme
2025-06-04 16:41:11 +02:00
Adolfo Gómez García
feaa780c54 Merge remote-tracking branch 'origin/v4.0' 2025-05-26 22:37:45 +02:00
Adolfo Gómez García
43d7096e6c Merge remote-tracking branch 'origin/v4.0' 2025-05-23 15:48:57 +02:00
Adolfo Gómez García
5a609d4df9 Merge remote-tracking branch 'origin/v4.0' 2025-05-14 18:47:24 +02:00
Adolfo Gómez García
da79c38fc2 Updated merged actor 2025-05-14 18:45:53 +02:00
Adolfo Gómez García
7037f4e362 Merge remote-tracking branch 'origin/v4.0' 2025-05-12 21:05:51 +02:00
Adolfo Gómez García
35ee5346d1 Merge remote-tracking branch 'origin/v4.0' 2025-05-09 19:33:43 +02:00
Alex
55adeea6e4 Update README.md 2025-05-03 23:37:14 +03:00
Alex
bed22c2aed Update README.md 2025-05-03 23:35:39 +03:00
Alex
45e0d61491 Update README.md 2025-05-03 23:34:54 +03:00
Alex
7746ba0335 Update README.md 2025-05-03 23:32:32 +03:00
Adolfo Gómez García
8e78884715 Merge remote-tracking branch 'origin/v4.0' 2025-04-30 12:14:12 +02:00
Adolfo Gómez García
3a0922e41b Merge remote-tracking branch 'origin/v4.0' 2025-04-23 20:45:18 +02:00
Adolfo Gómez García
f444b1222f Fix default_factory type in HelpDoc class for arguments field 2025-04-22 20:27:44 +02:00
Adolfo Gómez García
c4732791ff Fix help_paths documentation reference in Stats class 2025-04-22 20:26:23 +02:00
Adolfo Gómez García
452225b3a2 Merge remote-tracking branch 'origin/v4.0' 2025-04-21 17:17:30 +02:00
Adolfo Gómez García
4701e89e64 Merge remote-tracking branch 'origin/v4.0' 2025-04-11 14:46:43 +02:00
Adolfo Gómez García
911368ed43 Merge remote-tracking branch 'origin/v4.0' 2025-04-10 05:16:34 +02:00
Adolfo Gómez García
d3757343bc Merge remote-tracking branch 'origin/v4.0' 2025-04-09 21:38:33 +02:00
Adolfo Gómez García
2ab2e3219f Merge remote-tracking branch 'origin/v4.0' 2025-04-09 05:03:12 +02:00
Adolfo Gómez García
3c2041d5bf Change stamp type from int to datetime in ServersServers class 2025-04-08 17:38:13 +02:00
Adolfo Gómez García
c1fa4c17a5 Merge remote-tracking branch 'origin/v4.0' 2025-04-08 17:37:58 +02:00
Adolfo Gómez García
5f22412a10 Merge remote-tracking branch 'origin/v4.0' 2025-04-07 19:06:26 +02:00
Adolfo Gómez García
a8f17403df actor fixes 2025-04-06 14:16:42 +02:00
Adolfo Gómez García
30825d5538 Merge remote-tracking branch 'origin/v4.0' 2025-04-06 14:16:11 +02:00
Adolfo Gómez García
925ba00523 Merge remote-tracking branch 'origin/v4.0' 2025-04-02 22:57:20 +02:00
Adolfo Gómez García
0da3a5cccc Merge remote-tracking branch 'origin/v4.0' 2025-04-01 17:10:58 +02:00
Adolfo Gómez García
b724d526ac Merge remote-tracking branch 'origin/v4.0' 2025-03-27 17:19:35 +01:00
Adolfo Gómez García
fc883eac8d Merge remote-tracking branch 'origin/v4.0' 2025-03-26 16:30:11 +01:00
Adolfo Gómez García
13a1656b37 Merge remote-tracking branch 'origin/v4.0' 2025-03-20 20:51:19 +01:00
Adolfo Gómez García
de91ffcfa2 Merge remote-tracking branch 'origin/v4.0' 2025-03-19 00:56:03 +01:00
Adolfo Gómez García
f5254ca68a Merge remote-tracking branch 'origin/v4.0' 2025-03-18 22:10:31 +01:00
Adolfo Gómez García
fa95ca4d23 Merge remote-tracking branch 'origin/v4.0' 2025-03-18 21:19:28 +01:00
Adolfo Gómez García
e57de9520f Merge remote-tracking branch 'origin/v4.0' 2025-03-14 20:41:30 +01:00
Adolfo Gómez García
b3b8b037cc Merge remote-tracking branch 'origin/v4.0' 2025-03-12 19:20:08 +01:00
Adolfo Gómez García
4112ce053d Merge remote-tracking branch 'origin/v4.0' 2025-03-12 19:05:35 +01:00
Adolfo Gómez García
f65156602a Merge remote-tracking branch 'origin/v4.0' 2025-03-10 17:20:32 +01:00
Adolfo Gómez García
1c1f99febc Merge remote-tracking branch 'origin/v4.0' 2025-03-07 23:40:54 +01:00
Adolfo Gómez García
0871a0b2cd Merge remote-tracking branch 'origin/v4.0' 2025-03-03 20:31:34 +01:00
Adolfo Gómez García
411c9643fc Merge remote-tracking branch 'origin/v4.0' 2025-03-03 19:24:59 +01:00
Adolfo Gómez García
7646cd1dd3 Merge remote-tracking branch 'origin/v4.0' 2025-03-03 16:53:35 +01:00
Adolfo Gómez García
2b6759b7b7 Merge remote-tracking branch 'origin/v4.0' 2025-03-02 17:37:38 +01:00
Adolfo Gómez García
8358025f54 Merge remote-tracking branch 'origin/v4.0' 2025-03-02 16:10:51 +01:00
Adolfo Gómez García
3b26a25a2c Merge remote-tracking branch 'origin/v4.0' 2025-02-28 19:03:26 +01:00
Adolfo Gómez García
5d1ea79049 Merge remote-tracking branch 'origin/v4.0' 2025-02-28 18:07:02 +01:00
Adolfo Gómez García
68df062990 Merge remote-tracking branch 'origin/v4.0' 2025-02-26 17:51:05 +01:00
Adolfo Gómez García
a7272d700d erge remote-tracking branch 'origin/v4.0' 2025-02-25 18:45:51 +01:00
Adolfo Gómez García
c12c9d616c Merge remote-tracking branch 'origin/v4.0' 2025-02-19 19:21:40 +01:00
Adolfo Gómez García
a92fd0d150 Merge remote-tracking branch 'origin/v4.0' 2025-02-18 21:19:09 +01:00
Adolfo Gómez García
bee801296a Merge remote-tracking branch 'origin/v4.0' 2025-02-18 20:09:50 +01:00
Adolfo Gómez García
6b2b465c25 Fixed mfa data rest type, moved 2025-02-14 18:07:56 +01:00
Adolfo Gómez García
7b860bbd36 Merge remote-tracking branch 'origin/v4.0' 2025-02-14 18:07:34 +01:00
Adolfo Gómez García
1cfae43c2c Merge remote-tracking branch 'origin/v4.0' 2025-02-13 17:27:29 +01:00
Adolfo Gómez García
e71a131f1f Merge remote-tracking branch 'origin/v4.0' 2025-02-13 17:27:21 +01:00
Adolfo Gómez García
d6518ee30c Merge remote-tracking branch 'origin/v4.0' 2025-02-11 18:34:49 +01:00
Adolfo Gómez García
79aeaee67b Merge remote-tracking branch 'origin/v4.0' 2025-02-11 17:05:53 +01:00
Adolfo Gómez García
475fefbbfd Merge remote-tracking branch 'origin/v4.0' 2025-02-11 16:21:30 +01:00
Adolfo Gómez García
803c8ba7b2 Refactor REST methods to use specific ItemDictType for item serialization and improve type handling 2025-02-09 15:15:23 +01:00
Adolfo Gómez García
10076bf46a Fix test case to return TestResponse instead of an empty list in HelpDoc tests 2025-02-08 20:10:15 +01:00
Adolfo Gómez García
94249decfb Refactor HelpDoc class to support function-based help documentation and improve return type handling 2025-02-08 20:07:15 +01:00
Adolfo Gómez García
f9a0026d5d Add test for HelpDoc generation from nested dataclass response 2025-02-08 15:09:33 +01:00
Adolfo Gómez García
2d524fcdf2 Refactor API documentation for clarity and consistency in method descriptions 2025-02-08 15:00:48 +01:00
Adolfo Gómez García
d82e7dc838 Refactor HandlerNode to improve help path generation for custom methods 2025-02-06 01:22:01 +01:00
Adolfo Gómez García
8130afa2d5 Refactor HelpMethod and HelpInfo classes to include HTTP methods and improve documentation rendering 2025-02-04 13:20:03 +01:00
Adolfo Gómez García
458b4d3412 Refactor API documentation and improve help method descriptions for clarity 2025-02-04 03:40:57 +01:00
Adolfo Gómez García
4c6a9237e8 Enhance API documentation and improve help method clarity 2025-02-04 00:02:38 +01:00
Adolfo Gómez García
0c4a00e163 Refactor authentication and authorization logic for improved clarity and consistency 2025-02-03 18:33:02 +01:00
Adolfo Gómez García
6899cff246 Refactor HelpMethodInfo and HelpMethod classes for improved API documentation clarity 2025-02-03 17:16:33 +01:00
Adolfo Gómez García
4c59a25092 Add HelpMethod and HelpMethodInfo classes for improved API documentation 2025-02-03 04:49:04 +01:00
Adolfo Gómez García
b41a1afd43 Refactor access control roles to use UserRole constants for consistency 2025-02-03 01:29:09 +01:00
Adolfo Gómez García
ee2262a779 Fix 404 template to reference the correct utility JavaScript file 2025-02-02 12:32:37 +01:00
Adolfo Gómez García
c54111cf56 Merge remote-tracking branch 'origin/v4.0' 2025-01-30 18:26:06 +01:00
Adolfo Gómez García
beccee144a Refactor custom methods to use ModelCustomMethod for improved clarity and consistency 2025-01-25 20:03:35 +01:00
Adolfo Gómez García
b9f4e7f2ea Started endpoint for REST API documentation endpoint and refactor authentication role checks to be more clear. 2025-01-25 19:42:13 +01:00
Adolfo Gómez García
84d565ec19 Refactor stats counter mappings to use more descriptive variable names 2025-01-23 05:27:01 +01:00
Adolfo Gómez García
6424ca37cf Merge remote-tracking branch 'origin/v4.0' 2025-01-22 19:54:46 +01:00
Adolfo Gómez García
c758819c6b Merge remote-tracking branch 'origin/v4.0' 2025-01-22 17:48:44 +01:00
Adolfo Gómez García
6ab48c9d04 Merge remote-tracking branch 'origin/v4.0' 2025-01-21 17:33:54 +01:00
Adolfo Gómez García
8a148be042 Merge remote-tracking branch 'origin/v4.0' 2025-01-21 14:50:18 +01:00
Adolfo Gómez García
b20a051fd7 updated client (minor fixes 2025-01-21 14:49:50 +01:00
Adolfo Gómez García
e738b5c447 Merge remote-tracking branch 'origin/v4.0' 2025-01-20 23:52:24 +01:00
Adolfo Gómez García
cc03e5d6c3 Merge remote-tracking branch 'origin/v4.0' 2025-01-20 23:28:39 +01:00
Adolfo Gómez García
6239d499af Merge remote-tracking branch 'origin/v4.0' 2025-01-20 18:11:05 +01:00
Adolfo Gómez García
e37a65b8d1 Fixed client 2025-01-20 17:34:32 +01:00
Adolfo Gómez García
80d015b410 Merge remote-tracking branch 'origin/v4.0' 2025-01-10 17:14:15 +01:00
Adolfo Gómez García
6875e586cb Merge remote-tracking branch 'origin/v4.0' 2025-01-08 18:17:38 +01:00
Adolfo Gómez García
10fda01ad3 Merge remote-tracking branch 'origin/v4.0' 2025-01-08 17:22:04 +01:00
Adolfo Gómez García
482c537861 Merge remote-tracking branch 'origin/v4.0' 2025-01-08 16:43:38 +01:00
Adolfo Gómez García
afea5e1eaa Merge remote-tracking branch 'origin/v4.0' 2024-12-30 16:59:18 +01:00
Adolfo Gómez García
d6ea833674 Merge remote-tracking branch 'origin/v4.0' 2024-12-25 00:55:29 +01:00
Adolfo Gómez García
8450938c75 Merge remote-tracking branch 'origin/v4.0' 2024-12-23 17:32:46 +01:00
Adolfo Gómez García
541e29b27b Merge remote-tracking branch 'origin/v4.0' 2024-12-23 17:01:06 +01:00
Adolfo Gómez García
e1992cdc3e Merge remote-tracking branch 'origin/v4.0' 2024-12-23 16:39:54 +01:00
Adolfo Gómez García
c0faec45e6 Merge remote-tracking branch 'origin/v4.0' 2024-12-20 17:10:11 +01:00
Adolfo Gómez García
3ec42a9f68 Merge remote-tracking branch 'origin/v4.0' 2024-12-19 16:44:43 +01:00
Adolfo Gómez García
c111069e8c Merge remote-tracking branch 'origin/v4.0' 2024-12-19 16:20:24 +01:00
Adolfo Gómez García
c9201c91a3 Merge remote-tracking branch 'origin/v4.0' 2024-12-16 19:25:42 +01:00
Adolfo Gómez García
6230e80a30 Merge remote-tracking branch 'origin/v4.0' 2024-12-16 17:55:38 +01:00
Adolfo Gómez García
2db0fe725b Upgrading to next expected release verseion 2024-12-09 18:44:02 +01:00
179 changed files with 15260 additions and 9569 deletions

View File

@@ -13,5 +13,7 @@ Please feel free to contribute to this project.
Notes
=====
* From 4.0 onwards (current master), OpenUDS has been splitted in several repositories and contains submodules. Remember to use "git clone --resursive ..." to fetch it ;-).
* 4.0 version is tested on Python 3.11. It will probably work on 3.12 and 3.13 too (maybe 3.10, but not tested also)
* Master version is always under heavy development and it is not recommended for use, it will probably have unfixed bugs. Please use the latest stable branch (`v4.0` right now).
* From `v4.0` onwards (current master), OpenUDS has been splitted in several repositories and contains submodules. Remember to use "git clone --resursive ..." to fetch it ;-).
* `v4.0` version needs Python 3.11 (may work fine on newer versions). It uses new features only available on 3.10 or later, and is tested against 3.11. It will probably work on 3.10 too.

View File

@@ -1 +1 @@
4.0.0
5.0.0

2
actor

Submodule actor updated: a6ae57a964...04ce3fc2d1

2
client

Submodule client updated: 5b044bca34...4dfb56c5a1

View File

@@ -1,7 +1,7 @@
[mypy]
#plugins =
# mypy_django_plugin.main
python_version = 3.11
python_version = 3.12
# Exclude all .*/transports/.*/scripts/.* directories and all tests
exclude = (.*/transports/.*/scripts/.*|.*/tests/.*)
@@ -17,4 +17,4 @@ django_settings_module = "server.settings"
# Disable some anoying reports, because pyright needs the redundant cast on some cases
# [mypy-tests.*]
# disable_error_code =
# disable_error_code =

View File

@@ -11,4 +11,9 @@ python_classes =
filterwarnings =
error
ignore:The --rsyncdir command line argument and rsyncdirs config variable are deprecated.:DeprecationWarning
ignore::matplotlib._api.deprecation.MatplotlibDeprecationWarning:pydev
ignore::matplotlib._api.deprecation.MatplotlibDeprecationWarning:pydev
log_format = "%(asctime)s %(levelname)s %(message)s"
log_date_format = "%Y-%m-%d %H:%M:%S"
log_cli = true
log_level = debug

View File

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

View File

@@ -36,6 +36,14 @@ DATABASES = {
'HOST': 'localhost', # Set to empty string for localhost. Not used with sqlite3.
'PORT': '3306', # Set to empty string for default. Not used with sqlite3.
# 'CONN_MAX_AGE': 600, # Enable DB Pooling, 10 minutes max connection duration
},
'persistent': {
'ENGINE': 'django.db.backends.sqlite3', # Persistent DB, used for persistent data
'NAME': os.path.join(BASE_DIR, 'persistent.sqlite3'), # Path to persistent DB file
'OPTIONS': {
'timeout': 20, # Timeout for sqlite3 connections
'transaction_mode': 'IMMEDIATE', # Use immediate transaction mode for better concurrency
},
}
}
@@ -186,7 +194,7 @@ SECURE_CIPHERS = (
':ECDHE-ECDSA-CHACHA20-POLY1305'
)
# Min TLS version
SECURE_MIN_TLS_VERSION = '1.2'
# SECURE_MIN_TLS_VERSION = '1.2'
# LDAP CIFHER SUITE can be enforced here. Use GNU TLS cipher suite names in this case
# Debian libldap uses gnutls, and it's my development environment. Continue reading for more info:

View File

@@ -47,7 +47,7 @@ from uds.core.util.model import sql_stamp_seconds
from . import processors, log
from .handlers import Handler
from .model import DetailHandler
from . import model as rest_model
# Not imported at runtime, just for type checking
if typing.TYPE_CHECKING:
@@ -58,81 +58,48 @@ logger = logging.getLogger(__name__)
__all__ = ['Handler', 'Dispatcher']
@dataclasses.dataclass(frozen=True)
class HandlerNode:
"""
Represents a node on the handler tree
"""
name: str
handler: typing.Optional[type[Handler]]
children: collections.abc.MutableMapping[str, 'HandlerNode']
def __str__(self) -> str:
return f'HandlerNode({self.name}, {self.handler}, {self.children})'
def __repr__(self) -> str:
return str(self)
def tree(self, level: int = 0) -> str:
"""
Returns a string representation of the tree
"""
ret = f'{" " * level}{self.name} ({self.handler.__name__ if self.handler else "None"})\n'
for child in self.children.values():
ret += child.tree(level + 1)
return ret
class Dispatcher(View):
"""
This class is responsible of dispatching REST requests
"""
# This attribute will contain all paths--> handler relations, filled at Initialized method
services: typing.ClassVar[HandlerNode] = HandlerNode('', None, {})
root_node: typing.ClassVar[types.rest.HandlerNode] = types.rest.HandlerNode('', None, None, {})
@method_decorator(csrf_exempt)
def dispatch(
self, request: 'http.request.HttpRequest', *args: typing.Any, **kwargs: typing.Any
self, request: 'http.request.HttpRequest', path: str
) -> 'http.HttpResponse':
"""
Processes the REST request and routes it wherever it needs to be routed
"""
request = typing.cast('ExtendedHttpRequestWithUser', request) # Reconverting to typed request
if not hasattr(request, 'user'):
raise exceptions.rest.HandlerError('Request does not have a user, cannot process request')
# Remove session from request, so response middleware do nothing with this
del request.session
# Now we extract method and possible variables from path
path: list[str] = kwargs['arguments'].split('/')
del kwargs['arguments']
# path: list[str] = kwargs['arguments'].split('/')
# path = kwargs['arguments']
# del kwargs['arguments']
# Transverse service nodes, so we can locate class processing this path
service = Dispatcher.services
full_path_lst: list[str] = []
# Guess content type from content type header (post) or ".xxx" to method
# # Transverse service nodes, so we can locate class processing this path
# service = Dispatcher.services
# full_path_lst: list[str] = []
# # Guess content type from content type header (post) or ".xxx" to method
content_type: str = request.META.get('CONTENT_TYPE', 'application/json').split(';')[0]
while path:
clean_path = path[0]
# Skip empty path elements, so /x/y == /x////y for example (due to some bugs detected on some clients)
if not clean_path:
path = path[1:]
continue
handler_node = Dispatcher.root_node.find_path(path)
if not handler_node:
return http.HttpResponseNotFound('Service not found', content_type="text/plain")
if clean_path in service.children: # if we have a node for this path, walk down
service = service.children[clean_path]
full_path_lst.append(path[0]) # Add this path to full path
path = path[1:] # Remove first part of path
else:
break # If we don't have a node for this path, we are done
full_path = '/'.join(full_path_lst)
logger.debug("REST request: %s (%s)", full_path, content_type)
logger.debug("REST request: %s (%s)", handler_node, handler_node.full_path())
# Now, service points to the class that will process the request
# We get the '' node, that is the "current" node, and get the class from it
cls: typing.Optional[type[Handler]] = service.handler
cls: typing.Optional[type[Handler]] = handler_node.handler
if not cls:
return http.HttpResponseNotFound('Method not found', content_type="text/plain")
@@ -145,29 +112,35 @@ 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 = tuple(path)
args = path[len(node_full_path) :].split('/')[
1:
] # First element is always empty, so we skip it
handler: typing.Optional[Handler] = None
try:
handler = cls(
request,
full_path,
node_full_path,
http_method,
processor.process_parameters(),
*args,
**kwargs,
)
processor.set_odata(handler.odata)
operation: collections.abc.Callable[[], typing.Any] = getattr(handler, http_method)
except processors.ParametersException as e:
logger.debug('Path: %s', full_path)
logger.debug(
'Path: %s',
)
logger.debug('Error: %s', e)
log.log_operation(handler, 400, types.log.LogLevel.ERROR)
return http.HttpResponseBadRequest(
f'Invalid parameters invoking {full_path}: {e}',
f'Invalid parameters invoking {handler_node.full_path()}: {e}',
content_type="text/plain",
)
except AttributeError:
@@ -180,7 +153,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, full_path)
logger.debug('Getting attribute %s for %s', http_method, handler_node.full_path())
return http.HttpResponseServerError('Unexcepected error', content_type="text/plain")
# Invokes the handler's operation, add headers to response and returns
@@ -199,7 +172,7 @@ class Dispatcher(View):
),
)
else:
response = processor.get_response(response)
response = processor.get_response(response)
# Set response headers
response['UDS-Version'] = f'{consts.system.VERSION};{consts.system.VERSION_STAMP}'
response['Response-Stamp'] = sql_stamp_seconds()
@@ -210,12 +183,8 @@ class Dispatcher(View):
# Exceptiol will also be logged, but with ERROR level
log.log_operation(handler, response.status_code, types.log.LogLevel.INFO)
return response
except exceptions.rest.RequestError as e:
log.log_operation(handler, 400, types.log.LogLevel.ERROR)
return http.HttpResponseBadRequest(str(e), content_type="text/plain")
except exceptions.rest.ResponseError as e:
log.log_operation(handler, 500, types.log.LogLevel.ERROR)
return http.HttpResponseServerError(str(e), content_type="text/plain")
# Note that the order of exceptions is important
# because some exceptions are subclasses of others
except exceptions.rest.NotSupportedError as e:
log.log_operation(handler, 501, types.log.LogLevel.ERROR)
return http.HttpResponseBadRequest(str(e), content_type="text/plain")
@@ -225,6 +194,12 @@ 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")
@@ -232,7 +207,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', full_path)
logger.error('Exception processing request: %s', handler_node.full_path())
for i in trace_back.splitlines():
logger.error('* %s', i)
@@ -244,26 +219,26 @@ class Dispatcher(View):
Method to register a class as a REST service
param type_: Class to be registered
"""
if not type_.name:
if not type_.NAME:
name = sys.intern(type_.__name__.lower())
else:
name = type_.name
name = type_.NAME
# Fill the service_node tree with the class
service_node = Dispatcher.services # Root path
service_node = Dispatcher.root_node # Root path
# If path, ensure that the path exists on the tree
if type_.path:
logger.info('Path: /%s/%s', type_.path, name)
for k in type_.path.split('/'):
if type_.PATH:
logger.info('Path: /%s/%s', type_.PATH, name)
for k in type_.PATH.split('/'):
intern_k = sys.intern(k)
if intern_k not in service_node.children:
service_node.children[intern_k] = HandlerNode(k, None, {})
service_node.children[intern_k] = types.rest.HandlerNode(k, None, service_node, {})
service_node = service_node.children[intern_k]
else:
logger.info('Path: /%s', name)
if name not in service_node.children:
service_node.children[name] = HandlerNode(name, None, {})
service_node.children[name] = types.rest.HandlerNode(name, None, service_node, {})
service_node.children[name] = dataclasses.replace(service_node.children[name], handler=type_)
@@ -279,11 +254,7 @@ class Dispatcher(View):
module_name = __name__[: __name__.rfind('.')]
def checker(x: type[Handler]) -> bool:
# only register if final class, no classes that have subclasses
logger.debug(
'Checking %s - %s - %s', x.__name__, issubclass(x, DetailHandler), x.__subclasses__() == []
)
return not issubclass(x, DetailHandler) and not x.__subclasses__()
return not issubclass(x, rest_model.DetailHandler) and not x.__subclasses__()
# Register all subclasses of Handler
modfinder.dynamically_load_and_register_packages(
@@ -293,6 +264,8 @@ class Dispatcher(View):
checker=checker,
package_name='methods',
)
logger.info('REST Handlers initialized')
Dispatcher.initialize()

View File

@@ -29,17 +29,21 @@
"""
Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
import abc
import typing
import logging
import codecs
import collections.abc
from django.contrib.sessions.backends.base import SessionBase
from django.contrib.sessions.backends.db import SessionStore
from django.db.models import QuerySet
from uds.core import consts, types
from uds.core import consts, types, exceptions
from uds.core.util.config import GlobalConfig
from uds.core.auths.auth import root_user
from uds.core.util import net
from uds.core.util import net, query_db_filter, query_filter
from uds.models import Authenticator, User
from uds.core.managers.crypto import CryptoManager
@@ -52,30 +56,21 @@ if typing.TYPE_CHECKING:
logger = logging.getLogger(__name__)
T = typing.TypeVar('T')
class Handler:
class Handler(abc.ABC):
"""
REST requests handler base class
"""
name: typing.ClassVar[typing.Optional[str]] = (
NAME: typing.ClassVar[typing.Optional[str]] = (
None # If name is not used, name will be the class name in lower case
)
path: typing.ClassVar[typing.Optional[str]] = (
PATH: typing.ClassVar[typing.Optional[str]] = (
None # Path for this method, so we can do /auth/login, /auth/logout, /auth/auths in a simple way
)
authenticated: typing.ClassVar[bool] = (
True # By default, all handlers needs authentication. Will be overwriten if needs_admin or needs_staff,
)
needs_admin: typing.ClassVar[bool] = (
False # By default, the methods will be accessible by anyone if nothing else indicated
)
needs_staff: typing.ClassVar[bool] = False # By default, staff
# For implementing help
# A list of pairs of (path, help) for subpaths on this handler
help_paths: typing.ClassVar[list[tuple[str, str]]] = []
help_text: typing.ClassVar[str] = 'No help available'
ROLE: typing.ClassVar[consts.UserRole] = consts.UserRole.USER # By default, only users can access
_request: 'ExtendedHttpRequestWithUser' # It's a modified HttpRequest
_path: str
@@ -85,11 +80,13 @@ class Handler:
] # This is a deserliazied object from request. Can be anything as 'a' or {'a': 1} or ....
# These are the "path" split by /, that is, the REST invocation arguments
_args: list[str]
_kwargs: dict[str, typing.Any] # This are the "path" split by /, that is, the REST invocation arguments
_headers: dict[str, str] # Note: These are "output" headers, not input headers (input headers can be retrieved from request)
_headers: dict[
str, str
] # Note: These are "output" headers, not input headers (input headers can be retrieved from request)
_session: typing.Optional[SessionStore]
_auth_token: typing.Optional[str]
_user: 'User'
_odata: 'types.rest.api.ODataParams' # OData parameters, if any
# The dispatcher proceses the request and calls the method with the same name as the operation
# currently, only 'get', 'post, 'put' y 'delete' are supported
@@ -102,25 +99,16 @@ class Handler:
method: str,
params: dict[str, typing.Any],
*args: str,
**kwargs: typing.Any,
):
logger.debug('Data: %s %s %s', self.__class__, self.needs_admin, self.authenticated)
if (
self.needs_admin or self.needs_staff
) and not self.authenticated: # If needs_admin, must also be authenticated
raise Exception(
f'class {self.__class__} is not authenticated but has needs_admin or needs_staff set!!'
)
self._request = request
self._path = path
self._operation = method
self._params = params
self._args = list(args) # copy of args
self._kwargs = kwargs
self._headers = {}
self._auth_token = None
if self.authenticated: # Only retrieve auth related data on authenticated handlers
if self.ROLE.needs_authentication:
try:
self._auth_token = self._request.headers.get(consts.auth.AUTH_TOKEN_HEADER, '')
self._session = SessionStore(session_key=self._auth_token)
@@ -133,16 +121,14 @@ class Handler:
if self._auth_token is None:
raise AccessDenied()
if self.needs_admin and not self.is_admin():
raise AccessDenied()
if self.needs_staff and not self.is_staff_member():
raise AccessDenied()
try:
self._user = self.get_user()
except Exception as e:
# Maybe the user was deleted, so access is denied
raise AccessDenied() from e
if not self._user.can_access(self.ROLE):
raise AccessDenied()
else:
self._user = User() # Empty user for non authenticated handlers
self._user.state = types.states.State.ACTIVE # Ensure it's active
@@ -150,6 +136,8 @@ class Handler:
if self._user and self._user.state != types.states.State.ACTIVE:
raise AccessDenied()
self._odata = types.rest.api.ODataParams.from_dict(self.query_params())
def headers(self) -> dict[str, str]:
"""
Returns the headers of the REST request (all)
@@ -159,22 +147,34 @@ class Handler:
def header(self, header_name: str) -> typing.Optional[str]:
"""
Get's an specific header name from REST request
Args:
header_name: Name of header to retrieve
Returns:
Value of header or None if not found
"""
return self._headers.get(header_name)
def add_header(self, header: str, value: str) -> None:
def query_params(self) -> dict[str, str | list[str]]:
"""
Returns the query parameters from the request (GET parameters)
Note:
Dispatcher has it own parameters processor that fills our "_params".
The processor tries to get from POST body json (or whatever), and, if not available
from GET. So maybe this returns same values as _params, but, this always are GET parameters.
Useful for odata fields ($filter, $skip, $top, $orderby)
"""
return {k: v[0] if len(v) == 1 else v for k, v in self._request.GET.lists()}
def add_header(self, header: str, value: str | int) -> None:
"""
Inserts a new header inside the headers list
:param header: name of header to insert
:param value: value of header
"""
self._headers[header] = value
self._headers[header] = str(value)
def delete_header(self, header: str) -> None:
"""
@@ -207,6 +207,10 @@ class Handler:
"""
return self._args
@property
def odata(self) -> 'types.rest.api.ODataParams':
return self._odata
@property
def session(self) -> 'SessionStore':
if self._session is None:
@@ -228,8 +232,6 @@ class Handler:
password: str,
locale: str,
platform: str,
is_admin: bool,
staff_member: bool,
scrambler: str,
) -> None:
"""
@@ -241,11 +243,10 @@ class Handler:
:param is_admin: If user is considered admin or not
:param staff_member: If is considered as staff member
"""
if is_admin:
staff_member = True # Make admins also staff members :-)
# crypt password and convert to base64
passwd = codecs.encode(CryptoManager().symmetric_encrypt(password, scrambler), 'base64').decode()
passwd = codecs.encode(
CryptoManager.manager().symmetric_encrypt(password, scrambler), 'base64'
).decode()
session['REST'] = {
'auth': id_auth,
@@ -253,8 +254,6 @@ class Handler:
'password': passwd,
'locale': locale,
'platform': platform,
'is_admin': is_admin,
'staff_member': staff_member,
}
def gen_auth_token(
@@ -264,8 +263,6 @@ class Handler:
password: str,
locale: str,
platform: str,
is_admin: bool,
staf_member: bool,
scrambler: str,
) -> str:
"""
@@ -285,8 +282,6 @@ class Handler:
password,
locale,
platform,
is_admin,
staf_member,
scrambler,
)
session.save()
@@ -393,3 +388,67 @@ class Handler:
if name in self._params:
return self._params[name]
return ''
def filter_queryset(self, qs: QuerySet[typing.Any]) -> QuerySet[typing.Any]:
"""
Filters the queryset based on odata
"""
# OData filter
if self.odata.filter:
try:
qs = query_db_filter.exec_query(self.odata.filter, qs)
except ValueError as e:
raise exceptions.rest.RequestError(f'Invalid odata filter: {e}') from e
for order in self.odata.orderby:
qs = qs.order_by(order)
if self.odata.start is not None:
qs = qs[self.odata.start :]
if self.odata.limit is not None:
qs = qs[: self.odata.limit]
# Get total items and set it on X-Total-Count
try:
total_items = qs.count()
self.add_header('X-Total-Count', total_items)
except Exception as e:
raise exceptions.rest.RequestError(f'Invalid odata: {e}')
return qs
def filter_data(self, data: collections.abc.Iterable[T]) -> list[T]:
"""
Filters the dict base on the currnet odata
"""
if self.odata.filter:
try:
data = list(query_filter.exec_query(self.odata.filter, data))
except ValueError as e:
raise exceptions.rest.RequestError(f'Invalid odata filter: {e}') from e
else:
data = list(data)
# Get total items and set it on X-Total-Count
try:
self.add_header('X-Total-Count', len(data))
except Exception as e:
raise exceptions.rest.RequestError(f'Invalid odata: {e}')
return data
@classmethod
def api_components(cls: type[typing.Self]) -> types.rest.api.Components:
"""
Returns the types that should be registered
"""
return types.rest.api.Components()
@classmethod
def api_paths(cls: type[typing.Self]) -> dict[str, types.rest.api.PathItem]:
"""
Returns the API operations that should be registered
"""
return {}

View File

@@ -30,6 +30,7 @@
"""
@Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
import dataclasses
import datetime
import logging
import typing
@@ -39,59 +40,93 @@ 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
from uds.core.util import permissions, ensure, ui as ui_utils
from uds.models import Account
from .accountsusage import AccountsUsage
if typing.TYPE_CHECKING:
from django.db.models import Model
from django.db import models
logger = logging.getLogger(__name__)
# Enclosed methods under /item path
@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):
class Accounts(ModelHandler[AccountItem]):
"""
Processes REST requests about accounts
"""
model = Account
detail = {'usage': AccountsUsage}
MODEL = Account
DETAIL = {'usage': AccountsUsage}
custom_methods = [('clear', True), ('timemark', True)]
save_fields = ['name', 'comments', 'tags']
table_title = _('Accounts')
table_fields = [
{'name': {'title': _('Name'), 'visible': True}},
{'comments': {'title': _('Comments')}},
{'time_mark': {'title': _('Time mark'), 'type': 'callback'}},
{'tags': {'title': _('tags'), 'visible': False}},
CUSTOM_METHODS = [
types.rest.ModelCustomMethod('clear', True),
types.rest.ModelCustomMethod('timemark', True),
]
def item_as_dict(self, item: 'Model') -> types.rest.ItemDictType:
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:
item = ensure.is_instance(item, Account)
return {
'id': item.uuid,
'name': item.name,
'tags': [tag.tag for tag in item.tags.all()],
'comments': item.comments,
'time_mark': item.time_mark,
'permission': permissions.effective_permissions(self._user, item),
}
return AccountItem(
id=item.uuid,
name=item.name,
tags=[tag.tag for tag in item.tags.all()],
comments=item.comments,
time_mark=item.time_mark,
permission=permissions.effective_permissions(self._user, item),
)
def get_gui(self, type_: str) -> list[typing.Any]:
return self.add_default_fields([], ['name', 'comments', 'tags'])
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
return (
ui_utils.GuiBuilder()
.add_stock_field(types.rest.stock.StockField.NAME)
.add_stock_field(types.rest.stock.StockField.COMMENTS)
.add_stock_field(types.rest.stock.StockField.TAGS)
).build()
def timemark(self, item: 'Model') -> typing.Any:
def timemark(self, item: 'models.Model') -> typing.Any:
"""
API:
Generates a time mark associated with the account.
This is useful to easily identify when the account data was last updated.
(For example, one user enters an service, we get the usage data and "timemark" it, later read again
and we can identify that all data before this timemark has already been processed)
Arguments:
item: Account to timemark
"""
item = ensure.is_instance(item, Account)
item.time_mark = datetime.datetime.now()
item.save()
return ''
def clear(self, item: 'Model') -> typing.Any:
def clear(self, item: 'models.Model') -> typing.Any:
"""
Api documentation for the method. From here, will be used by the documentation generator
Always starts with API:
API:
Clears all usage associated with the account
"""
item = ensure.is_instance(item, Account)
self.ensure_has_access(item, uds.core.types.permissions.PermissionType.MANAGEMENT)
self.check_access(item, uds.core.types.permissions.PermissionType.MANAGEMENT)
return item.usages.filter(user_service=None).delete()

View File

@@ -30,78 +30,95 @@
"""
Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
import dataclasses
import datetime
import logging
import typing
from django.utils.translation import gettext as _
from django.db.models import Model
from uds.core import exceptions, types
from uds.core.util import ensure, permissions
from uds.core.types.rest import TableInfo
from uds.core.util import ensure, permissions, ui as ui_utils
from uds.core.util.model import process_uuid
from uds.models import Account, AccountUsage
from uds.REST.model import DetailHandler
# Not imported at runtime, just for type checking
if typing.TYPE_CHECKING:
from django.db.models import Model
logger = logging.getLogger(__name__)
class AccountsUsage(DetailHandler): # pylint: disable=too-many-public-methods
@dataclasses.dataclass
class AccountItem(types.rest.BaseRestItem):
uuid: str
pool_uuid: str
pool_name: str
user_uuid: str
user_name: str
start: datetime.datetime
end: datetime.datetime
running: bool
elapsed: str
elapsed_timemark: str
permission: int
class AccountsUsage(DetailHandler[AccountItem]): # pylint: disable=too-many-public-methods
"""
Detail handler for Services, whose parent is a Provider
"""
@staticmethod
def usage_to_dict(item: 'AccountUsage', perm: int) -> dict[str, typing.Any]:
def usage_to_dict(item: 'AccountUsage', perm: int) -> AccountItem:
"""
Convert an account usage to a dictionary
:param item: Account usage item (db)
:param perm: permission
"""
return {
'uuid': item.uuid,
'pool_uuid': item.pool_uuid,
'pool_name': item.pool_name,
'user_uuid': item.user_uuid,
'user_name': item.user_name,
'start': item.start,
'end': item.end,
'running': item.user_service is not None,
'elapsed': item.elapsed,
'elapsed_timemark': item.elapsed_timemark,
'permission': perm,
}
return AccountItem(
uuid=item.uuid,
pool_uuid=item.pool_uuid,
pool_name=item.pool_name,
user_uuid=item.user_uuid,
user_name=item.user_name,
start=item.start,
end=item.end,
running=item.user_service is not None,
elapsed=item.elapsed,
elapsed_timemark=item.elapsed_timemark,
permission=perm,
)
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ItemsResult[AccountItem]:
parent = ensure.is_instance(parent, Account)
# Check what kind of access do we have to parent provider
perm = permissions.effective_permissions(self._user, parent)
try:
if not item:
return [AccountsUsage.usage_to_dict(k, perm) for k in parent.usages.all()]
return [AccountsUsage.usage_to_dict(k, perm) for k in self.filter_queryset(parent.usages.all())]
k = parent.usages.get(uuid=process_uuid(item))
return AccountsUsage.usage_to_dict(k, perm)
except Exception:
logger.exception('itemId %s', item)
raise self.invalid_item_response()
raise exceptions.rest.NotFound(_('Account usage not found: {}').format(item)) from None
def get_fields(self, parent: 'Model') -> list[typing.Any]:
return [
{'pool_name': {'title': _('Pool name')}},
{'user_name': {'title': _('User name')}},
{'running': {'title': _('Running')}},
{'start': {'title': _('Starts'), 'type': 'datetime'}},
{'end': {'title': _('Ends'), 'type': 'datetime'}},
{'elapsed': {'title': _('Elapsed')}},
{'elapsed_timemark': {'title': _('Elapsed timemark')}},
]
def get_table(self, parent: 'Model') -> TableInfo:
parent = ensure.is_instance(parent, Account)
return (
ui_utils.TableBuilder(_('Usages of {0}').format(parent.name))
.text_column(name='pool_name', title=_('Pool name'))
.text_column(name='user_name', title=_('User name'))
.text_column(name='running', title=_('Running'))
.datetime_column(name='start', title=_('Starts'))
.datetime_column(name='end', title=_('Ends'))
.text_column(name='elapsed', title=_('Elapsed'))
.datetime_column(name='elapsed_timemark', title=_('Elapsed timemark'))
.row_style(prefix='row-running-', field='running')
.build()
)
def get_row_style(self, parent: 'Model') -> types.ui.RowStyleInfo:
return types.ui.RowStyleInfo(prefix='row-running-', field='running')
def save_item(self, parent: 'Model', item: typing.Optional[str]) -> None:
def save_item(self, parent: 'Model', item: typing.Optional[str]) -> AccountItem:
raise exceptions.rest.RequestError('Accounts usage cannot be edited')
def delete_item(self, parent: 'Model', item: str) -> None:
@@ -111,12 +128,5 @@ class AccountsUsage(DetailHandler): # pylint: disable=too-many-public-methods
usage = parent.usages.get(uuid=process_uuid(item))
usage.delete()
except Exception:
logger.exception('Exception')
raise self.invalid_item_response()
def get_title(self, parent: 'Model') -> str:
parent = ensure.is_instance(parent, Account)
try:
return _('Usages of {0}').format(parent.name)
except Exception:
return _('Current usages')
logger.error('Error deleting account usage %s from %s', item, parent)
raise exceptions.rest.NotFound(_('Account usage not found: {}').format(item)) from None

View File

@@ -30,68 +30,88 @@
"""
Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
import dataclasses
import datetime
import logging
import typing
from django.utils.translation import gettext_lazy as _
from django.db import models
from uds.core import types, consts
from uds.core.types import permissions
from uds.core.util import ensure
from uds.core.util import ensure, ui as ui_utils
from uds.core.util.log import LogLevel
from uds.models import Server
from uds.core.exceptions.rest import NotFound, RequestError
from uds.REST.model import ModelHandler
if typing.TYPE_CHECKING:
from django.db.models import Model
logger = logging.getLogger(__name__)
# Enclosed methods under /osm path
@dataclasses.dataclass
class ActorTokenItem(types.rest.BaseRestItem):
id: str
name: str
stamp: datetime.datetime
username: str
ip: str
host: str
hostname: str
version: str
pre_command: str
post_command: str
run_once_command: str
log_level: str
os: str
class ActorTokens(ModelHandler):
model = Server
model_filter = {'type': types.servers.ServerType.ACTOR}
table_title = _('Actor tokens')
table_fields = [
# {'token': {'title': _('Token')}},
{'stamp': {'title': _('Date'), 'type': 'datetime'}},
{'username': {'title': _('Issued by')}},
{'host': {'title': _('Origin')}},
{'version': {'title': _('Version')}},
{'hostname': {'title': _('Hostname')}},
{'pre_command': {'title': _('Pre-connect')}},
{'post_command': {'title': _('Post-Configure')}},
{'run_once_command': {'title': _('Run Once')}},
{'log_level': {'title': _('Log level')}},
{'os': {'title': _('OS')}},
]
class ActorTokens(ModelHandler[ActorTokenItem]):
def item_as_dict(self, item: 'Model') -> dict[str, typing.Any]:
MODEL = Server
FILTER = {'type': types.servers.ServerType.ACTOR}
TABLE = (
ui_utils.TableBuilder(_('Actor tokens'))
.datetime_column('stamp', _('Date'))
.text_column('username', _('Issued by'))
.text_column('host', _('Origin'))
.text_column('version', _('Version'))
.text_column('hostname', _('Hostname'))
.text_column('pre_command', _('Pre-connect'))
.text_column('post_command', _('Post-Configure'))
.text_column('run_once_command', _('Run Once'))
.text_column('log_level', _('Log level'))
.text_column('os', _('OS'))
.build()
)
def get_item(self, item: 'models.Model') -> ActorTokenItem:
item = ensure.is_instance(item, Server)
data: dict[str, typing.Any] = item.data or {}
if item.log_level < 10000: # Old log level, from actor, etc..
log_level = LogLevel.from_actor_level(item.log_level).name
else:
log_level = LogLevel(item.log_level).name
return {
'id': item.token,
'name': str(_('Token isued by {} from {}')).format(item.register_username, item.hostname or item.ip),
'stamp': item.stamp,
'username': item.register_username,
'ip': item.ip,
'host': f'{item.ip} - {data.get("mac")}',
'hostname': item.hostname,
'version': item.version,
'pre_command': data.get('pre_command', ''),
'post_command': data.get('post_command', ''),
'run_once_command': data.get('run_once_command', ''),
'log_level': log_level,
'os': item.os_type,
}
return ActorTokenItem(
id=item.token,
name=str(_('Token isued by {} from {}')).format(
item.register_username, item.hostname or item.ip
),
stamp=item.stamp,
username=item.register_username,
ip=item.ip,
host=f'{item.ip} - {data.get("mac")}',
hostname=item.hostname,
version=item.version,
pre_command=data.get('pre_command', ''),
post_command=data.get('post_command', ''),
run_once_command=data.get('run_once_command', ''),
log_level=log_level,
os=item.os_type,
)
def delete(self) -> str:
"""
@@ -100,13 +120,13 @@ class ActorTokens(ModelHandler):
if len(self._args) != 1:
raise RequestError('Delete need one and only one argument')
self.ensure_has_access(
self.model(), permissions.PermissionType.ALL, root=True
self.check_access(
self.MODEL(), permissions.PermissionType.ALL, root=True
) # Must have write permissions to delete
try:
self.model.objects.get(token=self._args[0]).delete()
except self.model.DoesNotExist:
self.MODEL.objects.get(token=self._args[0]).delete()
except self.MODEL.DoesNotExist:
raise NotFound('Element do not exists') from None
return consts.OK

View File

@@ -28,7 +28,6 @@
"""
Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
import enum
import functools
import logging
import time
@@ -64,16 +63,6 @@ logger = logging.getLogger(__name__)
cache = Cache('actorv3')
class NotifyActionType(enum.StrEnum):
LOGIN = 'login'
LOGOUT = 'logout'
DATA = 'data'
@staticmethod
def valid_names() -> list[str]:
return [e.value for e in NotifyActionType]
# Helpers
def get_list_of_ids(handler: 'Handler') -> list[str]:
"""
@@ -145,8 +134,9 @@ def clear_failed_ip_counter(request: 'ExtendedHttpRequest') -> None:
class ActorV3Action(Handler):
authenticated = False # Actor requests are not authenticated normally
path = 'actor/v3'
ROLE = consts.UserRole.ANONYMOUS
PATH = 'actor/v3'
NAME = 'actorv3'
@staticmethod
def actor_result(result: typing.Any = None, **kwargs: typing.Any) -> dict[str, typing.Any]:
@@ -197,7 +187,7 @@ class ActorV3Action(Handler):
raise exceptions.rest.AccessDenied('Access denied')
# Some helpers
def notify_service(self, action: NotifyActionType) -> None:
def notify_service(self, action: types.rest.actor.NotifyActionType) -> None:
"""
Notifies the Service (not userservice) that an action has been performed
@@ -227,17 +217,17 @@ class ActorV3Action(Handler):
is_remote = self._params.get('session_type', '')[:4] in ('xrdp', 'RDP-')
# Must be valid
if action in (NotifyActionType.LOGIN, NotifyActionType.LOGOUT):
if action in (types.rest.actor.NotifyActionType.LOGIN, types.rest.actor.NotifyActionType.LOGOUT):
if not service_id: # For login/logout, we need a valid id
raise Exception()
# Notify Service that someone logged in/out
if action == NotifyActionType.LOGIN:
if action == types.rest.actor.NotifyActionType.LOGIN:
# Try to guess if this is a remote session
service.process_login(service_id, remote_login=is_remote)
elif action == NotifyActionType.LOGOUT:
elif action == types.rest.actor.NotifyActionType.LOGOUT:
service.process_logout(service_id, remote_login=is_remote)
elif action == NotifyActionType.DATA:
elif action == types.rest.actor.NotifyActionType.DATA:
service.notify_data(service_id, self._params['data'])
else:
raise Exception('Invalid action')
@@ -254,7 +244,7 @@ class Test(ActorV3Action):
Tests UDS Broker actor connectivity & key
"""
name = 'test'
NAME = 'test'
def action(self) -> dict[str, typing.Any]:
# First, try to locate an user service providing this token.
@@ -291,10 +281,9 @@ class Register(ActorV3Action):
"""
authenticated = True
needs_staff = True
ROLE = consts.UserRole.STAFF
name = 'register'
NAME = 'register'
def post(self) -> dict[str, typing.Any]:
# If already exists a token for this MAC, return it instead of creating a new one, and update the information...
@@ -368,7 +357,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]:
"""
@@ -507,7 +496,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]:
"""
@@ -567,7 +556,7 @@ class IpChange(BaseReadyChange):
Processses IP Change.
"""
name = 'ipchange'
NAME = 'ipchange'
class Ready(BaseReadyChange):
@@ -575,7 +564,7 @@ class Ready(BaseReadyChange):
Notifies the user service is ready
"""
name = 'ready'
NAME = 'ready'
def action(self) -> dict[str, typing.Any]:
"""
@@ -595,7 +584,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)
@@ -608,7 +597,7 @@ class Version(ActorV3Action):
Used on possible "customized" actors.
"""
name = 'version'
NAME = 'version'
def action(self) -> dict[str, typing.Any]:
logger.debug('Version Args: %s, Params: %s', self._args, self._params)
@@ -624,7 +613,7 @@ class Login(ActorV3Action):
Notifies user logged id
"""
name = 'login'
NAME = 'login'
# payload received
# {
@@ -673,7 +662,7 @@ class Login(ActorV3Action):
): # If unamanaged host, lest do a bit more work looking for a service with the provided parameters...
if is_managed:
raise
self.notify_service(action=NotifyActionType.LOGIN)
self.notify_service(action=types.rest.actor.NotifyActionType.LOGIN)
return ActorV3Action.actor_result(
{
@@ -692,7 +681,7 @@ class Logout(ActorV3Action):
Notifies user logged out
"""
name = 'logout'
NAME = 'logout'
@staticmethod
def process_logout(userservice: UserService, username: str, session_id: str) -> None:
@@ -726,7 +715,7 @@ class Logout(ActorV3Action):
except Exception:
if is_managed:
raise
self.notify_service(NotifyActionType.LOGOUT) # Logout notification
self.notify_service(types.rest.actor.NotifyActionType.LOGOUT) # Logout notification
# Result is that we have not processed the logout in fact, but notified the service
return ActorV3Action.actor_result('notified')
@@ -738,7 +727,7 @@ class Log(ActorV3Action):
Sends a log from the service
"""
name = 'log'
NAME = 'log'
def action(self) -> dict[str, typing.Any]:
logger.debug('Args: %s, Params: %s', self._args, self._params)
@@ -763,7 +752,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)
@@ -783,7 +772,7 @@ class Ticket(ActorV3Action):
class Unmanaged(ActorV3Action):
name = 'unmanaged'
NAME = 'unmanaged'
def action(self) -> dict[str, typing.Any]:
"""
@@ -869,7 +858,7 @@ class Unmanaged(ActorV3Action):
class Notify(ActorV3Action):
name = 'notify'
NAME = 'notify'
def post(self) -> dict[str, typing.Any]:
# Raplaces original post (non existent here)
@@ -878,7 +867,7 @@ class Notify(ActorV3Action):
def get(self) -> collections.abc.MutableMapping[str, typing.Any]:
logger.debug('Args: %s, Params: %s', self._args, self._params)
try:
action = NotifyActionType(self._params['action'])
action = types.rest.actor.NotifyActionType(self._params['action'])
_token = self._params['token'] # Just to check it exists
except Exception as e:
# Requested login, logout or whatever
@@ -887,11 +876,11 @@ class Notify(ActorV3Action):
try:
# Check block manually
check_ip_is_blocked(self._request) # pylint: disable=protected-access
if action == NotifyActionType.LOGIN:
if action == types.rest.actor.NotifyActionType.LOGIN:
Login.action(typing.cast(Login, self))
elif action == NotifyActionType.LOGOUT:
elif action == types.rest.actor.NotifyActionType.LOGOUT:
Logout.action(typing.cast(Logout, self))
elif action == NotifyActionType.DATA:
elif action == types.rest.actor.NotifyActionType.DATA:
self.notify_service(action)
return ActorV3Action.actor_result('ok')

View File

@@ -31,18 +31,19 @@
Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
import collections.abc
import dataclasses
import itertools
import logging
import re
import typing
from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
from django.utils.translation import gettext, gettext_lazy as _
from django.db import models
from uds.core import auths, consts, exceptions, types
from uds.core import auths, consts, exceptions, types, ui
from uds.core.environment import Environment
from uds.core.ui import gui
from uds.core.util import ensure, permissions
from uds.core.util import ensure, permissions, ui as ui_utils
from uds.core.util.model import process_uuid
from uds.models import MFA, Authenticator, Network, Tag
from uds.REST.model import ModelHandler
@@ -50,45 +51,84 @@ from uds.REST.model import ModelHandler
from .users_groups import Groups, Users
# Not imported at runtime, just for type checking
if typing.TYPE_CHECKING:
from django.db.models import Model
from uds.core.module import Module
from uds.core.module import Module
logger = logging.getLogger(__name__)
@dataclasses.dataclass
class AuthenticatorTypeInfo(types.rest.ExtraTypeInfo):
search_users_supported: bool
search_groups_supported: bool
needs_password: bool
label_username: str
label_groupname: str
label_password: str
create_users_supported: bool
is_external: bool
mfa_data_enabled: bool
mfa_supported: bool
def as_dict(self) -> dict[str, typing.Any]:
return dataclasses.asdict(self)
@dataclasses.dataclass
class AuthenticatorItem(types.rest.ManagedObjectItem[Authenticator]):
numeric_id: int
id: str
name: str
priority: int
tags: list[str]
comments: str
net_filtering: str
networks: list[str]
state: str
mfa_id: str
small_name: str
users_count: int
permission: int
type_info: types.rest.TypeInfo|None
# Enclosed methods under /auth path
class Authenticators(ModelHandler):
model = Authenticator
class Authenticators(ModelHandler[AuthenticatorItem]):
ITEM_TYPE = AuthenticatorItem
MODEL = Authenticator
# Custom get method "search" that requires authenticator id
custom_methods = [('search', True)]
detail = {'users': Users, 'groups': Groups}
save_fields = ['name', 'comments', 'tags', 'priority', 'small_name', 'mfa_id:_', 'state']
CUSTOM_METHODS = [types.rest.ModelCustomMethod('search', True)]
DETAIL = {'users': Users, 'groups': Groups}
FIELDS_TO_SAVE = ['name', 'comments', 'tags', 'priority', 'small_name', 'mfa_id:_', 'state']
table_title = _('Authenticators')
table_fields = [
{'numeric_id': {'title': _('Id'), 'visible': True}},
{'name': {'title': _('Name'), 'visible': True, 'type': 'iconType'}},
{'type_name': {'title': _('Type')}},
{'comments': {'title': _('Comments')}},
{'priority': {'title': _('Priority'), 'type': 'numeric', 'width': '5rem'}},
{'small_name': {'title': _('Label')}},
{'users_count': {'title': _('Users'), 'type': 'numeric', 'width': '1rem'}},
{
'mfa_name': {
'title': _('MFA'),
}
},
{'tags': {'title': _('tags'), 'visible': False}},
]
TABLE = (
ui_utils.TableBuilder(_('Authenticators'))
.numeric_column(name='numeric_id', title=_('Id'), visible=True, width='1rem')
.icon(name='name', title=_('Name'), visible=True)
.text_column(name='type_name', title=_('Type'))
.text_column(name='comments', title=_('Comments'))
.numeric_column(name='priority', title=_('Priority'), width='5rem')
.text_column(name='small_name', title=_('Label'))
.numeric_column(name='users_count', title=_('Users'), width='1rem')
.text_column(name='mfa_name', title=_('MFA'))
.text_column(name='tags', title=_('tags'), visible=False)
.row_style(prefix='row-state-', field='state')
.build()
)
def enum_types(self) -> collections.abc.Iterable[type[auths.Authenticator]]:
@classmethod
def possible_types(cls: type[typing.Self]) -> collections.abc.Iterable[type[auths.Authenticator]]:
return auths.factory().providers().values()
def type_info(self, type_: type['Module']) -> typing.Optional[types.rest.AuthenticatorTypeInfo]:
@classmethod
def extra_type_info(
cls: type[typing.Self], type_: type['Module']
) -> typing.Optional[AuthenticatorTypeInfo]:
if issubclass(type_, auths.Authenticator):
return types.rest.AuthenticatorTypeInfo(
return AuthenticatorTypeInfo(
search_users_supported=type_.search_users != auths.Authenticator.search_users,
search_groups_supported=type_.search_groups != auths.Authenticator.search_groups,
needs_password=type_.needs_password,
@@ -98,95 +138,82 @@ class Authenticators(ModelHandler):
create_users_supported=type_.create_user != auths.Authenticator.create_user,
is_external=type_.external_source,
mfa_data_enabled=type_.mfa_data_enabled,
mfa_supported=type_.provides_mfa(),
mfa_supported=type_.provides_mfa_identifier(),
)
# Not of my type
return None
def get_gui(self, type_: str) -> list[typing.Any]:
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
try:
auth_type = auths.factory().lookup(type_)
auth_type = auths.factory().lookup(for_type)
if auth_type:
# Create a new instance of the authenticator to access to its GUI
with Environment.temporary_environment() as env:
# If supports mfa, add MFA provider selector field
auth_instance = auth_type(env, None)
field = self.add_default_fields(
auth_instance.gui_description(),
['name', 'comments', 'tags', 'priority', 'small_name', 'networks'],
)
self.add_field(
field,
{
'name': 'state',
'value': consts.auth.VISIBLE,
'choices': [
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=[
{'id': consts.auth.VISIBLE, 'text': _('Visible')},
{'id': consts.auth.HIDDEN, 'text': _('Hidden')},
{'id': consts.auth.DISABLED, 'text': _('Disabled')},
],
'label': gettext('Access'),
'tooltip': gettext(
'Access type for this transport. Disabled means not only hidden, but also not usable as login method.'
),
'type': types.ui.FieldType.CHOICE,
'order': 107,
'tab': gettext('Display'),
},
)
# If supports mfa, add MFA provider selector field
if auth_type.provides_mfa():
self.add_field(
field,
{
'name': 'mfa_id',
'choices': [gui.choice_item('', str(_('None')))]
+ gui.sorted_choices(
[gui.choice_item(v.uuid, v.name) for v in MFA.objects.all()]
),
'label': gettext('MFA Provider'),
'tooltip': gettext('MFA provider to use for this authenticator'),
'type': types.ui.FieldType.CHOICE,
'order': 108,
'tab': types.ui.Tab.MFA,
},
label=gettext('Access'),
)
return field
)
if auth_type.provides_mfa_identifier():
gui.add_choice(
name='mfa_id',
label=gettext('MFA Provider'),
choices=[ui.gui.choice_item('', str(_('None')))]
+ ui.gui.sorted_choices(
[ui.gui.choice_item(v.uuid, v.name) for v in MFA.objects.all()]
),
)
return gui.build()
raise Exception() # Not found
except Exception as e:
logger.info('Type not found: %s', e)
raise exceptions.rest.NotFound('type not found') from e
def item_as_dict(self, item: 'Model') -> dict[str, typing.Any]:
summary = 'summarize' in self._params
logger.info('Authenticator type not found: %s', e)
raise exceptions.rest.NotFound('Authenticator type not found') from e
def get_item(self, item: 'models.Model') -> AuthenticatorItem:
item = ensure.is_instance(item, Authenticator)
v: dict[str, typing.Any] = {
'numeric_id': item.id,
'id': item.uuid,
'name': item.name,
'priority': item.priority,
}
if not summary:
type_ = item.get_type()
v.update(
{
'tags': [tag.tag for tag in typing.cast(collections.abc.Iterable[Tag], item.tags.all())],
'comments': item.comments,
'net_filtering': item.net_filtering,
'networks': [n.uuid for n in item.networks.all()],
'state': item.state,
'mfa_id': item.mfa.uuid if item.mfa else '',
'small_name': item.small_name,
'users_count': item.users.count(),
'type': type_.mod_type(),
'type_name': type_.mod_name(),
'type_info': self.type_as_dict(type_),
'permission': permissions.effective_permissions(self._user, item),
}
)
return v
def post_save(self, item: 'Model') -> None:
return AuthenticatorItem(
numeric_id=item.id,
id=item.uuid,
name=item.name,
priority=item.priority,
tags=[tag.tag for tag in typing.cast(collections.abc.Iterable[Tag], item.tags.all())],
comments=item.comments,
net_filtering=item.net_filtering,
networks=[n.uuid for n in item.networks.all()],
state=item.state,
mfa_id=item.mfa.uuid if item.mfa else '',
small_name=item.small_name,
users_count=item.users.count(),
permission=permissions.effective_permissions(self._user, item),
item=item,
type_info=type(self).as_typeinfo(item.get_type()),
)
def post_save(self, item: 'models.Model') -> None:
item = ensure.is_instance(item, Authenticator)
try:
networks = self._params['networks']
@@ -199,13 +226,17 @@ class Authenticators(ModelHandler):
item.networks.set(Network.objects.filter(uuid__in=networks))
# Custom "search" method
def search(self, item: 'Model') -> list[types.rest.ItemDictType]:
def search(self, item: 'models.Model') -> list[types.auth.SearchResultItem.ItemDict]:
"""
API:
Search for users or groups in this authenticator
"""
item = ensure.is_instance(item, Authenticator)
self.ensure_has_access(item, types.permissions.PermissionType.READ)
self.check_access(item, types.permissions.PermissionType.READ)
try:
type_ = self._params['type']
if type_ not in ('user', 'group'):
raise self.invalid_request_response()
raise exceptions.rest.RequestError(_('Invalid type: {}').format(type_))
term = self._params['term']
@@ -227,7 +258,7 @@ class Authenticators(ModelHandler):
)
)
if search_supported is False:
raise self.not_supported_response()
raise exceptions.rest.NotSupportedError(_('Search not supported'))
if type_ == 'user':
iterable = auth.search_users(term)
@@ -237,13 +268,15 @@ class Authenticators(ModelHandler):
return [i.as_dict() for i in itertools.islice(iterable, limit)]
except Exception as e:
logger.exception('Too many results: %s', e)
return [{'id': _('Too many results...'), 'name': _('Refine your query')}]
return [
types.auth.SearchResultItem(id=_('Too many results...'), name=_('Refine your query')).as_dict()
]
# self.invalidResponseException('{}'.format(e))
def test(self, type_: str) -> typing.Any:
auth_type = auths.factory().lookup(type_)
if not auth_type:
raise self.invalid_request_response(f'Invalid type: {type_}')
raise exceptions.rest.RequestError(_('Invalid type: {}').format(type_))
dct = self._params.copy()
dct['_request'] = self._request
@@ -270,11 +303,9 @@ class Authenticators(ModelHandler):
fields['small_name'] = fields['small_name'].strip().replace(' ', '_')
# And ensure small_name chars are valid [a-zA-Z0-9:-]+
if fields['small_name'] and not re.match(r'^[a-zA-Z0-9:.-]+$', fields['small_name']):
raise self.invalid_request_response(
_('Label must contain only letters, numbers, or symbols: - : .')
)
raise exceptions.rest.RequestError(_('Label must contain only letters, numbers, or symbols: - : .'))
def delete_item(self, item: 'Model') -> None:
def delete_item(self, item: 'models.Model') -> None:
# For every user, remove assigned services (mark them for removal)
item = ensure.is_instance(item, Authenticator)

View File

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

View File

@@ -30,85 +30,97 @@
"""
Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
import dataclasses
import datetime
import logging
import typing
from django.db import IntegrityError
from django.db import IntegrityError, models
from django.utils.translation import gettext as _
from uds.core import exceptions
from uds.core.util import ensure, permissions
from uds.core import exceptions, types
from uds.core.util import ensure, permissions, ui as ui_utils
from uds.core.util.model import process_uuid, sql_now
from uds.models.calendar import Calendar
from uds.models.calendar_rule import CalendarRule, FrequencyInfo
from uds.REST.model import DetailHandler
# Not imported at runtime, just for type checking
if typing.TYPE_CHECKING:
from django.db.models import Model
logger = logging.getLogger(__name__)
class CalendarRules(DetailHandler): # pylint: disable=too-many-public-methods
@dataclasses.dataclass
class CalendarRuleItem(types.rest.BaseRestItem):
id: str
name: str
comments: str
start: datetime.datetime
end: datetime.datetime | None
frequency: str
interval: int
duration: int
duration_unit: str
permission: int
class CalendarRules(DetailHandler[CalendarRuleItem]): # pylint: disable=too-many-public-methods
"""
Detail handler for Services, whose parent is a Provider
"""
@staticmethod
def rule_as_dict(item: CalendarRule, perm: int) -> dict[str, typing.Any]:
def rule_as_dict(item: CalendarRule, perm: int) -> CalendarRuleItem:
"""
Convert a calrule db item to a dict for a rest response
:param item: Rule item (db)
:param perm: Permission of the object
"""
return {
'id': item.uuid,
'name': item.name,
'comments': item.comments,
'start': item.start,
'end': datetime.datetime.combine(item.end, datetime.time.max) if item.end else None,
'frequency': item.frequency,
'interval': item.interval,
'duration': item.duration,
'duration_unit': item.duration_unit,
'permission': perm,
}
return CalendarRuleItem(
id=item.uuid,
name=item.name,
comments=item.comments,
start=item.start,
end=datetime.datetime.combine(item.end, datetime.time.max) if item.end else None,
frequency=item.frequency,
interval=item.interval,
duration=item.duration,
duration_unit=item.duration_unit,
permission=perm,
)
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> typing.Any:
def get_items(
self, parent: 'models.Model', item: typing.Optional[str]
) -> types.rest.ItemsResult[CalendarRuleItem]:
parent = ensure.is_instance(parent, Calendar)
# Check what kind of access do we have to parent provider
perm = permissions.effective_permissions(self._user, parent)
try:
if item is None:
return [CalendarRules.rule_as_dict(k, perm) for k in parent.rules.all()]
return [CalendarRules.rule_as_dict(k, perm) for k in self.filter_queryset(parent.rules.all())]
k = parent.rules.get(uuid=process_uuid(item))
return CalendarRules.rule_as_dict(k, perm)
except CalendarRule.DoesNotExist:
raise exceptions.rest.NotFound(_('Calendar rule not found: {}').format(item)) from None
except Exception as e:
logger.exception('itemId %s', item)
raise self.invalid_item_response() from e
raise exceptions.rest.RequestError(f'Error retrieving calendar rule: {e}') from e
def get_fields(self, parent: 'Model') -> list[typing.Any]:
def get_table(self, parent: 'models.Model') -> types.rest.TableInfo:
parent = ensure.is_instance(parent, Calendar)
return (
ui_utils.TableBuilder(_('Rules of {0}').format(parent.name))
.text_column(name='name', title=_('Name'))
.datetime_column(name='start', title=_('Start'))
.date(name='end', title=_('End'))
.dict_column(name='frequency', title=_('Frequency'), dct=FrequencyInfo.literals_dict())
.numeric_column(name='interval', title=_('Interval'))
.numeric_column(name='duration', title=_('Duration'))
.text_column(name='comments', title=_('Comments'))
.build()
)
return [
{'name': {'title': _('Rule name')}},
{'start': {'title': _('Starts'), 'type': 'datetime'}},
{'end': {'title': _('Ends'), 'type': 'date'}},
{
'frequency': {
'title': _('Repeats'),
'type': 'dict',
'dict': dict((v.name, str(v.value.title)) for v in FrequencyInfo),
}
},
{'interval': {'title': _('Every'), 'type': 'callback'}},
{'duration': {'title': _('Duration'), 'type': 'callback'}},
{'comments': {'title': _('Comments')}},
]
def save_item(self, parent: 'Model', item: typing.Optional[str]) -> typing.Any:
def save_item(self, parent: 'models.Model', item: typing.Optional[str]) -> typing.Any:
parent = ensure.is_instance(parent, Calendar)
# Extract item db fields
@@ -128,7 +140,7 @@ class CalendarRules(DetailHandler): # pylint: disable=too-many-public-methods
)
if int(fields['interval']) < 1:
raise self.invalid_item_response('Repeat must be greater than zero')
raise exceptions.rest.RequestError('Repeat must be greater than zero')
# Convert timestamps to datetimes
fields['start'] = datetime.datetime.fromtimestamp(fields['start'])
@@ -145,14 +157,14 @@ class CalendarRules(DetailHandler): # pylint: disable=too-many-public-methods
calendar_rule.save()
return {'id': calendar_rule.uuid}
except CalendarRule.DoesNotExist:
raise self.invalid_item_response() from None
raise exceptions.rest.NotFound(_('Calendar rule not found: {}').format(item)) from None
except IntegrityError as e: # Duplicate key probably
raise exceptions.rest.RequestError(_('Element already exists (duplicate key error)')) from e
except Exception as e:
logger.exception('Saving calendar')
raise self.invalid_request_response(f'incorrect invocation to PUT: {e}') from e
raise exceptions.rest.RequestError(f'incorrect invocation to PUT: {e}') from e
def delete_item(self, parent: 'Model', item: str) -> None:
def delete_item(self, parent: 'models.Model', item: str) -> None:
parent = ensure.is_instance(parent, Calendar)
logger.debug('Deleting rule %s from %s', item, parent)
try:
@@ -160,13 +172,8 @@ class CalendarRules(DetailHandler): # pylint: disable=too-many-public-methods
calendar_rule.calendar.modified = sql_now()
calendar_rule.calendar.save()
calendar_rule.delete()
except CalendarRule.DoesNotExist:
raise exceptions.rest.NotFound(_('Calendar rule not found: {}').format(item)) from None
except Exception as e:
logger.exception('Exception')
raise self.invalid_item_response() from e
def get_title(self, parent: 'Model') -> str:
parent = ensure.is_instance(parent, Calendar)
try:
return _('Rules of {0}').format(parent.name)
except Exception:
return _('Current rules')
logger.error('Error deleting calendar rule %s from %s', item, parent)
raise exceptions.rest.RequestError(f'Error deleting calendar rule: {e}') from e

View File

@@ -30,66 +30,79 @@
"""
@Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
import dataclasses
import datetime
import logging
import typing
from django.utils.translation import gettext_lazy as _
from django.db import models
from uds.core import types
from uds.models import Calendar
from uds.core.util import permissions, ensure
from uds.core.util import permissions, ensure, ui as ui_utils
from uds.REST.model import ModelHandler
from .calendarrules import CalendarRules
if typing.TYPE_CHECKING:
from django.db.models import Model
logger = logging.getLogger(__name__)
# Enclosed methods under /item path
@dataclasses.dataclass
class CalendarItem(types.rest.BaseRestItem):
id: str
name: str
tags: list[str]
comments: str
modified: datetime.datetime
number_rules: int
number_access: int
number_actions: int
permission: types.permissions.PermissionType
class Calendars(ModelHandler):
class Calendars(ModelHandler[CalendarItem]):
"""
Processes REST requests about calendars
"""
model = Calendar
detail = {'rules': CalendarRules}
MODEL = Calendar
DETAIL = {'rules': CalendarRules}
save_fields = ['name', 'comments', 'tags']
FIELDS_TO_SAVE = ['name', 'comments', 'tags']
table_title = _('Calendars')
table_fields = [
{
'name': {
'title': _('Name'),
'visible': True,
'type': 'icon',
'icon': 'fa fa-calendar text-success',
}
},
{'comments': {'title': _('Comments')}},
{'modified': {'title': _('Modified'), 'type': 'datetime'}},
{'number_rules': {'title': _('Rules')}},
{'number_access': {'title': _('Pools with Accesses')}},
{'number_actions': {'title': _('Pools with Actions')}},
{'tags': {'title': _('tags'), 'visible': False}},
]
TABLE = (
ui_utils.TableBuilder(_('Calendars'))
.text_column(name='name', title=_('Name'), visible=True)
.text_column(name='comments', title=_('Comments'))
.datetime_column(name='modified', title=_('Modified'))
.numeric_column(name='number_rules', title=_('Rules'), width='5rem')
.numeric_column(name='number_access', title=_('Pools with Accesses'), width='5rem')
.numeric_column(name='number_actions', title=_('Pools with Actions'), width='5rem')
.text_column(name='tags', title=_('tags'), visible=False)
.build()
)
def item_as_dict(self, item: 'Model') -> dict[str, typing.Any]:
def get_item(self, item: 'models.Model') -> CalendarItem:
item = ensure.is_instance(item, Calendar)
return {
'id': item.uuid,
'name': item.name,
'tags': [tag.tag for tag in item.tags.all()],
'comments': item.comments,
'modified': item.modified,
'number_rules': item.rules.count(),
'number_access': item.calendaraccess_set.all().values('service_pool').distinct().count(),
'number_actions': item.calendaraction_set.all().values('service_pool').distinct().count(),
'permission': permissions.effective_permissions(self._user, item),
}
return CalendarItem(
id=item.uuid,
name=item.name,
tags=[tag.tag for tag in item.tags.all()],
comments=item.comments,
modified=item.modified,
number_rules=item.rules.count(),
number_access=item.calendaraccess_set.all().values('service_pool').distinct().count(),
number_actions=item.calendaraction_set.all().values('service_pool').distinct().count(),
permission=permissions.effective_permissions(self._user, item),
)
def get_gui(self, type_: str) -> list[typing.Any]:
return self.add_default_fields([], ['name', 'comments', 'tags'])
def get_gui(self, for_type: str) -> list[typing.Any]:
return (
ui_utils.GuiBuilder()
.add_stock_field(types.rest.stock.StockField.NAME)
.add_stock_field(types.rest.stock.StockField.COMMENTS)
.add_stock_field(types.rest.stock.StockField.TAGS)
.build()
)

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
from uds.core.util.rest.tools import match_args
from uds.models import TicketStore, User
from uds.REST import Handler
@@ -58,7 +58,7 @@ class Client(Handler):
Processes Client requests
"""
authenticated = False # Client requests are not authenticated
ROLE = consts.UserRole.ANONYMOUS
@staticmethod
def result(
@@ -282,7 +282,7 @@ class Client(Handler):
}
)
return match(
return match_args(
self._args,
_error, # In case of error, raises RequestError
((), _noargs), # No args, return version

View File

@@ -33,6 +33,7 @@ Author: Adolfo Gómez, dkmaster at dkmon dot com
import typing
import logging
from uds.core import consts
from uds.core.util.config import Config as CfgConfig
from uds.REST import Handler
@@ -42,10 +43,15 @@ logger = logging.getLogger(__name__)
# Enclosed methods under /config path
class Config(Handler):
needs_admin = True # By default, staff is lower level needed
"""
API:
Get or update UDS configuration
"""
ROLE = consts.UserRole.ADMIN
def get(self) -> typing.Any:
return CfgConfig.get_config_values(self.is_admin())
return self.filter_data(CfgConfig.get_config_values(self.is_admin()))
def put(self) -> typing.Any:
for section, section_dict in typing.cast(dict[str, dict[str, dict[str, str]]], self._params).items():
@@ -60,5 +66,11 @@ class Config(Handler):
self._user.name,
)
else:
logger.error('Non existing config value %s.%s to %s by %s', section, key, vals['value'], self._user.name)
logger.error(
'Non existing config value %s.%s to %s by %s',
section,
key,
vals['value'],
self._user.name,
)
return 'done'

View File

@@ -34,11 +34,11 @@ import datetime
import logging
import typing
from uds.core import exceptions, types
from uds.core import exceptions, types, consts
from uds.core.managers.crypto import CryptoManager
from uds.core.managers.userservice import UserServiceManager
from uds.core.exceptions.services import ServiceNotReadyError
from uds.core.util.rest.tools import match
from uds.core.util.rest.tools import match_args
from uds.REST import Handler
from uds.web.util import services
@@ -51,9 +51,7 @@ class Connection(Handler):
Processes actor requests
"""
authenticated = True # Actor requests are not authenticated
needs_admin = False
needs_staff = False
ROLE = consts.UserRole.USER
@staticmethod
def result(
@@ -87,7 +85,7 @@ class Connection(Handler):
# Ensure user is present on request, used by web views methods
self._request.user = self._user
return Connection.result(result=services.get_services_info_dict(self._request))
return Connection.result(result=self.filter_data(services.get_services_info_dict(self._request)))
def connection(self, id_service: str, id_transport: str, skip: str = '') -> dict[str, typing.Any]:
skip_check = skip in ('doNotCheck', 'do_not_check', 'no_check', 'nocheck', 'skip_check')
@@ -179,7 +177,7 @@ class Connection(Handler):
def error() -> dict[str, typing.Any]:
raise exceptions.rest.RequestError('Invalid Request')
return match(
return match_args(
self._args,
error,
((), self.service_list),

View File

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

View File

@@ -30,87 +30,82 @@
"""
@Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
import dataclasses
import logging
import typing
from django.utils.translation import gettext_lazy as _, gettext
from django.utils.translation import gettext_lazy as _
from django.db import models
from uds.models import Image
from uds.core import types
from uds.core.util import ensure
from uds.core.util import ensure, ui as ui_utils
from uds.REST.model import ModelHandler
if typing.TYPE_CHECKING:
from django.db.models import Model
logger = logging.getLogger(__name__)
# Enclosed methods under /item path
class Images(ModelHandler):
@dataclasses.dataclass
class ImageItem(types.rest.BaseRestItem):
id: str
name: str
data: str = ''
size: str = ''
thumb: str = ''
class Images(ModelHandler[ImageItem]):
"""
Handles the gallery REST interface
"""
path = 'gallery'
model = Image
save_fields = ['name', 'data']
PATH = 'gallery'
MODEL = Image
FIELDS_TO_SAVE = ['name', 'data']
table_title = _('Image Gallery')
table_fields = [
{
'thumb': {
'title': _('Image'),
'visible': True,
'type': 'image',
'width': '96px',
}
},
{'name': {'title': _('Name')}},
{'size': {'title': _('Size')}},
]
TABLE = (
ui_utils.TableBuilder(_('Image Gallery'))
.image('thumb', _('Image'), width='96px')
.text_column('name', _('Name'))
.text_column('size', _('Size'))
.build()
)
def pre_save(self, fields: dict[str, typing.Any]) -> None:
fields['image'] = fields['data']
del fields['data']
#fields['data'] = Image.prepareForDb(Image.decode64(fields['data']))[2]
# fields['data'] = Image.prepareForDb(Image.decode64(fields['data']))[2]
def post_save(self, item: 'Model') -> None:
def post_save(self, item: 'models.Model') -> None:
item = ensure.is_instance(item, Image)
# Updates the thumbnail and re-saves it
logger.debug('After save: item = %s', item)
#item.updateThumbnail()
#item.save()
# item.updateThumbnail()
# item.save()
def get_gui(self, type_: str) -> list[typing.Any]:
return self.add_field(
self.add_default_fields([], ['name']),
{
'name': 'data',
'value': '',
'label': gettext('Image'),
'tooltip': gettext('Image object'),
'type': types.ui.FieldType.IMAGECHOICE,
'order': 100, # At end
},
# Note:
# This has no get_gui because its treated on the admin or client.
# We expect an Image List
def get_item(self, item: 'models.Model') -> ImageItem:
item = ensure.is_instance(item, Image)
return ImageItem(
id=item.uuid,
name=item.name,
data=item.data64,
)
def item_as_dict(self, item: 'Model') -> dict[str, typing.Any]:
def get_item_summary(self, item: 'models.Model') -> ImageItem:
item = ensure.is_instance(item, Image)
return {
'id': item.uuid,
'name': item.name,
'data': item.data64,
}
def item_as_dict_overview(self, item: 'Model') -> dict[str, typing.Any]:
item = ensure.is_instance(item, Image)
return {
'id': item.uuid,
'size': '{}x{}, {} bytes (thumb {} bytes)'.format(
return ImageItem(
id=item.uuid,
size='{}x{}, {} bytes (thumb {} bytes)'.format(
item.width, item.height, len(item.data), len(item.thumb)
),
'name': item.name,
'thumb': item.thumb64,
}
name=item.name,
thumb=item.thumb64,
)

View File

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

View File

@@ -30,16 +30,17 @@
"""
Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
import dataclasses
import logging
import typing
from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
from django.utils.translation import gettext, gettext_lazy as _
from django.db import models
from uds.core import types, exceptions
from uds.core.consts.images import DEFAULT_THUMB_BASE64
from uds.core.ui import gui
from uds.core.util import ensure, permissions
from uds.core import ui
from uds.core.util import ensure, permissions, ui as ui_utils
from uds.core.util.model import process_uuid
from uds.core.types.states import State
from uds.models import Image, MetaPool, ServicePoolGroup
@@ -49,26 +50,47 @@ from uds.REST.model import ModelHandler
from .meta_service_pools import MetaAssignedService, MetaServicesPool
from .user_services import Groups
if typing.TYPE_CHECKING:
from django.db.models import Model
logger = logging.getLogger(__name__)
class MetaPools(ModelHandler):
@dataclasses.dataclass
class MetaPoolItem(types.rest.BaseRestItem):
id: str
name: str
short_name: str
tags: list[str]
comments: str
thumb: str
image_id: str | None
servicesPoolGroup_id: str | None
pool_group_name: str | None
pool_group_thumb: str | None
user_services_count: int
user_services_in_preparation: int
visible: bool
policy: str
fallbackAccess: str
permission: int
calendar_message: str
transport_grouping: int
ha_policy: str
class MetaPools(ModelHandler[MetaPoolItem]):
"""
Handles Services Pools REST requests
"""
model = MetaPool
detail = {
MODEL = MetaPool
DETAIL = {
'pools': MetaServicesPool,
'services': MetaAssignedService,
'groups': Groups,
'access': AccessCalendars,
}
save_fields = [
FIELDS_TO_SAVE = [
'name',
'short_name',
'comments',
@@ -82,35 +104,35 @@ class MetaPools(ModelHandler):
'transport_grouping',
]
table_title = _('Meta Pools')
table_fields = [
{'name': {'title': _('Name')}},
{'comments': {'title': _('Comments')}},
{
'policy': {
'title': _('Policy'),
'type': 'dict',
'dict': dict(types.pools.LoadBalancingPolicy.enumerate()),
}
},
{
'ha_policy': {
'title': _('HA Policy'),
'type': 'dict',
'dict': dict(types.pools.HighAvailabilityPolicy.enumerate()),
}
},
{'user_services_count': {'title': _('User services'), 'type': 'number'}},
{'user_services_in_preparation': {'title': _('In Preparation')}},
{'visible': {'title': _('Visible'), 'type': 'callback'}},
{'pool_group_name': {'title': _('Pool Group')}},
{'label': {'title': _('Label')}},
{'tags': {'title': _('tags'), 'visible': False}},
TABLE = (
ui_utils.TableBuilder(_('Meta Pools'))
.text_column(name='name', title=_('Name'))
.text_column(name='comments', title=_('Comments'))
.dict_column(
name='policy',
title=_('Policy'),
dct=dict(types.pools.LoadBalancingPolicy.enumerate()),
)
.dict_column(
name='ha_policy',
title=_('HA Policy'),
dct=dict(types.pools.HighAvailabilityPolicy.enumerate()),
)
.numeric_column(name='user_services_count', title=_('User services'))
.numeric_column(name='user_services_in_preparation', title=_('In Preparation'))
.boolean(name='visible', title=_('Visible'))
.text_column(name='pool_group_name', title=_('Pool Group'), width='16em')
.text_column(name='short_name', title=_('Label'))
.text_column(name='tags', title=_('tags'), visible=False)
.build()
)
CUSTOM_METHODS = [
types.rest.ModelCustomMethod('set_fallback_access', True),
types.rest.ModelCustomMethod('get_fallback_access', True),
]
custom_methods = [('setFallbackAccess', True), ('getFallbackAccess', True)]
def item_as_dict(self, item: 'Model') -> dict[str, typing.Any]:
def get_item(self, item: 'models.Model') -> MetaPoolItem:
item = ensure.is_instance(item, MetaPool)
# if item does not have an associated service, hide it (the case, for example, for a removed service)
# Access from dict will raise an exception, and item will be skipped
@@ -131,126 +153,93 @@ class MetaPools(ModelHandler):
(i.pool.userServices.filter(state=State.PREPARING).count()) for i in all_pools
)
val = {
'id': item.uuid,
'name': item.name,
'short_name': item.short_name,
'tags': [tag.tag for tag in item.tags.all()],
'comments': item.comments,
'thumb': item.image.thumb64 if item.image is not None else DEFAULT_THUMB_BASE64,
'image_id': item.image.uuid if item.image is not None else None,
'servicesPoolGroup_id': pool_group_id,
'pool_group_name': pool_group_name,
'pool_group_thumb': pool_group_thumb,
'user_services_count': userservices_total,
'user_services_in_preparation': userservices_in_preparation,
'visible': item.visible,
'policy': str(item.policy),
'fallbackAccess': item.fallbackAccess,
'permission': permissions.effective_permissions(self._user, item),
'calendar_message': item.calendar_message,
'transport_grouping': item.transport_grouping,
'ha_policy': str(item.ha_policy),
}
return val
return MetaPoolItem(
id=item.uuid,
name=item.name,
short_name=item.short_name,
tags=[tag.tag for tag in item.tags.all()],
comments=item.comments,
thumb=item.image.thumb64 if item.image is not None else DEFAULT_THUMB_BASE64,
image_id=item.image.uuid if item.image is not None else None,
servicesPoolGroup_id=pool_group_id,
pool_group_name=pool_group_name,
pool_group_thumb=pool_group_thumb,
user_services_count=userservices_total,
user_services_in_preparation=userservices_in_preparation,
visible=item.visible,
policy=str(item.policy),
fallbackAccess=item.fallbackAccess,
permission=permissions.effective_permissions(self._user, item),
calendar_message=item.calendar_message,
transport_grouping=item.transport_grouping,
ha_policy=str(item.ha_policy),
)
# Gui related
def get_gui(self, type_: str) -> list[typing.Any]:
local_gui = self.add_default_fields([], ['name', 'comments', 'tags'])
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
for field in [
{
'name': 'short_name',
'type': 'text',
'label': _('Short name'),
'tooltip': _('Short name for user service visualization'),
'required': False,
'length': 32,
'order': 0 - 95,
},
{
'name': 'policy',
'choices': [gui.choice_item(k, str(v)) for k, v in types.pools.LoadBalancingPolicy.enumerate()],
'label': gettext('Load balancing policy'),
'tooltip': gettext('Service pool load balancing policy'),
'type': types.ui.FieldType.CHOICE,
'order': 100,
},
{
'name': 'ha_policy',
'choices': [
gui.choice_item(k, str(v)) for k, v in types.pools.HighAvailabilityPolicy.enumerate()
return (
ui_utils.GuiBuilder()
.add_stock_field(types.rest.stock.StockField.TAGS)
.add_text(
name='short_name',
label=gettext('Short name'),
tooltip=gettext('Short name for user service visualization'),
length=32,
)
.add_stock_field(types.rest.stock.StockField.NAME)
.add_stock_field(types.rest.stock.StockField.COMMENTS)
.set_order(100)
.add_multichoice(
name='policy',
label=gettext('Load balancing policy'),
choices=[ui.gui.choice_item(k, str(v)) for k, v in types.pools.LoadBalancingPolicy.enumerate()],
tooltip=gettext('Service pool load balancing policy'),
)
.add_choice(
name='ha_policy',
label=gettext('HA Policy'),
choices=[
ui.gui.choice_item(k, str(v)) for k, v in types.pools.HighAvailabilityPolicy.enumerate()
],
'label': gettext('HA Policy'),
'tooltip': gettext(
'Service pool High Availability policy. If enabled and a pool fails, it will be restarted in another pool. Enable with care!.'
tooltip=gettext(
'Service pool High Availability policy. If enabled and a pool fails, it will be restarted in another pool. Enable with care!'
),
'type': types.ui.FieldType.CHOICE,
'order': 101,
},
{
'name': 'image_id',
'choices': [gui.choice_image(-1, '--------', DEFAULT_THUMB_BASE64)]
+ gui.sorted_choices(
[gui.choice_image(v.uuid, v.name, v.thumb64) for v in Image.objects.all()]
),
'label': gettext('Associated Image'),
'tooltip': gettext('Image assocciated with this service'),
'type': types.ui.FieldType.IMAGECHOICE,
'order': 120,
'tab': types.ui.Tab.DISPLAY,
},
{
'name': 'servicesPoolGroup_id',
'choices': [gui.choice_image(-1, _('Default'), DEFAULT_THUMB_BASE64)]
+ gui.sorted_choices(
[
gui.choice_image(v.uuid, v.name, v.thumb64)
for v in ServicePoolGroup.objects.all()
]
),
'label': gettext('Pool group'),
'tooltip': gettext('Pool group for this pool (for pool classify on display)'),
'type': types.ui.FieldType.IMAGECHOICE,
'order': 121,
'tab': types.ui.Tab.DISPLAY,
},
{
'name': 'visible',
'value': True,
'label': gettext('Visible'),
'tooltip': gettext('If active, metapool will be visible for users'),
'type': types.ui.FieldType.CHECKBOX,
'order': 123,
'tab': types.ui.Tab.DISPLAY,
},
{
'name': 'calendar_message',
'value': '',
'label': gettext('Calendar access denied text'),
'tooltip': gettext(
'Custom message to be shown to users if access is limited by calendar rules.'
),
'type': types.ui.FieldType.TEXT,
'order': 124,
'tab': types.ui.Tab.DISPLAY,
},
{
'name': 'transport_grouping',
'choices': [
gui.choice_item(k, str(v)) for k, v in types.pools.TransportSelectionPolicy.enumerate()
)
.new_tab(types.ui.Tab.DISPLAY)
.add_image_choice()
.add_image_choice(
name='servicesPoolGroup_id',
label=gettext('Pool group'),
choices=[
ui.gui.choice_image(
x.uuid, x.name, x.image.thumb64 if x.image is not None else DEFAULT_THUMB_BASE64
)
for x in ServicePoolGroup.objects.all()
],
'label': gettext('Transport Selection'),
'tooltip': gettext('Transport selection policy'),
'type': types.ui.FieldType.CHOICE,
'order': 125,
'tab': types.ui.Tab.DISPLAY,
},
]:
self.add_field(local_gui, field)
return local_gui
tooltip=gettext('Pool group for this pool (for pool classify on display)'),
)
.add_checkbox(
name='visible',
label=gettext('Visible'),
tooltip=gettext('If active, metapool will be visible for users'),
default=True,
)
.add_text(
name='calendar_message',
label=gettext('Calendar access denied text'),
tooltip=gettext('Custom message to be shown to users if access is limited by calendar rules.'),
)
.add_choice(
name='transport_grouping', # Transport Selection
label=gettext('Transport Selection'),
choices=[
ui.gui.choice_item(k, str(v)) for k, v in types.pools.TransportSelectionPolicy.enumerate()
],
tooltip=gettext('Transport selection policy'),
)
.build()
)
def pre_save(self, fields: dict[str, typing.Any]) -> None:
# logger.debug(self._params)
@@ -284,13 +273,17 @@ class MetaPools(ModelHandler):
logger.debug('Fields: %s', fields)
def delete_item(self, item: 'Model') -> None:
def delete_item(self, item: 'models.Model') -> None:
item = ensure.is_instance(item, MetaPool)
item.delete()
# Set fallback status
def set_fallback_access(self, item: MetaPool) -> typing.Any:
self.ensure_has_access(item, types.permissions.PermissionType.MANAGEMENT)
"""
API:
Sets the fallback access for a metapool
"""
self.check_access(item, types.permissions.PermissionType.MANAGEMENT)
fallback = self._params.get('fallbackAccess', 'ALLOW')
logger.debug('Setting fallback of %s to %s', item.name, fallback)

View File

@@ -29,69 +29,92 @@
"""
Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
import dataclasses
import logging
import typing
from django.utils.translation import gettext as _
from uds import models
from uds.core import types
from uds.core import exceptions, types
# from uds.models.meta_pool import MetaPool, MetaPoolMember
# from uds.models.service_pool import ServicePool
# from uds.models.user_service import UserService
# from uds.models.user import User
from uds.core.types.rest import TableInfo
from uds.core.types.states import State
from uds.core.util.model import process_uuid
from uds.core.util import log, ensure
from uds.core.util import log, ensure, ui as ui_utils
from uds.REST.model import DetailHandler
from .user_services import AssignedService
from .user_services import AssignedUserService, UserServiceItem
if typing.TYPE_CHECKING:
from django.db.models import Model
from django.db.models import Model
logger = logging.getLogger(__name__)
class MetaServicesPool(DetailHandler):
@dataclasses.dataclass
class MetaItem(types.rest.BaseRestItem):
"""
Item type for a Meta Pool Member
"""
id: str
pool_id: str
name: str
comments: str
priority: int
enabled: bool
user_services_count: int
user_services_in_preparation: int
pool_name: str = '' # Optional
class MetaServicesPool(DetailHandler[MetaItem]):
"""
Processes the transports detail requests of a Service Pool
"""
@staticmethod
def as_dict(item: models.MetaPoolMember) -> dict[str, typing.Any]:
return {
'id': item.uuid,
'pool_id': item.pool.uuid,
'name': item.pool.name,
'comments': item.pool.comments,
'priority': item.priority,
'enabled': item.enabled,
'user_services_count': item.pool.userServices.exclude(state__in=State.INFO_STATES).count(),
'user_services_in_preparation': item.pool.userServices.filter(state=State.PREPARING).count(),
}
def as_dict(item: models.MetaPoolMember) -> 'MetaItem':
return MetaItem(
id=item.uuid,
pool_id=item.pool.uuid,
name=item.pool.name,
comments=item.pool.comments,
priority=item.priority,
enabled=item.enabled,
user_services_count=item.pool.userServices.exclude(state__in=State.INFO_STATES).count(),
user_services_in_preparation=item.pool.userServices.filter(state=State.PREPARING).count(),
)
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ItemsResult['MetaItem']:
parent = ensure.is_instance(parent, models.MetaPool)
try:
if not item:
return [MetaServicesPool.as_dict(i) for i in parent.members.all()]
return [MetaServicesPool.as_dict(i) for i in self.filter_queryset(parent.members.all())]
i = parent.members.get(uuid=process_uuid(item))
return MetaServicesPool.as_dict(i)
except Exception:
except models.MetaPoolMember.DoesNotExist:
raise exceptions.rest.NotFound(_('Meta pool member not found: {}').format(item)) from None
except Exception as e:
logger.exception('err: %s', item)
raise self.invalid_item_response()
raise exceptions.rest.RequestError(f'Error retrieving meta pool member: {e}') from e
def get_title(self, parent: 'Model') -> str:
return _('Service pools')
def get_fields(self, parent: 'Model') -> list[typing.Any]:
return [
{'priority': {'title': _('Priority'), 'type': 'numeric', 'width': '6em'}},
{'name': {'title': _('Service Pool name')}},
{'enabled': {'title': _('Enabled')}},
]
def get_table(self, parent: 'Model') -> types.rest.TableInfo:
parent = ensure.is_instance(parent, models.MetaPool)
return (
ui_utils.TableBuilder(_('Members of {0}').format(parent.name))
.text_column(name='name', title=_('Name'))
.text_column(name='comments', title=_('Comments'))
.numeric_column(name='priority', title=_('Priority'))
.text_column(name='enabled', title=_('Enabled'))
.build()
)
def save_item(self, parent: 'Model', item: typing.Optional[str]) -> typing.Any:
parent = ensure.is_instance(parent, models.MetaPool)
@@ -105,13 +128,13 @@ class MetaServicesPool(DetailHandler):
if uuid is not None:
member = parent.members.get(uuid=uuid)
member.pool = pool
member.pool = pool
member.enabled = enabled
member.priority = priority
member.save()
else:
member = parent.members.create(pool=pool, priority=priority, enabled=enabled)
log.log(
parent,
types.log.LogLevel.INFO,
@@ -122,7 +145,6 @@ class MetaServicesPool(DetailHandler):
return {'id': member.uuid}
def delete_item(self, parent: 'Model', item: str) -> None:
parent = ensure.is_instance(parent, models.MetaPool)
member = parent.members.get(uuid=process_uuid(self._args[0]))
@@ -133,7 +155,7 @@ class MetaServicesPool(DetailHandler):
log.log(parent, types.log.LogLevel.INFO, log_str, types.log.LogSource.ADMIN)
class MetaAssignedService(DetailHandler):
class MetaAssignedService(DetailHandler[UserServiceItem]):
"""
Rest handler for Assigned Services, wich parent is Service
"""
@@ -143,10 +165,10 @@ class MetaAssignedService(DetailHandler):
meta_pool: 'models.MetaPool',
item: 'models.UserService',
props: typing.Optional[dict[str, typing.Any]],
) -> dict[str, typing.Any]:
element = AssignedService.item_as_dict(item, props, False)
element['pool_id'] = item.deployed_service.uuid
element['pool_name'] = item.deployed_service.name
) -> 'UserServiceItem':
element = AssignedUserService.userservice_item(item, props, False)
element.pool_id = item.deployed_service.uuid
element.pool_name = item.deployed_service.name
return element
def _get_assigned_userservice(self, metapool: models.MetaPool, userservice_id: str) -> models.UserService:
@@ -160,17 +182,21 @@ class MetaAssignedService(DetailHandler):
cache_level=0,
deployed_service__in=[i.pool for i in metapool.members.all()],
)[0]
except IndexError:
raise exceptions.rest.NotFound(_('User service not found: {}').format(userservice_id)) from None
except Exception:
raise self.invalid_item_response()
logger.error('Error getting assigned userservice %s for metapool %s', userservice_id, metapool.uuid)
raise exceptions.rest.RequestError(
_('Error retrieving assigned service: {}').format(userservice_id)
) from None
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ItemsResult[UserServiceItem]:
parent = ensure.is_instance(parent, models.MetaPool)
def _assigned_userservices_for_pools() -> (
typing.Generator[
tuple[models.UserService, typing.Optional[dict[str, typing.Any]]], None, None
]
typing.Generator[tuple[models.UserService, typing.Optional[dict[str, typing.Any]]], None, None]
):
for m in parent.members.filter(enabled=True):
for m in self.filter_queryset(parent.members.filter(enabled=True)):
properties: dict[str, typing.Any] = {
k: v
for k, v in models.Properties.objects.filter(
@@ -203,47 +229,40 @@ class MetaAssignedService(DetailHandler):
).values_list('key', 'value')
},
)
except Exception:
except Exception as e:
logger.exception('get_items')
raise self.invalid_item_response()
raise exceptions.rest.RequestError(f'Error retrieving meta pool member: {e}') from e
def get_title(self, parent: 'Model') -> str:
def get_table(self, parent: 'Model') -> TableInfo:
parent = ensure.is_instance(parent, models.MetaPool)
return _('Assigned services')
return (
ui_utils.TableBuilder(_('Assigned services to {0}').format(parent.name))
.datetime_column(name='creation_date', title=_('Creation date'))
.text_column(name='pool_name', title=_('Pool'))
.text_column(name='unique_id', title='Unique ID')
.text_column(name='ip', title=_('IP'))
.text_column(name='friendly_name', title=_('Friendly name'))
.dict_column(name='state', title=_('status'), dct=State.literals_dict())
.text_column(name='in_use', title=_('In Use'))
.text_column(name='source_host', title=_('Src Host'))
.text_column(name='source_ip', title=_('Src Ip'))
.text_column(name='owner', title=_('Owner'))
.text_column(name='actor_version', title=_('Actor version'))
.row_style(prefix='row-state-', field='state')
.build()
)
def get_fields(self, parent: 'Model') -> list[typing.Any]:
parent = ensure.is_instance(parent, models.MetaPool)
return [
{'creation_date': {'title': _('Creation date'), 'type': 'datetime'}},
{'pool_name': {'title': _('Pool')}},
{'unique_id': {'title': 'Unique ID'}},
{'ip': {'title': _('IP')}},
{'friendly_name': {'title': _('Friendly name')}},
{
'state': {
'title': _('status'),
'type': 'dict',
'dict': State.literals_dict(),
}
},
{'in_use': {'title': _('In Use')}},
{'source_host': {'title': _('Src Host')}},
{'source_ip': {'title': _('Src Ip')}},
{'owner': {'title': _('Owner')}},
{'actor_version': {'title': _('Actor version')}},
]
def get_row_style(self, parent: 'Model') -> types.ui.RowStyleInfo:
return types.ui.RowStyleInfo(prefix='row-state-', field='state')
def get_logs(self, parent: 'Model', item: str) -> list[typing.Any]:
parent = ensure.is_instance(parent, models.MetaPool)
try:
asigned_userservice = self._get_assigned_userservice(parent, item)
logger.debug('Getting logs for %s', asigned_userservice)
return log.get_logs(asigned_userservice)
except Exception:
raise self.invalid_item_response()
assigned_userservice = self._get_assigned_userservice(parent, item)
logger.debug('Getting logs for %s', assigned_userservice)
return log.get_logs(assigned_userservice)
except exceptions.rest.HandlerError:
raise
except Exception as e:
logger.error('Error getting logs for %s', e)
raise exceptions.rest.RequestError(f'Error retrieving logs for assigned service: {e}') from e
def delete_item(self, parent: 'Model', item: str) -> None:
parent = ensure.is_instance(parent, models.MetaPool)
@@ -256,16 +275,18 @@ class MetaAssignedService(DetailHandler):
self._user.pretty_name,
)
else:
log_str = 'Deleted cached service {} by {}'.format(userservice.friendly_name, self._user.pretty_name)
log_str = 'Deleted cached service {} by {}'.format(
userservice.friendly_name, self._user.pretty_name
)
if userservice.state in (State.USABLE, State.REMOVING):
userservice.release()
elif userservice.state == State.PREPARING:
userservice.cancel()
elif userservice.state == State.REMOVABLE:
raise self.invalid_item_response(_('Item already being removed'))
raise exceptions.rest.RequestError(_('Item already being removed'))
else:
raise self.invalid_item_response(_('Item is not removable'))
raise exceptions.rest.RequestError(_('Item is not removable'))
log.log(parent, types.log.LogLevel.INFO, log_str, types.log.LogSource.ADMIN)
@@ -273,14 +294,16 @@ class MetaAssignedService(DetailHandler):
def save_item(self, parent: 'Model', item: typing.Optional[str]) -> typing.Any:
parent = ensure.is_instance(parent, models.MetaPool)
if item is None:
raise self.invalid_item_response()
raise exceptions.rest.RequestError(_('Invalid item specified'))
fields = self.fields_from_params(['auth_id', 'user_id'])
userservice = self._get_assigned_userservice(parent, item)
user = models.User.objects.get(uuid=process_uuid(fields['user_id']))
log_str = 'Changing ownership of service from {} to {} by {}'.format(
userservice.user.pretty_name if userservice.user else 'unknown', user.pretty_name, self._user.pretty_name
userservice.user.pretty_name if userservice.user else 'unknown',
user.pretty_name,
self._user.pretty_name,
)
# If there is another service that has this same owner, raise an exception
@@ -291,7 +314,7 @@ class MetaAssignedService(DetailHandler):
.count()
> 0
):
raise self.invalid_response_response(
raise exceptions.rest.RequestError(
'There is already another user service assigned to {}'.format(user.pretty_name)
)
@@ -300,5 +323,5 @@ class MetaAssignedService(DetailHandler):
# Log change
log.log(parent, types.log.LogLevel.INFO, log_str, types.log.LogSource.ADMIN)
return {'id': userservice.uuid}

View File

@@ -30,91 +30,104 @@
'''
@Author: Adolfo Gómez, dkmaster at dkmon dot com
'''
import collections.abc
import dataclasses
import logging
import typing
import collections.abc
from django.db.models import Model
from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
from uds import models
from uds.core import mfas, types
from uds.core import exceptions, mfas, types
from uds.core.environment import Environment
from uds.core.util import ensure, permissions
from uds.core.util import ui as ui_utils
from uds.REST.model import ModelHandler
if typing.TYPE_CHECKING:
from django.db.models import Model
logger = logging.getLogger(__name__)
# Enclosed methods under /item path
class MFA(ModelHandler):
model = models.MFA
save_fields = ['name', 'comments', 'tags', 'remember_device', 'validity']
@dataclasses.dataclass
class MFAItem(types.rest.BaseRestItem):
id: str
name: str
remember_device: int
validity: int
tags: list[str]
comments: str
type: str
type_name: str
permission: int
table_title = _('Multi Factor Authentication')
table_fields = [
{'name': {'title': _('Name'), 'visible': True, 'type': 'iconType'}},
{'type_name': {'title': _('Type')}},
{'comments': {'title': _('Comments')}},
{'tags': {'title': _('tags'), 'visible': False}},
]
def enum_types(self) -> collections.abc.Iterable[type[mfas.MFA]]:
class MFA(ModelHandler[MFAItem]):
MODEL = models.MFA
FIELDS_TO_SAVE = ['name', 'comments', 'tags', 'remember_device', 'validity']
TABLE = (
ui_utils.TableBuilder(_('Multi Factor Authentication'))
.icon(name='name', title=_('Name'), visible=True)
.text_column(name='type_name', title=_('Type'))
.text_column(name='comments', title=_('Comments'))
.text_column(name='tags', title=_('tags'), visible=False)
.build()
)
@classmethod
def possible_types(cls: type[typing.Self]) -> collections.abc.Iterable[type[mfas.MFA]]:
return mfas.factory().providers().values()
def get_gui(self, type_: str) -> list[typing.Any]:
mfa_type = mfas.factory().lookup(type_)
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
mfa_type = mfas.factory().lookup(for_type)
if not mfa_type:
raise self.invalid_item_response()
raise exceptions.rest.NotFound(_('MFA type not found: {}').format(for_type))
# Create a temporal instance to get the gui
with Environment.temporary_environment() as env:
mfa = mfa_type(env, None)
local_gui = self.add_default_fields(mfa.gui_description(), ['name', 'comments', 'tags'])
self.add_field(
local_gui,
{
'name': 'remember_device',
'value': '0',
'min_value': '0',
'label': gettext('Device Caching'),
'tooltip': gettext('Time in hours to cache device so MFA is not required again. User based.'),
'type': types.ui.FieldType.NUMERIC,
'order': 111,
},
)
self.add_field(
local_gui,
{
'name': 'validity',
'value': '5',
'min_value': '0',
'label': gettext('MFA code validity'),
'tooltip': gettext('Time in minutes to allow MFA code to be used.'),
'type': types.ui.FieldType.NUMERIC,
'order': 112,
},
return (
ui_utils.GuiBuilder(100)
.add_stock_field(types.rest.stock.StockField.NAME)
.add_stock_field(types.rest.stock.StockField.COMMENTS)
.add_stock_field(
types.rest.stock.StockField.TAGS,
)
.add_fields(mfa.gui_description())
.add_numeric(
name='remember_device',
default=0,
min_value=0,
label=gettext('Device Caching'),
tooltip=gettext('Time in hours to cache device so MFA is not required again. User based.'),
)
.add_numeric(
name='validity',
default=5,
min_value=0,
label=gettext('MFA code validity'),
tooltip=gettext('Time in minutes to allow MFA code to be used.'),
)
.build()
)
return local_gui
def item_as_dict(self, item: 'Model') -> dict[str, typing.Any]:
def get_item(self, item: 'Model') -> MFAItem:
item = ensure.is_instance(item, models.MFA)
type_ = item.get_type()
return {
'id': item.uuid,
'name': item.name,
'remember_device': item.remember_device,
'validity': item.validity,
'tags': [tag.tag for tag in item.tags.all()],
'comments': item.comments,
'type': type_.mod_type(),
'type_name': type_.mod_name(),
'permission': permissions.effective_permissions(self._user, item),
}
return MFAItem(
id=item.uuid,
name=item.name,
remember_device=item.remember_device,
validity=item.validity,
tags=[tag.tag for tag in item.tags.all()],
comments=item.comments,
type=type_.mod_type(),
type_name=type_.mod_name(),
permission=permissions.effective_permissions(self._user, item),
)

View File

@@ -30,85 +30,77 @@
"""
@Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
import dataclasses
import logging
import typing
from django.utils.translation import gettext_lazy as _, gettext
from django.db.models import Model
from uds.models import Network
from uds.core import types
from uds.core.util import permissions, ensure
from uds.core.util import permissions, ensure, ui as ui_utils
from ..model import ModelHandler
if typing.TYPE_CHECKING:
from django.db.models import Model
logger = logging.getLogger(__name__)
# Enclosed methods under /item path
@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):
class Networks(ModelHandler[NetworkItem]):
"""
Processes REST requests about networks
Implements specific handling for network related requests using GUI
"""
model = Network
save_fields = ['name', 'net_string', 'tags']
MODEL = Network
FIELDS_TO_SAVE = ['name', 'net_string', 'tags']
table_title = _('Networks')
table_fields = [
{
'name': {
'title': _('Name'),
'visible': True,
'type': 'icon',
'icon': 'fa fa-globe text-success',
}
},
{'net_string': {'title': _('Range')}},
{
'transports_count': {
'title': _('Transports'),
'type': 'numeric',
'width': '8em',
}
},
{
'authenticators_count': {
'title': _('Authenticators'),
'type': 'numeric',
'width': '8em',
}
},
{'tags': {'title': _('tags'), 'visible': False}},
]
TABLE = (
ui_utils.TableBuilder(_('Networks'))
.text_column('name', _('Name'))
.text_column('net_string', _('Range'))
.numeric_column('transports_count', _('Transports'), width='8em')
.numeric_column('authenticators_count', _('Authenticators'), width='8em')
.text_column('tags', _('Tags'), visible=False)
.build()
)
def get_gui(self, type_: str) -> list[typing.Any]:
return self.add_field(
self.add_default_fields([], ['name', 'tags']),
{
'name': 'net_string',
'value': '',
'label': gettext('Network range'),
'tooltip': gettext(
'Network range. Accepts most network definitions formats (range, subnet, host, etc...'
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
return (
ui_utils.GuiBuilder()
.add_stock_field(types.rest.stock.StockField.NAME)
.add_stock_field(types.rest.stock.StockField.COMMENTS)
.add_stock_field(types.rest.stock.StockField.TAGS)
.add_text(
name='net_string',
label=gettext('Network range'),
tooltip=gettext(
'Network range. Accepts most network definitions formats (range, subnet, host, etc...)'
),
'type': types.ui.FieldType.TEXT,
'order': 100, # At end
},
)
.build()
)
def item_as_dict(self, item: 'Model') -> dict[str, typing.Any]:
def get_item(self, item: 'Model') -> NetworkItem:
item = ensure.is_instance(item, Network)
return {
'id': item.uuid,
'name': item.name,
'tags': [tag.tag for tag in item.tags.all()],
'net_string': item.net_string,
'transports_count': item.transports.count(),
'authenticators_count': item.authenticators.count(),
'permission': permissions.effective_permissions(self._user, item),
}
return NetworkItem(
id=item.uuid,
name=item.name,
tags=[tag.tag for tag in item.tags.all()],
net_string=item.net_string,
transports_count=item.transports.count(),
authenticators_count=item.authenticators.count(),
permission=permissions.effective_permissions(self._user, item),
)

View File

@@ -30,33 +30,46 @@
'''
@Author: Adolfo Gómez, dkmaster at dkmon dot com
'''
import collections.abc
import dataclasses
import logging
import typing
import collections.abc
from django.db.models import Model
from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
from uds.core import messaging, types
from uds.core import exceptions, messaging, types
from uds.core.environment import Environment
from uds.core.ui import gui
from uds.core.util import ensure, permissions
from uds.core.util import ui as ui_utils
from uds.models import LogLevel, Notifier
from uds.REST.model import ModelHandler
if typing.TYPE_CHECKING:
from django.db.models import Model
logger = logging.getLogger(__name__)
# Enclosed methods under /item path
class Notifiers(ModelHandler):
path = 'messaging'
model = Notifier
save_fields = [
@dataclasses.dataclass
class NotifierItem(types.rest.BaseRestItem):
id: str
name: str
level: str
enabled: bool
tags: list[str]
comments: str
type: str
type_name: str
permission: types.permissions.PermissionType
class Notifiers(ModelHandler[NotifierItem]):
PATH = 'messaging'
MODEL = Notifier
FIELDS_TO_SAVE = [
'name',
'comments',
'level',
@@ -64,66 +77,64 @@ class Notifiers(ModelHandler):
'enabled',
]
table_title = _('Notifiers')
table_fields = [
{'name': {'title': _('Name'), 'visible': True, 'type': 'iconType'}},
{'type_name': {'title': _('Type')}},
{'level': {'title': _('Level')}},
{'enabled': {'title': _('Enabled')}},
{'comments': {'title': _('Comments')}},
{'tags': {'title': _('tags'), 'visible': False}},
]
TABLE = (
ui_utils.TableBuilder(_('Notifiers'))
.icon(name='name', title=_('Name'))
.text_column(name='type_name', title=_('Type'))
.text_column(name='level', title=_('Level'))
.boolean(name='enabled', title=_('Enabled'))
.text_column(name='comments', title=_('Comments'))
.text_column(name='tags', title=_('Tags'), visible=False)
).build()
def enum_types(self) -> collections.abc.Iterable[type[messaging.Notifier]]:
@classmethod
def possible_types(cls: type[typing.Self]) -> collections.abc.Iterable[type[messaging.Notifier]]:
return messaging.factory().providers().values()
def get_gui(self, type_: str) -> list[typing.Any]:
notifier_type = messaging.factory().lookup(type_)
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
notifier_type = messaging.factory().lookup(for_type)
if not notifier_type:
raise self.invalid_item_response()
raise exceptions.rest.NotFound(_('Notifier type not found: {}').format(for_type))
with Environment.temporary_environment() as env:
notifier = notifier_type(env, None)
local_gui = self.add_default_fields(
notifier.gui_description(), ['name', 'comments', 'tags']
return (
(
ui_utils.GuiBuilder(100)
.add_stock_field(types.rest.stock.StockField.NAME)
.add_stock_field(types.rest.stock.StockField.COMMENTS)
.add_stock_field(types.rest.stock.StockField.TAGS)
)
.add_fields(notifier.gui_description())
.add_choice(
name='level',
choices=[gui.choice_item(i[0], i[1]) for i in LogLevel.interesting()],
label=gettext('Level'),
tooltip=gettext('Level of notifications'),
default=str(LogLevel.ERROR.value),
)
.add_checkbox(
name='enabled',
label=gettext('Enabled'),
tooltip=gettext('If checked, this notifier will be used'),
default=True,
)
.build()
)
for field in [
{
'name': 'level',
'choices': [gui.choice_item(i[0], i[1]) for i in LogLevel.interesting()],
'label': gettext('Level'),
'tooltip': gettext('Level of notifications'),
'type': types.ui.FieldType.CHOICE,
'order': 102,
'default': str(LogLevel.ERROR.value),
},
{
'name': 'enabled',
'label': gettext('Enabled'),
'tooltip': gettext('If checked, this notifier will be used'),
'type': types.ui.FieldType.CHECKBOX,
'order': 103,
'default': True,
}
]:
self.add_field(local_gui, field)
return local_gui
def item_as_dict(self, item: 'Model') -> dict[str, typing.Any]:
def get_item(self, item: 'Model') -> NotifierItem:
item = ensure.is_instance(item, Notifier)
type_ = item.get_type()
return {
'id': item.uuid,
'name': item.name,
'level': str(item.level),
'enabled': item.enabled,
'tags': [tag.tag for tag in item.tags.all()],
'comments': item.comments,
'type': type_.mod_type(),
'type_name': type_.mod_name(),
'permission': permissions.effective_permissions(self._user, item),
}
return NotifierItem(
id=item.uuid,
name=item.name,
level=str(item.level),
enabled=item.enabled,
tags=[tag.tag for tag in item.tags.all()],
comments=item.comments,
type=type_.mod_type(),
type_name=type_.mod_name(),
permission=permissions.effective_permissions(self._user, item),
)

View File

@@ -30,21 +30,23 @@
"""
Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
import dataclasses
import datetime
import json
import logging
import typing
from django.utils.translation import gettext as _
from django.db.models import Model
from uds.core import types, consts
from uds.core.util import log, ensure
from uds.core import exceptions, types, consts
from uds.core.types.rest import TableInfo
from uds.core.util import log, ensure, ui as ui_utils
from uds.core.util.model import process_uuid
from uds import models
from uds.REST.model import DetailHandler
# Not imported at runtime, just for type checking
if typing.TYPE_CHECKING:
from django.db.models import Model
logger = logging.getLogger(__name__)
@@ -52,38 +54,50 @@ ALLOW = 'ALLOW'
DENY = 'DENY'
class AccessCalendars(DetailHandler):
@staticmethod
def as_dict(item: 'models.CalendarAccess|models.CalendarAccessMeta') -> types.rest.ItemDictType:
return {
'id': item.uuid,
'calendar_id': item.calendar.uuid,
'calendar': item.calendar.name,
'access': item.access,
'priority': item.priority,
}
@dataclasses.dataclass
class AccessCalendarItem(types.rest.BaseRestItem):
id: str
calendar_id: str
calendar: str
access: str
priority: int
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
class AccessCalendars(DetailHandler[AccessCalendarItem]):
@staticmethod
def as_item(item: 'models.CalendarAccess|models.CalendarAccessMeta') -> AccessCalendarItem:
return AccessCalendarItem(
id=item.uuid,
calendar_id=item.calendar.uuid,
calendar=item.calendar.name,
access=item.access,
priority=item.priority,
)
def get_items(
self, parent: 'Model', item: typing.Optional[str]
) -> types.rest.ItemsResult[AccessCalendarItem]:
# parent can be a ServicePool or a metaPool
parent = typing.cast(typing.Union['models.ServicePool', 'models.MetaPool'], parent)
try:
if not item:
return [AccessCalendars.as_dict(i) for i in parent.calendarAccess.all()]
return AccessCalendars.as_dict(parent.calendarAccess.get(uuid=process_uuid(item)))
return [AccessCalendars.as_item(i) for i in self.filter_queryset(parent.calendarAccess.all())]
return AccessCalendars.as_item(parent.calendarAccess.get(uuid=process_uuid(item)))
except models.CalendarAccess.DoesNotExist:
raise exceptions.rest.NotFound(_('Access calendar not found: {}').format(item)) from None
except Exception as e:
logger.exception('err: %s', item)
raise self.invalid_item_response() from e
raise exceptions.rest.RequestError(f'Error retrieving access calendar: {e}') from e
def get_title(self, parent: 'Model') -> str:
return _('Access restrictions by calendar')
def get_fields(self, parent: 'Model') -> list[typing.Any]:
return [
{'priority': {'title': _('Priority'), 'type': 'numeric', 'width': '6em'}},
{'calendar': {'title': _('Calendar')}},
{'access': {'title': _('Access')}},
]
def get_table(self, parent: 'Model') -> types.rest.TableInfo:
return (
ui_utils.TableBuilder(_('Access calendars'))
.numeric_column('priority', _('Priority'))
.text_column('calendar', _('Calendar'))
.text_column('access', _('Access'))
.build()
)
def save_item(self, parent: 'Model', item: typing.Optional[str]) -> typing.Any:
parent = typing.cast(typing.Union['models.ServicePool', 'models.MetaPool'], parent)
@@ -91,12 +105,20 @@ class AccessCalendars(DetailHandler):
uuid = process_uuid(item) if item is not None else None
try:
calendar: models.Calendar = models.Calendar.objects.get(uuid=process_uuid(self._params['calendar_id']))
calendar: models.Calendar = models.Calendar.objects.get(
uuid=process_uuid(self._params['calendar_id'])
)
access: str = self._params['access'].upper()
if access not in (ALLOW, DENY):
raise Exception()
except models.Calendar.DoesNotExist:
raise exceptions.rest.NotFound(
_('Calendar not found: {}').format(self._params['calendar_id'])
) from None
except Exception as e:
raise self.invalid_request_response(_('Invalid parameters on request')) from e
logger.error('Error saving calendar access: %s', e)
raise exceptions.rest.RequestError(_('Invalid parameters on request')) from e
priority = int(self._params['priority'])
if uuid is not None:
@@ -114,7 +136,7 @@ class AccessCalendars(DetailHandler):
f'{"Added" if uuid is None else "Updated"} access calendar {calendar.name}/{access} by {self._user.pretty_name}',
types.log.LogSource.ADMIN,
)
return {'id': calendar_access.uuid}
def delete_item(self, parent: 'Model', item: str) -> None:
@@ -126,64 +148,76 @@ class AccessCalendars(DetailHandler):
log.log(parent, types.log.LogLevel.INFO, log_str, types.log.LogSource.ADMIN)
class ActionsCalendars(DetailHandler):
@dataclasses.dataclass
class ActionCalendarItem(types.rest.BaseRestItem):
id: str
calendar_id: str
calendar: str
action: str
description: str
at_start: bool
events_offset: int
params: dict[str, typing.Any]
pretty_params: str
next_execution: typing.Optional[datetime.datetime]
last_execution: typing.Optional[datetime.datetime]
class ActionsCalendars(DetailHandler[ActionCalendarItem]):
"""
Processes the transports detail requests of a Service Pool
"""
custom_methods = [
CUSTOM_METHODS = [
'execute',
]
@staticmethod
def as_dict(item: 'models.CalendarAction') -> dict[str, typing.Any]:
def as_dict(item: 'models.CalendarAction') -> ActionCalendarItem:
action = consts.calendar.CALENDAR_ACTION_DICT.get(item.action)
descrption = action.get('description') if action is not None else ''
params = json.loads(item.params)
return {
'id': item.uuid,
'calendar_id': item.calendar.uuid,
'calendar': item.calendar.name,
'action': item.action,
'description': descrption,
'at_start': item.at_start,
'events_offset': item.events_offset,
'params': params,
'pretty_params': item.pretty_params,
'next_execution': item.next_execution,
'last_execution': item.last_execution,
}
return ActionCalendarItem(
id=item.uuid,
calendar_id=item.calendar.uuid,
calendar=item.calendar.name,
action=item.action,
description=descrption,
at_start=item.at_start,
events_offset=item.events_offset,
params=params,
pretty_params=item.pretty_params,
next_execution=item.next_execution,
last_execution=item.last_execution,
)
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
def get_items(
self, parent: 'Model', item: typing.Optional[str]
) -> types.rest.ItemsResult[ActionCalendarItem]:
parent = ensure.is_instance(parent, models.ServicePool)
try:
if item is None:
return [ActionsCalendars.as_dict(i) for i in parent.calendaraction_set.all()]
return [ActionsCalendars.as_dict(i) for i in self.filter_queryset(parent.calendaraction_set.all())]
i = parent.calendaraction_set.get(uuid=process_uuid(item))
return ActionsCalendars.as_dict(i)
except models.CalendarAction.DoesNotExist:
raise exceptions.rest.NotFound(_('Scheduled action not found: {}').format(item)) from None
except Exception as e:
raise self.invalid_item_response() from e
logger.error('Error retrieving scheduled action %s: %s', item, e)
raise exceptions.rest.RequestError(f'Error retrieving scheduled action: {e}') from e
def get_title(self, parent: 'Model') -> str:
return _('Scheduled actions')
def get_fields(self, parent: 'Model') -> list[typing.Any]:
return [
{'calendar': {'title': _('Calendar')}},
{'description': {'title': _('Action')}},
{'pretty_params': {'title': _('Parameters')}},
{
'at_start': {
'title': _('Relative to'),
'type': 'dict',
'dict': {True: _('Start'), False: _('End')},
}
},
# {'at_start': {'title': _('At start')}},
{'events_offset': {'title': _('Time offset')}},
{'next_execution': {'title': _('Next execution'), 'type': 'datetime'}},
{'last_execution': {'title': _('Last execution'), 'type': 'datetime'}},
]
def get_table(self, parent: 'Model') -> TableInfo:
return (
ui_utils.TableBuilder(_('Scheduled actions'))
.text_column('calendar', _('Calendar'))
.text_column('description', _('Action'))
.text_column('pretty_params', _('Parameters'))
.dict_column('at_start', _('Relative to'), dct={True: _('Start'), False: _('End')})
.text_column('events_offset', _('Time offset'))
.datetime_column('next_execution', _('Next execution'))
.datetime_column('last_execution', _('Last execution'))
.build()
)
def save_item(self, parent: 'Model', item: typing.Optional[str]) -> typing.Any:
parent = ensure.is_instance(parent, models.ServicePool)
@@ -193,7 +227,7 @@ class ActionsCalendars(DetailHandler):
calendar = models.Calendar.objects.get(uuid=process_uuid(self._params['calendar_id']))
action = self._params['action'].upper()
if action not in consts.calendar.CALENDAR_ACTION_DICT:
raise self.invalid_request_response()
raise exceptions.rest.RequestError(_('Invalid action: {}').format(action))
events_offset = int(self._params['events_offset'])
at_start = self._params['at_start'] not in ('false', False, '0', 0)
params = json.dumps(self._params['params'])
@@ -225,7 +259,7 @@ class ActionsCalendars(DetailHandler):
)
log.log(parent, types.log.LogLevel.INFO, log_string, types.log.LogSource.ADMIN)
return {'id': calendar_action.uuid}
def delete_item(self, parent: 'Model', item: str) -> None:
@@ -247,7 +281,7 @@ class ActionsCalendars(DetailHandler):
logger.debug('Launching action')
uuid = process_uuid(item)
calendar_action: models.CalendarAction = models.CalendarAction.objects.get(uuid=uuid)
self.ensure_has_access(calendar_action, types.permissions.PermissionType.MANAGEMENT)
self.check_access(calendar_action, types.permissions.PermissionType.MANAGEMENT)
log_str = (
f'Launched scheduled action "{calendar_action.calendar.name},'

View File

@@ -31,55 +31,70 @@
Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
import collections.abc
import dataclasses
import logging
import typing
from django.utils.translation import gettext, gettext_lazy as _
from django.db.models import Model
from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
from uds.core import exceptions, osmanagers, types
from uds.core.environment import Environment
from uds.core.util import ensure, permissions
from uds.core.util import ui as ui_utils
from uds.models import OSManager
from uds.REST.model import ModelHandler
if typing.TYPE_CHECKING:
from django.db.models import Model
logger = logging.getLogger(__name__)
# Enclosed methods under /osm path
class OsManagers(ModelHandler):
model = OSManager
save_fields = ['name', 'comments', 'tags']
@dataclasses.dataclass
class OsManagerItem(types.rest.ManagedObjectItem[OSManager]):
id: str
name: str
tags: list[str]
deployed_count: int
servicesTypes: list[str]
comments: str
permission: types.permissions.PermissionType
table_title = _('OS Managers')
table_fields = [
{'name': {'title': _('Name'), 'visible': True, 'type': 'iconType'}},
{'type_name': {'title': _('Type')}},
{'comments': {'title': _('Comments')}},
{'deployed_count': {'title': _('Used by'), 'type': 'numeric', 'width': '8em'}},
{'tags': {'title': _('tags'), 'visible': False}},
]
def os_manager_as_dict(self, osm: OSManager) -> dict[str, typing.Any]:
type_ = osm.get_type()
return {
'id': osm.uuid,
'name': osm.name,
'tags': [tag.tag for tag in osm.tags.all()],
'deployed_count': osm.deployedServices.count(),
'type': type_.mod_type(),
'type_name': type_.mod_name(),
'servicesTypes': [
class OsManagers(ModelHandler[OsManagerItem]):
MODEL = OSManager
FIELDS_TO_SAVE = ['name', 'comments', 'tags']
TABLE = (
ui_utils.TableBuilder(_('OS Managers'))
.icon(name='name', title=_('Name'))
.text_column(name='type_name', title=_('Type'))
.text_column(name='comments', title=_('Comments'))
.numeric_column(name='deployed_count', title=_('Used by'), width='8em')
.text_column(name='tags', title=_('Tags'), visible=False)
.build()
)
def os_manager_as_dict(self, item: OSManager) -> OsManagerItem:
type_ = item.get_type()
ret_value = OsManagerItem(
id=item.uuid,
name=item.name,
tags=[tag.tag for tag in item.tags.all()],
deployed_count=item.deployedServices.count(),
servicesTypes=[
type_.services_types
], # A list for backward compatibility. TODO: To be removed when admin interface is changed
'comments': osm.comments,
'permission': permissions.effective_permissions(self._user, osm),
}
comments=item.comments,
permission=permissions.effective_permissions(self._user, item),
item=item,
)
# Fill type and type_name
return ret_value
def item_as_dict(self, item: 'Model') -> types.rest.ItemDictType:
def get_item(self, item: 'Model') -> OsManagerItem:
item = ensure.is_instance(item, OSManager)
return self.os_manager_as_dict(item)
@@ -92,22 +107,26 @@ class OsManagers(ModelHandler):
)
# Types related
def enum_types(self) -> collections.abc.Iterable[type[osmanagers.OSManager]]:
@classmethod
def possible_types(cls: type[typing.Self]) -> collections.abc.Iterable[type[osmanagers.OSManager]]:
return osmanagers.factory().providers().values()
# Gui related
def get_gui(self, type_: str) -> list[typing.Any]:
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
try:
osmanager_type = osmanagers.factory().lookup(type_)
osmanager_type = osmanagers.factory().lookup(for_type)
if not osmanager_type:
raise exceptions.rest.NotFound('OS Manager type not found')
with Environment.temporary_environment() as env:
osmanager = osmanager_type(env, None)
return self.add_default_fields(
osmanager.gui_description(),
['name', 'comments', 'tags'],
return (
ui_utils.GuiBuilder()
.add_stock_field(types.rest.stock.StockField.NAME)
.add_stock_field(types.rest.stock.StockField.TAGS)
.add_stock_field(types.rest.stock.StockField.COMMENTS)
.add_fields(osmanager.gui_description())
.build()
)
except:
raise exceptions.rest.NotFound('type not found')
raise exceptions.rest.NotFound(_('OS Manager type not found: {}').format(for_type))

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 exceptions
from uds.core import consts, exceptions
from uds.core.util import permissions
from uds.core.util.rest.tools import match
from uds.core.util.rest.tools import match_args
from uds.REST import Handler
# Not imported at runtime, just for type checking
if typing.TYPE_CHECKING:
from django.db.models import Model
logger = logging.getLogger(__name__)
@@ -54,7 +54,7 @@ class Permissions(Handler):
Processes permissions requests
"""
needs_admin = True
ROLE = consts.UserRole.ADMIN
@staticmethod
def get_class(class_name: str) -> type['Model']:
@@ -72,7 +72,6 @@ class Permissions(Handler):
'mfa': models.MFA,
'servers-groups': models.ServerGroup,
'tunnels-tunnels': models.ServerGroup, # Same as servers-groups, but different items
}.get(class_name, None)
if cls is None:
@@ -95,21 +94,19 @@ class Permissions(Handler):
entity = perm.user
# If entity is None, it means that the permission is not valid anymore (user or group deleted on db manually?)
if not entity:
continue
res.append(
{
'id': perm.uuid,
'type': kind,
'auth': entity.manager.uuid,
'auth_name': entity.manager.name,
'entity_id': entity.uuid,
'entity_name': entity.name,
'perm': perm.permission,
'perm_name': perm.as_str,
}
)
if entity:
res.append(
{
'id': perm.uuid,
'type': kind,
'auth': entity.manager.uuid,
'auth_name': entity.manager.name,
'entity_id': entity.uuid,
'entity_name': entity.name,
'perm': perm.permission,
'perm_name': perm.as_str,
}
)
return sorted(res, key=lambda v: v['auth_name'] + v['entity_name'])
@@ -118,10 +115,10 @@ class Permissions(Handler):
Processes get requests
"""
logger.debug('Permissions args for GET: %s', self._args)
# Update some XXX/YYYY to XXX-YYYY (as server/groups, that is a valid class name)
if len(self._args) == 3:
self._args = [self._args[0]+ '-' + self._args[1], self._args[2]]
self._args = [self._args[0] + '-' + self._args[1], self._args[2]]
if len(self._args) != 2:
raise exceptions.rest.RequestError('Invalid request')
@@ -136,11 +133,17 @@ class Permissions(Handler):
Processes put requests
"""
logger.debug('Put args: %s', self._args)
# Update some XXX/YYYY to XXX-YYYY (as server/groups, that is a valid class name)
if len(self._args) == 6:
self._args = [self._args[0]+ '-' + self._args[1], self._args[2], self._args[3], self._args[4], self._args[5]]
self._args = [
self._args[0] + '-' + self._args[1],
self._args[2],
self._args[3],
self._args[4],
self._args[5],
]
if len(self._args) != 5 and len(self._args) != 1:
raise exceptions.rest.RequestError('Invalid request')
@@ -169,33 +172,10 @@ class Permissions(Handler):
raise exceptions.rest.RequestError('Invalid request')
# match is a helper function that will match the args with the given patterns
return match(self._args,
return match_args(
self._args,
no_match,
(('<cls>', '<obj>', 'users', 'add', '<user>'), add_user_permission),
(('<cls>', '<obj>', 'groups', 'add', '<group>'), add_group_permission),
(('revoke', ), revoke)
(('revoke',), revoke),
)
# Old code: (Replaced by code above :) )
# if la == 5 and self._args[3] == 'add':
#
# cls = Permissions.getClass(self._args[0])
#
# obj = cls.objects.get(uuid=self._args[1])
#
# if self._args[2] == 'users':
# user = models.User.objects.get(uuid=self._args[4])
# permissions.add_user_permission(user, obj, perm)
# elif self._args[2] == 'groups':
# group = models.Group.objects.get(uuid=self._args[4])
# permissions.add_group_permission(group, obj, perm)
# else:
# raise exceptions.rest.RequestError('Ivalid request')
# return Permissions.permsToDict(permissions.getPermissions(obj))
#
# if la == 1 and self._args[0] == 'revoke':
# for permId in self._params.get('items', []):
# permissions.revoke_permission_by_id(permId)
# return []
#
# raise exceptions.rest.RequestError('Invalid request')

View File

@@ -31,16 +31,17 @@
Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
import collections.abc
import dataclasses
import logging
import typing
from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
from django.utils.translation import gettext, gettext_lazy as _
from django.db.models import Model
import uds.core.types.permissions
from uds.core import exceptions, services, types
from uds.core.environment import Environment
from uds.core.util import ensure, permissions
from uds.core.util import ensure, permissions, ui as ui_utils
from uds.core.types.states import State
from uds.models import Provider, Service, UserService
from uds.REST.model import ModelHandler
@@ -50,67 +51,83 @@ from .services_usage import ServicesUsage
logger = logging.getLogger(__name__)
if typing.TYPE_CHECKING:
from django.db.models import Model
class Providers(ModelHandler):
"""
Providers REST handler
"""
# Helper class for Provider offers
@dataclasses.dataclass
class OfferItem(types.rest.BaseRestItem):
name: str
type: str
description: str
icon: str
model = Provider
detail = {'services': DetailServices, 'usage': ServicesUsage}
custom_methods = [('allservices', False), ('service', False), ('maintenance', True)]
@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
save_fields = ['name', 'comments', 'tags']
table_title = _('Service providers')
class Providers(ModelHandler[ProviderItem]):
# 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}},
MODEL = Provider
DETAIL = {'services': DetailServices, 'usage': ServicesUsage}
CUSTOM_METHODS = [
types.rest.ModelCustomMethod('allservices', False),
types.rest.ModelCustomMethod('service', False),
types.rest.ModelCustomMethod('maintenance', True),
]
# Field from where to get "class" and prefix for that class, so this will generate "row-state-A, row-state-X, ....
table_row_style = types.ui.RowStyleInfo(prefix='row-maintenance-', field='maintenance_mode')
def item_as_dict(self, item: 'Model') -> types.rest.ItemDictType:
FIELDS_TO_SAVE = ['name', 'comments', 'tags']
TABLE = (
ui_utils.TableBuilder(_('Service providers'))
.icon(name='name', title=_('Name'))
.text_column(name='type_name', title=_('Type'))
.text_column(name='comments', title=_('Comments'))
.numeric_column(name='services_count', title=_('Services'))
.numeric_column(name='user_services_count', title=_('User Services'))
.text_column(name='tags', title=_('Tags'), visible=False)
.row_style(prefix='row-maintenance-', field='maintenance_mode')
).build()
def get_item(self, item: 'Model') -> ProviderItem:
item = ensure.is_instance(item, Provider)
type_ = item.get_type()
# Icon can have a lot of data (1-2 Kbytes), but it's not expected to have a lot of services providers, and even so, this will work fine
offers = [
{
'name': gettext(t.mod_name()),
'type': t.mod_type(),
'description': gettext(t.description()),
'icon': t.icon64().replace('\n', ''),
}
offers: list[OfferItem] = [
OfferItem(
name=gettext(t.mod_name()),
type=t.mod_type(),
description=gettext(t.description()),
icon=t.icon64().replace('\n', ''),
)
for t in type_.get_provided_services()
]
return {
'id': item.uuid,
'name': item.name,
'tags': [tag.vtag for tag in item.tags.all()],
'services_count': item.services.count(),
'user_services_count': UserService.objects.filter(deployed_service__service__provider=item)
return ProviderItem(
id=item.uuid,
name=item.name,
tags=[tag.vtag for tag in item.tags.all()],
services_count=item.services.count(),
user_services_count=UserService.objects.filter(deployed_service__service__provider=item)
.exclude(state__in=(State.REMOVED, State.ERROR))
.count(),
'maintenance_mode': item.maintenance_mode,
'offers': offers,
'type': type_.mod_type(),
'type_name': type_.mod_name(),
'comments': item.comments,
'permission': permissions.effective_permissions(self._user, item),
}
maintenance_mode=item.maintenance_mode,
offers=offers,
comments=item.comments,
permission=permissions.effective_permissions(self._user, item),
item=item,
)
def validate_delete(self, item: 'Model') -> None:
item = ensure.is_instance(item, Provider)
@@ -118,19 +135,27 @@ class Providers(ModelHandler):
raise exceptions.rest.RequestError(gettext('Can\'t delete providers with services'))
# Types related
def enum_types(self) -> collections.abc.Iterable[type[services.ServiceProvider]]:
@classmethod
def possible_types(cls: type[typing.Self]) -> collections.abc.Iterable[type[services.ServiceProvider]]:
return services.factory().providers().values()
# Gui related
def get_gui(self, type_: str) -> list[typing.Any]:
provider_type = services.factory().lookup(type_)
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
provider_type = services.factory().lookup(for_type)
if provider_type:
with Environment.temporary_environment() as env:
provider = provider_type(env, None)
return self.add_default_fields(provider.gui_description(), ['name', 'comments', 'tags'])
return (
ui_utils.GuiBuilder()
.add_stock_field(types.rest.stock.StockField.NAME)
.add_stock_field(types.rest.stock.StockField.TAGS)
.add_stock_field(types.rest.stock.StockField.COMMENTS)
.add_fields(provider.gui_description(), parent='instance')
).build()
raise exceptions.rest.NotFound('Type not found!')
def allservices(self) -> typing.Generator[types.rest.ItemDictType, None, None]:
def allservices(self) -> typing.Generator[types.rest.BaseRestItem, None, None]:
"""
Custom method that returns "all existing services", no mater who's his daddy :)
"""
@@ -138,33 +163,33 @@ class Providers(ModelHandler):
try:
perm = permissions.effective_permissions(self._user, s)
if perm >= uds.core.types.permissions.PermissionType.READ:
yield DetailServices.service_to_dict(s, perm, True)
yield DetailServices.service_item(s, perm, True)
except Exception:
logger.exception('Passed service cause type is unknown')
def service(self) -> types.rest.ItemDictType:
def service(self) -> types.rest.BaseRestItem:
"""
Custom method that returns a service by its uuid, no matter who's his daddy
"""
try:
service = Service.objects.get(uuid=self._args[1])
self.ensure_has_access(service.provider, uds.core.types.permissions.PermissionType.READ)
self.check_access(service.provider, uds.core.types.permissions.PermissionType.READ)
perm = self.get_permissions(service.provider)
return DetailServices.service_to_dict(service, perm, True)
return DetailServices.service_item(service, perm, True)
except Exception:
# logger.exception('Exception')
return {}
return types.rest.BaseRestItem()
def maintenance(self, item: 'Model') -> types.rest.ItemDictType:
def maintenance(self, item: 'Model') -> types.rest.BaseRestItem:
"""
Custom method that swaps maintenance mode state for a provider
:param item:
"""
item = ensure.is_instance(item, Provider)
self.ensure_has_access(item, uds.core.types.permissions.PermissionType.MANAGEMENT)
self.check_access(item, uds.core.types.permissions.PermissionType.MANAGEMENT)
item.maintenance_mode = not item.maintenance_mode
item.save()
return self.item_as_dict(item)
return self.get_item(item)
def test(self, type_: str) -> str:
from uds.core.environment import Environment

View File

@@ -30,13 +30,15 @@
"""
Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
import dataclasses
import logging
import typing
from django.utils.translation import gettext_lazy as _
from uds.core import types, consts
from uds.core.util.rest.tools import match
from uds.core import exceptions, types, consts
from uds.core.util.rest.tools import match_args
from uds.core.util import ui as ui_utils
from uds.REST import model
from uds import reports
@@ -58,25 +60,37 @@ VALID_PARAMS = (
)
@dataclasses.dataclass
class ReportItem(types.rest.BaseRestItem):
id: str
mime_type: str
encoded: bool
group: str
name: str
description: str
# Enclosed methods under /actor path
class Reports(model.BaseModelHandler):
class Reports(model.BaseModelHandler[ReportItem]):
"""
Processes reports requests
"""
needs_admin = True # By default, staff is lower level needed
ROLE = consts.UserRole.ADMIN
table_title = _('Available reports')
table_fields = [
{'group': {'title': _('Group')}},
{'name': {'title': _('Name')}},
{'description': {'title': _('Description')}},
{'mime_type': {'title': _('Generates')}},
]
# Field from where to get "class" and prefix for that class, so this will generate "row-state-A, row-state-X, ....
table_row_style = types.ui.RowStyleInfo(prefix='row-state-', field='state')
TABLE = (
ui_utils.TableBuilder(_('Available reports'))
.text_column(name='group', title=_('Group'), visible=True)
.text_column(name='name', title=_('Name'), visible=True)
.text_column(name='description', title=_('Description'), visible=True)
.text_column(name='mime_type', title=_('Generates'), visible=True)
.row_style(prefix='row-state-', field='state')
.build()
)
def _locate_report(self, uuid: str, values: typing.Optional[typing.Dict[str, typing.Any]] = None) -> 'Report':
def _locate_report(
self, uuid: str, values: typing.Optional[typing.Dict[str, typing.Any]] = None
) -> 'Report':
found = None
logger.debug('Looking for report %s', uuid)
for i in reports.available_reports:
@@ -85,7 +99,7 @@ class Reports(model.BaseModelHandler):
break
if not found:
raise self.invalid_request_response('Invalid report uuid!')
raise exceptions.rest.NotFound(f'Report not found: {uuid}') from None
return found
@@ -93,21 +107,19 @@ class Reports(model.BaseModelHandler):
logger.debug('method GET for %s, %s', self.__class__.__name__, self._args)
def error() -> typing.NoReturn:
raise self.invalid_request_response()
raise exceptions.rest.RequestError('Invalid report uuid!')
def report_gui(report_id: str) -> typing.Any:
return self.get_gui(report_id)
return match(
return match_args(
self._args,
error,
((), lambda: list(self.get_items())),
((), lambda: list(self.filter_data(self.get_items()))),
((consts.rest.OVERVIEW,), lambda: list(self.get_items())),
(
(consts.rest.TABLEINFO,),
lambda: self.process_table_fields(
str(self.table_title), self.table_fields, self.table_row_style
),
lambda: self.TABLE.as_dict(),
),
((consts.rest.GUI, '<report>'), report_gui),
)
@@ -124,7 +136,7 @@ class Reports(model.BaseModelHandler):
)
if len(self._args) != 1:
raise self.invalid_request_response()
raise exceptions.rest.RequestError('Invalid report uuid!')
report = self._locate_report(self._args[0], self._params)
@@ -142,23 +154,21 @@ class Reports(model.BaseModelHandler):
return data
except Exception as e:
logger.exception('Generating report')
raise self.invalid_request_response(str(e))
raise exceptions.rest.RequestError(str(e)) from e
# Gui related
def get_gui(self, type_: str) -> list[typing.Any]:
report = self._locate_report(type_)
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
report = self._locate_report(for_type)
return sorted(report.gui_description(), key=lambda f: f['gui']['order'])
# Returns the list of
def get_items(
self, *args: typing.Any, **kwargs: typing.Any
) -> typing.Generator[types.rest.ItemDictType, None, None]:
def get_items(self, *args: typing.Any, **kwargs: typing.Any) -> typing.Generator[ReportItem, None, None]:
for i in reports.available_reports:
yield {
'id': i.get_uuid(),
'mime_type': i.mime_type,
'encoded': i.encoded,
'group': i.translated_group(),
'name': i.translated_name(),
'description': i.translated_description(),
}
yield ReportItem(
id=i.get_uuid(),
mime_type=i.mime_type,
encoded=i.encoded,
group=i.translated_group(),
name=i.translated_name(),
description=i.translated_description(),
)

View File

@@ -138,17 +138,34 @@ class ServerRegisterBase(Handler):
class ServerRegister(ServerRegisterBase):
needs_staff = True
path = 'servers'
name = 'register'
ROLE = consts.UserRole.STAFF
PATH = 'servers'
NAME = 'register'
@classmethod
def api_components(cls: type[typing.Self]) -> types.rest.api.Components:
return types.rest.api.Components(schemas={
'ServerRegisterItem': types.rest.api.Schema(
type='object',
description='A server object',
properties={
'id': types.rest.api.SchemaProperty(type='string'),
'name': types.rest.api.SchemaProperty(type='string'),
'ip': types.rest.api.SchemaProperty(type='string'),
'port': types.rest.api.SchemaProperty(type='integer'),
}
)
})
# REST handlers for server actions
class ServerTest(Handler):
authenticated = False # Test is not authenticated, the auth is the token to test itself
ROLE = consts.UserRole.ANONYMOUS
path = 'servers'
name = 'test'
PATH = 'servers'
NAME = 'test'
@decorators.blocker()
def post(self) -> collections.abc.MutableMapping[str, typing.Any]:
@@ -172,9 +189,9 @@ class ServerEvent(Handler):
* log
"""
authenticated = False # Actor requests are not authenticated normally
path = 'servers'
name = 'event'
ROLE = consts.UserRole.ANONYMOUS
PATH = 'servers'
NAME = 'event'
def get_user_service(self) -> models.UserService:
'''

View File

@@ -29,63 +29,92 @@
"""
Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
import dataclasses
import datetime
import logging
import typing
from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
from django.utils.translation import gettext, gettext_lazy as _
from django.db.models import Model
from uds import models
from uds.core import consts, types, ui
from uds.core.util import net, permissions, ensure
from uds.core import consts, exceptions, types
from uds.core.types.rest import TableInfo
from uds.core.util import net, permissions, ensure, ui as ui_utils
from uds.core.util.model import sql_now, process_uuid
from uds.core.exceptions.rest import NotFound, RequestError
from uds.REST.model import DetailHandler, ModelHandler
if typing.TYPE_CHECKING:
from django.db.models import Model
logger = logging.getLogger(__name__)
@dataclasses.dataclass
class TokenItem(types.rest.BaseRestItem):
id: str
name: str
stamp: datetime.datetime
username: str
ip: str
hostname: str
listen_port: int
mac: str
token: str
type: str
os: str
# REST API for Server Tokens management (for admin interface)
class ServersTokens(ModelHandler):
class ServersTokens(ModelHandler[TokenItem]):
# servers/groups/[id]/servers
model = models.Server
model_exclude = {
MODEL = models.Server
EXCLUDE = {
'type__in': [
types.servers.ServerType.ACTOR,
types.servers.ServerType.UNMANAGED,
]
}
path = 'servers'
name = 'tokens'
PATH = 'servers'
NAME = 'tokens'
table_title = _('Registered Servers')
table_fields = [
{'hostname': {'title': _('Hostname')}},
{'ip': {'title': _('IP')}},
{'type': {'title': _('Type'), 'type': 'dict', 'dict': dict(types.servers.ServerType.enumerate())}},
{'os': {'title': _('OS')}},
{'username': {'title': _('Issued by')}},
{'stamp': {'title': _('Date'), 'type': 'datetime'}},
]
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()
)
def item_as_dict(self, item: 'Model') -> dict[str, typing.Any]:
# 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:
item = typing.cast('models.Server', item) # We will receive for sure
return {
'id': item.uuid,
'name': str(_('Token isued by {} from {}')).format(item.register_username, item.ip),
'stamp': item.stamp,
'username': item.register_username,
'ip': item.ip,
'hostname': item.hostname,
'listen_port': item.listen_port,
'mac': item.mac,
'token': item.token,
'type': types.servers.ServerType(item.type).as_str(),
'os': item.os_type,
}
return TokenItem(
id=item.uuid,
name=str(_('Token isued by {} from {}')).format(item.register_username, item.ip),
stamp=item.stamp,
username=item.register_username,
ip=item.ip,
hostname=item.hostname,
listen_port=item.listen_port,
mac=item.mac,
token=item.token,
type=types.servers.ServerType(item.type).as_str(),
os=item.os_type,
)
def delete(self) -> str:
"""
@@ -94,154 +123,129 @@ class ServersTokens(ModelHandler):
if len(self._args) != 1:
raise RequestError('Delete need one and only one argument')
self.ensure_has_access(
self.model(), types.permissions.PermissionType.ALL, root=True
self.check_access(
self.MODEL(), types.permissions.PermissionType.ALL, root=True
) # Must have write permissions to delete
try:
self.model.objects.get(uuid=process_uuid(self._args[0])).delete()
except self.model.DoesNotExist:
self.MODEL.objects.get(uuid=process_uuid(self._args[0])).delete()
except self.MODEL.DoesNotExist:
raise NotFound('Element do not exists') from None
return consts.OK
# REST API For servers (except tunnel servers nor actors)
class ServersServers(DetailHandler):
custom_methods = ['maintenance', 'importcsv']
@dataclasses.dataclass
class ServerItem(types.rest.BaseRestItem):
id: str
hostname: str
ip: str
listen_port: int
mac: str
maintenance_mode: bool
register_username: str
stamp: datetime.datetime
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
# REST API For servers (except tunnel servers nor actors)
class ServersServers(DetailHandler[ServerItem]):
CUSTOM_METHODS = ['maintenance', 'importcsv']
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ItemsResult[ServerItem]:
parent = typing.cast('models.ServerGroup', parent) # We will receive for sure
try:
if item is None:
q = parent.servers.all()
q = self.filter_queryset(parent.servers.all())
else:
q = parent.servers.filter(uuid=process_uuid(item))
res: types.rest.ItemListType = []
res: list[ServerItem] = []
i = None
for i in q:
val = {
'id': i.uuid,
'hostname': i.hostname,
'ip': i.ip,
'listen_port': i.listen_port,
'mac': i.mac if i.mac != consts.MAC_UNKNOWN else '',
'maintenance_mode': i.maintenance_mode,
'register_username': i.register_username,
'stamp': i.stamp,
}
res.append(val)
res.append(
ServerItem(
id=i.uuid,
hostname=i.hostname,
ip=i.ip,
listen_port=i.listen_port,
mac=i.mac if i.mac != consts.MAC_UNKNOWN else '',
maintenance_mode=i.maintenance_mode,
register_username=i.register_username,
stamp=i.stamp,
)
)
if item is None:
return res
if not i:
raise Exception('Item not found')
raise exceptions.rest.NotFound(f'Server not found: {item}')
return res[0]
except Exception as e:
logger.exception('REST servers')
raise self.invalid_item_response() from e
def get_title(self, parent: 'Model') -> str:
parent = ensure.is_instance(parent, models.ServerGroup)
try:
return (_('Servers of {0}')).format(parent.name)
except exceptions.rest.HandlerError:
raise
except Exception:
return str(_('Servers'))
logger.exception('Error getting server')
raise exceptions.rest.ResponseError(_('Error getting server')) from None
def get_fields(self, parent: 'Model') -> list[typing.Any]:
def get_table(self, parent: 'Model') -> TableInfo:
parent = ensure.is_instance(parent, models.ServerGroup)
table_info = (
ui_utils.TableBuilder(_('Servers of {0}').format(parent.name))
.text_column(name='hostname', title=_('Hostname'))
.text_column(name='ip', title=_('Ip'))
.text_column(name='mac', title=_('Mac'))
)
if parent.is_managed():
table_info.text_column(name='listen_port', title=_('Port'))
return (
[
{
'hostname': {
'title': _('Hostname'),
}
},
{'ip': {'title': _('Ip')}},
] # If not managed, we can show mac, else listen port (related to UDS Server)
+ (
[
{'mac': {'title': _('Mac')}},
]
if not parent.is_managed()
else [{'listen_port': {'title': _('Port')}}]
table_info.dict_column(
name='maintenance_mode',
title=_('State'),
dct={True: _('Maintenance'), False: _('Normal')},
)
+ [
{
'maintenance_mode': {
'title': _('State'),
'type': 'dict',
'dict': {True: _('Maintenance'), False: _('Normal')},
}
},
]
.row_style(prefix='row-maintenance-', field='maintenance_mode')
.build()
)
def get_row_style(self, parent: 'Model') -> types.ui.RowStyleInfo:
return types.ui.RowStyleInfo(prefix='row-maintenance-', field='maintenance_mode')
def get_gui(self, parent: 'Model', for_type: str = '') -> list[typing.Any]:
def get_gui(self, parent: 'Model', for_type: str = '') -> list[types.ui.GuiElement]:
parent = ensure.is_instance(parent, models.ServerGroup)
kind, subkind = parent.server_type, parent.subtype
title = _('of type') + f' {subkind.upper()} {kind.name.capitalize()}'
gui_builder = ui_utils.GuiBuilder(order=100)
if kind == types.servers.ServerType.UNMANAGED:
return self.add_field(
[],
[
{
'name': 'hostname',
'value': '',
'label': gettext('Hostname'),
'tooltip': gettext('Hostname of the server. It must be resolvable by UDS'),
'type': types.ui.FieldType.TEXT,
'order': 100, # At end
},
{
'name': 'ip',
'value': '',
'label': gettext('IP'),
'tooltip': gettext('IP of the server. Used if hostname is not resolvable by UDS'),
'type': types.ui.FieldType.TEXT,
'order': 101, # At end
},
{
'name': 'mac',
'value': '',
'label': gettext('Server MAC'),
'tooltip': gettext('Optional MAC address of the server'),
'type': types.ui.FieldType.TEXT,
'order': 102, # At end
},
{
'name': 'title',
'value': title,
'type': types.ui.FieldType.INFO,
},
],
return (
gui_builder.add_text(
name='hostname',
label=gettext('Hostname'),
tooltip=gettext('Hostname of the server. It must be resolvable by UDS'),
default='',
)
.add_text(
name='ip',
label=gettext('IP'),
)
.add_text(
name='mac',
label=gettext('Server MAC'),
tooltip=gettext('Optional MAC address of the server'),
default='',
)
.add_info(
name='title',
default=title,
)
.build()
)
else:
return self.add_field(
[],
[
{
'name': 'server',
'value': '',
'label': gettext('Server'),
'tooltip': gettext('Server to include on group'),
'type': types.ui.FieldType.CHOICE,
'choices': [
ui.gui.choice_item(item.uuid, item.hostname)
for item in models.Server.objects.filter(type=parent.type, subtype=parent.subtype)
if item.groups.count() == 0
],
'order': 100, # At end
},
{
'name': 'title',
'value': title,
'type': types.ui.FieldType.INFO,
},
],
return (
gui_builder.add_text(
name='server',
label=gettext('Server'),
tooltip=gettext('Server to include on group'),
default='',
)
.add_info(name='title', default=title)
.build()
)
def save_item(self, parent: 'Model', item: typing.Optional[str]) -> typing.Any:
parent = ensure.is_instance(parent, models.ServerGroup)
@@ -251,10 +255,10 @@ class ServersServers(DetailHandler):
if item is None:
# Create new, depending on server type
if parent.type == types.servers.ServerType.UNMANAGED:
# Ensure mac is emty or valid
# Ensure mac is empty or valid
mac = self._params['mac'].strip().upper()
if mac and not net.is_valid_mac(mac):
raise self.invalid_request_response('Invalid MAC address')
raise exceptions.rest.RequestError(_('Invalid MAC address'))
# Create a new one, and add it to group
server = models.Server.objects.create(
register_username=self._user.pretty_name,
@@ -277,16 +281,20 @@ class ServersServers(DetailHandler):
# Check server type is also SERVER
if server and server.type != types.servers.ServerType.SERVER:
logger.error('Server type for %s is not SERVER', server.host)
raise self.invalid_request_response() from None
raise exceptions.rest.RequestError('Invalid server type') from None
parent.servers.add(server)
except Exception:
raise self.invalid_item_response() from None
except models.Server.DoesNotExist:
raise exceptions.rest.NotFound(f'Server not found: {self._params["server"]}') from None
except Exception as e:
logger.error('Error getting server: %s', e)
raise exceptions.rest.ResponseError('Error getting server') from None
return {'id': server.uuid}
else:
if parent.type == types.servers.ServerType.UNMANAGED:
mac = self._params['mac'].strip().upper()
if mac and not net.is_valid_mac(mac):
raise self.invalid_request_response('Invalid MAC address')
raise exceptions.rest.RequestError('Invalid MAC address')
try:
models.Server.objects.filter(uuid=process_uuid(item)).update(
# Update register info also on update
@@ -297,15 +305,19 @@ class ServersServers(DetailHandler):
mac=mac,
stamp=sql_now(), # Modified now
)
except Exception:
raise self.invalid_item_response() from None
except models.Server.DoesNotExist:
raise exceptions.rest.NotFound(f'Server not found: {item}') from None
except Exception as e:
logger.error('Error updating server: %s', e)
raise exceptions.rest.ResponseError('Error updating server') from None
else:
try:
server = models.Server.objects.get(uuid=process_uuid(item))
parent.servers.add(server)
except Exception:
raise self.invalid_item_response() from None
except models.Server.DoesNotExist:
raise exceptions.rest.NotFound(f'Server not found: {item}') from None
return {'id': item}
def delete_item(self, parent: 'Model', item: str) -> None:
@@ -317,8 +329,11 @@ class ServersServers(DetailHandler):
server.delete() # and delete server
else:
parent.servers.remove(server) # Just remove reference
except Exception:
raise self.invalid_item_response() from None
except models.Server.DoesNotExist:
raise exceptions.rest.NotFound(f'Server not found: {item}') from None
except Exception as e:
logger.error('Error deleting server %s from %s: %s', item, parent, e)
raise exceptions.rest.ResponseError('Error deleting server') from None
# Custom methods
def maintenance(self, parent: 'Model', id: str) -> typing.Any:
@@ -328,7 +343,7 @@ class ServersServers(DetailHandler):
:param item:
"""
item = models.Server.objects.get(uuid=process_uuid(id))
self.ensure_has_access(item, types.permissions.PermissionType.MANAGEMENT)
self.check_access(item, types.permissions.PermissionType.MANAGEMENT)
item.maintenance_mode = not item.maintenance_mode
item.save()
return 'ok'
@@ -405,71 +420,83 @@ class ServersServers(DetailHandler):
return import_errors
class ServersGroups(ModelHandler):
custom_methods = [('stats', True)]
model = models.ServerGroup
model_filter = {
@dataclasses.dataclass
class GroupItem(types.rest.BaseRestItem):
id: str
name: str
comments: str
type: str
subtype: str
type_name: str
tags: list[str]
servers_count: int
permission: types.permissions.PermissionType
class ServersGroups(ModelHandler[GroupItem]):
CUSTOM_METHODS = [
types.rest.ModelCustomMethod('stats', True),
]
MODEL = models.ServerGroup
FILTER = {
'type__in': [
types.servers.ServerType.SERVER,
types.servers.ServerType.UNMANAGED,
]
}
detail = {'servers': ServersServers}
DETAIL = {'servers': ServersServers}
path = 'servers'
name = 'groups'
PATH = 'servers'
NAME = 'groups'
save_fields = ['name', 'comments', 'type', 'tags'] # Subtype is appended on pre_save
table_title = _('Servers Groups')
table_fields = [
{'name': {'title': _('Name')}},
{'comments': {'title': _('Comments')}},
{'type_name': {'title': _('Type')}},
{'type': {'title': '', 'visible': False}},
{'subtype': {'title': _('Subtype')}},
{'servers_count': {'title': _('Servers')}},
{'tags': {'title': _('tags'), 'visible': False}},
]
FIELDS_TO_SAVE = ['name', 'comments', 'type', 'tags'] # Subtype is appended on pre_save
def get_types(
TABLE = (
ui_utils.TableBuilder(_('Servers Groups'))
.text_column(name='name', title=_('Name'), visible=True)
.text_column(name='comments', title=_('Comments'))
.text_column(name='type_name', title=_('Type'), visible=True)
.text_column(name='type', title='', visible=False)
.text_column(name='subtype', title=_('Subtype'), visible=True)
.numeric_column(name='servers_count', title=_('Servers'), width='5rem')
.text_column(name='tags', title=_('tags'), visible=False)
.build()
)
def enum_types(
self, *args: typing.Any, **kwargs: typing.Any
) -> typing.Generator[types.rest.TypeInfoDict, None, None]:
) -> typing.Generator[types.rest.TypeInfo, None, None]:
for i in types.servers.ServerSubtype.manager().enum():
v = types.rest.TypeInfo(
yield types.rest.TypeInfo(
name=i.description,
type=f'{i.type.name}@{i.subtype}',
description='',
icon=i.icon,
group=gettext('Managed') if i.managed else gettext('Unmanaged'),
).as_dict()
yield v
)
def get_gui(self, type_: str) -> list[typing.Any]:
if '@' not in type_: # If no subtype, use default
type_ += '@default'
kind, subkind = type_.split('@')[:2]
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
if '@' not in for_type: # If no subtype, use default
for_type += '@default'
kind, subkind = for_type.split('@')[:2]
if kind == types.servers.ServerType.SERVER.name:
kind = _('Standard')
elif kind == types.servers.ServerType.UNMANAGED.name:
kind = _('Unmanaged')
title = _('of type') + f' {subkind.upper()} {kind}'
return self.add_field(
self.add_default_fields(
[],
['name', 'comments', 'tags'],
),
[
{
'name': 'type',
'value': type_,
'type': types.ui.FieldType.HIDDEN,
},
{
'name': 'title',
'value': title,
'type': types.ui.FieldType.INFO,
},
],
return (
ui_utils.GuiBuilder()
.add_stock_field(types.rest.stock.StockField.NAME)
.add_stock_field(types.rest.stock.StockField.TAGS)
.add_stock_field(types.rest.stock.StockField.COMMENTS)
.add_hidden(name='type', default=for_type)
.add_info(
name='title',
default=title,
)
.build()
)
def pre_save(self, fields: dict[str, typing.Any]) -> None:
@@ -479,27 +506,27 @@ class ServersGroups(ModelHandler):
fields['subtype'] = subtype
return super().pre_save(fields)
def item_as_dict(self, item: 'Model') -> dict[str, typing.Any]:
def get_item(self, item: 'Model') -> GroupItem:
item = ensure.is_instance(item, models.ServerGroup)
return {
'id': item.uuid,
'name': item.name,
'comments': item.comments,
'type': f'{types.servers.ServerType(item.type).name}@{item.subtype}',
'subtype': item.subtype.capitalize(),
'type_name': types.servers.ServerType(item.type).name.capitalize(),
'tags': [tag.tag for tag in item.tags.all()],
'servers_count': item.servers.count(),
'permission': permissions.effective_permissions(self._user, item),
}
return GroupItem(
id=item.uuid,
name=item.name,
comments=item.comments,
type=f'{types.servers.ServerType(item.type).name}@{item.subtype}',
subtype=item.subtype.capitalize(),
type_name=types.servers.ServerType(item.type).name.capitalize(),
tags=[tag.tag for tag in item.tags.all()],
servers_count=item.servers.count(),
permission=permissions.effective_permissions(self._user, item),
)
def delete_item(self, item: 'Model') -> None:
item = ensure.is_instance(item, models.ServerGroup)
"""
Processes a DELETE request
"""
self.ensure_has_access(
self.model(), permissions.PermissionType.ALL, root=True
self.check_access(
self.MODEL(), permissions.PermissionType.ALL, root=True
) # Must have write permissions to delete
try:
@@ -508,7 +535,7 @@ class ServersGroups(ModelHandler):
for server in item.servers.all():
server.delete()
item.delete()
except self.model.DoesNotExist:
except self.MODEL.DoesNotExist:
raise NotFound('Element do not exists') from None
def stats(self, item: 'Model') -> typing.Any:

View File

@@ -30,106 +30,143 @@
"""
Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
import dataclasses
import logging
import typing
import collections.abc
from django.db import IntegrityError
from django.utils.translation import gettext as _
from django.db.models import Model
from uds import models
from uds.core import exceptions, types
from uds.core import exceptions, types, module, services
import uds.core.types.permissions
from uds.core.util import log, permissions, ensure
from uds.core.types.rest import TableInfo
from uds.core.util import log, permissions, ensure, ui as ui_utils
from uds.core.util.model import process_uuid
from uds.core.environment import Environment
from uds.core.consts.images import DEFAULT_THUMB_BASE64
from uds.core.ui import gui
from uds.core import ui
from uds.core.types.states import State
from uds.REST.model import DetailHandler
# Not imported at runtime, just for type checking
if typing.TYPE_CHECKING:
from django.db.models import Model
logger = logging.getLogger(__name__)
class Services(DetailHandler): # pylint: disable=too-many-public-methods
@dataclasses.dataclass
class ServiceItem(types.rest.ManagedObjectItem['models.Service']):
id: str
name: str
tags: list[str]
comments: str
deployed_services_count: int
user_services_count: int
max_services_count_type: str
maintenance_mode: bool
permission: int
info: 'ServiceInfo|types.rest.NotRequired' = types.rest.NotRequired.field()
@dataclasses.dataclass
class ServiceInfo(types.rest.BaseRestItem):
icon: str
needs_publication: bool
max_deployed: int
uses_cache: bool
uses_cache_l2: bool
cache_tooltip: str
cache_tooltip_l2: str
needs_osmanager: bool
allowed_protocols: list[str]
services_type_provided: str
can_reset: bool
can_list_assignables: bool
@dataclasses.dataclass
class ServicePoolResumeItem(types.rest.BaseRestItem):
id: str
name: str
thumb: str
user_services_count: int
state: str
class Services(DetailHandler[ServiceItem]): # pylint: disable=too-many-public-methods
"""
Detail handler for Services, whose parent is a Provider
"""
custom_methods = ['servicepools']
CUSTOM_METHODS = ['servicepools']
@staticmethod
def service_info(item: models.Service) -> dict[str, typing.Any]:
def service_info(item: models.Service) -> ServiceInfo:
info = item.get_type()
overrided_fields = info.overrided_pools_fields or {}
return {
'icon': info.icon64().replace('\n', ''),
'needs_publication': info.publication_type is not None,
'max_deployed': info.userservices_limit,
'uses_cache': info.uses_cache and overrided_fields.get('uses_cache', True),
'uses_cache_l2': info.uses_cache_l2,
'cache_tooltip': _(info.cache_tooltip),
'cache_tooltip_l2': _(info.cache_tooltip_l2),
'needs_osmanager': info.needs_osmanager,
'allowed_protocols': info.allowed_protocols,
'services_type_provided': info.services_type_provided,
'can_reset': info.can_reset,
'can_list_assignables': info.can_assign(),
}
return ServiceInfo(
icon=info.icon64().replace('\n', ''),
needs_publication=info.publication_type is not None,
max_deployed=info.userservices_limit,
uses_cache=info.uses_cache and overrided_fields.get('uses_cache', True),
uses_cache_l2=info.uses_cache_l2,
cache_tooltip=_(info.cache_tooltip),
cache_tooltip_l2=_(info.cache_tooltip_l2),
needs_osmanager=info.needs_osmanager,
allowed_protocols=[str(i) for i in info.allowed_protocols],
services_type_provided=info.services_type_provided,
can_reset=info.can_reset,
can_list_assignables=info.can_assign(),
)
@staticmethod
def service_to_dict(item: models.Service, perm: int, full: bool = False) -> types.rest.ItemDictType:
def service_item(item: models.Service, perm: int, full: bool = False) -> ServiceItem:
"""
Convert a service db item to a dict for a rest response
:param item: Service item (db)
:param full: If full is requested, add "extra" fields to complete information
"""
item_type = item.get_type()
ret_value: dict[str, typing.Any] = {
'id': item.uuid,
'name': item.name,
'tags': [tag.tag for tag in item.tags.all()],
'comments': item.comments,
'type': item.data_type, # Compat with old code
'data_type': item.data_type,
'type_name': _(item_type.mod_name()),
'deployed_services_count': item.deployedServices.count(),
'user_services_count': models.UserService.objects.filter(deployed_service__service=item)
ret_value = ServiceItem(
id=item.uuid,
name=item.name,
tags=[tag.tag for tag in item.tags.all()],
comments=item.comments,
deployed_services_count=item.deployedServices.count(),
user_services_count=models.UserService.objects.filter(deployed_service__service=item)
.exclude(state__in=State.INFO_STATES)
.count(),
'max_services_count_type': str(item.max_services_count_type),
'maintenance_mode': item.provider.maintenance_mode,
'permission': perm,
}
max_services_count_type=str(item.max_services_count_type),
maintenance_mode=item.provider.maintenance_mode,
permission=perm,
item=item,
)
if full:
ret_value['info'] = Services.service_info(item)
ret_value.info = Services.service_info(item)
return ret_value
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ItemsResult[ServiceItem]:
parent = ensure.is_instance(parent, models.Provider)
# Check what kind of access do we have to parent provider
perm = permissions.effective_permissions(self._user, parent)
try:
if item is None:
return [Services.service_to_dict(k, perm) for k in parent.services.all()]
return [Services.service_item(k, perm) for k in self.filter_queryset(parent.services.all())]
k = parent.services.get(uuid=process_uuid(item))
val = Services.service_to_dict(k, perm, full=True)
return self.fill_instance_fields(k, val)
val = Services.service_item(k, perm, full=True)
# On detail, ne wee to fill the instance fields by hand
return val
except models.Service.DoesNotExist:
raise exceptions.rest.NotFound(_('Service not found')) from None
except Exception as e:
logger.error('Error getting services for %s: %s', parent, e)
raise self.invalid_item_response(repr(e)) from e
def get_row_style(self, parent: 'Model') -> types.ui.RowStyleInfo:
return types.ui.RowStyleInfo(prefix='row-maintenance-', field='maintenance_mode')
raise exceptions.rest.ResponseError(_('Error getting services')) from None
def _delete_incomplete_service(self, service: models.Service) -> None:
"""
@@ -141,7 +178,7 @@ class Services(DetailHandler): # pylint: disable=too-many-public-methods
except Exception: # nosec: This is a delete, we don't care about exceptions
pass
def save_item(self, parent: 'Model', item: typing.Optional[str]) -> typing.Any:
def save_item(self, parent: 'Model', item: typing.Optional[str]) -> ServiceItem:
parent = ensure.is_instance(parent, models.Provider)
# Extract item db fields
# We need this fields for all
@@ -188,22 +225,25 @@ class Services(DetailHandler): # pylint: disable=too-many-public-methods
service.data = service_instance.serialize()
service.save()
return {'id': service.uuid}
return Services.service_item(
service, permissions.effective_permissions(self._user, service), full=True
)
except models.Service.DoesNotExist:
raise self.invalid_item_response() from None
raise exceptions.rest.NotFound('Service not found') from None
except IntegrityError as e: # Duplicate key probably
if service and service.token and not item:
service.delete()
raise exceptions.rest.RequestError(
_('Service token seems to be in use by other service. Please, select a new one.')
'Service token seems to be in use by other service. Please, select a new one.'
) from e
raise exceptions.rest.RequestError(_('Element already exists (duplicate key error)')) from e
raise exceptions.rest.RequestError('Element already exists (duplicate key error)') from e
except exceptions.ui.ValidationError as e:
if (
not item and service
): # Only remove partially saved element if creating new (if editing, ignore this)
self._delete_incomplete_service(service)
raise exceptions.rest.RequestError(_('Input error: {0}'.format(e))) from e
raise exceptions.rest.ValidationError('Input error: {0}'.format(e)) from e
except Exception as e:
if not item and service:
self._delete_incomplete_service(service)
@@ -217,110 +257,99 @@ class Services(DetailHandler): # pylint: disable=too-many-public-methods
if service.deployedServices.count() == 0:
service.delete()
return
except Exception:
logger.exception('Deleting service')
raise self.invalid_item_response() from None
except models.Service.DoesNotExist:
raise exceptions.rest.NotFound(_('Service not found')) from None
except Exception as e:
logger.error('Error deleting service %s from %s: %s', item, parent, e)
raise exceptions.rest.ResponseError(_('Error deleting service')) from None
raise exceptions.rest.RequestError('Item has associated deployed services')
def get_title(self, parent: 'Model') -> str:
def get_table(self, parent: 'Model') -> TableInfo:
parent = ensure.is_instance(parent, models.Provider)
try:
return _('Services of {}').format(parent.name)
except Exception:
return _('Current services')
def get_fields(self, parent: 'Model') -> list[typing.Any]:
return [
{'name': {'title': _('Service name'), 'visible': True, 'type': 'iconType'}},
{'comments': {'title': _('Comments')}},
{'type_name': {'title': _('Type')}},
{
'deployed_services_count': {
'title': _('Services Pools'),
'type': 'numeric',
}
},
{'user_services_count': {'title': _('User services'), 'type': 'numeric'}},
{
'max_services_count_type': {
'title': _('Max services count type'),
'type': 'dict',
'dict': {'0': _('Standard'), '1': _('Conservative')},
return (
ui_utils.TableBuilder(_('Services of {0}').format(parent.name))
.icon(name='name', title=_('Name'))
.text_column(name='type_name', title=_('Type'))
.text_column(name='comments', title=_('Comments'))
.numeric_column(name='deployed_services_count', title=_('Services Pools'), width='12em')
.numeric_column(name='user_services_count', title=_('User Services'), width='12em')
.dict_column(
name='max_services_count_type',
title=_('Counting method'),
dct={
types.services.ServicesCountingType.STANDARD: _('Standard'),
types.services.ServicesCountingType.CONSERVATIVE: _('Conservative'),
},
},
{'tags': {'title': _('tags'), 'visible': False}},
]
def get_types(
self, parent: 'Model', for_type: typing.Optional[str]
) -> collections.abc.Iterable[types.rest.TypeInfoDict]:
)
.text_column(name='tags', title=_('Tags'), visible=False)
.row_style(prefix='row-maintenance-', field='maintenance_mode')
.build()
)
def enum_types(self, parent: 'Model', for_type: typing.Optional[str]) -> list[types.rest.TypeInfo]:
parent = ensure.is_instance(parent, models.Provider)
logger.debug('get_types parameters: %s, %s', parent, for_type)
offers: list[types.rest.TypeInfoDict] = []
offers: list[types.rest.TypeInfo] = []
if for_type is None:
offers = [
{
'name': _(t.mod_name()),
'type': t.mod_type(),
'description': _(t.description()),
'icon': t.icon64().replace('\n', ''),
}
for t in parent.get_type().get_provided_services()
]
offers = [type(self).as_typeinfo(t) for t in parent.get_type().get_provided_services()]
else:
for t in parent.get_type().get_provided_services():
if for_type == t.mod_type():
offers = [
{
'name': _(t.mod_name()),
'type': t.mod_type(),
'description': _(t.description()),
'icon': t.icon64().replace('\n', ''),
}
]
offers = [type(self).as_typeinfo(t)]
break
if not offers:
raise exceptions.rest.NotFound('type not found')
return offers # Default is that details do not have types
return offers
def get_gui(self, parent: 'Model', for_type: str) -> collections.abc.Iterable[typing.Any]:
@classmethod
def possible_types(cls: type[typing.Self]) -> collections.abc.Iterable[type[module.Module]]:
"""
If the detail has any possible types, provide them overriding this method
:param cls:
"""
for parent_type in services.factory().providers().values():
for service in parent_type.get_provided_services():
yield service
def get_gui(self, parent: 'Model', for_type: str) -> list[types.ui.GuiElement]:
parent = ensure.is_instance(parent, models.Provider)
try:
logger.debug('getGui parameters: %s, %s', parent, for_type)
parent_instance = parent.get_instance()
service_type = parent_instance.get_service_by_type(for_type)
if not service_type:
raise self.invalid_item_response(f'Gui for {for_type} not found')
raise exceptions.rest.RequestError(f'Gui for type "{for_type}" not found')
with Environment.temporary_environment() as env:
service = service_type(
env, parent_instance
) # Instantiate it so it has the opportunity to alter gui description based on parent
local_gui = self.add_default_fields(service.gui_description(), ['name', 'comments', 'tags'])
self.add_field(
local_gui,
{
'name': 'max_services_count_type',
'choices': [
gui.choice_item('0', _('Standard')),
gui.choice_item('1', _('Conservative')),
overrided_fields = service.overrided_fields or {}
gui = (
ui_utils.GuiBuilder()
.add_stock_field(types.rest.stock.StockField.TAGS)
.add_stock_field(types.rest.stock.StockField.NAME)
.add_stock_field(types.rest.stock.StockField.COMMENTS)
.add_choice(
name='max_services_count_type',
choices=[
ui.gui.choice_item(
str(types.services.ServicesCountingType.STANDARD.value), _('Standard')
),
ui.gui.choice_item(
str(types.services.ServicesCountingType.CONSERVATIVE.value), _('Conservative')
),
],
'label': _('Service counting method'),
'tooltip': _('Kind of service counting for calculating if MAX is reached'),
'type': types.ui.FieldType.CHOICE,
'readonly': False,
'order': 110,
'tab': types.ui.Tab.ADVANCED,
},
label=_('Service counting method'),
tooltip=_('Kind of service counting for calculating if MAX is reached'),
tab=types.ui.Tab.ADVANCED,
)
.add_fields(service.gui_description())
)
# Remove all overrided fields from editables
overrided_fields = service.overrided_fields or {}
local_gui = [field_gui for field_gui in local_gui if field_gui['name'] not in overrided_fields]
return local_gui
return [field_gui for field_gui in gui.build() if field_gui['name'] not in overrided_fields]
except Exception as e:
logger.exception('get_gui')
@@ -332,29 +361,32 @@ class Services(DetailHandler): # pylint: disable=too-many-public-methods
service = parent.services.get(uuid=process_uuid(item))
logger.debug('Getting logs for %s', item)
return log.get_logs(service)
except Exception:
raise self.invalid_item_response() from None
except models.Service.DoesNotExist:
raise exceptions.rest.NotFound(_('Service not found')) from None
except Exception as e:
logger.error('Error getting logs for %s: %s', item, e)
raise exceptions.rest.ResponseError(_('Error getting logs')) from None
def servicepools(self, parent: 'Model', item: str) -> types.rest.ManyItemsDictType:
def servicepools(self, parent: 'Model', item: str) -> list[ServicePoolResumeItem]:
parent = ensure.is_instance(parent, models.Provider)
service = parent.services.get(uuid=process_uuid(item))
logger.debug('Got parameters for servicepools: %s, %s', parent, item)
res: types.rest.ItemListType = []
res: list[ServicePoolResumeItem] = []
for i in service.deployedServices.all():
try:
self.ensure_has_access(
self.check_access(
i, uds.core.types.permissions.PermissionType.READ
) # Ensures access before listing...
res.append(
{
'id': i.uuid,
'name': i.name,
'thumb': i.image.thumb64 if i.image is not None else DEFAULT_THUMB_BASE64,
'user_services_count': i.userServices.exclude(
ServicePoolResumeItem(
id=i.uuid,
name=i.name,
thumb=i.image.thumb64 if i.image is not None else DEFAULT_THUMB_BASE64,
user_services_count=i.userServices.exclude(
state__in=(State.REMOVED, State.ERROR)
).count(),
'state': _('With errors') if i.is_restrained() else _('Ok'),
}
state=_('With errors') if i.is_restrained() else _('Ok'),
)
)
except exceptions.rest.AccessDenied:
pass

View File

@@ -30,54 +30,49 @@
"""
@Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
import dataclasses
import logging
import typing
from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
from django.db.models import Model
from uds.core import types
from uds.core.consts.images import DEFAULT_THUMB_BASE64
from uds.core.ui import gui
from uds.core.util import ensure
from uds.core.util import ensure, ui as ui_utils
from uds.core.util.model import process_uuid
from uds.models import Image, ServicePoolGroup
from uds.REST.model import ModelHandler
logger = logging.getLogger(__name__)
if typing.TYPE_CHECKING:
from django.db.models import Model
from uds.core.ui import gui
# Enclosed methods under /item path
class ServicesPoolGroups(ModelHandler):
"""
Handles the gallery REST interface
"""
@dataclasses.dataclass
class ServicePoolGroupItem(types.rest.BaseRestItem):
id: str
name: str
comments: str
priority: int
image_id: str | None | types.rest.NotRequired = types.rest.NotRequired.field()
thumb: str | types.rest.NotRequired = types.rest.NotRequired.field()
# needs_admin = True
path = 'gallery'
model = ServicePoolGroup
save_fields = ['name', 'comments', 'image_id', 'priority']
class ServicesPoolGroups(ModelHandler[ServicePoolGroupItem]):
table_title = _('Services Pool Groups')
table_fields = [
{'priority': {'title': _('Priority'), 'type': 'numeric', 'width': '6em'}},
{
'thumb': {
'title': _('Image'),
'visible': True,
'type': 'image',
'width': '96px',
}
},
{'name': {'title': _('Name')}},
{'comments': {'title': _('Comments')}},
]
PATH = 'gallery'
MODEL = ServicePoolGroup
FIELDS_TO_SAVE = ['name', 'comments', 'image_id', 'priority']
TABLE = (
ui_utils.TableBuilder(_('Services Pool Groups'))
.numeric_column(name='priority', title=_('Priority'), width='6em')
.image(name='thumb', title=_('Image'), width='96px')
.text_column(name='name', title=_('Name'))
.text_column(name='comments', title=_('Comments'))
.build()
)
def pre_save(self, fields: dict[str, typing.Any]) -> None:
img_id = fields['image_id']
@@ -91,47 +86,33 @@ class ServicesPoolGroups(ModelHandler):
logger.exception('At image recovering')
# Gui related
def get_gui(self, type_: str) -> list[typing.Any]:
local_gui = self.add_default_fields([], ['name', 'comments', 'priority'])
def get_gui(self, for_type: str) -> list[typing.Any]:
return (
ui_utils.GuiBuilder()
.add_stock_field(types.rest.stock.StockField.NAME)
.add_stock_field(types.rest.stock.StockField.COMMENTS)
.add_stock_field(types.rest.stock.StockField.PRIORITY)
.new_tab(types.ui.Tab.DISPLAY)
.add_image_choice()
.build()
)
for field in [
{
'name': 'image_id',
'choices': [gui.choice_image(-1, '--------', DEFAULT_THUMB_BASE64)]
+ gui.sorted_choices(
[
gui.choice_image(v.uuid, v.name, v.thumb64)
for v in Image.objects.all()
]
),
'label': gettext('Associated Image'),
'tooltip': gettext('Image assocciated with this service'),
'type': types.ui.FieldType.IMAGECHOICE,
'order': 102,
}
]:
self.add_field(local_gui, field)
return local_gui
def item_as_dict(self, item: 'Model') -> dict[str, typing.Any]:
def get_item(self, item: 'Model') -> ServicePoolGroupItem:
item = ensure.is_instance(item, ServicePoolGroup)
return {
'id': item.uuid,
'priority': item.priority,
'name': item.name,
'comments': item.comments,
'image_id': item.image.uuid if item.image else None,
}
return ServicePoolGroupItem(
id=item.uuid,
name=item.name,
comments=item.comments,
priority=item.priority,
image_id=item.image.uuid if item.image else None,
)
def item_as_dict_overview(
self, item: 'Model'
) -> dict[str, typing.Any]:
def get_item_summary(self, item: 'Model') -> ServicePoolGroupItem:
item = ensure.is_instance(item, ServicePoolGroup)
return {
'id': item.uuid,
'priority': item.priority,
'name': item.name,
'comments': item.comments,
'thumb': item.thumb64,
}
return ServicePoolGroupItem(
id=item.uuid,
priority=item.priority,
name=item.name,
comments=item.comments,
thumb=item.thumb64,
)

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.db.models import Count, Q
from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
from django.utils.translation import gettext, gettext_lazy as _
from django.db.models import Model, Count, Q
from uds.core import types, exceptions, consts
from uds.core.managers.userservice import UserServiceManager
from uds.core.ui import gui
from uds.core import ui
from uds.core.consts.images import DEFAULT_THUMB_BASE64
from uds.core.util import log, permissions, ensure
from uds.core.util import log, permissions, ensure, ui as ui_utils
from uds.core.util.config import GlobalConfig
from uds.core.util.model import sql_now, process_uuid
from uds.core.types.states import State
@@ -50,23 +50,64 @@ from uds.models import Account, Image, OSManager, Service, ServicePool, ServiceP
from uds.REST.model import ModelHandler
from .op_calendars import AccessCalendars, ActionsCalendars
from .services import Services
from .user_services import AssignedService, CachedService, Changelog, Groups, Publications, Transports
from .services import Services, ServiceInfo
from .user_services import AssignedUserService, CachedService, Changelog, Groups, Publications, Transports
if typing.TYPE_CHECKING:
from django.db.models import Model
logger = logging.getLogger(__name__)
class ServicesPools(ModelHandler):
@dataclasses.dataclass
class ServicePoolItem(types.rest.BaseRestItem):
id: str
name: str
short_name: str
tags: typing.List[str]
parent: str
parent_type: str
comments: str
state: str
thumb: str
account: str
account_id: str | None
service_id: str
provider_id: str
image_id: str | None
initial_srvs: int
cache_l1_srvs: int
cache_l2_srvs: int
max_srvs: int
show_transports: bool
visible: bool
allow_users_remove: bool
allow_users_reset: bool
ignores_unused: bool
fallbackAccess: str
meta_member: list[dict[str, str]]
calendar_message: str
custom_message: str
display_custom_message: bool
osmanager_id: str | None
user_services_count: int | types.rest.NotRequired = types.rest.NotRequired.field()
user_services_in_preparation: int | types.rest.NotRequired = types.rest.NotRequired.field()
restrained: bool | types.rest.NotRequired = types.rest.NotRequired.field()
permission: types.permissions.PermissionType | types.rest.NotRequired = types.rest.NotRequired.field()
info: ServiceInfo | types.rest.NotRequired = types.rest.NotRequired.field()
pool_group_id: str | None | types.rest.NotRequired = types.rest.NotRequired.field()
pool_group_name: str | types.rest.NotRequired = types.rest.NotRequired.field()
pool_group_thumb: str | types.rest.NotRequired = types.rest.NotRequired.field()
usage: str | types.rest.NotRequired = types.rest.NotRequired.field()
class ServicesPools(ModelHandler[ServicePoolItem]):
"""
Handles Services Pools REST requests
"""
model = ServicePool
detail = {
'services': AssignedService,
MODEL = ServicePool
DETAIL = {
'services': AssignedUserService,
'cache': CachedService,
'servers': CachedService, # Alias for cache, but will change in a future release
'groups': Groups,
@@ -77,7 +118,7 @@ class ServicesPools(ModelHandler):
'actions': ActionsCalendars,
}
save_fields = [
FIELDS_TO_SAVE = [
'name',
'short_name',
'comments',
@@ -102,36 +143,36 @@ class ServicesPools(ModelHandler):
'state:_', # Optional field, defaults to Nothing (to apply default or existing value)
]
remove_fields = ['osmanager_id', 'service_id']
EXCLUDED_FIELDS = ['osmanager_id', 'service_id']
table_title = _('Service Pools')
table_fields = [
{'name': {'title': _('Name')}},
{'state': {'title': _('Status'), 'type': 'dict', 'dict': State.literals_dict()}},
{'user_services_count': {'title': _('User services'), 'type': 'number'}},
{'user_services_in_preparation': {'title': _('In Preparation')}},
{'usage': {'title': _('Usage')}},
{'visible': {'title': _('Visible'), 'type': 'callback'}},
{'show_transports': {'title': _('Shows transports'), 'type': 'callback'}},
{'pool_group_name': {'title': _('Pool group')}},
{'parent': {'title': _('Parent service')}},
{'tags': {'title': _('tags'), 'visible': False}},
]
# Field from where to get "class" and prefix for that class, so this will generate "row-state-A, row-state-X, ....
table_row_style = types.ui.RowStyleInfo(prefix='row-state-', field='state')
TABLE = (
ui_utils.TableBuilder(_('Service Pools'))
.text_column(name='name', title=_('Name'))
.dict_column(name='state', title=_('Status'), dct=State.literals_dict())
.numeric_column(name='user_services_count', title=_('User services'))
.numeric_column(name='user_services_in_preparation', title=_('In Preparation'))
.text_column(name='usage', title=_('Usage'))
.boolean(name='visible', title=_('Visible'))
.boolean(name='show_transports', title=_('Shows transports'))
.text_column(name='pool_group_name', title=_('Pool group'))
.text_column(name='parent', title=_('Parent service'))
.text_column(name='tags', title=_('tags'), visible=False)
.row_style(prefix='row-state-', field='state')
.build()
)
custom_methods = [
('set_fallback_access', True),
('get_fallback_access', True),
('actions_list', True),
('list_assignables', True),
('create_from_assignable', True),
('add_log', True),
CUSTOM_METHODS = [
types.rest.ModelCustomMethod('set_fallback_access', True),
types.rest.ModelCustomMethod('get_fallback_access', True),
types.rest.ModelCustomMethod('actions_list', True),
types.rest.ModelCustomMethod('list_assignables', True),
types.rest.ModelCustomMethod('create_from_assignable', True),
types.rest.ModelCustomMethod('add_log', True),
]
def get_items(
self, *args: typing.Any, **kwargs: typing.Any
) -> typing.Generator[types.rest.ItemDictType, None, None]:
) -> typing.Generator[ServicePoolItem, None, None]:
# Optimized query, due that there is a lot of info needed for theee
d = sql_now() - datetime.timedelta(seconds=GlobalConfig.RESTRAINT_TIME.as_int())
return super().get_items(
@@ -178,7 +219,7 @@ class ServicesPools(ModelHandler):
# return super().get_items(overview=kwargs.get('overview', True), prefetch=['service', 'service__provider', 'servicesPoolGroup', 'image', 'tags'])
# return super(ServicesPools, self).get_items(*args, **kwargs)
def item_as_dict(self, item: 'Model') -> dict[str, typing.Any]:
def get_item(self, item: 'Model') -> ServicePoolItem:
item = ensure.is_instance(item, ServicePool)
summary = 'summarize' in self._params
# if item does not have an associated service, hide it (the case, for example, for a removed service)
@@ -199,78 +240,76 @@ class ServicesPools(ModelHandler):
# This needs a lot of queries, and really does not apport anything important to the report
# elif UserServiceManager.manager().canInitiateServiceFromDeployedService(item) is False:
# state = State.SLOWED_DOWN
val: dict[str, typing.Any] = {
'id': item.uuid,
'name': item.name,
'short_name': item.short_name,
'tags': [tag.tag for tag in item.tags.all()],
'parent': item.service.name,
'parent_type': item.service.data_type,
'comments': item.comments,
'state': state,
'thumb': item.image.thumb64 if item.image is not None else DEFAULT_THUMB_BASE64,
'account': item.account.name if item.account is not None else '',
'account_id': item.account.uuid if item.account is not None else None,
'service_id': item.service.uuid,
'provider_id': item.service.provider.uuid,
'image_id': item.image.uuid if item.image is not None else None,
'initial_srvs': item.initial_srvs,
'cache_l1_srvs': item.cache_l1_srvs,
'cache_l2_srvs': item.cache_l2_srvs,
'max_srvs': item.max_srvs,
'show_transports': item.show_transports,
'visible': item.visible,
'allow_users_remove': item.allow_users_remove,
'allow_users_reset': item.allow_users_reset,
'ignores_unused': item.ignores_unused,
'fallbackAccess': item.fallbackAccess,
'meta_member': [
{'id': i.meta_pool.uuid, 'name': i.meta_pool.name} for i in item.memberOfMeta.all()
],
'calendar_message': item.calendar_message,
'custom_message': item.custom_message,
'display_custom_message': item.display_custom_message,
}
val: ServicePoolItem = ServicePoolItem(
id=item.uuid,
name=item.name,
short_name=item.short_name,
tags=[tag.tag for tag in item.tags.all()],
parent=item.service.name,
parent_type=item.service.data_type,
comments=item.comments,
state=state,
thumb=item.image.thumb64 if item.image is not None else DEFAULT_THUMB_BASE64,
account=item.account.name if item.account is not None else '',
account_id=item.account.uuid if item.account is not None else None,
service_id=item.service.uuid,
provider_id=item.service.provider.uuid,
image_id=item.image.uuid if item.image is not None else None,
initial_srvs=item.initial_srvs,
cache_l1_srvs=item.cache_l1_srvs,
cache_l2_srvs=item.cache_l2_srvs,
max_srvs=item.max_srvs,
show_transports=item.show_transports,
visible=item.visible,
allow_users_remove=item.allow_users_remove,
allow_users_reset=item.allow_users_reset,
ignores_unused=item.ignores_unused,
fallbackAccess=item.fallbackAccess,
meta_member=[{'id': i.meta_pool.uuid, 'name': i.meta_pool.name} for i in item.memberOfMeta.all()],
calendar_message=item.calendar_message,
custom_message=item.custom_message,
display_custom_message=item.display_custom_message,
osmanager_id=item.osmanager.uuid if item.osmanager else None,
)
if summary:
return val
# Extended info
if not summary:
if hasattr(item, 'valid_count'):
valid_count = getattr(item, 'valid_count')
preparing_count = getattr(item, 'preparing_count')
restrained = getattr(item, 'error_count') >= GlobalConfig.RESTRAINT_COUNT.as_int()
usage_count = getattr(item, 'usage_count')
else:
valid_count = item.userServices.exclude(state__in=State.INFO_STATES).count()
preparing_count = item.userServices.filter(state=State.PREPARING).count()
restrained = item.is_restrained()
usage_count = -1
if hasattr(item, 'valid_count'):
valid_count = getattr(item, 'valid_count')
preparing_count = getattr(item, 'preparing_count')
restrained = getattr(item, 'error_count') >= GlobalConfig.RESTRAINT_COUNT.as_int()
usage_count = getattr(item, 'usage_count')
else:
valid_count = item.userServices.exclude(state__in=State.INFO_STATES).count()
preparing_count = item.userServices.filter(state=State.PREPARING).count()
restrained = item.is_restrained()
usage_count = -1
poolgroup_id = None
poolgroup_name = _('Default')
poolgroup_thumb = DEFAULT_THUMB_BASE64
if item.servicesPoolGroup is not None:
poolgroup_id = item.servicesPoolGroup.uuid
poolgroup_name = item.servicesPoolGroup.name
if item.servicesPoolGroup.image is not None:
poolgroup_thumb = item.servicesPoolGroup.image.thumb64
poolgroup_id = None
poolgroup_name = _('Default')
poolgroup_thumb = DEFAULT_THUMB_BASE64
if item.servicesPoolGroup is not None:
poolgroup_id = item.servicesPoolGroup.uuid
poolgroup_name = item.servicesPoolGroup.name
if item.servicesPoolGroup.image is not None:
poolgroup_thumb = item.servicesPoolGroup.image.thumb64
val['user_services_count'] = valid_count
val['user_services_in_preparation'] = preparing_count
val['restrained'] = restrained
val['permission'] = permissions.effective_permissions(self._user, item)
val['info'] = Services.service_info(item.service)
val['pool_group_id'] = poolgroup_id
val['pool_group_name'] = poolgroup_name
val['pool_group_thumb'] = poolgroup_thumb
val['usage'] = str(item.usage(usage_count).percent) + '%'
if item.osmanager:
val['osmanager_id'] = item.osmanager.uuid
val.thumb = item.image.thumb64 if item.image is not None else DEFAULT_THUMB_BASE64
val.user_services_count = valid_count
val.user_services_in_preparation = preparing_count
val.tags = [tag.tag for tag in item.tags.all()]
val.restrained = restrained
val.permission = permissions.effective_permissions(self._user, item)
val.info = Services.service_info(item.service)
val.pool_group_id = poolgroup_id
val.pool_group_name = poolgroup_name
val.pool_group_thumb = poolgroup_thumb
val.usage = str(item.usage(usage_count).percent) + '%'
return val
# Gui related
def get_gui(self, type_: str) -> list[typing.Any]:
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
# if OSManager.objects.count() < 1: # No os managers, can't create db
# raise exceptions.rest.ResponseError(gettext('Create at least one OS Manager before creating a new service pool'))
if Service.objects.count() < 1:
@@ -278,202 +317,148 @@ class ServicesPools(ModelHandler):
gettext('Create at least a service before creating a new service pool')
)
g = self.add_default_fields([], ['name', 'comments', 'tags'])
for f in [
{
'name': 'short_name',
'type': 'text',
'label': _('Short name'),
'tooltip': _('Short name for user service visualization'),
'required': False,
'length': 64,
'order': 0 - 95,
},
{
'name': 'service_id',
'choices': [gui.choice_item('', '')]
+ gui.sorted_choices(
[gui.choice_item(v.uuid, v.provider.name + '\\' + v.name) for v in Service.objects.all()]
gui = (
(
ui_utils.GuiBuilder()
.add_stock_field(types.rest.stock.StockField.NAME)
.add_stock_field(types.rest.stock.StockField.COMMENTS)
.add_stock_field(types.rest.stock.StockField.TAGS)
)
.set_order(-95)
.add_text(
name='short_name',
label=gettext('Short name'),
tooltip=gettext('Short name for user service visualization'),
length=32,
)
.set_order(100)
.add_choice(
name='service_id',
choices=[ui.gui.choice_item('', '')]
+ ui.gui.sorted_choices(
[ui.gui.choice_item(v.uuid, v.provider.name + '\\' + v.name) for v in Service.objects.all()]
),
'label': gettext('Base service'),
'tooltip': gettext('Service used as base of this service pool'),
'type': types.ui.FieldType.CHOICE,
'readonly': True,
'order': 100, # Ensures is At end
},
{
'name': 'osmanager_id',
'choices': [gui.choice_item(-1, '')]
+ gui.sorted_choices([gui.choice_item(v.uuid, v.name) for v in OSManager.objects.all()]),
'label': gettext('OS Manager'),
'tooltip': gettext('OS Manager used as base of this service pool'),
'type': types.ui.FieldType.CHOICE,
'readonly': True,
'order': 101,
},
{
'name': 'allow_users_remove',
'value': False,
'label': gettext('Allow removal by users'),
'tooltip': gettext(
'If active, the user will be allowed to remove the service "manually". Be careful with this, because the user will have the "power" to delete it\'s own service'
),
'type': types.ui.FieldType.CHECKBOX,
'order': 111,
'tab': gettext('Advanced'),
},
{
'name': 'allow_users_reset',
'value': False,
'label': gettext('Allow reset by users'),
'tooltip': gettext('If active, the user will be allowed to reset the service'),
'type': types.ui.FieldType.CHECKBOX,
'order': 112,
'tab': gettext('Advanced'),
},
{
'name': 'ignores_unused',
'value': False,
'label': gettext('Ignores unused'),
'tooltip': gettext(
'If the option is enabled, UDS will not attempt to detect and remove the user services assigned but not in use.'
),
'type': types.ui.FieldType.CHECKBOX,
'order': 113,
'tab': gettext('Advanced'),
},
{
'name': 'visible',
'value': True,
'label': gettext('Visible'),
'tooltip': gettext('If active, transport will be visible for users'),
'type': types.ui.FieldType.CHECKBOX,
'order': 107,
'tab': gettext('Display'),
},
{
'name': 'image_id',
'choices': [gui.choice_image(-1, '--------', DEFAULT_THUMB_BASE64)]
+ gui.sorted_choices(
[gui.choice_image(v.uuid, v.name, v.thumb64) for v in Image.objects.all()]
),
'label': gettext('Associated Image'),
'tooltip': gettext('Image assocciated with this service'),
'type': types.ui.FieldType.IMAGECHOICE,
'order': 120,
'tab': gettext('Display'),
},
{
'name': 'pool_group_id',
'choices': [gui.choice_image(-1, _('Default'), DEFAULT_THUMB_BASE64)]
+ gui.sorted_choices(
[gui.choice_image(v.uuid, v.name, v.thumb64) for v in ServicePoolGroup.objects.all()]
),
'label': gettext('Pool group'),
'tooltip': gettext('Pool group for this pool (for pool classify on display)'),
'type': types.ui.FieldType.IMAGECHOICE,
'order': 121,
'tab': gettext('Display'),
},
{
'name': 'calendar_message',
'value': '',
'label': gettext('Calendar access denied text'),
'tooltip': gettext(
'Custom message to be shown to users if access is limited by calendar rules.'
),
'type': types.ui.FieldType.TEXT,
'order': 122,
'tab': gettext('Display'),
},
{
'name': 'custom_message',
'value': '',
'label': gettext('Custom launch message text'),
'tooltip': gettext(
label=gettext('Base service'),
tooltip=gettext('Service used as base of this service pool'),
readonly=True,
)
.add_choice(
name='osmanager_id',
choices=[ui.gui.choice_item(-1, '')]
+ ui.gui.sorted_choices([ui.gui.choice_item(v.uuid, v.name) for v in OSManager.objects.all()]),
label=gettext('OS Manager'),
tooltip=gettext('OS Manager used as base of this service pool'),
readonly=True,
)
.add_checkbox(
name='publish_on_save',
default=True,
label=gettext('Publish on save'),
tooltip=gettext('If active, the service will be published when saved'),
)
.new_tab(types.ui.Tab.DISPLAY)
.add_checkbox(
name='visible',
default=True,
label=gettext('Visible'),
tooltip=gettext('If active, transport will be visible for users'),
)
.add_image_choice()
.add_image_choice(
name='pool_group_id',
choices=[
ui.gui.choice_image(v.uuid, v.name, v.thumb64) for v in ServicePoolGroup.objects.all()
],
label=gettext('Pool group'),
tooltip=gettext('Pool group for this pool (for pool classify on display)'),
)
.add_text(
name='calendar_message',
label=gettext('Calendar access denied text'),
tooltip=gettext('Custom message to be shown to users if access is limited by calendar rules.'),
)
.add_text(
name='custom_message',
label=gettext('Custom launch message text'),
tooltip=gettext(
'Custom message to be shown to users, if active, when trying to start a service from this pool.'
),
'type': types.ui.FieldType.TEXT,
'order': 123,
'tab': gettext('Display'),
},
{
'name': 'display_custom_message',
'value': False,
'label': gettext('Enable custom launch message'),
'tooltip': gettext('If active, the custom launch message will be shown to users'),
'type': types.ui.FieldType.CHECKBOX,
'order': 124,
'tab': gettext('Display'),
},
{
'name': 'initial_srvs',
'value': '0',
'min_value': '0',
'label': gettext('Initial available services'),
'tooltip': gettext('Services created initially for this service pool'),
'type': types.ui.FieldType.NUMERIC,
'order': 130,
'tab': gettext('Availability'),
},
{
'name': 'cache_l1_srvs',
'value': '0',
'min_value': '0',
'label': gettext('Services to keep in cache'),
'tooltip': gettext('Services kept in cache for improved user service assignation'),
'type': types.ui.FieldType.NUMERIC,
'order': 131,
'tab': gettext('Availability'),
},
{
'name': 'cache_l2_srvs',
'value': '0',
'min_value': '0',
'label': gettext('Services to keep in L2 cache'),
'tooltip': gettext('Services kept in cache of level2 for improved service generation'),
'type': types.ui.FieldType.NUMERIC,
'order': 132,
'tab': gettext('Availability'),
},
{
'name': 'max_srvs',
'value': '0',
'min_value': '0',
'label': gettext('Maximum number of services to provide'),
'tooltip': gettext(
'Maximum number of service (assigned and L1 cache) that can be created for this service'
)
.add_checkbox(
name='display_custom_message',
default=False,
label=gettext('Enable custom launch message'),
tooltip=gettext('If active, the custom launch message will be shown to users'),
)
.new_tab(gettext('Availability'))
.add_numeric(
name='initial_srvs',
default=0,
min_value=0,
label=gettext('Initial available services'),
tooltip=gettext('Services created initially for this service pool'),
)
.add_numeric(
name='cache_l1_srvs',
default=0,
min_value=0,
label=gettext('Services to keep in cache'),
tooltip=gettext('Services kept in cache for improved user service assignation'),
)
.add_numeric(
name='cache_l2_srvs',
default=0,
min_value=0,
label=gettext('Services to keep in L2 cache'),
tooltip=gettext('Services kept in cache of level2 for improved service assignation'),
)
.add_numeric(
name='max_srvs',
default=0,
min_value=0,
label=gettext('Max services per user'),
tooltip=gettext('Maximum number of services that can be assigned to a user from this pool'),
)
.add_checkbox(
name='show_transports',
default=False,
label=gettext('Show transports'),
tooltip=gettext('If active, transports will be shown to users'),
)
.new_tab(types.ui.Tab.ADVANCED)
.add_checkbox(
name='allow_users_remove',
default=False,
label=gettext('Allow removal by users'),
tooltip=gettext(
'If active, the user will be allowed to remove the service "manually". Be careful with this, because the user will have the "power" to delete its own service'
),
'type': types.ui.FieldType.NUMERIC,
'order': 133,
'tab': gettext('Availability'),
},
{
'name': 'show_transports',
'value': True,
'label': gettext('Show transports'),
'tooltip': gettext('If active, alternative transports for user will be shown'),
'type': types.ui.FieldType.CHECKBOX,
'tab': gettext('Advanced'),
'order': 130,
},
{
'name': 'account_id',
'choices': [gui.choice_item(-1, '')]
+ gui.sorted_choices([gui.choice_item(v.uuid, v.name) for v in Account.objects.all()]),
'label': gettext('Accounting'),
'tooltip': gettext('Account associated to this service pool'),
'type': types.ui.FieldType.CHOICE,
'tab': gettext('Advanced'),
'order': 131,
},
]:
self.add_field(g, f)
)
.add_checkbox(
name='allow_users_reset',
default=False,
label=gettext('Allow reset by users'),
tooltip=gettext('If active, the user will be allowed to reset the service'),
)
.add_checkbox(
name='ignores_unused',
default=False,
label=gettext('Ignores unused'),
tooltip=gettext(
'If the option is enabled, UDS will not attempt to detect and remove the user services assigned but not in use.'
),
)
.add_choice(
name='account_id',
choices=[ui.gui.choice_item('', '')]
+ ui.gui.sorted_choices([ui.gui.choice_item(v.uuid, v.name) for v in Account.objects.all()]),
label=gettext('Account'),
tooltip=gettext('Account used for this service pool'),
readonly=True,
)
)
return gui.build()
return g
# pylint: disable=too-many-statements
def pre_save(self, fields: dict[str, typing.Any]) -> None:
# logger.debug(self._params)
@@ -505,7 +490,9 @@ class ServicesPools(ModelHandler):
fields['osmanager_id'] = osmanager.id
except Exception:
if fields.get('state') != State.LOCKED:
raise exceptions.rest.RequestError(gettext('This service requires an OS Manager')) from None
raise exceptions.rest.RequestError(
gettext('This service requires an OS Manager')
) from None
del fields['osmanager_id']
else:
del fields['osmanager_id']
@@ -536,7 +523,7 @@ class ServicesPools(ModelHandler):
# fields['initial_srvs'] = min(fields['initial_srvs'], service_type.userservices_limit)
# fields['cache_l1_srvs'] = min(fields['cache_l1_srvs'], service_type.userservices_limit)
except Exception as e:
raise exceptions.rest.RequestError(gettext('This parameters provided are not valid')) from e
raise exceptions.rest.RequestError(gettext('This service requires an OS Manager')) from e
# If max < initial or cache_1 or cache_l2
fields['max_srvs'] = max(
@@ -550,36 +537,36 @@ class ServicesPools(ModelHandler):
# *** ACCOUNT ***
account_id = fields['account_id']
fields['account_id'] = None
logger.debug('Account id: %s', account_id)
if account_id and account_id != '-1':
logger.debug('Account id: %s', account_id)
if account_id != '-1':
try:
fields['account_id'] = Account.objects.get(uuid=process_uuid(account_id)).id
except Exception:
logger.warning('Getting account ID: %s %s', account_id)
logger.exception('Getting account ID')
# **** IMAGE ***
image_id = fields['image_id']
fields['image_id'] = None
if image_id and image_id != '-1':
logger.debug('Image id: %s', image_id)
try:
logger.debug('Image id: %s', image_id)
try:
if image_id != '-1':
image = Image.objects.get(uuid=process_uuid(image_id))
fields['image_id'] = image.id
except Exception:
logger.warning('At image recovering: %s', image_id)
except Exception:
logger.exception('At image recovering')
# Servicepool Group
pool_group_id = fields['pool_group_id']
del fields['pool_group_id']
fields['servicesPoolGroup_id'] = None
if pool_group_id and pool_group_id != '-1':
logger.debug('pool_group_id: %s', pool_group_id)
try:
logger.debug('pool_group_id: %s', pool_group_id)
try:
if pool_group_id != '-1':
spgrp = ServicePoolGroup.objects.get(uuid=process_uuid(pool_group_id))
fields['servicesPoolGroup_id'] = spgrp.id
except Exception:
logger.warning('At service pool group recovering: %s', pool_group_id)
except Exception:
logger.exception('At service pool group recovering')
except (exceptions.rest.RequestError, exceptions.rest.ResponseError):
raise
@@ -614,7 +601,7 @@ class ServicesPools(ModelHandler):
# Set fallback status
def set_fallback_access(self, item: 'Model') -> typing.Any:
item = ensure.is_instance(item, ServicePool)
self.ensure_has_access(item, types.permissions.PermissionType.MANAGEMENT)
self.check_access(item, types.permissions.PermissionType.MANAGEMENT)
fallback = self._params.get('fallbackAccess', self.params.get('fallback', None))
if fallback:
@@ -683,7 +670,7 @@ class ServicesPools(ModelHandler):
def create_from_assignable(self, item: 'Model') -> typing.Any:
item = ensure.is_instance(item, ServicePool)
if 'user_id' not in self._params or 'assignable_id' not in self._params:
return self.invalid_request_response('Invalid parameters')
raise exceptions.rest.RequestError('Invalid parameters')
logger.debug('Creating from assignable: %s', self._params)
UserServiceManager.manager().create_from_assignable(
@@ -697,10 +684,10 @@ class ServicesPools(ModelHandler):
def add_log(self, item: 'Model') -> typing.Any:
item = ensure.is_instance(item, ServicePool)
if 'message' not in self._params:
return self.invalid_request_response('Invalid parameters')
raise exceptions.rest.RequestError('Invalid parameters')
if 'level' not in self._params:
return self.invalid_request_response('Invalid parameters')
raise exceptions.rest.RequestError('Invalid parameters')
log.log(
item,
level=types.log.LogLevel.from_str(self._params['level']),
@@ -708,4 +695,3 @@ class ServicesPools(ModelHandler):
source=types.log.LogSource.REST,
log_name=self._params.get('log_name', None),
)

View File

@@ -31,32 +31,53 @@
Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
import dataclasses
import logging
import typing
import datetime
from django.utils.translation import gettext as _
from uds.core import types
from django.db.models import Model
from uds.core import exceptions, types
from uds.models import UserService, Provider
from uds.core.types.states import State
from uds.core.util.model import process_uuid
from uds.REST.model import DetailHandler
from uds.core.util import ensure
from uds.core.util import ensure, ui as ui_utils
# Not imported at runtime, just for type checking
if typing.TYPE_CHECKING:
from django.db.models import Model
logger = logging.getLogger(__name__)
class ServicesUsage(DetailHandler):
@dataclasses.dataclass
class ServicesUsageItem(types.rest.BaseRestItem):
id: str
state_date: datetime.datetime
creation_date: datetime.datetime
unique_id: str
friendly_name: str
owner: str
owner_info: dict[str, str]
service: str
service_id: str
pool: str
pool_id: str
ip: str
source_host: str
source_ip: str
in_use: bool
class ServicesUsage(DetailHandler[ServicesUsageItem]):
"""
Rest handler for Assigned Services, which parent is Service
"""
@staticmethod
def item_as_dict(item: UserService) -> dict[str, typing.Any]:
def item_as_dict(item: UserService) -> ServicesUsageItem:
"""
Converts an assigned/cached service db item to a dictionary for REST response
:param item: item to convert
@@ -72,30 +93,32 @@ class ServicesUsage(DetailHandler):
owner = item.user.pretty_name
owner_info = {'auth_id': item.user.manager.uuid, 'user_id': item.user.uuid}
return {
'id': item.uuid,
'state_date': item.state_date,
'creation_date': item.creation_date,
'unique_id': item.unique_id,
'friendly_name': item.friendly_name,
'owner': owner,
'owner_info': owner_info,
'service': item.deployed_service.service.name,
'service_id': item.deployed_service.service.uuid,
'pool': item.deployed_service.name,
'pool_id': item.deployed_service.uuid,
'ip': props.get('ip', _('unknown')),
'source_host': item.src_hostname,
'source_ip': item.src_ip,
'in_use': item.in_use,
}
return ServicesUsageItem(
id=item.uuid,
state_date=item.state_date,
creation_date=item.creation_date,
unique_id=item.unique_id,
friendly_name=item.friendly_name,
owner=owner,
owner_info=owner_info,
service=item.deployed_service.service.name,
service_id=item.deployed_service.service.uuid,
pool=item.deployed_service.name,
pool_id=item.deployed_service.uuid,
ip=props.get('ip', _('unknown')),
source_host=item.src_hostname,
source_ip=item.src_ip,
in_use=item.in_use,
)
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
def get_items(
self, parent: 'Model', item: typing.Optional[str]
) -> types.rest.ItemsResult[ServicesUsageItem]:
parent = ensure.is_instance(parent, Provider)
try:
if item is None:
userservices_query = UserService.objects.filter(
deployed_service__service__provider=parent
userservices_query = self.filter_queryset(
UserService.objects.filter(deployed_service__service__provider=parent)
)
else:
userservices_query = UserService.objects.filter(
@@ -109,29 +132,26 @@ class ServicesUsage(DetailHandler):
.prefetch_related('deployed_service', 'deployed_service__service', 'user', 'user__manager')
]
except Exception:
logger.exception('get_items')
raise self.invalid_item_response()
except Exception as e:
logger.error('Error getting services usage for %s: %s', parent.uuid, e)
raise exceptions.rest.ResponseError(_('Error getting services usage')) from None
def get_title(self, parent: 'Model') -> str:
return _('Services Usage')
def get_fields(self, parent: 'Model') -> list[typing.Any]:
return [
# {'creation_date': {'title': _('Creation date'), 'type': 'datetime'}},
{'state_date': {'title': _('Access'), 'type': 'datetime'}},
{'owner': {'title': _('Owner')}},
{'service': {'title': _('Service')}},
{'pool': {'title': _('Pool')}},
{'unique_id': {'title': 'Unique ID'}},
{'ip': {'title': _('IP')}},
{'friendly_name': {'title': _('Friendly name')}},
{'source_ip': {'title': _('Src Ip')}},
{'source_host': {'title': _('Src Host')}},
]
def get_row_style(self, parent: 'Model') -> types.ui.RowStyleInfo:
return types.ui.RowStyleInfo(prefix='row-state-', field='state')
def get_table(self, parent: 'Model') -> types.rest.TableInfo:
parent = ensure.is_instance(parent, Provider)
return (
ui_utils.TableBuilder(_('Services Usage'))
.datetime_column(name='state_date', title=_('Access'))
.text_column(name='owner', title=_('Owner'))
.text_column(name='service', title=_('Service'))
.text_column(name='pool', title=_('Pool'))
.text_column(name='unique_id', title='Unique ID')
.text_column(name='ip', title=_('IP'))
.text_column(name='friendly_name', title=_('Friendly name'))
.text_column(name='source_ip', title=_('Src Ip'))
.text_column(name='source_host', title=_('Src Host'))
.row_style(prefix='row-state-', field='state')
.build()
)
def delete_item(self, parent: 'Model', item: str) -> None:
parent = ensure.is_instance(parent, Provider)
@@ -140,8 +160,11 @@ class ServicesUsage(DetailHandler):
userservice = UserService.objects.get(
uuid=process_uuid(item), deployed_service__service__provider=parent
)
except Exception:
raise self.invalid_item_response()
except UserService.DoesNotExist:
raise exceptions.rest.NotFound(_('User service not found')) from None
except Exception as e:
logger.error('Error getting user service %s from %s: %s', item, parent, e)
raise exceptions.rest.ResponseError(_('Error getting user service')) from None
logger.debug('Deleting user service')
if userservice.state in (State.USABLE, State.REMOVING):
@@ -149,6 +172,6 @@ class ServicesUsage(DetailHandler):
elif userservice.state == State.PREPARING:
userservice.cancel()
elif userservice.state == State.REMOVABLE:
raise self.invalid_item_response(_('Item already being removed'))
raise exceptions.rest.ResponseError(_('Item already being removed')) from None
else:
raise self.invalid_item_response(_('Item is not removable'))
raise exceptions.rest.ResponseError(_('Item is not removable')) from None

View File

@@ -34,7 +34,7 @@ import logging
import datetime
import typing
from uds.core import types
from uds.core import types, consts
from uds.REST import Handler
from uds import models
from uds.core.util.stats import counters
@@ -44,13 +44,7 @@ logger = logging.getLogger(__name__)
# Enclosed methods under /cache path
class Stats(Handler):
authenticated = True
needs_admin = True
help_paths = [
('', 'Returns the last day usage statistics for all authenticators'),
]
help_text = 'Provides access to usage statistics'
ROLE = consts.UserRole.ADMIN
def _usage_stats(self, since: datetime.datetime) -> dict[str, list[dict[str, typing.Any]]]:
"""

View File

@@ -37,8 +37,10 @@ import pickle # nosec: pickle is used to cache data, not to load it
import pickletools
import typing
from django.db.models import Model
from uds import models
from uds.core import exceptions, types
from uds.core import exceptions, types, consts
from uds.core.util import permissions
from uds.core.util.cache import Cache
from uds.core.util.model import process_uuid, sql_now
@@ -48,8 +50,6 @@ from uds.REST import Handler
logger = logging.getLogger(__name__)
if typing.TYPE_CHECKING:
from django.db.models import Model
cache = Cache('StatsDispatcher')
@@ -66,9 +66,7 @@ def get_servicepools_counters(
) -> list[dict[str, typing.Any]]:
val: list[dict[str, typing.Any]] = []
try:
cache_key = (
(servicepool and str(servicepool.id) or 'all') + str(counter_type) + str(since_days)
)
cache_key = (servicepool and str(servicepool.id) or 'all') + str(counter_type) + str(since_days)
# Get now but with 0 minutes and 0 seconds
to = sql_now().replace(minute=0, second=0, microsecond=0)
since: datetime.datetime = to - datetime.timedelta(days=since_days)
@@ -87,7 +85,7 @@ def get_servicepools_counters(
owner_type=types.stats.CounterOwnerType.SERVICEPOOL,
owner_id=servicepool.id if servicepool.id != -1 else None,
since=since,
points=since_days*24, # One point per hour
points=since_days * 24, # One point per hour
)
val = [
{
@@ -107,8 +105,7 @@ def get_servicepools_counters(
else:
# Generate as much points as needed with 0 value
val = [
{'stamp': since + datetime.timedelta(hours=i), 'value': 0}
for i in range(since_days * 24)
{'stamp': since + datetime.timedelta(hours=i), 'value': 0} for i in range(since_days * 24)
]
else:
val = pickle.loads(
@@ -143,21 +140,7 @@ class System(Handler):
}
"""
needs_admin = False
needs_staff = True
help_paths = [
('', ''),
('stats/assigned', ''),
('stats/inuse', ''),
('stats/cached', ''),
('stats/complete', ''),
('stats/assigned/<servicePoolId>', ''),
('stats/inuse/<servicePoolId>', ''),
('stats/cached/<servicePoolId>', ''),
('stats/complete/<servicePoolId>', ''),
]
help_text = 'Provides system information. Must be admin to access this'
ROLE = consts.UserRole.STAFF
def get(self) -> typing.Any:
logger.debug('args: %s', self._args)
@@ -166,14 +149,16 @@ class System(Handler):
if self._args[0] == 'overview': # System overview
if not self._user.is_admin:
raise exceptions.rest.AccessDenied()
fltr_user = models.User.objects.filter(userServices__state__in=types.states.State.VALID_STATES).order_by()
fltr_user = models.User.objects.filter(
userServices__state__in=types.states.State.VALID_STATES
).order_by()
users = models.User.objects.all().count()
users_with_services = (
fltr_user.values('id').distinct().count()
) # Use "values" to simplify query (only id)
number_assigned_user_services = fltr_user.values('id').count()
groups: int = models.Group.objects.count()
services: int = models.Service.objects.count()
service_pools: int = models.ServicePool.objects.count()
@@ -188,7 +173,7 @@ class System(Handler):
calendars: int = models.Calendar.objects.count()
tunnels: int = models.Server.objects.filter(type=types.servers.ServerType.TUNNEL).count()
auths: int = models.Authenticator.objects.count()
return {
'users': users,
'users_with_services': users_with_services,

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 exceptions
from uds.core import consts, exceptions
logger = logging.getLogger(__name__)
@@ -89,7 +89,7 @@ class Tickets(Handler):
- servicePool has these groups in it's allowed list
"""
needs_admin = True # By default, staff is lower level needed
ROLE = consts.UserRole.ADMIN
@staticmethod
def result(result: str = '', error: typing.Optional[str] = None) -> dict[str, typing.Any]:

View File

@@ -30,31 +30,48 @@
'''
@Author: Adolfo Gómez, dkmaster at dkmon dot com
'''
import dataclasses
import logging
import re
import typing
import collections.abc
from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
from django.utils.translation import gettext, gettext_lazy as _
from django.db.models import Model
from uds.core import consts, transports, types, ui
from uds.core import consts, exceptions, transports, types, ui
from uds.core.environment import Environment
from uds.core.util import ensure, permissions
from uds.core.util import ensure, permissions, ui as ui_utils
from uds.models import Network, ServicePool, Transport
from uds.REST.model import ModelHandler
if typing.TYPE_CHECKING:
from django.db.models import Model
logger = logging.getLogger(__name__)
# Enclosed methods under /item path
@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):
model = Transport
save_fields = [
class Transports(ModelHandler[TransportItem]):
MODEL = Transport
FIELDS_TO_SAVE = [
'name',
'comments',
'tags',
@@ -64,112 +81,92 @@ class Transports(ModelHandler):
'label',
]
table_title = _('Transports')
table_fields = [
{'priority': {'title': _('Priority'), 'type': 'numeric', 'width': '6em'}},
{'name': {'title': _('Name'), 'visible': True, 'type': 'iconType'}},
{'type_name': {'title': _('Type')}},
{'comments': {'title': _('Comments')}},
{
'pools_count': {
'title': _('Service Pools'),
'type': 'numeric',
'width': '6em',
}
},
{'allowed_oss': {'title': _('Devices'), 'width': '8em'}},
{'tags': {'title': _('tags'), 'visible': False}},
]
TABLE = (
ui_utils.TableBuilder(_('Transports'))
.numeric_column(name='priority', title=_('Priority'), width='6em')
.icon(name='name', title=_('Name'))
.text_column(name='type_name', title=_('Type'))
.text_column(name='comments', title=_('Comments'))
.numeric_column(name='pools_count', title=_('Service Pools'), width='6em')
.text_column(name='allowed_oss', title=_('Devices'), width='8em')
.text_column(name='tags', title=_('tags'), visible=False)
).build()
def enum_types(self) -> collections.abc.Iterable[type[transports.Transport]]:
@classmethod
def possible_types(cls: type[typing.Self]) -> collections.abc.Iterable[type[transports.Transport]]:
return transports.factory().providers().values()
def get_gui(self, type_: str) -> list[typing.Any]:
transport_type = transports.factory().lookup(type_)
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
transport_type = transports.factory().lookup(for_type)
if not transport_type:
raise self.invalid_item_response()
raise exceptions.rest.NotFound(_('Transport type not found: {}').format(for_type))
with Environment.temporary_environment() as env:
transport = transport_type(env, None)
field = self.add_default_fields(
transport.gui_description(), ['name', 'comments', 'tags', 'priority', 'networks']
)
field = self.add_field(
field,
{
'name': 'allowed_oss',
'value': [],
'choices': sorted(
[ui.gui.choice_item(x.db_value(), x.os_name().title()) for x in consts.os.KNOWN_OS_LIST],
key=lambda x: x['text'].lower(),
),
'label': gettext('Allowed Devices'),
'tooltip': gettext(
'If empty, any kind of device compatible with this transport will be allowed. Else, only devices compatible with selected values will be allowed'
),
'type': types.ui.FieldType.MULTICHOICE,
'tab': types.ui.Tab.ADVANCED,
'order': 102,
},
)
field = self.add_field(
field,
{
'name': 'pools',
'value': [],
'choices': [
return (
ui_utils.GuiBuilder()
.add_stock_field(types.rest.stock.StockField.NAME)
.add_stock_field(types.rest.stock.StockField.COMMENTS)
.add_stock_field(types.rest.stock.StockField.TAGS)
.add_stock_field(types.rest.stock.StockField.PRIORITY)
.add_stock_field(types.rest.stock.StockField.NETWORKS)
.add_fields(transport.gui_description())
.add_multichoice(
name='pools',
label=gettext('Service Pools'),
choices=[
ui.gui.choice_item(x.uuid, x.name)
for x in ServicePool.objects.filter(service__isnull=False)
.order_by('name')
.prefetch_related('service')
if transport_type.protocol in x.service.get_type().allowed_protocols
if transport_type.PROTOCOL in x.service.get_type().allowed_protocols
],
'label': gettext('Service Pools'),
'tooltip': gettext('Currently assigned services pools'),
'type': types.ui.FieldType.MULTICHOICE,
'order': 103,
},
)
field = self.add_field(
field,
{
'name': 'label',
'length': 32,
'value': '',
'label': gettext('Label'),
'tooltip': gettext('Metapool transport label (only used on metapool transports grouping)'),
'type': types.ui.FieldType.TEXT,
'order': 201,
'tab': types.ui.Tab.ADVANCED,
},
tooltip=gettext(
'Currently assigned services pools. If empty, no service pool is assigned to this transport'
),
)
.new_tab(types.ui.Tab.ADVANCED)
.add_multichoice(
name='allowed_oss',
label=gettext('Allowed Devices'),
choices=[
ui.gui.choice_item(x.db_value(), x.os_name().title()) for x in consts.os.KNOWN_OS_LIST
],
tooltip=gettext(
'If empty, any kind of device compatible with this transport will be allowed. Else, only devices compatible with selected values will be allowed'
),
)
.add_text(
name='label',
label=gettext('Label'),
tooltip=gettext('Metapool transport label (only used on metapool transports grouping)'),
)
.build()
)
return field
def item_as_dict(self, item: 'Model') -> dict[str, typing.Any]:
def get_item(self, item: 'Model') -> TransportItem:
item = ensure.is_instance(item, Transport)
type_ = item.get_type()
pools = list(item.deployedServices.all().values_list('uuid', flat=True))
return {
'id': item.uuid,
'name': item.name,
'tags': [tag.tag for tag in item.tags.all()],
'comments': item.comments,
'priority': item.priority,
'label': item.label,
'net_filtering': item.net_filtering,
'networks': list(item.networks.all().values_list('uuid', flat=True)),
'allowed_oss': [x for x in item.allowed_oss.split(',')] if item.allowed_oss != '' else [],
'pools': pools,
'pools_count': len(pools),
'deployed_count': item.deployedServices.count(),
'type': type_.mod_type(),
'type_name': type_.mod_name(),
'protocol': type_.protocol,
'permission': permissions.effective_permissions(self._user, item),
}
return TransportItem(
id=item.uuid,
name=item.name,
tags=[tag.tag for tag in item.tags.all()],
comments=item.comments,
priority=item.priority,
label=item.label,
net_filtering=item.net_filtering,
networks=list(item.networks.all().values_list('uuid', flat=True)),
allowed_oss=[x for x in item.allowed_oss.split(',')] if item.allowed_oss != '' else [],
pools=pools,
pools_count=len(pools),
deployed_count=item.deployedServices.count(),
protocol=type_.PROTOCOL,
permission=permissions.effective_permissions(self._user, item),
item=item,
)
def pre_save(self, fields: dict[str, typing.Any]) -> None:
fields['allowed_oss'] = ','.join(fields['allowed_oss'])
@@ -177,7 +174,7 @@ class Transports(ModelHandler):
fields['label'] = fields['label'].strip().replace(' ', '-')
# And ensure small_name chars are valid [ a-zA-Z0-9:-]+
if fields['label'] and not re.match(r'^[a-zA-Z0-9:-]+$', fields['label']):
raise self.invalid_request_response(
raise exceptions.rest.ValidationError(
gettext('Label must contain only letters, numbers, ":" and "-"')
)

View File

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

View File

@@ -29,87 +29,84 @@
"""
Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
import dataclasses
import logging
import typing
from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
from django.utils.translation import gettext, gettext_lazy as _
from django.db.models import Model
import uds.core.types.permissions
from uds.core import exceptions, types, consts
from uds.core.util import permissions, validators, ensure
from uds.core.types.rest import TableInfo
from uds.core.util import permissions, validators, ensure, ui as ui_utils
from uds.core.util.model import process_uuid
from uds import models
from uds.REST.model import DetailHandler, ModelHandler
# Not imported at runtime, just for type checking
if typing.TYPE_CHECKING:
from django.db.models import Model
logger = logging.getLogger(__name__)
@dataclasses.dataclass
class TunnelServerItem(types.rest.BaseRestItem):
id: str
hostname: str
ip: str
mac: str
maintenance: bool
class TunnelServers(DetailHandler):
class TunnelServers(DetailHandler[TunnelServerItem]):
# tunnels/[id]/servers
custom_methods = ['maintenance']
CUSTOM_METHODS = ['maintenance']
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
def get_items(
self, parent: 'Model', item: typing.Optional[str]
) -> types.rest.ItemsResult[TunnelServerItem]:
parent = ensure.is_instance(parent, models.ServerGroup)
try:
multi = False
if item is None:
multi = True
q = parent.servers.all().order_by('hostname')
q = self.filter_queryset(parent.servers.all())
else:
q = parent.servers.filter(uuid=process_uuid(item))
res: list[dict[str, typing.Any]] = []
i = None
for i in q:
val = {
'id': i.uuid,
'hostname': i.hostname,
'ip': i.ip,
'mac': i.mac if not multi or i.mac != consts.MAC_UNKNOWN else '',
'maintenance': i.maintenance_mode,
}
res.append(val)
res: list[TunnelServerItem] = [
TunnelServerItem(
id=i.uuid,
hostname=i.hostname,
ip=i.ip,
mac=i.mac if i.mac != consts.MAC_UNKNOWN else '',
maintenance=i.maintenance_mode,
)
for i in q
]
if multi:
return res
if not i:
raise Exception('Item not found')
if not res:
raise exceptions.rest.NotFound(f'Tunnel server {item} not found')
return res[0]
except exceptions.rest.HandlerError:
raise
except Exception as e:
logger.exception('REST groups')
raise self.invalid_item_response() from e
logger.error('Error getting tunnel servers for %s: %s', parent, e)
raise exceptions.rest.ResponseError(_('Error getting tunnel servers')) from e
def get_title(self, parent: 'Model') -> str:
def get_table(self, parent: 'Model') -> TableInfo:
parent = ensure.is_instance(parent, models.ServerGroup)
try:
return _('Servers of {0}').format(parent.name)
except Exception:
return gettext('Servers')
def get_fields(self, parent: 'Model') -> list[typing.Any]:
parent = ensure.is_instance(parent, models.ServerGroup)
return [
{
'hostname': {
'title': _('Hostname'),
}
},
{'ip': {'title': _('Ip')}},
{'mac': {'title': _('Mac')}},
{
'maintenance_mode': {
'title': _('State'),
'type': 'dict',
'dict': {True: _('Maintenance'), False: _('Normal')},
}
},
]
def get_row_style(self, parent: 'Model') -> types.ui.RowStyleInfo:
return types.ui.RowStyleInfo(prefix='row-maintenance-', field='maintenance_mode')
return (
ui_utils.TableBuilder(_('Servers of {0}').format(parent.name))
.text_column(name='hostname', title=_('Hostname'))
.text_column(name='ip', title=_('Ip'))
.text_column(name='mac', title=_('Mac'))
.dict_column(
name='maintenance',
title=_('State'),
dct={True: _('Maintenance'), False: _('Normal')},
)
.row_style(prefix='row-maintenance-', field='maintenance')
).build()
# Cannot save a tunnel server, it's not editable...
@@ -117,88 +114,100 @@ class TunnelServers(DetailHandler):
parent = ensure.is_instance(parent, models.ServerGroup)
try:
parent.servers.remove(models.Server.objects.get(uuid=process_uuid(item)))
except Exception:
raise self.invalid_item_response() from None
except models.Server.DoesNotExist:
raise exceptions.rest.NotFound(_('Tunnel server not found')) from None
except Exception as e:
logger.error('Error deleting tunnel server %s from %s: %s', item, parent, e)
raise exceptions.rest.ResponseError(_('Error deleting tunnel server')) from None
# Custom methods
def maintenance(self, parent: 'Model', id: str) -> typing.Any:
"""
API:
Custom method that swaps maintenance mode state for a tunnel server
"""
parent = ensure.is_instance(parent, models.ServerGroup)
"""
Custom method that swaps maintenance mode state for a tunnel server
:param item:
"""
item = models.Server.objects.get(uuid=process_uuid(id))
self.ensure_has_access(item, uds.core.types.permissions.PermissionType.MANAGEMENT)
self.check_access(item, uds.core.types.permissions.PermissionType.MANAGEMENT)
item.maintenance_mode = not item.maintenance_mode
item.save()
return 'ok'
@dataclasses.dataclass
class TunnelItem(types.rest.BaseRestItem):
id: str
name: str
comments: str
host: str
port: int
tags: list[str]
transports_count: int
servers_count: int
permission: uds.core.types.permissions.PermissionType
# Enclosed methods under /auth path
class Tunnels(ModelHandler):
path = 'tunnels'
name = 'tunnels'
model = models.ServerGroup
model_filter = {'type': types.servers.ServerType.TUNNEL}
custom_methods = [
class Tunnels(ModelHandler[TunnelItem]):
PATH = 'tunnels'
NAME = 'tunnels'
MODEL = models.ServerGroup
FILTER = {'type': types.servers.ServerType.TUNNEL}
CUSTOM_METHODS = [
types.rest.ModelCustomMethod('tunnels', needs_parent=True),
types.rest.ModelCustomMethod('assign', needs_parent=True),
]
detail = {'servers': TunnelServers}
save_fields = ['name', 'comments', 'host:', 'port:0']
DETAIL = {'servers': TunnelServers}
FIELDS_TO_SAVE = ['name', 'comments', 'host:', 'port:0']
table_title = _('Tunnels')
table_fields = [
{'name': {'title': _('Name'), 'visible': True, 'type': 'iconType'}},
{'comments': {'title': _('Comments')}},
{'host': {'title': _('Host')}},
{'port': {'title': _('Port')}},
{'servers_count': {'title': _('Servers'), 'type': 'numeric', 'width': '1rem'}},
{'tags': {'title': _('tags'), 'visible': False}},
]
TABLE = (
ui_utils.TableBuilder(_('Tunnels'))
.icon(name='name', title=_('Name'))
.text_column(name='comments', title=_('Comments'))
.text_column(name='host', title=_('Host'))
.numeric_column(name='port', title=_('Port'), width='6em')
.numeric_column(name='servers_count', title=_('Servers'), width='1rem')
.text_column(name='tags', title=_('tags'), visible=False)
.build()
)
def get_gui(self, type_: str) -> list[typing.Any]:
return self.add_field(
self.add_default_fields(
[],
['name', 'comments', 'tags'],
),
[
{
'name': 'host',
'value': '',
'label': gettext('Hostname'),
'tooltip': gettext(
'Hostname or IP address of the server where the tunnel is visible by the users'
),
'type': types.ui.FieldType.TEXT,
'order': 100, # At end
},
{
'name': 'port',
'value': 443,
'label': gettext('Port'),
'tooltip': gettext('Port where the tunnel is visible by the users'),
'type': types.ui.FieldType.NUMERIC,
'order': 101, # At end
},
],
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
return (
ui_utils.GuiBuilder()
.add_stock_field(types.rest.stock.StockField.NAME)
.add_stock_field(types.rest.stock.StockField.COMMENTS)
.add_stock_field(types.rest.stock.StockField.TAGS)
.add_text(
name='host',
label=gettext('Hostname'),
tooltip=gettext(
'Hostname or IP address of the server where the tunnel is visible by the users'
),
)
.add_numeric(
name='port',
default=443,
label=gettext('Port'),
tooltip=gettext('Port where the tunnel is visible by the users'),
)
.build()
)
def item_as_dict(self, item: 'Model') -> dict[str, typing.Any]:
def get_item(self, item: 'Model') -> TunnelItem:
item = ensure.is_instance(item, models.ServerGroup)
return {
'id': item.uuid,
'name': item.name,
'comments': item.comments,
'host': item.host,
'port': item.port,
'tags': [tag.tag for tag in item.tags.all()],
'transports_count': item.transports.count(),
'servers_count': item.servers.count(),
'permission': permissions.effective_permissions(self._user, item),
}
return TunnelItem(
id=item.uuid,
name=item.name,
comments=item.comments,
host=item.host,
port=item.port,
tags=[tag.tag for tag in item.tags.all()],
transports_count=item.transports.count(),
servers_count=item.servers.count(),
permission=permissions.effective_permissions(self._user, item),
)
def pre_save(self, fields: dict[str, typing.Any]) -> None:
fields['type'] = types.servers.ServerType.TUNNEL.value
@@ -216,21 +225,24 @@ class Tunnels(ModelHandler):
def assign(self, parent: 'Model') -> typing.Any:
parent = ensure.is_instance(parent, models.ServerGroup)
self.ensure_has_access(parent, uds.core.types.permissions.PermissionType.MANAGEMENT)
self.check_access(parent, uds.core.types.permissions.PermissionType.MANAGEMENT)
server: typing.Optional['models.Server'] = None # Avoid warning on reference before assignment
item = self._args[-1]
if not item:
raise self.invalid_item_response('No server specified')
raise exceptions.rest.RequestError('No server specified')
try:
server = models.Server.objects.get(uuid=process_uuid(item))
self.ensure_has_access(server, uds.core.types.permissions.PermissionType.READ)
self.check_access(server, uds.core.types.permissions.PermissionType.READ)
parent.servers.add(server)
except Exception:
raise self.invalid_item_response() from None
except models.Server.DoesNotExist:
raise exceptions.rest.NotFound(_('Tunnel server not found')) from None
except Exception as e:
logger.error('Error assigning server %s to %s: %s', item, parent, e)
raise exceptions.rest.ResponseError(_('Error assigning server')) from None
return 'ok'

View File

@@ -31,71 +31,100 @@
Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
import collections.abc
import dataclasses
import datetime
import logging
import typing
from django.utils.translation import gettext as _
from django.db.models import Model
import uds.core.types.permissions
from uds import models
from uds.core import exceptions, types
from uds.core.managers.userservice import UserServiceManager
from uds.core.types.rest import TableInfo
from uds.core.types.states import State
from uds.core.util import ensure, log, permissions
from uds.core.util import ensure, log, permissions, ui as ui_utils
from uds.core.util.model import process_uuid
from uds.REST.model import DetailHandler
if typing.TYPE_CHECKING:
from django.db.models import Model
logger = logging.getLogger(__name__)
class AssignedService(DetailHandler):
@dataclasses.dataclass
class UserServiceItem(types.rest.BaseRestItem):
id: str
pool_id: str
unique_id: str
friendly_name: str
state: str
os_state: str
state_date: datetime.datetime
creation_date: datetime.datetime
revision: str
ip: str
actor_version: str
# For cache
cache_level: int | types.rest.NotRequired = types.rest.NotRequired.field()
# Optional, used on some cases (e.g. assigned services)
pool_name: str | types.rest.NotRequired = types.rest.NotRequired.field()
# For assigned
owner: str | types.rest.NotRequired = types.rest.NotRequired.field()
owner_info: dict[str, str] | types.rest.NotRequired = types.rest.NotRequired.field()
in_use: bool | types.rest.NotRequired = types.rest.NotRequired.field()
in_use_date: datetime.datetime | types.rest.NotRequired = types.rest.NotRequired.field()
source_host: str | types.rest.NotRequired = types.rest.NotRequired.field()
source_ip: str | types.rest.NotRequired = types.rest.NotRequired.field()
class AssignedUserService(DetailHandler[UserServiceItem]):
"""
Rest handler for Assigned Services, wich parent is Service
"""
custom_methods = [
'reset',
]
custom_methods = ['reset']
CUSTOM_METHODS = ['reset']
@staticmethod
def item_as_dict(
def userservice_item(
item: models.UserService,
props: typing.Optional[dict[str, typing.Any]] = None,
is_cache: bool = False,
) -> dict[str, typing.Any]:
) -> 'UserServiceItem':
"""
Converts an assigned/cached service db item to a dictionary for REST response
:param item: item to convert
:param is_cache: If item is from cache or not
Args:
item: item to convert
props: properties to include
is_cache: If item is from cache or not
"""
if props is None:
props = dict(item.properties)
val = {
'id': item.uuid,
'id_deployed_service': item.deployed_service.uuid,
'unique_id': item.unique_id,
'friendly_name': item.friendly_name,
'state': (
val = UserServiceItem(
id=item.uuid,
pool_id=item.deployed_service.uuid,
unique_id=item.unique_id,
friendly_name=item.friendly_name,
state=(
item.state
if not (props.get('destroy_after') and item.state == State.PREPARING)
else State.CANCELING
), # Destroy after means that we need to cancel AFTER finishing preparing, but not before...
'os_state': item.os_state,
'state_date': item.state_date,
'creation_date': item.creation_date,
'revision': item.publication and item.publication.revision or '',
'ip': props.get('ip', _('unknown')),
'actor_version': props.get('actor_version', _('unknown')),
}
os_state=item.os_state,
state_date=item.state_date,
creation_date=item.creation_date,
revision=item.publication and str(item.publication.revision) or '',
ip=props.get('ip', _('unknown')),
actor_version=props.get('actor_version', _('unknown')),
)
if is_cache:
val['cache_level'] = item.cache_level
val.cache_level = item.cache_level
else:
if item.user is None:
owner = ''
@@ -107,19 +136,18 @@ class AssignedService(DetailHandler):
'user_id': item.user.uuid,
}
val.update(
{
'owner': owner,
'owner_info': owner_info,
'in_use': item.in_use,
'in_use_date': item.in_use_date,
'source_host': item.src_hostname,
'source_ip': item.src_ip,
}
)
val.owner = owner
val.owner_info = owner_info
val.in_use = item.in_use
val.in_use_date = item.in_use_date
val.source_host = item.src_hostname
val.source_ip = item.src_ip
return val
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
def get_items(
self, parent: 'Model', item: typing.Optional[str]
) -> types.rest.ItemsResult['UserServiceItem']:
parent = ensure.is_instance(parent, models.ServicePool)
try:
@@ -127,19 +155,21 @@ class AssignedService(DetailHandler):
# First, fetch all properties for all assigned services on this pool
# We can cache them, because they are going to be readed anyway...
properties: dict[str, typing.Any] = collections.defaultdict(dict)
for id, key, value in models.Properties.objects.filter(
owner_type='userservice',
owner_id__in=parent.assigned_user_services().values_list('uuid', flat=True),
for id, key, value in self.filter_queryset(
models.Properties.objects.filter(
owner_type='userservice',
owner_id__in=parent.assigned_user_services().values_list('uuid', flat=True),
)
).values_list('owner_id', 'key', 'value'):
properties[id][key] = value
return [
AssignedService.item_as_dict(k, properties.get(k.uuid, {}))
AssignedUserService.userservice_item(k, properties.get(k.uuid, {}))
for k in parent.assigned_user_services()
.all()
.prefetch_related('deployed_service', 'publication', 'user')
]
return AssignedService.item_as_dict(
return AssignedUserService.userservice_item(
parent.assigned_user_services().get(process_uuid(uuid=process_uuid(item))),
props={
k: v
@@ -149,48 +179,30 @@ class AssignedService(DetailHandler):
},
)
except Exception as e:
logger.exception('get_items')
raise self.invalid_item_response() from e
logger.error('Error getting user service %s: %s', item, e)
raise exceptions.rest.ResponseError(_('Error getting user service')) from e
def get_title(self, parent: 'Model') -> str:
return _('Assigned services')
def get_fields(self, parent: 'Model') -> list[typing.Any]:
def get_table(self, parent: 'Model') -> types.rest.TableInfo:
parent = ensure.is_instance(parent, models.ServicePool)
# Revision is only shown if publication type is not None
return (
[
{'creation_date': {'title': _('Creation date'), 'type': 'datetime'}},
]
+ (
[
{'revision': {'title': _('Revision')}},
]
if parent.service.get_type().publication_type is not None
else []
)
+ [
{'unique_id': {'title': 'Unique ID'}},
{'ip': {'title': _('IP')}},
{'friendly_name': {'title': _('Friendly name')}},
{
'state': {
'title': _('status'),
'type': 'dict',
'dict': State.literals_dict(),
}
},
{'state_date': {'title': _('Status date'), 'type': 'datetime'}},
{'in_use': {'title': _('In Use')}},
{'source_host': {'title': _('Src Host')}},
{'source_ip': {'title': _('Src Ip')}},
{'owner': {'title': _('Owner')}},
{'actor_version': {'title': _('Actor version')}},
]
table_info = ui_utils.TableBuilder(_('Assigned Services')).datetime_column(
name='creation_date', title=_('Creation date')
)
if parent.service.get_type().publication_type is not None:
table_info.text_column(name='revision', title=_('Revision'))
def get_row_style(self, parent: 'Model') -> types.ui.RowStyleInfo:
return types.ui.RowStyleInfo(prefix='row-state-', field='state')
return (
table_info.text_column(name='unique_id', title='Unique ID')
.text_column(name='ip', title=_('IP'))
.text_column(name='friendly_name', title=_('Friendly name'))
.dict_column(name='state', title=_('status'), dct=State.literals_dict())
.datetime_column(name='state_date', title=_('Status date'))
.text_column(name='in_use', title=_('In Use'))
.text_column(name='source_host', title=_('Src Host'))
.text_column(name='source_ip', title=_('Src Ip'))
.text_column(name='owner', title=_('Owner'))
.text_column(name='actor_version', title=_('Actor version'))
.row_style(prefix='row-state-', field='state')
).build()
def get_logs(self, parent: 'Model', item: str) -> list[typing.Any]:
parent = ensure.is_instance(parent, models.ServicePool)
@@ -198,8 +210,11 @@ class AssignedService(DetailHandler):
user_service: models.UserService = parent.assigned_user_services().get(uuid=process_uuid(item))
logger.debug('Getting logs for %s', user_service)
return log.get_logs(user_service)
except models.UserService.DoesNotExist:
raise exceptions.rest.NotFound(_('User service not found')) from None
except Exception as e:
raise self.invalid_item_response() from e
logger.error('Error getting user service logs for %s: %s', item, e)
raise exceptions.rest.ResponseError(_('Error getting user service logs')) from e
# This is also used by CachedService, so we use "userServices" directly and is valid for both
def delete_item(self, parent: 'Model', item: str, cache: bool = False) -> None:
@@ -210,8 +225,8 @@ class AssignedService(DetailHandler):
else:
userservice = parent.assigned_user_services().get(uuid=process_uuid(item))
except Exception as e:
logger.exception('delete_item')
raise self.invalid_item_response() from e
logger.error('Error deleting user service %s from %s: %s', item, parent, e)
raise exceptions.rest.ResponseError(_('Error deleting user service')) from None
if userservice.user: # All assigned services have a user
log_string = f'Deleted assigned user service {userservice.friendly_name} to user {userservice.user.pretty_name} by {self._user.pretty_name}'
@@ -223,9 +238,9 @@ class AssignedService(DetailHandler):
elif userservice.state == State.PREPARING:
userservice.cancel()
elif userservice.state == State.REMOVABLE:
raise self.invalid_item_response(_('Item already being removed'))
raise exceptions.rest.RequestError(_('Item already being removed')) from None
else:
raise self.invalid_item_response(_('Item is not removable'))
raise exceptions.rest.RequestError(_('Item is not removable')) from None
log.log(parent, types.log.LogLevel.INFO, log_string, types.log.LogSource.ADMIN)
log.log(userservice, types.log.LogLevel.INFO, log_string, types.log.LogSource.ADMIN)
@@ -234,9 +249,9 @@ class AssignedService(DetailHandler):
def save_item(self, parent: 'Model', item: typing.Optional[str]) -> typing.Any:
parent = ensure.is_instance(parent, models.ServicePool)
if not item:
raise self.invalid_item_response('Only modify is allowed')
raise exceptions.rest.RequestError('Only modify is allowed')
fields = self.fields_from_params(['auth_id:_', 'user_id:_', 'ip:_'])
userservice = parent.userServices.get(uuid=process_uuid(item))
if 'user_id' in fields and 'auth_id' in fields:
user = models.User.objects.get(uuid=process_uuid(fields['user_id']))
@@ -251,7 +266,7 @@ class AssignedService(DetailHandler):
.count()
> 0
):
raise self.invalid_response_response(
raise exceptions.rest.RequestError(
f'There is already another user service assigned to {user.pretty_name}'
)
@@ -261,7 +276,7 @@ class AssignedService(DetailHandler):
log_string = f'Changed IP of user service {userservice.friendly_name} to {fields["ip"]} by {self._user.pretty_name}'
userservice.log_ip(fields['ip'])
else:
raise self.invalid_item_response('Invalid fields')
raise exceptions.rest.RequestError('Invalid fields')
# Log change
log.log(parent, types.log.LogLevel.INFO, log_string, types.log.LogSource.ADMIN)
@@ -274,56 +289,51 @@ class AssignedService(DetailHandler):
UserServiceManager.manager().reset(userservice)
class CachedService(AssignedService):
class CachedService(AssignedUserService):
"""
Rest handler for Cached Services, which parent is ServicePool
"""
custom_methods: typing.ClassVar[list[str]] = [] # Remove custom methods from assigned services
CUSTOM_METHODS = [] # Remove custom methods from assigned services
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
def get_items(
self, parent: 'Model', item: typing.Optional[str]
) -> types.rest.ItemsResult['UserServiceItem']:
parent = ensure.is_instance(parent, models.ServicePool)
try:
if not item:
return [
AssignedService.item_as_dict(k, is_cache=True)
for k in parent.cached_users_services()
.all()
.prefetch_related('deployed_service', 'publication')
AssignedUserService.userservice_item(k, is_cache=True)
for k in self.filter_queryset(parent.cached_users_services().all()).prefetch_related(
'deployed_service', 'publication'
)
]
cached_userservice: models.UserService = parent.cached_users_services().get(uuid=process_uuid(item))
return AssignedService.item_as_dict(cached_userservice, is_cache=True)
return AssignedUserService.userservice_item(cached_userservice, is_cache=True)
except models.UserService.DoesNotExist:
raise exceptions.rest.NotFound(_('User service not found')) from None
except Exception as e:
logger.exception('get_items')
raise self.invalid_item_response() from e
logger.error('Error getting user service %s: %s', item, e)
raise exceptions.rest.ResponseError(_('Error getting user service')) from e
def get_title(self, parent: 'Model') -> str:
return _('Cached services')
def get_fields(self, parent: 'Model') -> list[typing.Any]:
def get_table(self, parent: 'Model') -> types.rest.TableInfo:
parent = ensure.is_instance(parent, models.ServicePool)
return [
{'creation_date': {'title': _('Creation date'), 'type': 'datetime'}},
{'revision': {'title': _('Revision')}},
{'unique_id': {'title': 'Unique ID'}},
{'ip': {'title': _('IP')}},
{'friendly_name': {'title': _('Friendly name')}},
] + (
[
{
'state': {
'title': _('State'),
'type': 'dict',
'dict': State.literals_dict(),
}
},
{'cache_level': {'title': _('Cache level')}},
{'actor_version': {'title': _('Actor version')}},
]
if parent.state != State.LOCKED
else []
table_info = (
ui_utils.TableBuilder(_('Cached Services'))
.datetime_column(name='creation_date', title=_('Creation date'))
.text_column(name='revision', title=_('Revision'))
.text_column(name='unique_id', title='Unique ID')
.text_column(name='ip', title=_('IP'))
.text_column(name='friendly_name', title=_('Friendly name'))
.dict_column(name='state', title=_('State'), dct=State.literals_dict())
)
if parent.state != State.LOCKED:
table_info = table_info.text_column(name='cache_level', title=_('Cache level')).text_column(
name='actor_version', title=_('Actor version')
)
return table_info.build()
def delete_item(self, parent: 'Model', item: str, cache: bool = False) -> None:
return super().delete_item(parent, item, cache=True)
@@ -334,63 +344,57 @@ class CachedService(AssignedService):
userservice = parent.cached_users_services().get(uuid=process_uuid(item))
logger.debug('Getting logs for %s', item)
return log.get_logs(userservice)
except Exception:
raise self.invalid_item_response() from None
except Exception as e:
logger.error('Error getting user service logs for %s: %s', item, e)
raise exceptions.rest.ResponseError(_('Error getting user service logs')) from None
class Groups(DetailHandler):
@dataclasses.dataclass
class GroupItem(types.rest.BaseRestItem):
id: str
auth_id: str
name: str
group_name: str
comments: str
state: str
type: str
auth_name: str
class Groups(DetailHandler[GroupItem]):
"""
Processes the groups detail requests of a Service Pool
"""
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> list['GroupItem']:
parent = typing.cast(typing.Union['models.ServicePool', 'models.MetaPool'], parent)
return [
{
'id': group.uuid,
'auth_id': group.manager.uuid,
'name': group.name,
'group_name': group.pretty_name,
'comments': group.comments,
'state': group.state,
'type': 'meta' if group.is_meta else 'group',
'auth_name': group.manager.name,
}
for group in typing.cast(collections.abc.Iterable[models.Group], parent.assignedGroups.all())
GroupItem(
id=group.uuid,
auth_id=group.manager.uuid,
name=group.name,
group_name=group.pretty_name,
comments=group.comments,
state=group.state,
type='meta' if group.is_meta else 'group',
auth_name=group.manager.name,
)
for group in typing.cast(
collections.abc.Iterable[models.Group], self.filter_queryset(parent.assignedGroups.all())
)
]
def get_title(self, parent: 'Model') -> str:
def get_table(self, parent: 'Model') -> TableInfo:
parent = typing.cast(typing.Union['models.ServicePool', 'models.MetaPool'], parent)
return _('Assigned groups')
def get_fields(self, parent: 'Model') -> list[typing.Any]:
return [
# Note that this field is "self generated" on client table
{
'group_name': {
'title': _('Name'),
'type': 'alphanumeric',
}
},
{'comments': {'title': _('comments')}},
{
'type': {
'title': _('Type'),
# Alphanumeric, default is alphanumeric
}
},
{
'state': {
'title': _('State'),
'type': 'dict',
'dict': State.literals_dict(),
}
},
]
def get_row_style(self, parent: 'Model') -> types.ui.RowStyleInfo:
return types.ui.RowStyleInfo(prefix='row-state-', field='state')
return (
ui_utils.TableBuilder(_('Assigned groups'))
.text_column(name='group_name', title=_('Name'))
.text_column(name='comments', title=_('comments'))
.dict_column(name='state', title=_('State'), dct=State.literals_dict())
.row_style(prefix='row-state-', field='state')
.build()
)
def save_item(self, parent: 'Model', item: typing.Optional[str]) -> typing.Any:
parent = typing.cast(typing.Union['models.ServicePool', 'models.MetaPool'], parent)
@@ -418,44 +422,46 @@ class Groups(DetailHandler):
)
class Transports(DetailHandler):
@dataclasses.dataclass
class TransportItem(types.rest.BaseRestItem):
id: str
name: str
type: dict[str, typing.Any] # TypeInfo
comments: str
priority: int
trans_type: str
class Transports(DetailHandler[TransportItem]):
"""
Processes the transports detail requests of a Service Pool
"""
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> list['TransportItem']:
parent = ensure.is_instance(parent, models.ServicePool)
def get_type(trans: 'models.Transport') -> types.rest.TypeInfoDict:
try:
return self.type_as_dict(trans.get_type())
except Exception: # No type found
raise self.invalid_item_response()
return [
{
'id': i.uuid,
'name': i.name,
'type': get_type(i),
'comments': i.comments,
'priority': i.priority,
'trans_type': _(i.get_type().mod_name()),
}
for i in parent.transports.all()
if get_type(i)
TransportItem(
id=trans.uuid,
name=trans.name,
type=type(self).as_typeinfo(trans.get_type()).as_dict(),
comments=trans.comments,
priority=trans.priority,
trans_type=trans.get_type().mod_name(),
)
for trans in self.filter_queryset(parent.transports.all())
]
def get_title(self, parent: 'Model') -> str:
def get_table(self, parent: 'Model') -> TableInfo:
parent = ensure.is_instance(parent, models.ServicePool)
return _('Assigned transports')
def get_fields(self, parent: 'Model') -> list[typing.Any]:
return [
{'priority': {'title': _('Priority'), 'type': 'numeric', 'width': '6em'}},
{'name': {'title': _('Name')}},
{'trans_type': {'title': _('Type')}},
{'comments': {'title': _('Comments')}},
]
return (
ui_utils.TableBuilder(_('Assigned transports'))
.numeric_column(name='priority', title=_('Priority'), width='6em')
.text_column(name='name', title=_('Name'))
.text_column(name='trans_type', title=_('Type'))
.text_column(name='comments', title=_('Comments'))
.build()
)
def save_item(self, parent: 'Model', item: typing.Optional[str]) -> typing.Any:
parent = ensure.is_instance(parent, models.ServicePool)
@@ -482,12 +488,22 @@ class Transports(DetailHandler):
)
class Publications(DetailHandler):
@dataclasses.dataclass
class PublicationItem(types.rest.BaseRestItem):
id: str
revision: int
publish_date: datetime.datetime
state: str
reason: str
state_date: datetime.datetime
class Publications(DetailHandler[PublicationItem]):
"""
Processes the publications detail requests of a Service Pool
"""
custom_methods = ['publish', 'cancel'] # We provided these custom methods
CUSTOM_METHODS = ['publish', 'cancel'] # We provided these custom methods
def publish(self, parent: 'Model') -> typing.Any:
"""
@@ -502,7 +518,7 @@ class Publications(DetailHandler):
is False
):
logger.debug('Management Permission failed for user %s', self._user)
raise self.access_denied_response()
raise exceptions.rest.AccessDenied(_('Access denied to publish service pool')) from None
logger.debug('Custom "publish" invoked for %s', parent)
parent.publish(change_log) # Can raise exceptions that will be processed on response
@@ -529,7 +545,7 @@ class Publications(DetailHandler):
is False
):
logger.debug('Management Permission failed for user %s', self._user)
raise self.access_denied_response()
raise exceptions.rest.AccessDenied(_('Access denied to cancel service pool publication')) from None
try:
ds = models.ServicePoolPublication.objects.get(uuid=process_uuid(uuid))
@@ -546,65 +562,60 @@ class Publications(DetailHandler):
return self.success()
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> list['PublicationItem']:
parent = ensure.is_instance(parent, models.ServicePool)
return [
{
'id': i.uuid,
'revision': i.revision,
'publish_date': i.publish_date,
'state': i.state,
'reason': State.from_str(i.state).is_errored() and i.get_instance().error_reason() or '',
'state_date': i.state_date,
}
for i in parent.publications.all()
PublicationItem(
id=i.uuid,
revision=i.revision,
publish_date=i.publish_date,
state=i.state,
reason=State.from_str(i.state).is_errored() and i.get_instance().error_reason() or '',
state_date=i.state_date,
)
for i in self.filter_queryset(parent.publications.all())
]
def get_title(self, parent: 'Model') -> str:
def get_table(self, parent: 'Model') -> TableInfo:
parent = ensure.is_instance(parent, models.ServicePool)
return _('Publications')
def get_fields(self, parent: 'Model') -> list[typing.Any]:
return [
{'revision': {'title': _('Revision'), 'type': 'numeric', 'width': '6em'}},
{'publish_date': {'title': _('Publish date'), 'type': 'datetime'}},
{
'state': {
'title': _('State'),
'type': 'dict',
'dict': State.literals_dict(),
}
},
{'reason': {'title': _('Reason')}},
]
def get_row_style(self, parent: 'Model') -> types.ui.RowStyleInfo:
return types.ui.RowStyleInfo(prefix='row-state-', field='state')
return (
ui_utils.TableBuilder(_('Publications'))
.numeric_column(name='revision', title=_('Revision'), width='6em')
.datetime_column(name='publish_date', title=_('Publish date'))
.dict_column(name='state', title=_('State'), dct=State.literals_dict())
.text_column(name='reason', title=_('Reason'))
.row_style(prefix='row-state-', field='state')
).build()
class Changelog(DetailHandler):
@dataclasses.dataclass
class ChangelogItem(types.rest.BaseRestItem):
revision: int
stamp: datetime.datetime
log: str
class Changelog(DetailHandler[ChangelogItem]):
"""
Processes the transports detail requests of a Service Pool
"""
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> list['ChangelogItem']:
parent = ensure.is_instance(parent, models.ServicePool)
return [
{
'revision': i.revision,
'stamp': i.stamp,
'log': i.log,
}
for i in parent.changelog.all()
ChangelogItem(
revision=i.revision,
stamp=i.stamp,
log=i.log,
)
for i in self.filter_queryset(parent.changelog.all())
]
def get_title(self, parent: 'Model') -> str:
def get_table(self, parent: 'Model') -> types.rest.TableInfo:
parent = ensure.is_instance(parent, models.ServicePool)
return _(f'Changelog')
def get_fields(self, parent: 'Model') -> list[typing.Any]:
return [
{'revision': {'title': _('Revision'), 'type': 'numeric', 'width': '6em'}},
{'stamp': {'title': _('Publish date'), 'type': 'datetime'}},
{'log': {'title': _('Comment')}},
]
return (
ui_utils.TableBuilder(_('Changelog'))
.numeric_column(name='revision', title=_('Revision'), width='6em')
.datetime_column(name='stamp', title=_('Publish date'))
.text_column(name='log', title=_('Comment'))
).build()

View File

@@ -29,31 +29,29 @@
"""
Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
import dataclasses
import datetime
import logging
import typing
import collections.abc
from django.utils.translation import gettext as _
from django.forms.models import model_to_dict
from django.db import IntegrityError, transaction
from django.db.models import Model
from django.core.exceptions import ValidationError
from uds.core.types.states import State
from uds.core.auths.user import User as AUser
from uds.core.util import log, ensure
from uds.core.util import log, ensure, ui as ui_utils
from uds.core.util.model import process_uuid, sql_stamp_seconds
from uds.models import Authenticator, User, Group, ServicePool
from uds.models import Authenticator, User, Group, ServicePool, UserService
from uds.core.managers.crypto import CryptoManager
from uds.core import consts, exceptions, types
from uds.REST.model import DetailHandler
from .user_services import AssignedService
if typing.TYPE_CHECKING:
from django.db.models import Model
from uds.models import UserService
from .user_services import AssignedUserService, UserServiceItem
logger = logging.getLogger(__name__)
@@ -77,8 +75,24 @@ def get_service_pools_for_groups(
yield servicepool
class Users(DetailHandler):
custom_methods = [
@dataclasses.dataclass
class UserItem(types.rest.BaseRestItem):
id: str
name: str
real_name: str
comments: str
state: str
staff_member: bool
is_admin: bool
last_access: datetime.datetime
mfa_data: str
role: str
parent: str | None
groups: list[str] | types.rest.NotRequired = types.rest.NotRequired.field()
class Users(DetailHandler[UserItem]):
CUSTOM_METHODS = [
'services_pools',
'user_services',
'clean_related',
@@ -86,116 +100,67 @@ class Users(DetailHandler):
'enable_client_logging',
]
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> typing.Any:
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ItemsResult[UserItem]:
parent = ensure.is_instance(parent, Authenticator)
# processes item to change uuid key for id
def uuid_to_id(
iterable: collections.abc.Iterable[typing.Any],
) -> collections.abc.Generator[typing.Any, None, None]:
for v in iterable:
v['id'] = v['uuid']
del v['uuid']
yield v
def as_user_item(user: 'User') -> UserItem:
return UserItem(
id=user.uuid,
name=user.name,
real_name=user.real_name,
comments=user.comments,
state=user.state,
staff_member=user.staff_member,
is_admin=user.is_admin,
last_access=user.last_access,
mfa_data=user.mfa_data,
parent=user.parent,
groups=[i.uuid for i in user.get_groups()],
role=user.get_role().as_str(),
)
logger.debug(item)
# Extract authenticator
try:
if item is None:
values = list(
uuid_to_id(
(
i
for i in parent.users.all().values(
'uuid',
'name',
'real_name',
'comments',
'state',
'staff_member',
'is_admin',
'last_access',
'parent',
'mfa_data',
)
)
)
)
for res in values:
res['role'] = (
res['staff_member']
and (res['is_admin'] and _('Admin') or _('Staff member'))
or _('User')
)
return values
if item is None: # All users
return [as_user_item(i) for i in self.filter_queryset(parent.users.all())]
u = parent.users.get(uuid__iexact=process_uuid(item))
res = model_to_dict(
u,
fields=(
'name',
'real_name',
'comments',
'state',
'staff_member',
'is_admin',
'last_access',
'parent',
'mfa_data',
),
)
res['id'] = u.uuid
res['role'] = (
res['staff_member'] and (res['is_admin'] and _('Admin') or _('Staff member')) or _('User')
)
res = as_user_item(u)
usr = AUser(u)
res['groups'] = [g.db_obj().uuid for g in usr.groups()]
res.groups = [g.db_obj().uuid for g in usr.groups()]
logger.debug('Item: %s', res)
return res
except User.DoesNotExist:
raise exceptions.rest.NotFound(_('User not found')) from None
except Exception as e:
# User not found
raise self.invalid_item_response() from e
logger.error('Error getting user %s: %s', item, e)
raise exceptions.rest.ResponseError(_('Error getting user')) from e
def get_title(self, parent: 'Model') -> str:
try:
return _('Users of {0}').format(
Authenticator.objects.get(uuid=process_uuid(self._kwargs['parent_id'])).name
def get_table(self, parent: 'Model') -> types.rest.TableInfo:
parent = ensure.is_instance(parent, Authenticator)
return (
ui_utils.TableBuilder(_('Users of {0}').format(parent.name))
.icon(name='name', title=_('Username'), visible=True)
.text_column(name='role', title=_('Role'))
.text_column(name='real_name', title=_('Name'))
.text_column(name='comments', title=_('Comments'))
.dict_column(
name='state', title=_('Status'), dct={State.ACTIVE: _('Enabled'), State.INACTIVE: _('Disabled')}
)
except Exception:
return _('Current users')
def get_fields(self, parent: 'Model') -> list[typing.Any]:
return [
{
'name': {
'title': _('Username'),
'visible': True,
'type': 'icon',
'icon': 'fa fa-user text-success',
}
},
{'role': {'title': _('Role')}},
{'real_name': {'title': _('Name')}},
{'comments': {'title': _('Comments')}},
{
'state': {
'title': _('state'),
'type': 'dict',
'dict': {State.ACTIVE: _('Enabled'), State.INACTIVE: _('Disabled')},
}
},
{'last_access': {'title': _('Last access'), 'type': 'datetime'}},
]
def get_row_style(self, parent: 'Model') -> types.ui.RowStyleInfo:
return types.ui.RowStyleInfo(prefix='row-state-', field='state')
.datetime_column(name='last_access', title=_('Last access'))
.row_style(prefix='row-state-', field='state')
).build()
def get_logs(self, parent: 'Model', item: str) -> list[typing.Any]:
parent = ensure.is_instance(parent, Authenticator)
user = None
try:
user = parent.users.get(uuid=process_uuid(item))
except Exception:
raise self.invalid_item_response() from None
except User.DoesNotExist:
raise exceptions.rest.NotFound(_('User not found')) from None
except Exception as e:
logger.error('Error getting user %s: %s', item, e)
raise exceptions.rest.ResponseError(_('Error getting user')) from e
return log.get_logs(user)
@@ -247,21 +212,21 @@ class Users(DetailHandler):
groups = self.fields_from_params(['groups'])['groups']
# Save but skip meta groups, they are not real groups, but just a way to group users based on rules
user.groups.set(g for g in parent.groups.filter(uuid__in=groups) if g.is_meta is False)
return {'id': user.uuid}
except User.DoesNotExist:
raise self.invalid_item_response() from None
raise exceptions.rest.NotFound(_('User not found')) from None
except IntegrityError: # Duplicate key probably
raise exceptions.rest.RequestError(_('User already exists (duplicate key error)')) from None
except ValidationError as e:
raise exceptions.rest.RequestError(str(e.message)) from e
except exceptions.auth.AuthenticatorException as e:
raise exceptions.rest.RequestError(str(e)) from e
except exceptions.rest.RequestError: # pylint: disable=try-except-raise
except exceptions.rest.RequestError:
raise # Re-raise
except Exception as e:
logger.exception('Saving user')
raise self.invalid_request_response() from e
logger.error('Error saving user %s: %s', item, e)
raise exceptions.rest.ResponseError(_('Error saving user')) from e
def delete_item(self, parent: 'Model', item: str) -> None:
parent = ensure.is_instance(parent, Authenticator)
@@ -272,7 +237,7 @@ class Users(DetailHandler):
'Removal of user %s denied due to insufficients rights',
user.pretty_name,
)
raise self.invalid_item_response(
raise exceptions.rest.AccessDenied(
f'Removal of user {user.pretty_name} denied due to insufficients rights'
)
@@ -292,9 +257,13 @@ class Users(DetailHandler):
user.delete()
except Exception as e:
logger.error('Error on user removal of %s.%s: %s', parent.name, item, e)
raise self.invalid_item_response() from e
raise exceptions.rest.ResponseError(_('Error removing user')) from e
def services_pools(self, parent: 'Model', item: str) -> list[dict[str, typing.Any]]:
"""
API:
Returns the service pools assigned to a user
"""
parent = ensure.is_instance(parent, Authenticator)
uuid = process_uuid(item)
user = parent.users.get(uuid=process_uuid(uuid))
@@ -315,19 +284,21 @@ class Users(DetailHandler):
return res
def user_services(self, parent: 'Authenticator', item: str) -> list[dict[str, typing.Any]]:
def user_services(self, parent: 'Authenticator', item: str) -> list[UserServiceItem]:
parent = ensure.is_instance(parent, Authenticator)
uuid = process_uuid(item)
user = parent.users.get(uuid=process_uuid(uuid))
res: list[dict[str, typing.Any]] = []
for i in user.userServices.all():
if i.state == State.USABLE:
v = AssignedService.item_as_dict(i)
v['pool'] = i.deployed_service.name
v['pool_id'] = i.deployed_service.uuid
res.append(v)
return res
def item_as_dict(assigned_user_service: 'UserService') -> UserServiceItem:
base = AssignedUserService.userservice_item(assigned_user_service)
base.pool_name = assigned_user_service.deployed_service.name
base.pool_id = assigned_user_service.deployed_service.uuid
return base
return [
item_as_dict(i)
for i in user.userServices.all().prefetch_related('deployed_service').filter(state=State.USABLE)
]
def clean_related(self, parent: 'Authenticator', item: str) -> dict[str, str]:
uuid = process_uuid(item)
@@ -361,101 +332,97 @@ class Users(DetailHandler):
return {'status': 'ok'}
class Groups(DetailHandler):
custom_methods = ['services_pools', 'users']
@dataclasses.dataclass
class GroupItem(types.rest.BaseRestItem):
id: str
name: str
comments: str
state: str
type: str
meta_if_any: bool
skip_mfa: str
groups: list[str] | types.rest.NotRequired = types.rest.NotRequired.field()
pools: list[str] | types.rest.NotRequired = types.rest.NotRequired.field()
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
class Groups(DetailHandler[GroupItem]):
CUSTOM_METHODS = ['services_pools', 'users']
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ItemsResult['GroupItem']:
parent = ensure.is_instance(parent, Authenticator)
try:
multi = False
if item is None:
multi = True
q = parent.groups.all().order_by('name')
q = self.filter_queryset(parent.groups.all())
else:
q = parent.groups.filter(uuid=process_uuid(item))
res: list[dict[str, typing.Any]] = []
res: list[GroupItem] = []
i = None
for i in q:
val: dict[str, typing.Any] = {
'id': i.uuid,
'name': i.name,
'comments': i.comments,
'state': i.state,
'type': i.is_meta and 'meta' or 'group',
'meta_if_any': i.meta_if_any,
'skip_mfa': i.skip_mfa,
}
val = GroupItem(
id=i.uuid,
name=i.name,
comments=i.comments,
state=i.state,
type=i.is_meta and 'meta' or 'group',
meta_if_any=i.meta_if_any,
skip_mfa=i.skip_mfa,
)
if i.is_meta:
val['groups'] = list(x.uuid for x in i.groups.all().order_by('name'))
val.groups = list(x.uuid for x in i.groups.all().order_by('name'))
res.append(val)
if multi:
return res
if not i:
raise Exception('Item not found')
raise exceptions.rest.NotFound(_('Group not found')) from None
# Add pools field if 1 item only
result = res[0]
result['pools'] = [v.uuid for v in get_service_pools_for_groups([i])]
return result
res[0].pools = [v.uuid for v in get_service_pools_for_groups([i])]
return res[0]
except exceptions.rest.HandlerError:
raise # Re-raise
except Exception as e:
logger.error('Group item not found: %s.%s: %s', parent.name, item, e)
raise self.invalid_item_response() from e
raise exceptions.rest.ResponseError(_('Error getting group')) from e
def get_title(self, parent: 'Model') -> str:
def get_table(self, parent: 'Model') -> types.rest.TableInfo:
parent = ensure.is_instance(parent, Authenticator)
try:
return _('Groups of {0}').format(parent.name)
except Exception:
return _('Current groups')
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')},
}
},
]
return (
ui_utils.TableBuilder(_('Groups of {0}').format(parent.name))
.text_column(name='name', title=_('Group'), visible=True)
.text_column(name='comments', title=_('Comments'))
.dict_column(name='state', title=_('Status'), dct=State.literals_dict())
.dict_column(name='skip_mfa', title=_('Skip MFA'), dct=State.literals_dict())
).build()
def get_types(
self, parent: 'Model', for_type: typing.Optional[str]
) -> collections.abc.Iterable[types.rest.TypeInfoDict]:
) -> collections.abc.Iterable[types.rest.TypeInfo]:
parent = ensure.is_instance(parent, Authenticator)
types_dict: dict[str, dict[str, str]] = {
'group': {'name': _('Group'), 'description': _('UDS Group')},
'meta': {'name': _('Meta group'), 'description': _('UDS Meta Group')},
}
types_list: list[types.rest.TypeInfoDict] = [
{
'name': v['name'],
'type': k,
'description': v['description'],
'icon': '',
}
types_list: list[types.rest.TypeInfo] = [
types.rest.TypeInfo(
name=v['name'],
type=k,
description=v['description'],
icon='',
)
for k, v in types_dict.items()
]
if for_type is None:
if not for_type:
return types_list
try:
return [next(filter(lambda x: x['type'] == for_type, types_list))]
except Exception:
raise self.invalid_request_response() from None
return [next(filter(lambda x: x.type == for_type, types_list))]
except StopIteration:
logger.error('Type %s not found in %s', for_type, types_list)
raise exceptions.rest.NotFound(_('Group type not found')) from None
def save_item(self, parent: 'Model', item: typing.Optional[str]) -> typing.Any:
parent = ensure.is_instance(parent, Authenticator)
@@ -513,7 +480,7 @@ class Groups(DetailHandler):
group.save()
return {'id': group.uuid}
except Group.DoesNotExist:
raise self.invalid_item_response() from None
raise exceptions.rest.NotFound(_('Group not found')) from None
except IntegrityError: # Duplicate key probably
raise exceptions.rest.RequestError(_('User already exists (duplicate key error)')) from None
except exceptions.auth.AuthenticatorException as e:
@@ -521,8 +488,8 @@ class Groups(DetailHandler):
except exceptions.rest.RequestError: # pylint: disable=try-except-raise
raise # Re-raise
except Exception as e:
logger.exception('Saving group')
raise self.invalid_request_response() from e
logger.error('Error saving group %s: %s', item, e)
raise exceptions.rest.ResponseError(_('Error saving group')) from e
def delete_item(self, parent: 'Model', item: str) -> None:
parent = ensure.is_instance(parent, Authenticator)
@@ -530,10 +497,13 @@ class Groups(DetailHandler):
group = parent.groups.get(uuid=item)
group.delete()
except Exception:
raise self.invalid_item_response() from None
except exceptions.rest.NotFound:
raise exceptions.rest.NotFound(_('Group not found')) from None
except Exception as e:
logger.error('Error deleting group %s: %s', item, e)
raise exceptions.rest.ResponseError(_('Error deleting group')) from e
def servicesPools(self, parent: 'Model', item: str) -> list[collections.abc.Mapping[str, typing.Any]]:
def services_pools(self, parent: 'Model', item: str) -> list[collections.abc.Mapping[str, typing.Any]]:
parent = ensure.is_instance(parent, Authenticator)
uuid = process_uuid(item)
group = parent.groups.get(uuid=process_uuid(uuid))

View File

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

View File

@@ -29,9 +29,8 @@
"""
Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
# pylint: disable=too-many-public-methods
import inspect
import abc
import logging
import typing
@@ -41,9 +40,10 @@ from django.utils.translation import gettext as _
from uds.core import consts
from uds.core import exceptions
from uds.core import types
from uds.core.module import Module
from uds.core.util import permissions
from uds.models import ManagedObjectModel, Network
from uds.core.module import Module
# from uds.models import ManagedObjectModel
from ..handlers import Handler
@@ -55,247 +55,48 @@ logger = logging.getLogger(__name__)
# pylint: disable=unused-argument
class BaseModelHandler(Handler):
class BaseModelHandler(Handler, abc.ABC, typing.Generic[types.rest.T_Item]):
"""
Base Handler for Master & Detail Handlers
"""
def add_field(
self, gui: list[typing.Any], field: typing.Union[types.rest.FieldType, list[types.rest.FieldType]]
) -> list[typing.Any]:
"""
Add a field to a "gui" description.
This method checks that every required field element is in there.
If not, defaults are assigned
:param gui: List of "gui" items where the field will be added
:param field: Field to be added (dictionary)
"""
if isinstance(field, list):
for i in field:
gui = self.add_field(gui, i)
else:
if 'values' in field:
caller = inspect.stack()[1]
logger.warning(
'Field %s has "values" attribute, this is deprecated and will be removed in future versions. Use "choices" instead. Called from %s:%s',
field.get('name', ''),
caller.filename,
caller.lineno,
)
choices = field['values']
else:
choices = field.get('choices', None)
# Build gui with non empty values
gui_description: dict[str, typing.Any] = {}
# First, mandatory fields
for fld in ('name', 'type'):
if fld not in field:
caller = inspect.stack()[1]
logger.error(
'Field %s does not have mandatory field %s. Called from %s:%s',
field.get('name', ''),
fld,
caller.filename,
caller.lineno,
)
raise exceptions.rest.RequestError(
f'Field {fld} is mandatory on {field.get("name", "")} field.'
)
if choices:
gui_description['choices'] = choices
# "fillable" fields (optional and mandatory on gui)
for fld in (
'type',
'default',
'required',
'min_value',
'max_value',
'length',
'lines',
'tooltip',
'readonly',
):
if fld in field and field[fld] is not None:
gui_description[fld] = field[fld]
# Order and label optional, but must be present on gui
gui_description['order'] = field.get('order', 0)
gui_description['label'] = field.get('label', field['name'])
v: dict[str, typing.Any] = {
'name': field.get('name', ''),
'value': field.get('value', ''),
'gui': gui_description,
}
if field.get('tab', None):
v['gui']['tab'] = _(str(field['tab']))
gui.append(v)
return gui
def add_default_fields(self, gui: list[typing.Any], flds: list[str]) -> list[typing.Any]:
"""
Adds default fields (based in a list) to a "gui" description
:param gui: Gui list where the "default" fielsds will be added
:param flds: List of fields names requested to be added. Valid values are 'name', 'comments',
'priority' and 'small_name', 'short_name', 'tags'
"""
if 'tags' in flds:
self.add_field(
gui,
{
'name': 'tags',
'label': _('Tags'),
'type': 'taglist',
'tooltip': _('Tags for this element'),
'order': 0 - 105,
},
)
if 'name' in flds:
self.add_field(
gui,
{
'name': 'name',
'type': 'text',
'required': True,
'label': _('Name'),
'length': 128,
'tooltip': _('Name of this element'),
'order': 0 - 100,
},
)
if 'comments' in flds:
self.add_field(
gui,
{
'name': 'comments',
'label': _('Comments'),
'type': 'text',
'lines': 3,
'tooltip': _('Comments for this element'),
'length': 256,
'order': 0 - 90,
},
)
if 'priority' in flds:
self.add_field(
gui,
{
'name': 'priority',
'type': 'numeric',
'label': _('Priority'),
'tooltip': _('Selects the priority of this element (lower number means higher priority)'),
'required': True,
'value': 1,
'length': 4,
'order': 0 - 85,
},
)
if 'small_name' in flds:
self.add_field(
gui,
{
'name': 'small_name',
'type': 'text',
'label': _('Label'),
'tooltip': _('Label for this element'),
'required': True,
'length': 128,
'order': 0 - 80,
},
)
if 'networks' in flds:
self.add_field(
gui,
{
'name': 'net_filtering',
'value': 'n',
'choices': [
{'id': 'n', 'text': _('No filtering')},
{'id': 'a', 'text': _('Allow selected networks')},
{'id': 'd', 'text': _('Deny selected networks')},
],
'label': _('Network Filtering'),
'tooltip': _(
'Type of network filtering. Use "Disabled" to disable origin check, "Allow" to only enable for selected networks or "Deny" to deny from selected networks'
),
'type': 'choice',
'order': 100, # At end
'tab': types.ui.Tab.ADVANCED,
},
)
self.add_field(
gui,
{
'name': 'networks',
'value': [],
'choices': sorted(
[{'id': x.uuid, 'text': x.name} for x in Network.objects.all()],
key=lambda x: x['text'].lower(),
),
'label': _('Networks'),
'tooltip': _('Networks associated. If No network selected, will mean "all networks"'),
'type': 'multichoice',
'order': 101,
'tab': types.ui.Tab.ADVANCED,
},
)
return gui
def ensure_has_access(
def check_access(
self,
obj: models.Model,
permission: 'types.permissions.PermissionType',
root: bool = False,
) -> None:
if not permissions.has_access(self._user, obj, permission, root):
raise self.access_denied_response()
raise exceptions.rest.AccessDenied('Access denied')
def get_permissions(self, obj: models.Model, root: bool = False) -> int:
return permissions.effective_permissions(self._user, obj, root)
def type_info(self, type_: type['Module']) -> typing.Optional[types.rest.ExtraTypeInfo]:
@classmethod
def extra_type_info(cls: type[typing.Self], type_: type['Module']) -> types.rest.ExtraTypeInfo | None:
"""
Returns info about the type
In fact, right now, it returns an empty dict, that will be extended by typeAsDict
"""
return None
def type_as_dict(self, type_: type['Module']) -> types.rest.TypeInfoDict:
@typing.final
@classmethod
def as_typeinfo(cls: type[typing.Self], type_: type['Module']) -> types.rest.TypeInfo:
"""
Returns a dictionary describing the type (the name, the icon, description, etc...)
"""
res = types.rest.TypeInfo(
return types.rest.TypeInfo(
name=_(type_.mod_name()),
type=type_.mod_type(),
description=_(type_.description()),
icon=type_.icon64().replace('\n', ''),
extra=self.type_info(type_),
extra=cls.extra_type_info(type_),
group=getattr(type_, 'group', None),
).as_dict()
return res
def process_table_fields(
self,
title: str,
fields: list[typing.Any],
row_style: types.ui.RowStyleInfo,
subtitle: typing.Optional[str] = None,
) -> dict[str, typing.Any]:
"""
Returns a dict containing the table fields description
"""
return {
'title': title,
'fields': fields,
'row-style': row_style.as_dict(),
'subtitle': subtitle or '',
}
)
def fields_from_params(
self, fields_list: list[str], *, defaults: 'dict[str, typing.Any]|None' = None
self, fields_list: list[str], *, defaults: dict[str, typing.Any] | None = None
) -> dict[str, typing.Any]:
"""
Reads the indicated fields from the parameters received, and if
@@ -303,9 +104,10 @@ class BaseModelHandler(Handler):
:return: A dictionary containing all required fields
"""
args: dict[str, str] = {}
default: typing.Optional[str]
default: str | None = None
try:
for key in fields_list:
# if : is in the field, it is an optional field, with an "static" default value
if ':' in key: # optional field? get default if not present
k, default = key.split(':')[:2]
# Convert "None" to None
@@ -314,14 +116,15 @@ class BaseModelHandler(Handler):
if default == '_' and k not in self._params:
continue
args[k] = self._params.get(k, default)
else:
try:
args[key] = self._params[key]
except KeyError:
if defaults is not None and key in defaults:
else: # Required field, with a possible default on defaults dict
if key not in self._params:
if defaults and key in defaults:
args[key] = defaults[key]
else:
raise
raise exceptions.rest.RequestError(f'needed parameter not found in data {key}')
else:
# Set the value
args[key] = self._params[key]
# del self._params[key]
except KeyError as e:
@@ -329,63 +132,41 @@ class BaseModelHandler(Handler):
return args
def fill_instance_fields(self, item: 'models.Model', res: dict[str, typing.Any]) -> dict[str, typing.Any]:
"""
For Managed Objects (db element that contains a serialized object), fills a dictionary with the "field" parameters values.
For non managed objects, it does nothing
:param item: Item to extract fields
:param res: Dictionary to "extend" with instance key-values pairs
"""
if isinstance(item, ManagedObjectModel):
i = item.get_instance()
i.init_gui() # Defaults & stuff
res.update(i.get_fields_as_dict())
return res
# Exceptions
def invalid_request_response(self, message: typing.Optional[str] = None) -> exceptions.rest.HandlerError:
"""
Raises an invalid request error with a default translated string
:param message: Custom message to add to exception. If it is None, "Invalid Request" is used
"""
message = message or _('Invalid Request')
return exceptions.rest.RequestError(f'{message} {self.__class__}: {self._args}')
def invalid_response_response(self, message: typing.Optional[str] = None) -> exceptions.rest.HandlerError:
message = 'Invalid response' if message is None else message
return exceptions.rest.ResponseError(message)
def invalid_method_response(self) -> exceptions.rest.HandlerError:
"""
Raises a NotFound exception with translated "Method not found" string to current locale
"""
return exceptions.rest.RequestError(_('Method not found in {}: {}').format(self.__class__, self._args))
def invalid_item_response(self, message: typing.Optional[str] = None) -> exceptions.rest.HandlerError:
"""
Raises a NotFound exception, with location info
"""
message = message or _('Item not found')
return exceptions.rest.NotFound(message)
# raise NotFound('{} {}: {}'.format(message, self.__class__, self._args))
def access_denied_response(self, message: typing.Optional[str] = None) -> exceptions.rest.HandlerError:
return exceptions.rest.AccessDenied(message or _('Access denied'))
def not_supported_response(self, message: typing.Optional[str] = None) -> exceptions.rest.HandlerError:
return exceptions.rest.NotSupportedError(message or _('Operation not supported'))
# Success methods
def success(self) -> str:
"""
Utility method to be invoked for simple methods that returns nothing in fact
Utility method to be invoked for simple methods that returns a simple OK response
"""
logger.debug('Returning success on %s %s', self.__class__, self._args)
return consts.OK
def test(self, type_: str) -> str: # pylint: disable=unused-argument
def test(self, type_: str) -> str:
"""
Invokes a test for an item
"""
logger.debug('Called base test for %s --> %s', self.__class__.__name__, self._params)
raise self.invalid_method_response()
raise exceptions.rest.NotSupportedError(_('Testing not supported'))
@classmethod
def api_components(cls: type[typing.Self]) -> types.rest.api.Components:
"""
Default implementation does not have any component types. (for Api specification purposes)
"""
return types.rest.api.Components()
@classmethod
def api_paths(cls: type[typing.Self]) -> 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)

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
from uds.core import types
from uds.core import consts, exceptions, types, module
from uds.core.util.model import process_uuid
from uds.core.util import api as api_utils
from uds.REST.utils import rest_result
from .base import BaseModelHandler
@@ -57,7 +57,7 @@ logger = logging.getLogger(__name__)
# Details do not have types at all
# so, right now, we only process details petitions for Handling & tables info
# noinspection PyMissingConstructor
class DetailHandler(BaseModelHandler):
class DetailHandler(BaseModelHandler[types.rest.T_Item]):
"""
Detail handler (for relations such as provider-->services, authenticators-->users,groups, deployed services-->cache,assigned, groups, transports
Urls recognized for GET are:
@@ -79,22 +79,24 @@ class DetailHandler(BaseModelHandler):
Also accepts GET methods for "custom" methods
"""
custom_methods: typing.ClassVar[list[str]] = []
_parent: typing.Optional['ModelHandler']
CUSTOM_METHODS: typing.ClassVar[list[str]] = []
_parent: typing.Optional[
'ModelHandler[types.rest.T_Item]'
] # Parent handler, that is the ModelHandler that contains this detail
_path: str
_params: typing.Any # _params is deserialized object from request
_args: list[str]
_kwargs: dict[str, typing.Any]
_parent_item: models.Model # Parent item, that is the parent model element
_user: 'User'
def __init__(
self,
parent_handler: 'ModelHandler',
parent_handler: 'ModelHandler[types.rest.T_Item]',
path: str,
params: typing.Any,
*args: str,
user: 'User',
**kwargs: typing.Any,
parent_item: models.Model,
) -> None:
"""
Detail Handlers in fact "disabled" handler most initialization, that is no needed because
@@ -106,8 +108,10 @@ class DetailHandler(BaseModelHandler):
self._path = path
self._params = params
self._args = list(args)
self._kwargs = kwargs
self._parent_item = parent_item
self._user = user
self._odata = parent_handler._odata # Ref to parent OData
self._headers = parent_handler._headers # "link" headers
def _check_is_custom_method(self, check: str, parent: models.Model, arg: typing.Any = None) -> typing.Any:
"""
@@ -116,7 +120,7 @@ class DetailHandler(BaseModelHandler):
:param parent: Parent Model Element
:param arg: argument to pass to custom method
"""
for to_check in self.custom_methods:
for to_check in self.CUSTOM_METHODS:
camel_case_name, snake_case_name = camel_and_snake_case_from(to_check)
if check in (camel_case_name, snake_case_name):
operation = getattr(self, snake_case_name, None) or getattr(self, camel_case_name, None)
@@ -136,7 +140,7 @@ class DetailHandler(BaseModelHandler):
logger.debug('Detail args for GET: %s', self._args)
num_args = len(self._args)
parent: models.Model = self._kwargs['parent']
parent: models.Model = self._parent_item
if num_args == 0:
return self.get_items(parent, None)
@@ -146,41 +150,40 @@ class DetailHandler(BaseModelHandler):
if r is not consts.rest.NOT_FOUND:
return r
if num_args == 1:
match self._args[0]:
case consts.rest.OVERVIEW:
return self.get_items(parent, None)
case consts.rest.TYPES:
types_ = self.get_types(parent, None)
logger.debug('Types: %s', types_)
return types_
case consts.rest.TABLEINFO:
return self.process_table_fields(
self.get_title(parent),
self.get_fields(parent),
self.get_row_style(parent),
)
case consts.rest.GUI: # Used on some cases to get the gui for a detail with no subtypes
gui = self.get_processed_gui(parent, '')
return sorted(gui, key=lambda f: f['gui']['order'])
case _:
# try to get id
return self.get_items(parent, process_uuid(self._args[0]))
if num_args == 2:
if self._args[0] == consts.rest.GUI:
return self.get_processed_gui(parent, self._args[1])
if self._args[0] == consts.rest.TYPES:
types_ = self.get_types(parent, self._args[1])
logger.debug('Types: %s', types_)
return types_
if self._args[1] == consts.rest.LOG:
return self.get_logs(parent, self._args[0])
# Maybe a custom method?
r = self._check_is_custom_method(self._args[1], parent, self._args[0])
if r is not None:
return r
match self._args:
case [consts.rest.OVERVIEW]:
return self.get_items(parent, None)
case [consts.rest.OVERVIEW, *_fails]:
raise exceptions.rest.RequestError('Invalid overview request') from None
case [consts.rest.TYPES]:
types = self.enum_types(parent, None)
logger.debug('Types: %s', types)
return [i.as_dict() for i in types]
case [consts.rest.TYPES, for_type]:
return [i.as_dict() for i in self.enum_types(parent, for_type)]
case [consts.rest.TYPES, for_type, *_fails]:
raise exceptions.rest.RequestError('Invalid types request') from None
case [consts.rest.TABLEINFO]:
return self.get_table(parent).as_dict()
case [consts.rest.TABLEINFO, *_fails]:
raise exceptions.rest.RequestError('Invalid table info request') from None
case [consts.rest.GUI]:
return sorted(self.get_processed_gui(parent, ''), key=lambda f: f['gui']['order'])
case [consts.rest.GUI, for_type]:
return sorted(self.get_processed_gui(parent, for_type), key=lambda f: f['gui']['order'])
case [consts.rest.GUI, for_type, *_fails]:
raise exceptions.rest.RequestError('Invalid GUI request') from None
case [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
# Not understood, fallback, maybe the derived class can understand it
return self.fallback_get()
@@ -193,7 +196,7 @@ class DetailHandler(BaseModelHandler):
"""
logger.debug('Detail args for PUT: %s, %s', self._args, self._params)
parent: models.Model = self._kwargs['parent']
parent: models.Model = self._parent_item
# if has custom methods, look for if this request matches any of them
if len(self._args) > 1:
@@ -206,7 +209,7 @@ class DetailHandler(BaseModelHandler):
if len(self._args) == 1:
item = self._args[0]
elif len(self._args) > 1: # PUT expects 0 or 1 parameters. 0 == NEW, 1 = EDIT
raise self.invalid_request_response()
raise exceptions.rest.RequestError('Invalid PUT request') from None
logger.debug('Invoking proper saving detail item %s', item)
return rest_result(self.save_item(parent, item))
@@ -217,7 +220,7 @@ class DetailHandler(BaseModelHandler):
Post can be used for, for example, testing.
Right now is an invalid method for Detail elements
"""
raise self.invalid_request_response('This method does not accepts POST')
raise exceptions.rest.RequestError('This method does not accepts POST') from None
def delete(self) -> typing.Any:
"""
@@ -226,10 +229,10 @@ class DetailHandler(BaseModelHandler):
"""
logger.debug('Detail args for DELETE: %s', self._args)
parent = self._kwargs['parent']
parent = self._parent_item
if len(self._args) != 1:
raise self.invalid_request_response()
raise exceptions.rest.RequestError('Invalid DELETE request') from None
self.delete_item(parent, self._args[0])
@@ -240,11 +243,13 @@ class DetailHandler(BaseModelHandler):
Invoked if default get can't process request.
Here derived classes can process "non default" (and so, not understood) GET constructions
"""
raise self.invalid_request_response('Fallback invoked')
raise exceptions.rest.RequestError('Invalid GET request') from None
# Override this to provide functionality
# Default (as sample) get_items
def get_items(self, parent: models.Model, item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
def get_items(
self, parent: models.Model, item: typing.Optional[str]
) -> types.rest.ItemsResult[types.rest.T_Item]:
"""
This MUST be overridden by derived classes
Excepts to return a list of dictionaries or a single dictionary, depending on "item" param
@@ -257,7 +262,7 @@ class DetailHandler(BaseModelHandler):
raise NotImplementedError(f'Must provide an get_items method for {self.__class__} class')
# Default save
def save_item(self, parent: models.Model, item: typing.Optional[str]) -> typing.Any:
def save_item(self, parent: models.Model, item: typing.Optional[str]) -> types.rest.T_Item:
"""
Invoked for a valid "put" operation
If this method is not overridden, the detail class will not have "Save/modify" operations.
@@ -267,7 +272,7 @@ class DetailHandler(BaseModelHandler):
:return: Normally "success" is expected, but can throw any "exception"
"""
logger.debug('Default save_item handler caller for %s', self._path)
raise self.invalid_request_response()
raise exceptions.rest.RequestError('Invalid PUT request') from None
# Default delete
def delete_item(self, parent: models.Model, item: str) -> None:
@@ -278,41 +283,17 @@ class DetailHandler(BaseModelHandler):
:param item: Item id (uuid)
:return: Normally "success" is expected, but can throw any "exception"
"""
raise self.invalid_request_response()
raise exceptions.rest.InvalidMethodError('Object does not support delete')
# A detail handler must also return title & fields for tables
def get_title(self, parent: models.Model) -> str: # pylint: disable=no-self-use
def get_table(self, parent: models.Model) -> types.rest.TableInfo:
"""
A "generic" title for a view based on this detail.
If not overridden, defaults to ''
Returns the table info for this detail, that is the title, fields and row style
:param parent: Parent object
:return: Expected to return an string that is the "title".
:return: TableInfo object with title, fields and row style
"""
return ''
return types.rest.TableInfo.null()
def get_fields(self, parent: models.Model) -> list[typing.Any]:
"""
A "generic" list of fields for a view based on this detail.
If not overridden, defaults to emty list
:param parent: Parent object
:return: Expected to return a list of fields
"""
return []
def get_row_style(self, parent: models.Model) -> types.ui.RowStyleInfo:
"""
A "generic" row style based on row field content.
If not overridden, defaults to {}
Args:
parent (models.Model): Parent object
Return:
dict[str, typing.Any]: A dictionary with 'field' and 'prefix' keys
"""
return types.ui.RowStyleInfo.null()
def get_gui(self, parent: models.Model, for_type: str) -> collections.abc.Iterable[typing.Any]:
def get_gui(self, parent: models.Model, for_type: str) -> list[types.ui.GuiElement]:
"""
Gets the gui that is needed in order to "edit/add" new items on this detail
If not overriden, means that the detail has no edit/new Gui
@@ -322,18 +303,17 @@ class DetailHandler(BaseModelHandler):
for_type (str): Type of object needing gui
Return:
collections.abc.Iterable[typing.Any]: A list of gui fields
list[types.ui.GuiElement]: A list of gui fields
"""
# raise RequestError('Gui not provided for this type of object')
return []
def get_processed_gui(self, parent: models.Model, for_type: str) -> collections.abc.Iterable[typing.Any]:
gui = self.get_gui(parent, for_type)
return sorted(gui, key=lambda f: f['gui']['order'])
def get_processed_gui(self, parent: models.Model, for_type: str) -> list[types.ui.GuiElement]:
return sorted(self.get_gui(parent, for_type), key=lambda f: f['gui']['order'])
def get_types(
def enum_types(
self, parent: models.Model, for_type: typing.Optional[str]
) -> collections.abc.Iterable[types.rest.TypeInfoDict]:
) -> collections.abc.Iterable[types.rest.TypeInfo]:
"""
The default is that detail element will not have any types (they are "homogeneous")
but we provided this method, that can be overridden, in case one detail needs it
@@ -347,6 +327,10 @@ class DetailHandler(BaseModelHandler):
"""
return [] # Default is that details do not have types
@classmethod
def possible_types(cls: type[typing.Self]) -> collections.abc.Iterable[type[module.Module]]:
return []
def get_logs(self, parent: models.Model, item: str) -> list[typing.Any]:
"""
If the detail has any log associated with it items, provide it overriding this method
@@ -354,4 +338,19 @@ class DetailHandler(BaseModelHandler):
:param item:
:return: a list of log elements (normally got using "uds.core.util.log.get_logs" method)
"""
raise self.invalid_method_response()
raise exceptions.rest.InvalidMethodError('Object does not support logs')
@classmethod
def api_components(cls: type[typing.Self]) -> types.rest.api.Components:
"""
Default implementation does not have any component types. (for Api specification purposes)
"""
# If no get_items, has no components (if custom components is needed, override this classmethod)
return api_utils.get_component_from_type(cls)
@classmethod
def api_paths(cls: type[typing.Self]) -> dict[str, types.rest.api.PathItem]:
"""
Returns the API operations that should be registered
"""
return {}

View File

@@ -33,16 +33,18 @@ Author: Adolfo Gómez, dkmaster at dkmon dot com
import logging
import typing
import abc
import collections.abc
from django.db import IntegrityError, models
from django.db.models import QuerySet
from django.utils.translation import gettext as _
from uds.core import consts
from uds.core import exceptions
from uds.core import types
from uds.core.module import Module
from uds.core.util import log, permissions
from uds.core.util import log, permissions, api as api_utils
from uds.models import ManagedObjectModel, Tag, TaggingMixin
from .base import BaseModelHandler
@@ -54,8 +56,9 @@ if typing.TYPE_CHECKING:
logger = logging.getLogger(__name__)
T = typing.TypeVar('T', bound=models.Model)
class ModelHandler(BaseModelHandler):
class ModelHandler(BaseModelHandler[types.rest.T_Item], abc.ABC):
"""
Basic Handler for a model
Basically we will need same operations for all models, so we can
@@ -72,27 +75,23 @@ class ModelHandler(BaseModelHandler):
"""
# Authentication related
authenticated = True
needs_staff = True
ROLE = consts.UserRole.STAFF
# Which model does this manage, must be a django model ofc
model: 'typing.ClassVar[type[models.Model]]'
MODEL: 'typing.ClassVar[type[models.Model]]'
# If the model is filtered (for overviews)
model_filter: 'typing.ClassVar[typing.Optional[collections.abc.Mapping[str, typing.Any]]]' = None
FILTER: 'typing.ClassVar[typing.Optional[collections.abc.Mapping[str, typing.Any]]]' = None
# Same, but for exclude
model_exclude: 'typing.ClassVar[typing.Optional[collections.abc.Mapping[str, typing.Any]]]' = None
# By default, filter is empty
fltr: typing.Optional[str] = None
EXCLUDE: 'typing.ClassVar[typing.Optional[collections.abc.Mapping[str, typing.Any]]]' = None
# This is an array of tuples of two items, where first is method and second inticates if method needs parent id (normal behavior is it needs it)
# For example ('services', True) -- > .../id_parent/services
# ('services', False) --> ..../services
custom_methods: typing.ClassVar[list[tuple[str, bool]]] = (
CUSTOM_METHODS: typing.ClassVar[list[types.rest.ModelCustomMethod]] = (
[]
) # 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']]]] = (
DETAIL: typing.ClassVar[typing.Optional[dict[str, type['DetailHandler[typing.Any]']]]] = (
None # Dictionary containing detail routing
)
# Fields that are going to be saved directly
@@ -100,62 +99,41 @@ class ModelHandler(BaseModelHandler):
# * If the "default" is the string "None", then the default will be None
# * If the "default" is _ (underscore), then the field will be ignored (not saved) if not present in the request
# Note that these fields has to be present in the model, and they can be "edited" in the pre_save method
save_fields: typing.ClassVar[list[str]] = []
FIELDS_TO_SAVE: typing.ClassVar[list[str]] = []
# Put removable fields before updating
remove_fields: typing.ClassVar[list[str]] = []
EXCLUDED_FIELDS: typing.ClassVar[list[str]] = []
# Table info needed fields and title
table_fields: typing.ClassVar[list[typing.Any]] = []
table_row_style: typing.ClassVar[types.ui.RowStyleInfo] = types.ui.RowStyleInfo.null()
table_title: typing.ClassVar[str] = ''
table_subtitle: typing.ClassVar[str] = ''
TABLE: typing.ClassVar[types.rest.TableInfo] = types.rest.TableInfo.null()
# This methods must be override, depending on what is provided
# Data related
def item_as_dict(self, item: models.Model) -> types.rest.ItemDictType:
"""
Must be overriden by descendants.
Expects the return of an item as a dictionary
"""
return {}
def item_as_dict_overview(self, item: models.Model) -> dict[str, typing.Any]:
"""
Invoked when request is an "overview"
default behavior is return item_as_dict
"""
return self.item_as_dict(item)
# types related
def enum_types(self) -> collections.abc.Iterable[type['Module']]: # override this
# def possible_types(cls: type[typing.Self]) -> collections.abc.Iterable[type[module.Module]]:
@classmethod
def possible_types(cls: type[typing.Self]) -> collections.abc.Iterable[type['Module']]: # override this
"""
Must be overriden by desdencents if they support types
Excpetcs the list of types that the handler supports
"""
return []
def get_types(
def enum_types(
self, *args: typing.Any, **kwargs: typing.Any
) -> typing.Generator[types.rest.TypeInfoDict, None, None]:
for type_ in self.enum_types():
yield self.type_as_dict(type_)
) -> typing.Generator[types.rest.TypeInfo, None, None]:
for type_ in self.possible_types():
yield type(self).as_typeinfo(type_)
def get_type(self, type_: str) -> types.rest.TypeInfoDict:
found = None
for v in self.get_types():
if v['type'] == type_:
found = v
break
def get_type(self, type_: str) -> types.rest.TypeInfo:
for v in self.enum_types():
if v.type == type_:
return v
if found is None:
raise exceptions.rest.NotFound('type not found')
logger.debug('Found type %s', found)
return found
raise exceptions.rest.NotFound('type not found')
# log related
def get_logs(self, item: models.Model) -> list[dict[typing.Any, typing.Any]]:
self.ensure_has_access(item, types.permissions.PermissionType.READ)
self.check_access(item, types.permissions.PermissionType.READ)
try:
return log.get_logs(item)
except Exception as e:
@@ -163,10 +141,13 @@ class ModelHandler(BaseModelHandler):
return []
# gui related
def get_gui(self, type_: str) -> list[typing.Any]:
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
return []
# raise self.invalidRequestException()
def get_processed_gui(self, for_type: str) -> list[types.ui.GuiElement]:
return sorted(self.get_gui(for_type), key=lambda f: f['gui']['order'])
# Delete related, checks if the item can be deleted
# If it can't be so, raises an exception
def validate_delete(self, item: models.Model) -> None:
@@ -192,7 +173,7 @@ class ModelHandler(BaseModelHandler):
def process_detail(self) -> typing.Any:
logger.debug('Processing detail %s for with params %s', self._path, self._params)
try:
item: models.Model = self.model.objects.get(uuid__iexact=self._args[0])
item: models.Model = self.MODEL.objects.get(uuid__iexact=self._args[0])
# If we do not have access to parent to, at least, read...
if self._operation in ('put', 'post', 'delete'):
@@ -206,61 +187,74 @@ class ModelHandler(BaseModelHandler):
self._user,
required_permission,
)
raise self.access_denied_response()
raise exceptions.rest.AccessDenied()
if not self.detail:
raise self.invalid_request_response()
if not self.DETAIL:
raise exceptions.rest.NotFound('Detail not found')
# pylint: disable=unsubscriptable-object
handler_type = self.detail[self._args[1]]
handler_type = self.DETAIL[self._args[1]]
args = list(self._args[2:])
path = self._path + '/' + '/'.join(args[:2])
detail_handler = handler_type(self, path, self._params, *args, parent=item, user=self._user)
detail_handler = handler_type(self, path, self._params, *args, parent_item=item, user=self._user)
method = getattr(detail_handler, self._operation)
return method()
except self.model.DoesNotExist:
raise self.invalid_item_response()
except self.MODEL.DoesNotExist:
raise exceptions.rest.NotFound('Item not found on model {self.MODEL.__name__}')
except (KeyError, AttributeError) as e:
raise self.invalid_method_response() from e
raise exceptions.rest.InvalidMethodError(f'Invalid method {self._operation}') from e
except exceptions.rest.HandlerError:
raise
except Exception as e:
logger.error('Exception processing detail: %s', e)
raise self.invalid_request_response() from e
raise exceptions.rest.RequestError(f'Error processing detail: {e}') from e
# Data related
def get_item(self, item: models.Model) -> types.rest.T_Item:
"""
Must be overriden by descendants.
Expects the return of an item as a dictionary
"""
raise NotImplementedError()
def get_item_summary(self, item: models.Model) -> types.rest.T_Item:
"""
Invoked when request is an "overview"
default behavior is return item_as_dict
"""
return self.get_item(item)
def get_items(
self, *args: typing.Any, **kwargs: typing.Any
) -> typing.Generator[types.rest.ItemDictType, None, None]:
if 'overview' in kwargs:
overview = kwargs['overview']
del kwargs['overview']
self,
*,
overview: bool = False,
query: QuerySet[T] | None = None
) -> typing.Generator[types.rest.T_Item, None, None]:
"""
Get items from the model.
Args:
overview: If True, return a summary of the items.
query: Optional queryset to filter the items. Used to optimize the process for some models
(such as ServicePools)
"""
# Basic model filter
if query:
qs = query
else:
overview = True
qs = self.MODEL.objects.all()
if self.FILTER is not None:
qs = qs.filter(**self.FILTER)
if self.EXCLUDE is not None:
qs = qs.exclude(**self.EXCLUDE)
if 'prefetch' in kwargs:
prefetch = kwargs['prefetch']
logger.debug('Prefetching %s', prefetch)
del kwargs['prefetch']
else:
prefetch = []
qs = self.filter_queryset(qs)
if 'query' in kwargs:
query = kwargs['query'] # We are using a prebuilt query on args
logger.debug('Got query: %s', query)
del kwargs['query']
else:
logger.debug('Args: %s, kwargs: %s', args, kwargs)
query = self.model.objects.filter(*args, **kwargs).prefetch_related(*prefetch)
if self.model_filter is not None:
query = query.filter(**self.model_filter)
if self.model_exclude is not None:
query = query.exclude(**self.model_exclude)
for item in query:
for item in qs:
try:
# Note: Due to this, the response may not have the required elements, but a subset will be returned
if (
permissions.has_access(
self._user,
@@ -270,12 +264,7 @@ class ModelHandler(BaseModelHandler):
is False
):
continue
if overview:
yield self.item_as_dict_overview(item)
else:
res = self.item_as_dict(item)
self.fill_instance_fields(item, res)
yield res
yield self.get_item_summary(item) if overview else self.get_item(item)
except Exception as e: # maybe an exception is thrown to skip an item
logger.debug('Got exception processing item from model: %s', e)
# logger.exception('Exception getting item from {0}'.format(self.model))
@@ -295,10 +284,10 @@ class ModelHandler(BaseModelHandler):
return list(self.get_items(overview=False))
# if has custom methods, look for if this request matches any of them
for cm in self.custom_methods:
for cm in self.CUSTOM_METHODS:
# Convert to snake case
camel_case_name, snake_case_name = camel_and_snake_case_from(cm[0])
if number_of_args > 1 and cm[1] is True: # Method needs parent (existing item)
camel_case_name, snake_case_name = camel_and_snake_case_from(cm.name)
if number_of_args > 1 and cm.needs_parent: # Method needs parent (existing item)
if self._args[1] in (camel_case_name, snake_case_name):
item = None
# Check if operation method exists
@@ -306,9 +295,9 @@ class ModelHandler(BaseModelHandler):
try:
if not operation:
raise Exception() # Operation not found
item = self.model.objects.get(uuid__iexact=self._args[0])
except self.model.DoesNotExist:
raise self.invalid_item_response()
item = self.MODEL.objects.get(uuid__iexact=self._args[0])
except self.MODEL.DoesNotExist:
raise exceptions.rest.NotFound('Item not found') from None
except Exception as e:
logger.error(
'Invalid custom method exception %s/%s/%s: %s',
@@ -317,74 +306,63 @@ class ModelHandler(BaseModelHandler):
self._params,
e,
)
raise self.invalid_method_response()
raise exceptions.rest.ResponseError(
f'Error processing custom method: {self.__class__.__name__}/{self._args}'
) from e
return operation(item)
elif self._args[0] in (snake_case_name, snake_case_name):
operation = getattr(self, snake_case_name) or getattr(self, snake_case_name)
if not operation:
raise self.invalid_method_response()
raise exceptions.rest.InvalidMethodError(f'Invalid method {self._operation}') from None
return operation()
if number_of_args == 1:
if self._args[0] == consts.rest.OVERVIEW:
return list(self.get_items())
if self._args[0] == consts.rest.TYPES:
return list(self.get_types())
if self._args[0] == consts.rest.TABLEINFO:
return self.process_table_fields(
self.table_title,
self.table_fields,
self.table_row_style,
self.table_subtitle,
)
if self._args[0] == consts.rest.GUI:
return self.get_gui('')
match self._args:
case []: # Same as overview, but with all data
return [i.as_dict() for i in self.get_items(overview=False)]
case [consts.rest.OVERVIEW]:
return [i.as_dict() for i in self.get_items()]
case [consts.rest.OVERVIEW, *_fails]:
raise exceptions.rest.RequestError('Invalid overview request') from None
case [consts.rest.TABLEINFO]:
return self.TABLE.as_dict()
case [consts.rest.TABLEINFO, *_fails]:
raise exceptions.rest.RequestError('Invalid table info request') from None
case [consts.rest.TYPES]:
return [i.as_dict() for i in self.enum_types()]
case [consts.rest.TYPES, for_type]:
return self.get_type(for_type).as_dict()
case [consts.rest.TYPES, for_type, *_fails]:
raise exceptions.rest.RequestError('Invalid type request') from None
case [consts.rest.GUI]:
return self.get_processed_gui('')
case [consts.rest.GUI, for_type]:
return self.get_processed_gui(for_type)
case [consts.rest.GUI, for_type, *_fails]:
raise exceptions.rest.RequestError('Invalid GUI request') from None
case _: # Maybe an item or a detail
if number_of_args == 1:
try:
item = self.MODEL.objects.get(uuid__iexact=self._args[0].lower())
self.check_access(item, types.permissions.PermissionType.READ)
return self.get_item(item).as_dict()
except Exception as e:
logger.exception('Got Exception looking for item')
raise exceptions.rest.NotFound('Item not found') from e
elif number_of_args == 2:
if self._args[1] == consts.rest.LOG:
try:
item = self.MODEL.objects.get(uuid__iexact=self._args[0].lower())
return self.get_logs(item)
except Exception as e:
raise exceptions.rest.NotFound('Item not found') from e
# get item ID
try:
item = self.model.objects.get(uuid__iexact=self._args[0].lower())
if self.DETAIL is not None:
return self.process_detail()
self.ensure_has_access(item, types.permissions.PermissionType.READ)
res = self.item_as_dict(item)
self.fill_instance_fields(item, res)
return res
except Exception as e:
logger.exception('Got Exception looking for item')
raise self.invalid_item_response() from e
# nArgs > 1
# Request type info or gui, or detail
if self._args[0] == consts.rest.OVERVIEW:
if number_of_args != 2:
raise self.invalid_request_response()
elif self._args[0] == consts.rest.TYPES:
if number_of_args != 2:
raise self.invalid_request_response()
return self.get_type(self._args[1])
elif self._args[0] == consts.rest.GUI:
if number_of_args != 2:
raise self.invalid_request_response()
gui = self.get_gui(self._args[1])
return sorted(gui, key=lambda f: f['gui']['order'])
elif self._args[1] == consts.rest.LOG:
if number_of_args != 2:
raise self.invalid_request_response()
try:
# DB maybe case sensitive??, anyway, uuids are stored in lowercase
item = self.model.objects.get(uuid__iexact=self._args[0].lower())
return self.get_logs(item)
except Exception as e:
raise self.invalid_item_response() from e
# If has detail and is requesting detail
if self.detail is not None:
return self.process_detail()
raise self.invalid_request_response() # Will not return
raise exceptions.rest.RequestError('Invalid request') from None
def post(self) -> typing.Any:
"""
@@ -396,7 +374,7 @@ class ModelHandler(BaseModelHandler):
if self._args[0] == 'test':
return self.test(self._args[1])
raise self.invalid_method_response() # Will not return
raise exceptions.rest.InvalidMethodError(f'Invalid method {self._operation}') from None
def put(self) -> typing.Any:
"""
@@ -414,17 +392,17 @@ class ModelHandler(BaseModelHandler):
return self.process_detail()
# Here, self.model() indicates an "django model object with default params"
self.ensure_has_access(
self.model(), types.permissions.PermissionType.ALL, root=True
self.check_access(
self.MODEL(), types.permissions.PermissionType.ALL, root=True
) # Must have write permissions to create, modify, etc..
try:
# Extract fields
args = self.fields_from_params(self.save_fields)
args = self.fields_from_params(self.FIELDS_TO_SAVE)
logger.debug('Args: %s', args)
self.pre_save(args)
# If tags is in save fields, treat it "specially"
if 'tags' in self.save_fields:
if 'tags' in self.FIELDS_TO_SAVE:
tags = args['tags']
del args['tags']
else:
@@ -433,12 +411,12 @@ class ModelHandler(BaseModelHandler):
delete_on_error = False
item: models.Model
if not self._args: # create new?
item = self.model.objects.create(**args)
item = self.MODEL.objects.create(**args)
delete_on_error = True
else: # Must have 1 arg
# We have to take care with this case, update will efectively update records on db
item = self.model.objects.get(uuid__iexact=self._args[0].lower())
for v in self.remove_fields:
item = self.MODEL.objects.get(uuid__iexact=self._args[0].lower())
for v in self.EXCLUDED_FIELDS:
if v in args:
del args[v]
# Upadte fields from args
@@ -464,12 +442,14 @@ class ModelHandler(BaseModelHandler):
data_type: typing.Optional[str] = self._params.get('data_type', self._params.get('type'))
if data_type:
item.data_type = data_type
item.data = item.get_instance(self._params).serialize()
# TODO: Currently support parameters outside "instance". Will be removed after tests
item.data = item.get_instance(
self._params['instance'] if 'instance' in self._params else self._params
).serialize()
item.save()
res = self.item_as_dict(item)
self.fill_instance_fields(item, res)
res = self.get_item(item)
except Exception:
logger.exception('Exception on put')
if delete_on_error:
@@ -478,9 +458,9 @@ class ModelHandler(BaseModelHandler):
self.post_save(item)
return res
return res.as_dict()
except self.model.DoesNotExist:
except self.MODEL.DoesNotExist:
raise exceptions.rest.NotFound('Item not found') from None
except IntegrityError: # Duplicate key probably
raise exceptions.rest.RequestError('Element already exists (duplicate key error)') from None
@@ -503,15 +483,15 @@ class ModelHandler(BaseModelHandler):
if len(self._args) != 1:
raise exceptions.rest.RequestError('Delete need one and only one argument')
self.ensure_has_access(
self.model(), types.permissions.PermissionType.ALL, root=True
self.check_access(
self.MODEL(), types.permissions.PermissionType.ALL, root=True
) # Must have write permissions to delete
try:
item = self.model.objects.get(uuid__iexact=self._args[0].lower())
item = self.MODEL.objects.get(uuid__iexact=self._args[0].lower())
self.validate_delete(item)
self.delete_item(item)
except self.model.DoesNotExist:
except self.MODEL.DoesNotExist:
raise exceptions.rest.NotFound('Element do not exists') from None
return consts.OK
@@ -521,3 +501,45 @@ class ModelHandler(BaseModelHandler):
Basic, overridable method for deleting an item
"""
item.delete()
@classmethod
def api_components(cls: type[typing.Self]) -> types.rest.api.Components:
return api_utils.get_component_from_type(cls)
@classmethod
def api_paths(cls: type[typing.Self]) -> 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
from uds.core import consts, types
from .utils import to_incremental_json
@@ -65,9 +65,14 @@ class ContentProcessor:
extensions: typing.ClassVar[collections.abc.Iterable[str]] = []
_request: 'HttpRequest'
_odata: 'types.rest.api.ODataParams|None' = None
def __init__(self, request: 'HttpRequest'):
self._request = request
self._odata = None
def set_odata(self, odata: 'types.rest.api.ODataParams') -> None:
self._odata = odata
def process_get_parameters(self) -> dict[str, typing.Any]:
"""
@@ -105,38 +110,47 @@ class ContentProcessor:
yield self.render(obj).encode('utf8')
@staticmethod
def process_for_render(obj: typing.Any) -> typing.Any:
def process_for_render(
obj: typing.Any,
data_transformer: collections.abc.Callable[[dict[str, typing.Any]], dict[str, typing.Any]],
) -> typing.Any:
"""
Helper for renderers. Alters some types so they can be serialized correctly (as we want them to be)
"""
if obj is None or isinstance(obj, (bool, int, float, str)):
return obj
match obj:
case types.rest.BaseRestItem():
return ContentProcessor.process_for_render(obj.as_dict(), data_transformer)
case None | bool() | int() | float() | str():
return obj
case dict():
return data_transformer(
{
k: ContentProcessor.process_for_render(v, data_transformer)
for k, v in typing.cast(dict[str, typing.Any], obj).items()
if not isinstance(v, types.rest.NotRequired) # Skip
}
)
if isinstance(obj, DjangoPromise):
return str(obj) # This is for translations
case DjangoPromise():
return str(obj) # This is for translations
if isinstance(obj, dict):
return {
k: ContentProcessor.process_for_render(v)
for k, v in typing.cast(dict[str, typing.Any], obj).items()
}
case bytes():
return obj.decode('utf-8')
if isinstance(obj, bytes):
return obj.decode('utf-8')
case collections.abc.Iterable():
return [
ContentProcessor.process_for_render(v, data_transformer)
for v in typing.cast(collections.abc.Iterable[typing.Any], obj)
]
if isinstance(obj, collections.abc.Iterable):
return [
ContentProcessor.process_for_render(v)
for v in typing.cast(collections.abc.Iterable[typing.Any], obj)
]
case datetime.datetime():
return int(time.mktime(obj.timetuple()))
if isinstance(obj, (datetime.datetime,)): # Datetime as timestamp
return int(time.mktime(obj.timetuple()))
case datetime.date():
return '{}-{:02d}-{:02d}'.format(obj.year, obj.month, obj.day)
if isinstance(obj, (datetime.date,)): # Date as string
return '{}-{:02d}-{:02d}'.format(obj.year, obj.month, obj.day)
return str(obj)
case _:
return str(obj)
class MarshallerProcessor(ContentProcessor):
@@ -169,7 +183,10 @@ class MarshallerProcessor(ContentProcessor):
raise ParametersException(str(e))
def render(self, obj: typing.Any) -> str:
return self.marshaller.dumps(ContentProcessor.process_for_render(obj))
def none_transformer(dct: dict[str, typing.Any]) -> dict[str, typing.Any]:
return dct
dct_filter = none_transformer if self._odata is None else self._odata.select_filter
return self.marshaller.dumps(ContentProcessor.process_for_render(obj, dct_filter))
# ---------------

View File

@@ -34,7 +34,6 @@ import logging
from django.http import HttpResponse
from django.middleware import csrf
from django.shortcuts import render
from django.template import RequestContext, loader
from django.utils.translation import gettext as _
from uds.core import consts
@@ -46,7 +45,7 @@ if typing.TYPE_CHECKING:
from django.http import HttpRequest
@weblogin_required(admin=True)
@weblogin_required(role=consts.UserRole.ADMIN)
def index(request: 'HttpRequest') -> HttpResponse:
# Gets csrf token
csrf_token = csrf.get_token(request)
@@ -57,21 +56,14 @@ def index(request: 'HttpRequest') -> HttpResponse:
{'csrf_field': consts.auth.CSRF_FIELD, 'csrf_token': csrf_token},
)
# Samples, not used in fact from anywhere
# Usef for reference
@weblogin_required(admin=True)
def tmpl(request: 'HttpRequest', template: str) -> HttpResponse:
try:
t = loader.get_template('uds/admin/tmpl/' + template + ".html")
c = RequestContext(request)
resp = t.render(c.flatten())
except Exception as e:
logger.debug('Exception getting template: %s', e)
resp = _('requested a template that do not exist')
return HttpResponse(resp, content_type="text/plain")
@weblogin_required(admin=True)
def sample(request: 'HttpRequest') -> HttpResponse:
return render(request, 'uds/admin/sample.html')
# from django.template import RequestContext, loader
# @weblogin_required(role=consts.Roles.ADMIN)
# def tmpl(request: 'HttpRequest', template: str) -> HttpResponse:
# try:
# t = loader.get_template('uds/admin/tmpl/' + template + ".html")
# c = RequestContext(request)
# resp = t.render(c.flatten())
# except Exception as e:
# logger.debug('Exception getting template: %s', e)
# resp = _('requested a template that do not exist')
# return HttpResponse(resp, content_type="text/plain")

View File

@@ -163,7 +163,7 @@ class RegexLdap(auths.Authenticator):
# Label for password field
label_password = _("Password")
_connection: typing.Optional['ldaputil.LDAPObject'] = None
_connection: typing.Optional['ldaputil.LDAPConnection'] = None
def initialize(self, values: typing.Optional[dict[str, typing.Any]]) -> None:
if values:
@@ -235,7 +235,7 @@ class RegexLdap(auths.Authenticator):
self.mark_for_upgrade() # Old version, so flag for upgrade if possible
def _stablish_connection(self) -> 'ldaputil.LDAPObject':
def _stablish_connection(self) -> 'ldaputil.LDAPConnection':
"""
Tries to connect to ldap. If username is None, it tries to connect using user provided credentials.
@return: Connection established
@@ -254,7 +254,7 @@ class RegexLdap(auths.Authenticator):
return self._connection
def _stablish_connection_as(self, username: str, password: str) -> 'ldaputil.LDAPObject':
def _stablish_connection_as(self, username: str, password: str) -> 'ldaputil.LDAPConnection':
return ldaputil.connection(
username,
password,

View File

@@ -33,14 +33,13 @@ import logging
import typing
import collections.abc
import ldap # pyright: ignore # Needed to import ldap.filter without errors
import ldap.filter
from uds.core.util import ldaputil
from django.utils.translation import gettext_noop as _
from uds.core import auths, environment, types, exceptions
from uds.core.auths.auth import log_login
from uds.core.ui import gui
from uds.core.util import ensure, fields, ldaputil, validators, auth as auth_utils
from uds.core.util import fields, ldaputil, validators, auth as auth_utils
# Not imported at runtime, just for type checking
if typing.TYPE_CHECKING:
@@ -183,7 +182,7 @@ class SimpleLDAPAuthenticator(auths.Authenticator):
# Label for password field
label_password = _("Password")
_connection: typing.Optional['ldaputil.LDAPObject'] = None
_connection: typing.Optional['ldaputil.LDAPConnection'] = None
def initialize(self, values: typing.Optional[dict[str, typing.Any]]) -> None:
if values:
@@ -232,51 +231,54 @@ class SimpleLDAPAuthenticator(auths.Authenticator):
def mfa_identifier(self, username: str) -> str:
return self.storage.read_pickled(self.mfa_storage_key(username)) or ''
def _get_connection(self) -> 'ldaputil.LDAPObject':
def _get_connection(self) -> 'ldaputil.LDAPConnection':
"""
Tries to connect to ldap. If username is None, it tries to connect using user provided credentials.
@return: Connection established
@raise exception: If connection could not be established
Tries to connect to LDAP using ldaputil. If username is None, it tries to connect using user provided credentials.
Returns:
Connection established
Raises:
Exception if connection could not be established
"""
if self._connection is None: # We are not connected
if self._connection is None:
self._connection = ldaputil.connection(
self.username.as_str(),
self.password.as_str(),
self.host.as_str(),
username=self.username.as_str(),
passwd=self.password.as_str(),
host=self.host.as_str(),
port=self.port.as_int(),
ssl=self.use_ssl.as_bool(),
use_ssl=self.use_ssl.as_bool(),
timeout=self.timeout.as_int(),
debug=False,
verify_ssl=self.verify_ssl.as_bool(),
certificate=self.certificate.as_str(),
certificate_data=self.certificate.as_str(),
)
return self._connection
def _connect_as(self, username: str, password: str) -> typing.Any:
return ldaputil.connection(
username,
password,
self.host.as_str(),
username=username,
passwd=password,
host=self.host.as_str(),
port=self.port.as_int(),
ssl=self.use_ssl.as_bool(),
use_ssl=self.use_ssl.as_bool(),
timeout=self.timeout.as_int(),
debug=False,
verify_ssl=self.verify_ssl.as_bool(),
certificate=self.certificate.as_str(),
certificate_data=self.certificate.as_str(),
)
def _get_user(self, username: str) -> typing.Optional[ldaputil.LDAPResultType]:
"""
Searchs for the username and returns its LDAP entry
@param username: username to search, using user provided parameters at configuration to map search entries.
@return: None if username is not found, an dictionary of LDAP entry attributes if found.
@note: Active directory users contains the groups it belongs to in "memberOf" attribute
Searches for the username and returns its LDAP entry.
Args:
username: username to search, using user provided parameters at configuration to map search entries.
Returns:
None if username is not found, a dictionary of LDAP entry attributes if found.
Note:
Active directory users contain the groups it belongs to in "memberOf" attribute
"""
attributes = self.username_attr.as_str().split(',') + [self.user_id_attr.as_str()]
if self.mfa_attribute.as_str():
attributes = attributes + [self.mfa_attribute.as_str()]
return ldaputil.first(
con=self._get_connection(),
base=self.ldap_base.as_str(),
@@ -289,13 +291,11 @@ class SimpleLDAPAuthenticator(auths.Authenticator):
def _get_group(self, groupname: str) -> typing.Optional[ldaputil.LDAPResultType]:
"""
Searchs for the groupname and returns its LDAP entry
Searches for the groupname and returns its LDAP entry.
Args:
groupname (str): groupname to search, using user provided parameters at configuration to map search entries.
Returns:
typing.Optional[ldaputil.LDAPResultType]: None if groupname is not found, an dictionary of LDAP entry attributes if found.
typing.Optional[ldaputil.LDAPResultType]: None if groupname is not found, a dictionary of LDAP entry attributes if found.
"""
return ldaputil.first(
con=self._get_connection(),
@@ -309,17 +309,14 @@ class SimpleLDAPAuthenticator(auths.Authenticator):
def _get_groups(self, user: ldaputil.LDAPResultType) -> list[str]:
"""
Searchs for the groups the user belongs to and returns a list of group names
Searches for the groups the user belongs to and returns a list of group names.
Args:
user (ldaputil.LDAPResultType): The user to search for groups
Returns:
list[str]: A list of group names the user belongs to
"""
try:
groups: list[str] = []
filter_ = f'(&(objectClass={self.group_class.as_str()})(|({self.member_attr.as_str()}={user["_id"]})({self.member_attr.as_str()}={user["dn"]})))'
for d in ldaputil.as_dict(
con=self._get_connection(),
@@ -331,19 +328,17 @@ class SimpleLDAPAuthenticator(auths.Authenticator):
if self.group_id_attr.as_str() in d:
for k in d[self.group_id_attr.as_str()]:
groups.append(k)
logger.debug('Groups: %s', groups)
return groups
except Exception:
logger.exception('Exception at __getGroups')
logger.exception('Exception at _get_groups')
return []
def _get_user_realname(self, user: ldaputil.LDAPResultType) -> str:
'''
Tries to extract the real name for this user. Will return all atttributes (joint)
specified in _userNameAttr (comma separated).
'''
"""
Tries to extract the real name for this user. Will return all attributes (joined)
specified in username_attr (comma separated).
"""
return ' '.join(auth_utils.process_regex_field(self.username_attr.value, user))
def authenticate(
@@ -353,41 +348,26 @@ class SimpleLDAPAuthenticator(auths.Authenticator):
groups_manager: 'auths.GroupsManager',
request: 'ExtendedHttpRequest',
) -> types.auth.AuthenticationResult:
'''
Must authenticate the user.
We can have to different situations here:
1.- The authenticator is external source, what means that users may be unknown to system before callig this
2.- The authenticator isn't external source, what means that users have been manually added to system and are known before this call
We receive the username, the credentials used (normally password, but can be a public key or something related to pk) and a group manager.
The group manager is responsible for letting know the authenticator which groups we currently has active.
@see: uds.core.auths.groups_manager
'''
"""
Authenticates the user using ldaputil.
"""
try:
# Locate the user at LDAP
user = self._get_user(username)
if user is None:
log_login(request, self.db_obj(), username, 'Invalid user', as_error=True)
return types.auth.FAILED_AUTH
try:
# Let's see first if it credentials are fine
self._connect_as(user['dn'], credentials) # Will raise an exception if it can't connect
self._connect_as(user['dn'], credentials)
except Exception:
log_login(request, self.db_obj(), username, 'Invalid password', as_error=True)
return types.auth.FAILED_AUTH
# store the user mfa attribute if it is set
if self.mfa_attribute.as_str():
self.storage.save_pickled(
self.mfa_storage_key(username),
user[self.mfa_attribute.as_str()][0],
)
groups_manager.validate(self._get_groups(user))
return types.auth.SUCCESS_AUTH
except Exception:
return types.auth.FAILED_AUTH
@@ -470,120 +450,104 @@ class SimpleLDAPAuthenticator(auths.Authenticator):
except Exception as e:
return types.core.TestResult(False, str(e))
# Test base search
try:
con.search_s( # pyright: ignore reportGeneralTypeIssues
base=self.ldap_base.as_str(),
scope=ldaputil.SCOPE_BASE, # pyright: ignore reportGeneralTypeIssues
)
next(ldaputil.as_dict(
con,
self.ldap_base.as_str(),
'(objectClass=*)',
limit=1,
scope=ldaputil.SCOPE_BASE,
))
except Exception:
return types.core.TestResult(False, _('Ldap search base is incorrect'))
# Test user class
try:
if (
len(
ensure.as_list(
con.search_ext_s( # pyright: ignore reportGeneralTypeIssues
base=self.ldap_base.as_str(),
scope=ldaputil.SCOPE_SUBTREE, # pyright: ignore reportGeneralTypeIssues
filterstr=f'(objectClass={self.user_class.as_str()})',
sizelimit=1,
)
)
)
!= 1
):
raise Exception(_('Ldap user class seems to be incorrect (no user found by that class)'))
count = sum(1 for _ in ldaputil.as_dict(
con,
self.ldap_base.as_str(),
f'(objectClass={self.user_class.as_str()})',
limit=1,
scope=ldaputil.SCOPE_SUBTREE,
))
if count == 0:
return types.core.TestResult(False, _('Ldap user class seems to be incorrect (no user found by that class)'))
except Exception:
pass
if (
len(
ensure.as_list(
con.search_ext_s( # pyright: ignore reportGeneralTypeIssues
base=self.ldap_base.as_str(),
scope=ldaputil.SCOPE_SUBTREE, # pyright: ignore reportGeneralTypeIssues
filterstr=f'(objectClass={self.group_class.as_str()})',
sizelimit=1,
)
)
)
!= 1
):
raise Exception(_('Ldap group class seems to be incorrect (no group found by that class)'))
# Test group class
try:
count = sum(1 for _ in ldaputil.as_dict(
con,
self.ldap_base.as_str(),
f'(objectClass={self.group_class.as_str()})',
limit=1,
scope=ldaputil.SCOPE_SUBTREE,
))
if count == 0:
return types.core.TestResult(False, _('Ldap group class seems to be incorrect (no group found by that class)'))
except Exception:
pass
if (
len(
ensure.as_list(
con.search_ext_s( # pyright: ignore reportGeneralTypeIssues
base=self.ldap_base.as_str(),
scope=ldaputil.SCOPE_SUBTREE, # pyright: ignore reportGeneralTypeIssues
filterstr=f'({self.user_id_attr.as_str()}=*)',
sizelimit=1,
)
)
)
!= 1
):
raise Exception(
_('Ldap user id attribute seems to be incorrect (no user found by that attribute)')
)
# Test user id attribute
try:
count = sum(1 for _ in ldaputil.as_dict(
con,
self.ldap_base.as_str(),
f'({self.user_id_attr.as_str()}=*)',
limit=1,
scope=ldaputil.SCOPE_SUBTREE,
))
if count == 0:
return types.core.TestResult(False, _('Ldap user id attribute seems to be incorrect (no user found by that attribute)'))
except Exception:
pass
if (
len(
ensure.as_list(
con.search_ext_s( # pyright: ignore reportGeneralTypeIssues
base=self.ldap_base.as_str(),
scope=ldaputil.SCOPE_SUBTREE, # pyright: ignore reportGeneralTypeIssues
filterstr=f'({self.group_id_attr.as_str()}=*)',
sizelimit=1,
)
)
)
!= 1
):
raise Exception(
_('Ldap group id attribute seems to be incorrect (no group found by that attribute)')
)
# Test group id attribute
try:
count = sum(1 for _ in ldaputil.as_dict(
con,
self.ldap_base.as_str(),
f'({self.group_id_attr.as_str()}=*)',
limit=1,
scope=ldaputil.SCOPE_SUBTREE,
))
if count == 0:
return types.core.TestResult(False, _('Ldap group id attribute seems to be incorrect (no group found by that attribute)'))
except Exception:
pass
if (
len(
ensure.as_list(
con.search_ext_s( # pyright: ignore reportGeneralTypeIssues
base=self.ldap_base.as_str(),
scope=ldaputil.SCOPE_SUBTREE, # pyright: ignore reportGeneralTypeIssues
filterstr=f'(&(objectClass={self.user_class.as_str()})({self.user_id_attr.as_str()}=*))',
sizelimit=1,
)
)
)
!= 1
):
raise Exception(
_(
'Ldap user class or user id attr is probably wrong (can\'t find any user with both conditions)'
)
)
# Test user class and user id attribute together
try:
count = sum(1 for _ in ldaputil.as_dict(
con,
self.ldap_base.as_str(),
f'(&(objectClass={self.user_class.as_str()})({self.user_id_attr.as_str()}=*))',
limit=1,
scope=ldaputil.SCOPE_SUBTREE,
))
if count == 0:
return types.core.TestResult(False, _('Ldap user class or user id attr is probably wrong (can\'t find any user with both conditions)'))
except Exception:
pass
res = ensure.as_list(
con.search_ext_s( # pyright: ignore reportGeneralTypeIssues
base=self.ldap_base.as_str(),
scope=ldaputil.SCOPE_SUBTREE, # pyright: ignore reportGeneralTypeIssues
filterstr=f'(&(objectClass={self.group_class.as_str()})({self.group_id_attr.as_str()}=*))',
attrlist=[self.member_attr.as_str()],
)
)
if not res:
raise Exception(
_(
'Ldap group class or group id attr is probably wrong (can\'t find any group with both conditions)'
)
)
ok = False
for r in res:
if self.member_attr.as_str() in r[1]:
ok = True
# Test group class and group id attribute together
try:
found = False
for r in ldaputil.as_dict(
con,
self.ldap_base.as_str(),
f'(&(objectClass={self.group_class.as_str()})({self.group_id_attr.as_str()}=*))',
attributes=[self.member_attr.as_str()],
limit=LDAP_RESULT_LIMIT,
scope=ldaputil.SCOPE_SUBTREE,
):
if self.member_attr.as_str() in r:
found = True
break
if ok is False:
raise Exception(_('Can\'t locate any group with the membership attribute specified'))
if not found:
return types.core.TestResult(False, _('Can\'t locate any group with the membership attribute specified'))
except Exception as e:
return types.core.TestResult(False, str(e))

View File

@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
#
# Copyright (c) 2012-2021 Virtual Cable S.L.U.
# Copyright (c) 2012-2025 Virtual Cable S.L.U.
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without modification,
@@ -80,13 +80,20 @@ def uds_cookie(
cookie,
samesite='Lax',
httponly=config.GlobalConfig.ENHANCED_SECURITY.as_bool(),
secure=True if config.GlobalConfig.ENHANCED_SECURITY.as_bool() else False,
)
request.COOKIES['uds'] = cookie
else:
cookie = request.COOKIES['uds'][: consts.auth.UDS_COOKIE_LENGTH]
if response and force:
response.set_cookie('uds', cookie)
response.set_cookie(
'uds',
cookie,
samesite='Lax',
httponly=config.GlobalConfig.ENHANCED_SECURITY.as_bool(),
secure=True if config.GlobalConfig.ENHANCED_SECURITY.as_bool() else False,
)
return cookie
@@ -117,17 +124,14 @@ def root_user() -> models.User:
# Decorator to make easier protect pages that needs to be logged in
def weblogin_required(
admin: typing.Union[bool, typing.Literal['admin']] = False
role: typing.Optional[consts.UserRole] = None,
) -> collections.abc.Callable[
[collections.abc.Callable[..., HttpResponse]], collections.abc.Callable[..., HttpResponse]
]:
"""Decorator to set protection to access page
Look for samples at uds.core.web.views
if admin == True, needs admin or staff
if admin == 'admin', needs admin
Args:
admin (bool, optional): If True, needs admin or staff. Is it's "admin" literal, needs admin . Defaults to False (any user).
role (str, optional): If set, needs this role. Defaults to None.
Returns:
collections.abc.Callable[[collections.abc.Callable[..., HttpResponse]], collections.abc.Callable[..., HttpResponse]]: Decorator
@@ -135,6 +139,7 @@ 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(
@@ -151,8 +156,8 @@ def weblogin_required(
if not request.user or not request.authorized:
return weblogout(request)
if admin in (True, 'admin'):
if request.user.is_staff() is False or (admin == 'admin' and not request.user.is_admin):
if role in (consts.UserRole.ADMIN, consts.UserRole.STAFF):
if request.user.is_staff() is False or (role == consts.UserRole.ADMIN and not request.user.is_admin):
return HttpResponseForbidden(_('Forbidden'))
return view_func(request, *args, **kwargs)
@@ -413,7 +418,7 @@ def weblogin(
request.session[consts.auth.SESSION_USER_KEY] = user.id
request.session[consts.auth.SESSION_PASS_KEY] = codecs.encode(
CryptoManager().symmetric_encrypt(password, cookie), "base64"
CryptoManager.manager().symmetric_encrypt(password, cookie), "base64"
).decode() # as str
# Ensures that this user will have access through REST api if logged in through web interface
@@ -425,8 +430,6 @@ 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(cls: typing.Type['Authenticator']) -> bool:
def provides_mfa_identifier(cls: typing.Type['Authenticator']) -> bool:
"""
Returns if this authenticator provides a MFA identifier
"""

View File

@@ -31,10 +31,13 @@
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
@@ -74,3 +77,60 @@ 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

View File

@@ -31,50 +31,83 @@ Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
from uds.core.exceptions.common import UDSException
class HandlerError(UDSException):
"""
Generic error for a REST handler
"""
pass
class NotFound(HandlerError):
"""
Item not found error
"""
pass
class AccessDenied(HandlerError):
"""
Access denied error
"""
pass
class RequestError(HandlerError):
"""
Request is invalid error
"""
pass
class ResponseError(HandlerError):
"""
Generic response error
"""
pass
class NotSupportedError(HandlerError):
class NotSupportedError(RequestError):
"""
Some elements do not support some operations (as searching over an authenticator that does not supports it)
"""
pass
# Exception to "rethrow" on save error
class SaveException(HandlerError):
"""
Exception thrown if couldn't save
"""
pass
class BlockAccess(UDSException):
"""
Exception used to signal that the access to a resource is blocked
"""
pass
class ValidationError(RequestError):
"""
Exception raised for validation errors
"""
pass
class InvalidMethodError(RequestError):
"""
Exception raised for invalid HTTP methods
"""
pass

View File

@@ -108,23 +108,27 @@ class DelayedTaskRunner(metaclass=singleton.Singleton):
def execute_delayed_task(self) -> None:
now = sql_now()
filt = Q(execution_time__lt=now) | Q(insert_date__gt=now + timedelta(seconds=30))
filt = Q(execution_time__lt=now) | Q(insert_date__gt=now + timedelta(seconds=3))
# If next execution is before now or last execution is in the future (clock changed on this server, we take that task as executable)
try:
with transaction.atomic(): # Encloses
# Throws exception if no delayed task is avilable
task: DBDelayedTask = (
DBDelayedTask.objects.select_for_update()
.filter(filt)
.order_by('execution_time')[0]
) # @UndefinedVariable
if task.insert_date > now + timedelta(seconds=30):
logger.warning('Executed %s due to insert_date being in the future!', task.type)
task: DBDelayedTask|None = DBDelayedTask.objects.select_for_update().filter(filt).order_by('execution_time').first()
if not task:
return
logger.debug('Obtained delayed task %s for execution', task)
if task.insert_date > now + timedelta(seconds=3):
logger.warning(
'Executed %s due to insert_date being in the future!, insert_date: %s, now: %s',
task.type,
task.insert_date,
now,
)
task_instance_dump = base64.b64decode(task.instance.encode())
task.delete()
task_instance = pickle.loads(task_instance_dump) # nosec: controlled pickle
except IndexError:
return # No problem, there is no waiting delayed task
except OperationalError:
logger.info('Retrying delayed task')
return
@@ -139,6 +143,8 @@ class DelayedTaskRunner(metaclass=singleton.Singleton):
# Re-create environment data
task_instance.env = Environment.type_environment(task_instance.__class__)
DelayedTaskThread(task_instance).start()
else:
logger.error('Could not load delayed task instance from %s <%s>', task, task_instance_dump)
def _insert(self, instance: DelayedTask, delay: int, tag: str) -> None:
now = sql_now()
@@ -160,14 +166,16 @@ class DelayedTaskRunner(metaclass=singleton.Singleton):
exec_time,
)
DBDelayedTask.objects.create(
created = DBDelayedTask.objects.create(
type=type_name,
instance=instance_dump, # @UndefinedVariable
instance=instance_dump,
insert_date=now,
execution_delay=delay,
execution_time=exec_time,
tag=tag,
)
logger.debug('Delayed task %s inserted with', created)
def insert(self, instance: DelayedTask, delay: int, tag: str = '') -> bool:
retries = 3
@@ -175,6 +183,7 @@ class DelayedTaskRunner(metaclass=singleton.Singleton):
retries -= 1
try:
self._insert(instance, delay, tag)
logger.debug('Delayed task %s inserted with tag %s', instance, tag)
break
except Exception as e:
logger.info('Exception inserting a delayed task %s: %s', e.__class__, e)
@@ -212,7 +221,6 @@ class DelayedTaskRunner(metaclass=singleton.Singleton):
logger.debug("At loop")
while DelayedTaskRunner._keep_running:
try:
time.sleep(self.granularity)
self.execute_delayed_task()
except Exception as e:
logger.error('Unexpected exception at run loop %s: %s', e.__class__, e)
@@ -220,4 +228,5 @@ class DelayedTaskRunner(metaclass=singleton.Singleton):
connections['default'].close()
except Exception:
logger.exception('Exception clossing connection at delayed task')
time.sleep(self.granularity)
logger.info('Exiting DelayedTask Runner because stop has been requested')

View File

@@ -162,10 +162,12 @@ class Scheduler:
.filter(fltr)
.order_by('next_execution')[0]
)
if job.last_execution > now:
if job.last_execution > now + timedelta(seconds=3): # Give some skew
logger.warning(
'EXecuted %s due to last_execution being in the future!',
'Executed %s due to last_execution being in the future!: %s > %s + 3',
job.name,
job.last_execution,
now,
)
job.state = State.RUNNING
job.owner_server = self._hostname

View File

@@ -117,7 +117,8 @@ class PublicationLauncher(DelayedTask):
): # If not preparing (may has been canceled by user) just return
return
servicepool_publication.state = State.PREPARING
servicepool_publication.save()
servicepool_publication.state_date = now
servicepool_publication.save(update_fields=['state', 'state_date'])
pi = servicepool_publication.get_instance()
state = pi.publish()
servicepool: ServicePool = servicepool_publication.deployed_service

View File

@@ -50,13 +50,18 @@ def process_log(server: 'models.Server', data: dict[str, typing.Any]) -> typing.
try:
userservice = models.UserService.objects.get(uuid=data['userservice_uuid'])
log.log(
userservice, types.log.LogLevel.from_str(data['level']), data['message'], source=types.log.LogSource.SERVER
userservice,
types.log.LogLevel.from_str(data['level']),
data['message'],
source=types.log.LogSource.SERVER,
)
return rest_result(consts.OK)
except models.UserService.DoesNotExist:
pass # If not found, log on server
log.log(server, types.log.LogLevel.from_str(data['level']), data['message'], source=types.log.LogSource.SERVER)
log.log(
server, types.log.LogLevel.from_str(data['level']), data['message'], source=types.log.LogSource.SERVER
)
return rest_result(consts.OK)
@@ -196,5 +201,5 @@ def process(server: 'models.Server', data: dict[str, typing.Any]) -> typing.Any:
try:
return fnc(server, data)
except Exception as e:
logger.error('Exception processing event %s: %s', data, e)
logger.exception('Exception processing event %s: %s', data, e)
return rest_result('error', error=str(e))

View File

@@ -219,18 +219,33 @@ class ServerApiRequester:
logger.debug(
'Notifying preconnect of service %s to server %s: %s', userservice.uuid, self.server.host, info
)
# Fix username to contain the domain if needed
username = info.username
if info.domain:
if '@' in username:
username = username.split('@')[0]
if '.' in info.domain: # FQDN domain
username = f'{username}@{info.domain}'
else: # NetBIOS domain
username = f'{info.domain}\\{username}'
connect_data = types.connections.PreconnectRequest(
user=username, # The username that will be used to login
protocol=info.protocol,
ip=src.ip,
hostname=src.hostname,
udsuser=userservice.user.name + '@' + userservice.user.manager.name if userservice.user else '',
udsuser_uuid=userservice.user.uuid if userservice.user else '',
userservice_uuid=userservice.uuid,
service_type=info.service_type,
)
logger.debug('Preconnect data to send: %s', connect_data)
self.post(
'preconnect',
types.connections.PreconnectRequest(
user=info.username,
protocol=info.protocol,
ip=src.ip,
hostname=src.hostname,
udsuser=userservice.user.name + '@' + userservice.user.manager.name if userservice.user else '',
udsuser_uuid=userservice.user.uuid if userservice.user else '',
userservice_uuid=userservice.uuid,
service_type=info.service_type,
).as_dict(),
connect_data.as_dict(),
)
return True

View File

@@ -362,7 +362,7 @@ class StatsManager(metaclass=singleton.Singleton):
return StatsEvents.objects.filter(id__gt=starting_id).order_by('-id')[:number]
return StatsEvents.objects.order_by('-id')[:number]
def perform_events_maintenancecleanupEvents(self) -> None:
def perform_events_maintenancecleanup_events(self) -> None:
"""
Removes all events previous to configured max keep time for stat information from database.
"""

View File

@@ -234,7 +234,19 @@ class UserServiceManager(metaclass=singleton.Singleton):
assigned = self._create_assigned_user_service_at_db_from_pool(service_pool, user)
assigned_instance = assigned.get_instance()
state = assigned_instance.deploy_for_user(user)
try:
state = assigned_instance.deploy_for_user(user)
except MaxServicesReachedError:
# If we reach this point, it means that the service has reached its maximum number of user services
operations_logger.error(
'Cannot create assigned service for user %s on pool %s: Maximum number of user services reached',
user.pretty_name,
service_pool.name,
)
# Remove existing assigned service
if assigned.id:
assigned.delete()
raise MaxServicesReachedError()
UserServiceOpChecker.make_unique(assigned, state)

View File

@@ -291,9 +291,13 @@ class DynamicPublication(services.Publication, autoserializable.AutoSerializable
if op == Operation.ERROR:
return self._error('Machine is already in error state!')
destroy_operations = [
types.services.Operation.DESTROY_VALIDATOR
] + self._destroy_queue # copy is not needed due to list concatenation
# If a "paused" state, reset queue to destroy
if op == Operation.FINISH:
self._queue = self._destroy_queue.copy()
self._queue = destroy_operations
return self._execute_queue()
# If must wait until finish, flag for destroy and wait
@@ -301,9 +305,7 @@ class DynamicPublication(services.Publication, autoserializable.AutoSerializable
self._is_flagged_for_destroy = True
else:
# If other operation, wait for finish before destroying
self._queue = [
op
] + self._destroy_queue # Copy not needed, will be copied anyway due to list concatenation
self._queue = [op] + destroy_operations # Add destroy operations to the queue
# Do not execute anything.here, just continue normally
return types.states.TaskState.RUNNING

View File

@@ -178,14 +178,25 @@ class DynamicService(services.Service, abc.ABC): # pylint: disable=too-many-pub
caller_instance: typing.Optional['DynamicUserService | DynamicPublication'],
vmid: str,
*,
force_new: bool = False,
for_unique_id: bool = False,
) -> str:
"""
Returns the mac of the machine
If cannot be obtained, MUST raise an exception
Args:
caller_instance: The instance of the caller
vmid: The vmid of the machine
for_unique_id: Whether to force a new mac address
Note:
vmid can be '' or force_new can be True, in this case, a new mac must be generated
If the service does not support this, it can raise an exception
vmid can be '', or for_unique_id can be True. Is up tu the service to treat this situation
Why is this?
Because we need to give the oportunity to discern if the call to the get_mac is for a new unique_id
for de userservice, or it is to force to generate one.
For example:
some_userservice ---> get_unique_id --> get_mac('xxxxxxx', for_unique_id=True) --> '' (because no mac until the end of the process...)
some_userservice ---> on termination --> get_mac('', for_unique_id=False) --> 'the_mac' (because at the end, the mac will be available)
"""
...

View File

@@ -252,9 +252,14 @@ class DynamicUserService(services.UserService, autoserializable.AutoSerializable
op = self._current_op()
if op == types.services.Operation.ERROR:
return types.states.TaskState.ERROR
return types.states.TaskState.ERROR # Error is returned as soon as we find it
# We also check here the finish operation, because some other methods
# as set_ready, expects this to return FINISHED. So keeping this here
# is a good idea, because no check is needed if already in FINISH state
if op == types.services.Operation.FINISH:
if def_op := self._check_deferred_operations(): # Check if we have deferred operations to execute
return def_op # If we have deferred operations, return their state
return types.states.TaskState.FINISHED
try:
@@ -278,6 +283,21 @@ class DynamicUserService(services.UserService, autoserializable.AutoSerializable
logger.exception('Unexpected DynamicUserService exception: %s', e)
return self.error(e)
def _check_deferred_operations(self) -> typing.Optional[types.states.TaskState]:
"""
Checks if we have deferred operations to execute.
Deferred operations are operations that are not executed immediately, but are stored in the queue
to be executed later.
"""
# If has a deferred destroy, do it now
if self.wait_until_finish_to_destroy and self._is_flagged_for_destroy:
# Simply ensures nothing is left on queue and returns FINISHED
logger.debug('Destroying service after finish')
self._set_queue([types.services.Operation.FINISH])
return self.destroy()
return None
@typing.final
def retry_later(self) -> types.states.TaskState:
"""
@@ -342,7 +362,7 @@ class DynamicUserService(services.UserService, autoserializable.AutoSerializable
# Note that get_mac is used for creating a new mac, returning the one of the vm or whatever
# This is responsibility of the service, not of the user service
if not self._mac:
self._mac = self.service().get_mac(self, self._vmid, force_new=True) or ''
self._mac = self.service().get_mac(self, self._vmid, for_unique_id=True) or ''
return self._mac
@typing.final
@@ -354,7 +374,7 @@ class DynamicUserService(services.UserService, autoserializable.AutoSerializable
self._ip = self.service().get_ip(self, self._vmid)
except Exception:
logger.warning(
'Error obtaining IP for %s: %s', self.__class__.__name__, self._vmid, exc_info=True
'Error obtaining IP for %s: %s', self.__class__.__name__, self._vmid # , exc_info=True
)
return self._ip
@@ -413,6 +433,7 @@ class DynamicUserService(services.UserService, autoserializable.AutoSerializable
)
except Exception as e:
return self.error(f'Error on set_ready: {e}')
return self._execute_queue()
def reset(self) -> types.states.TaskState:
@@ -438,12 +459,8 @@ class DynamicUserService(services.UserService, autoserializable.AutoSerializable
return types.states.TaskState.ERROR
if op == types.services.Operation.FINISH:
# If has a deferred destroy, do it now
if self.wait_until_finish_to_destroy and self._is_flagged_for_destroy:
self._is_flagged_for_destroy = False
# Simply ensures nothing is left on queue and returns FINISHED
self._set_queue([types.services.Operation.FINISH])
return self.destroy()
if def_op := self._check_deferred_operations(): # Check if we have deferred operations to execute
return def_op # If we have deferred operations, return their state
return types.states.TaskState.FINISHED
if op != types.services.Operation.WAIT:
@@ -499,6 +516,7 @@ class DynamicUserService(services.UserService, autoserializable.AutoSerializable
# If a "paused" state, reset queue to destroy
if op in (types.services.Operation.FINISH, types.services.Operation.WAIT):
logger.debug('Destroying service with these operations: %s', destroy_operations)
self._set_queue(destroy_operations)
return self._execute_queue()
@@ -561,7 +579,9 @@ class DynamicUserService(services.UserService, autoserializable.AutoSerializable
"""
This method is called when the service creation is completed
"""
pass
# By default, get the MAC address if not set already by get_unique_id at start
if self._mac == '' and self._vmid != '':
self._mac = self.service().get_mac(self, self._vmid)
@must_have_vmid
def op_start(self) -> None:
@@ -858,8 +878,16 @@ class DynamicUserService(services.UserService, autoserializable.AutoSerializable
def _op2str(op: types.services.Operation) -> str:
return op.name
def _debug(self, txt: str) -> str:
return f'Queue at {txt} for {self._name}: {", ".join([DynamicUserService._op2str(op) for op in self._queue])}, mac:{self._mac}, vmid:{self._vmid}'
def _debug(self, txt: str) -> None:
# f'Queue at {txt} for {self._name}: {", ".join([DynamicUserService._op2str(op) for op in self._queue])}, mac:{self._mac}, vmid:{self._vmid}'
logger.debug(
'Queue at %s for %s: %s, mac:%s, vmid:%s',
txt,
self._name,
', '.join([DynamicUserService._op2str(op) for op in self._queue]),
self._mac,
self._vmid,
)
# This is a map of operations to methods

View File

@@ -77,7 +77,7 @@ class Transport(Module):
own_link: bool = False
# Protocol "type". This is not mandatory, but will help
protocol: types.transports.Protocol = types.transports.Protocol.NONE
PROTOCOL: typing.ClassVar[types.transports.Protocol] = types.transports.Protocol.NONE
# For allowing grouping transport on dashboard "new" menu, and maybe other places
group: typing.ClassVar[types.transports.Grouping] = types.transports.Grouping.DIRECT
@@ -146,12 +146,12 @@ class Transport(Module):
return f'Not accessible (using service ip {ip})'
@classmethod
def supports_protocol(cls, protocol: typing.Union[collections.abc.Iterable[str], str]) -> bool:
def is_protocol_supported(cls, protocol: typing.Union[collections.abc.Iterable[str], str]) -> bool:
if isinstance(protocol, str):
return protocol.lower() == cls.protocol.lower()
return protocol.lower() == cls.PROTOCOL.lower()
# Not string group of strings
for v in protocol:
if cls.supports_protocol(v):
if cls.is_protocol_supported(v):
return True
return False
@@ -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,6 +30,7 @@
Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
# pyright: reportUnusedImport=false
from . import rest
from . import (
auth,
calendar,
@@ -41,7 +42,6 @@ from . import (
permissions,
pools,
requests,
rest,
servers,
services,
states,
@@ -50,6 +50,7 @@ from . import (
ui,
core,
log,
net,
)
# Log is not imported here, as it is a special case with lots of dependencies

View File

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

View File

@@ -0,0 +1,39 @@
# -*- 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 dataclasses
@dataclasses.dataclass
class Iface:
name: str
mac: str
ip: str

View File

@@ -1,110 +0,0 @@
# -*- coding: utf-8 -*-
#
# Copyright (c) 2023 Virtual Cable S.L.U.
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without modification,
# are permitted provided that the following conditions are met:
#
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
# * Neither the name of Virtual Cable S.L.U. nor the names of its contributors
# may be used to endorse or promote products derived from this software
# without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
"""
Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
import abc
import typing
import dataclasses
import collections.abc
TypeInfoDict = dict[str, typing.Any] # Alias for type info dict
class ExtraTypeInfo(abc.ABC):
def as_dict(self) -> TypeInfoDict:
return {}
@dataclasses.dataclass
class AuthenticatorTypeInfo(ExtraTypeInfo):
search_users_supported: bool
search_groups_supported: bool
needs_password: bool
label_username: str
label_groupname: str
label_password: str
create_users_supported: bool
is_external: bool
mfa_data_enabled: bool
mfa_supported: bool
def as_dict(self) -> TypeInfoDict:
return dataclasses.asdict(self)
@dataclasses.dataclass
class TypeInfo:
name: str
type: str
description: str
icon: str
group: typing.Optional[str] = None
extra: 'ExtraTypeInfo|None' = None
def as_dict(self) -> TypeInfoDict:
res: dict[str, typing.Any] = {
'name': self.name,
'type': self.type,
'description': self.description,
'icon': self.icon,
}
# Add optional fields
if self.group:
res['group'] = self.group
if self.extra:
res.update(self.extra.as_dict())
return res
@staticmethod
def null() -> 'TypeInfo':
return TypeInfo(name='', type='', description='', icon='', extra=None)
# This is a named tuple for convenience, and must be
# compatible with tuple[str, bool] (name, needs_parent)
class ModelCustomMethod(typing.NamedTuple):
name: str
needs_parent: bool = True
# Alias for item type
ItemDictType = dict[str, typing.Any]
ItemListType = list[ItemDictType]
ItemGeneratorType = typing.Generator[ItemDictType, None, None]
# Alias for get_items return type
ManyItemsDictType = typing.Union[ItemListType, ItemDictType, ItemGeneratorType]
#
FieldType = collections.abc.Mapping[str, typing.Any]

View File

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

@@ -0,0 +1,42 @@
# -*- coding: utf-8 -*-
#
# Copyright (c) 2025 Virtual Cable S.L.U.
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without modification,
# are permitted provided that the following conditions are met:
#
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
# * Neither the name of Virtual Cable S.L.U. nor the names of its contributors
# may be used to endorse or promote products derived from this software
# without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
"""
Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
import enum
class NotifyActionType(enum.StrEnum):
LOGIN = 'login'
LOGOUT = 'logout'
DATA = 'data'
@staticmethod
def valid_names() -> list[str]:
return [e.value for e in NotifyActionType]

View File

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

@@ -0,0 +1,33 @@
# -*- coding: utf-8 -*-
#
# Copyright (c) 2025 Virtual Cable S.L.U.
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without modification,
# are permitted provided that the following conditions are met:
#
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
# * Neither the name of Virtual Cable S.L.U. nor the names of its contributors
# may be used to endorse or promote products derived from this software
# without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
"""
Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
# pyright: reportUnusedImport=false
from .fields import StockField

View File

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

@@ -37,7 +37,8 @@ import collections.abc
from django.utils.translation import gettext_noop
# Old Field name type
OldFieldNameType = typing.Union[str,list[str],None]
OldFieldNameType = typing.Union[str, list[str], None]
class Tab(enum.StrEnum):
ADVANCED = gettext_noop('Advanced')
@@ -79,6 +80,7 @@ class FieldType(enum.StrEnum):
IMAGECHOICE = 'imgchoice'
DATE = 'date'
INFO = 'internal-info'
TAGLIST = 'taglist'
@staticmethod
def from_str(value: str) -> 'FieldType':
@@ -168,38 +170,31 @@ 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
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(), '')
value: typing.NotRequired[typing.Any]
gui: GuiDescription

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) -> dict[str, typing.Any]:
def gui_description(self) -> types.ui.GuiDescription:
"""
Returns the dictionary with the description of this item.
We copy it, cause we need to translate the label and tooltip fields
@@ -400,12 +400,17 @@ 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 # We need to translate default value
return data
data['default'] = self.default
return typing.cast(types.ui.GuiDescription, data)
@property
def default(self) -> typing.Any:
@@ -586,7 +591,7 @@ class gui:
validators.validate_hostname(self.value)
case types.ui.FieldPatternType.HOST:
try:
validators.validate_hostname(self.value, domain_allowed=True)
validators.validate_hostname(self.value, allow_domain=True)
except exceptions.ui.ValidationError:
validators.validate_ip(self.value)
case types.ui.FieldPatternType.PATH:
@@ -799,7 +804,7 @@ class gui:
def value(self, value: datetime.date | str) -> None:
self._set_value(value)
def gui_description(self) -> dict[str, typing.Any]:
def gui_description(self) -> types.ui.GuiDescription:
fldgui = super().gui_description()
# Convert if needed value and default to string (YYYY-MM-DD)
if 'default' in fldgui:
@@ -1522,6 +1527,17 @@ 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:
"""
@@ -1653,10 +1669,13 @@ 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,
@@ -1791,12 +1810,13 @@ def password_compat_field_decoder(value: str) -> str:
"""
Compatibility function to decode text fields converted to password fields
"""
try:
try:
value = CryptoManager.manager().aes_decrypt(value.encode('utf8'), UDSK, True).decode()
except Exception:
pass
return value
# Dictionaries used to encode/decode fields to be stored on database
FIELDS_ENCODERS: typing.Final[
collections.abc.Mapping[

View File

@@ -0,0 +1,228 @@
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,7 +66,9 @@ class Cache:
_serializer: typing.ClassVar[collections.abc.Callable[[typing.Any], str]] = _basic_serialize
_deserializer: typing.ClassVar[collections.abc.Callable[[str], typing.Any]] = _basic_deserialize
def __init__(self, owner: typing.Union[str, bytes], default_timeout: int = consts.cache.DEFAULT_CACHE_TIMEOUT) -> None:
def __init__(
self, owner: typing.Union[str, bytes], default_timeout: int = consts.cache.DEFAULT_CACHE_TIMEOUT
) -> None:
self._owner = owner.decode('utf-8') if isinstance(owner, bytes) else owner
self._timeout = default_timeout

View File

@@ -0,0 +1,93 @@
import datetime
import logging
import socket
import typing
from django.db import transaction, OperationalError
from uds import models
from uds.core.util.iface import get_first_iface
from uds.core.util.model import sql_now, get_my_ip_from_db
logger = logging.getLogger(__name__)
class UDSClusterNode(typing.NamedTuple):
"""
Represents a node in the cluster with its hostname and last seen date.
"""
hostname: str
ip: str
last_seen: datetime.datetime
mac: str = '00:00:00:00:00:00'
def as_dict(self) -> dict[str, str]:
"""
Returns a dictionary representation of the UDSClusterNode.
"""
return {
'hostname': self.hostname,
'ip': self.ip,
'last_seen': self.last_seen.isoformat(),
'mac': self.mac,
}
def __str__(self) -> str:
return f'{self.hostname} ({self.ip}) - Last seen: {self.last_seen.isoformat()} - MAC: {self.mac}'
def store_cluster_info() -> None:
"""
Stores the current hostname in the database, ensuring that it is unique.
This is used to identify the current node in a cluster.
"""
iface = get_first_iface()
ip = iface.ip if iface else get_my_ip_from_db()
mac = iface.mac if iface else '00:00:00:00:00:00'
try:
hostname = socket.getfqdn() + '|' + ip
date = sql_now().isoformat()
with transaction.atomic():
current_host_property = (
models.Properties.objects.select_for_update()
.filter(owner_id='cluster', owner_type='cluster', key=hostname)
.first()
)
if current_host_property:
# Update existing property
current_host_property.value = {'last_seen': date, 'mac': mac}
current_host_property.save()
else:
# Create new property
models.Properties.objects.create(
owner_id='cluster', owner_type='cluster', key=hostname, value={'last_seen': date}
)
except OperationalError as e:
# If we cannot connect to the database, we log the error
logger.error("Could not store cluster hostname: %s", e)
def enumerate_cluster_nodes() -> list[UDSClusterNode]:
"""
Enumerates all nodes in the cluster by fetching properties with owner_type 'cluster'.
Returns a list of hostnames.
"""
try:
properties = models.Properties.objects.filter(owner_type='cluster')
return [
UDSClusterNode(
hostname=prop.key.split('|')[0],
ip=prop.key.split('|')[1],
last_seen=datetime.datetime.fromisoformat(prop.value['last_seen']),
mac=prop.value.get('mac', '00:00:00:00:00:00'),
)
for prop in properties
if 'last_seen' in prop.value and '|' in prop.key
]
except OperationalError as e:
# If we cannot connect to the database, we log the error and return an empty list
logger.error("Could not enumerate cluster nodes: %s", e)
return []

View File

@@ -349,7 +349,7 @@ class Config:
@staticmethod
def get_config_values(
include_passwords: bool = False,
) -> collections.abc.Mapping[str, collections.abc.Mapping[str, collections.abc.Mapping[str, typing.Any]]]:
) -> dict[str, dict[str, dict[str, typing.Any]]]:
"""
Returns a dictionary with all config values
"""

View File

@@ -45,7 +45,8 @@ logger = logging.getLogger(__name__)
# FT = typing.TypeVar('FT', bound=collections.abc.Callable[..., typing.Any])
P = typing.ParamSpec('P')
R = typing.TypeVar('R')
R = typing.TypeVar('R', bound=typing.Any, covariant=True) # R is covariant, so we can return a subclass of R
@dataclasses.dataclass
class CacheInfo:
@@ -146,21 +147,22 @@ class _HasConnect(typing.Protocol):
# Keep this, but mypy does not likes it... it's perfect with pyright
# We use pyright for type checking, so we will use this
HasConnect = typing.TypeVar('HasConnect', bound=_HasConnect)
HAS_CONNECT = typing.TypeVar('HAS_CONNECT', bound=_HasConnect)
def ensure_connected(
func: collections.abc.Callable[typing.Concatenate[HasConnect, P], R]
) -> collections.abc.Callable[typing.Concatenate[HasConnect, P], R]:
func: collections.abc.Callable[typing.Concatenate[HAS_CONNECT, P], R],
) -> collections.abc.Callable[typing.Concatenate[HAS_CONNECT, P], R]:
"""This decorator calls "connect" method of the class of the wrapped object"""
@functools.wraps(func)
def new_func(obj: HasConnect, /, *args: P.args, **kwargs: P.kwargs) -> R:
def connect_and_execute(obj: HAS_CONNECT, /, *args: P.args, **kwargs: P.kwargs) -> R:
# self = typing.cast(_HasConnect, args[0])
obj.connect()
return func(obj, *args, **kwargs)
return new_func
return connect_and_execute
# To be used in a future, for type checking only
# currently the problem is that the signature of a function is diferent
@@ -178,11 +180,11 @@ def ensure_connected(
# Decorator for caching
# This decorator will cache the result of the function for a given time, and given parameters
def cached(
prefix: typing.Optional[str] = None,
timeout: typing.Union[collections.abc.Callable[[], int], int] = -1,
args: typing.Optional[typing.Union[collections.abc.Iterable[int], int]] = None,
kwargs: typing.Optional[typing.Union[collections.abc.Iterable[str], str]] = None,
key_helper: typing.Optional[collections.abc.Callable[[typing.Any], str]] = None,
prefix: str | None = None,
timeout: collections.abc.Callable[[], int] | int = -1,
args: collections.abc.Iterable[int] | int | None = None,
kwargs: collections.abc.Iterable[str] | str | None = None,
key_helper: collections.abc.Callable[[typing.Any], str] | None = None,
) -> collections.abc.Callable[[collections.abc.Callable[P, R]], collections.abc.Callable[P, R]]:
"""
Decorator that gives us a "quick & clean" caching feature on the database.
@@ -244,7 +246,7 @@ def cached(
except Exception:
logger.debug('Function %s is not inspectable, no caching possible', fnc.__name__)
# Not inspectable, no caching possible, return original function
# Ensure compat with methods of cached functions
setattr(fnc, 'cache_info', cache_info)
setattr(fnc, 'cache_clear', cache_clear)
@@ -286,6 +288,9 @@ def cached(
data: typing.Any = None
# If misses is 0, we are starting, so we will not try to get from cache
if not kwargs.get('force', False) and effective_timeout > 0 and misses > 0:
if 'force' in kwargs:
# Remove force key
del kwargs['force']
data = cache.get(cache_key, default=consts.cache.CACHE_NOT_FOUND)
if data is not consts.cache.CACHE_NOT_FOUND:
hits += 1
@@ -293,10 +298,6 @@ def cached(
misses += 1
if 'force' in kwargs:
# Remove force key
del kwargs['force']
# Execute the function outside the DB transaction
t = time.thread_time_ns()
data = fnc(*args, **kwargs) # pyright: ignore # For some reason, pyright does not like this line
@@ -337,8 +338,8 @@ def threaded(func: collections.abc.Callable[P, None]) -> collections.abc.Callabl
def blocker(
request_attr: typing.Optional[str] = None,
max_failures: typing.Optional[int] = None,
request_attr: str | None = None,
max_failures: int | None = None,
ignore_block_config: bool = False,
) -> collections.abc.Callable[[collections.abc.Callable[P, R]], collections.abc.Callable[P, R]]:
"""
@@ -372,7 +373,7 @@ def blocker(
except uds.core.exceptions.rest.BlockAccess:
raise exceptions.rest.AccessDenied
request: typing.Optional[typing.Any] = getattr(args[0], request_attr or '_request', None)
request: typing.Any | None = 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):
@@ -407,7 +408,7 @@ def blocker(
def profiler(
log_file: typing.Optional[str] = None,
log_file: str | None = None,
) -> collections.abc.Callable[[collections.abc.Callable[P, R]], collections.abc.Callable[P, R]]:
"""
Decorator that will profile the wrapped function and log the results to the provided file
@@ -441,3 +442,40 @@ def profiler(
return wrapper
return decorator
def retry_on_exception(
retries: int,
*,
wait_seconds: float = 2,
retryable_exceptions: list[type[Exception]] | None = None,
do_log: bool = False,
) -> collections.abc.Callable[[collections.abc.Callable[P, R]], collections.abc.Callable[P, R]]:
to_retry = retryable_exceptions or [Exception]
def decorator(fnc: collections.abc.Callable[P, R]) -> collections.abc.Callable[P, R]:
@functools.wraps(fnc)
def wrapper(*args: typing.Any, **kwargs: typing.Any) -> R:
for i in range(retries):
try:
return fnc(*args, **kwargs)
except Exception as e:
if do_log:
logger.error('Exception raised in function %s: %s', fnc.__name__, e)
if not any(isinstance(e, exception_type) for exception_type in to_retry):
raise e
# if this is the last retry, raise the exception
if i == retries - 1:
raise e
time.sleep(wait_seconds * (2 ** min(i, 4))) # Exponential backoff until 16x
# retries == 0 allowed, but only use it for testing purposes
# because it's a nonsensical decorator otherwise
return fnc(*args, **kwargs)
return wrapper
return decorator

View File

@@ -495,11 +495,11 @@ else:
class FuseContext(ctypes.Structure):
_fields_ = [
('fuse', ctypes.c_voidp), # type: ignore
('fuse', ctypes.c_voidp),
('uid', c_uid_t),
('gid', c_gid_t),
('pid', c_pid_t),
('private_data', ctypes.c_voidp), # type: ignore
('private_data', ctypes.c_voidp),
]
@@ -521,7 +521,7 @@ class FuseOperations(ctypes.Structure):
ctypes.c_size_t,
),
),
('getdir', ctypes.c_voidp), # type: ignore # Deprecated, use readdir
('getdir', ctypes.c_voidp), # Deprecated, use readdir
('mknod', ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_char_p, c_mode_t, c_dev_t)),
('mkdir', ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_char_p, c_mode_t)),
('unlink', ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_char_p)),
@@ -532,7 +532,7 @@ class FuseOperations(ctypes.Structure):
('chmod', ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_char_p, c_mode_t)),
('chown', ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_char_p, c_uid_t, c_gid_t)),
('truncate', ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_char_p, c_off_t)),
('utime', ctypes.c_voidp), # type: ignore # Deprecated, use utimens
('utime', ctypes.c_voidp), # Deprecated, use utimens
(
'open',
ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_char_p, ctypes.POINTER(fuse_file_info)),
@@ -604,10 +604,10 @@ class FuseOperations(ctypes.Structure):
ctypes.CFUNCTYPE(
ctypes.c_int,
ctypes.c_char_p,
ctypes.c_voidp, # type: ignore
ctypes.c_voidp,
ctypes.CFUNCTYPE(
ctypes.c_int,
ctypes.c_voidp, # type: ignore
ctypes.c_voidp,
ctypes.c_char_p,
ctypes.POINTER(c_stat),
c_off_t,
@@ -629,8 +629,8 @@ class FuseOperations(ctypes.Structure):
ctypes.POINTER(fuse_file_info),
),
),
('init', ctypes.CFUNCTYPE(ctypes.c_voidp, ctypes.c_voidp)), # type: ignore
('destroy', ctypes.CFUNCTYPE(ctypes.c_voidp, ctypes.c_voidp)), # type: ignore
('init', ctypes.CFUNCTYPE(ctypes.c_voidp, ctypes.c_voidp)),
('destroy', ctypes.CFUNCTYPE(ctypes.c_voidp, ctypes.c_voidp)),
('access', ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_char_p, ctypes.c_int)),
(
'create',
@@ -656,7 +656,7 @@ class FuseOperations(ctypes.Structure):
ctypes.c_char_p,
ctypes.POINTER(fuse_file_info),
ctypes.c_int,
ctypes.c_voidp, # type: ignore
ctypes.c_voidp,
),
),
(
@@ -798,7 +798,7 @@ class FUSE:
continue
if hasattr(typing.cast(typing.Any, prototype), 'argtypes'):
val = prototype(partial(FUSE._wrapper, getattr(self, name))) # type: ignore
val = prototype(partial(FUSE._wrapper, getattr(self, name)))
setattr(fuse_ops, name, val)
@@ -846,14 +846,14 @@ class FUSE:
return func(*args, **kwargs) or 0
except OSError as e:
if e.errno > 0: # pyright: ignore
if e.errno and e.errno > 0:
logger.debug(
"FUSE operation %s raised a %s, returning errno %s.",
func.__name__,
type(e),
e.errno,
)
return -e.errno # pyright: ignore
return -e.errno
logger.error(
"FUSE operation %s raised an OSError with negative " "errno %s, returning errno.EINVAL.",
func.__name__,

View File

@@ -0,0 +1,116 @@
# -*- coding: utf-8 -*-
#
# Copyright (c) 2014-2023 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. 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 platform
import socket
import fcntl
import struct
import array
import typing
from uds.core import types
def list_ifaces() -> typing.Iterator[types.net.Iface]:
def _get_iface_mac_addr(ifname: str) -> typing.Optional[str]:
'''
Returns the mac address of an interface
Mac is returned as unicode utf-8 encoded
'''
ifnameBytes = ifname.encode('utf-8')
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
info = bytearray(fcntl.ioctl(s.fileno(), 0x8927, struct.pack(str('256s'), ifnameBytes[:15])))
return str(''.join(['%02x:' % char for char in info[18:24]])[:-1]).upper()
except Exception:
return None
def _get_iface_ip_addr(ifname: str) -> typing.Optional[str]:
'''
Returns the ip address of an interface
Ip is returned as unicode utf-8 encoded
'''
ifnameBytes = ifname.encode('utf-8')
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
return str(
socket.inet_ntoa(
fcntl.ioctl(
s.fileno(),
0x8915, # SIOCGIFADDR
struct.pack(str('256s'), ifnameBytes[:15]),
)[20:24]
)
)
except Exception:
return None
def _list_ifaces() -> list[str]:
'''
Returns a list of interfaces names coded in utf-8
'''
max_possible = 128 # arbitrary. raise if needed.
space = max_possible * 16
if platform.architecture()[0] == '32bit':
offset, length = 32, 32
elif platform.architecture()[0] == '64bit':
offset, length = 16, 40
else:
raise OSError('Unknown arquitecture {0}'.format(platform.architecture()[0]))
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
names = array.array(str('B'), b'\0' * space)
outbytes = struct.unpack(
'iL',
fcntl.ioctl(
s.fileno(),
0x8912, # SIOCGIFCONF
struct.pack('iL', space, names.buffer_info()[0]),
),
)[0]
namestr = names.tobytes()
# return namestr, outbytes
return [namestr[i : i + offset].split(b'\0', 1)[0].decode('utf-8') for i in range(0, outbytes, length)]
for ifname in _list_ifaces():
ip, mac = _get_iface_ip_addr(ifname), _get_iface_mac_addr(ifname)
if (
mac != '00:00:00:00:00:00' and mac and ip and ip.startswith('169.254') is False
): # Skips local interfaces & interfaces with no dhcp IPs
yield types.net.Iface(name=ifname, mac=mac, ip=ip)
def get_first_iface() -> typing.Optional[types.net.Iface]:
"""
Returns the first interface found, or None if no interface is found.
"""
try:
return next(list_ifaces())
except StopIteration:
return None

View File

@@ -1,89 +1,64 @@
# 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.
# pyright: reportUnknownMemberType=false
"""
Author: Adolfo Gómez, dkmaster at dkmon dot com
Converted to ldap3 by GitHub Copilot
"""
import logging
import typing
import collections.abc
import tempfile
import os.path
import ssl
import ldap.filter
# 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 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
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,
)
# 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]]]]
# Re-export with our nomenclature
SCOPE_BASE = BASE
SCOPE_SUBTREE = SUBTREE
SCOPE_ONELEVEL = LEVEL
# About ldap filters: (just for reference)
# https://ldap.com/ldap-filters/
# 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]]]
LDAPConnection: typing.TypeAlias = Connection
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)
_str += str(e)
raise LDAPError(_str) from e
@@ -91,246 +66,235 @@ def escape(value: str) -> str:
"""
Escape filter chars for ldap search filter
"""
return ldap.filter.escape_filter_chars(value) # pyright: ignore reportGeneralTypeIssues
# 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')
)
def connection(
username: str,
passwd: typing.Union[str, bytes],
passwd: str,
host: str,
*,
port: int = -1,
ssl: bool = False,
read_only: bool = True, # Most times we want read-only connections, so default to True
use_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':
certificate_data: typing.Optional[str] = None, # Content of the certificate, not the file itself
) -> 'LDAPConnection':
"""
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
Tries to connect to ldap using ldap3. If username is None, it tries to connect using user provided credentials.
"""
logger.debug('Login in to %s as user %s', host, username)
password = passwd.encode('utf-8') if isinstance(passwd, str) else passwd
l: 'LDAPObject'
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,
)
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.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
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))
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)
return conn
except Exception as e:
logger.exception('Exception connection:')
raise LDAPError(str(e)) from e
raise LDAPError(_('Unknown error'))
def as_dict(
con: 'LDAPObject',
con: Connection,
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
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
"""
logger.debug('Filter: %s, attr list: %s', ldap_filter, attributes)
if attributes:
attributes = list(attributes) # Ensures iterable is a list
res: LDAPSearchResultType = None
attr_list = list(attributes) if attributes else ALL_ATTRIBUTES
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,
con.search(
search_base=base,
search_filter=ldap_filter,
search_scope=scope,
attributes=attr_list,
size_limit=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({'dn': r[0]})
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 Exception as e:
logger.exception('Exception in search:')
raise LDAPError(str(e)) from e
def first(
con: 'LDAPObject',
con: Connection,
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 []
value = escape(value)
attr_list = [field] + list(attributes) if attributes else [field]
ldap_filter = f'(&(objectClass={object_class})({field}={value}))'
try:
obj = next(as_dict(con, base, ldap_filter, attributes, max_entries))
gen = as_dict(con, base, ldap_filter, attributes=attr_list, limit=max_entries)
obj = next(gen)
except StopIteration:
return None # None found
return None
obj['_id'] = value
return obj
# 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 get_root_dse(con: 'LDAPObject') -> typing.Optional[LDAPResultType]:
def add(
con: Connection,
dn: str,
*,
attributes: dict[str, list[bytes | str]],
) -> bool:
"""
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).
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
"""
return next(
as_dict(
con=con,
base='',
ldap_filter='(objectClass=*)',
scope=SCOPE_BASE,
)
)
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
def delete(con: Connection, dn: str, *, depth: int = 1) -> None:
"""
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.
"""
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

View File

@@ -0,0 +1,344 @@
# 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
from threading import Lock
import threading
import datetime
from time import mktime
import time
from django.db import connection
from uds.core import consts
from uds.core.managers.crypto import CryptoManager
logger = logging.getLogger(__name__)
CACHE_TIME_TIMEOUT = 60 # Every 60 second, refresh the time from database (to avoid drifts)
CACHE_TIME_TIMEOUT: typing.Final[int] = 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[Lock] = Lock()
lock: typing.ClassVar[threading.Lock] = threading.Lock()
last_check: typing.ClassVar[datetime.datetime] = consts.NEVER
cached_time: typing.ClassVar[datetime.datetime] = consts.NEVER
hits: typing.ClassVar[int] = 0
@@ -75,7 +75,9 @@ class TimeTrack:
if connection.vendor in ('mysql', 'microsoft', 'postgresql'):
cursor = connection.cursor()
sentence = (
'SELECT CURRENT_TIMESTAMP(4)' if connection.vendor in ('mysql', 'postgresql') else 'SELECT CURRENT_TIMESTAMP'
'SELECT CURRENT_TIMESTAMP(4)'
if connection.vendor in ('mysql', 'postgresql')
else 'SELECT CURRENT_TIMESTAMP'
)
cursor.execute(sentence)
date = (cursor.fetchone() or [datetime.datetime.now()])[0]
@@ -94,12 +96,15 @@ class TimeTrack:
# If in last_check is in the future, or more than CACHE_TIME_TIMEOUT seconds ago, we need to refresh
# Future is possible if we have a clock update, or a big drift
if diff > datetime.timedelta(seconds=CACHE_TIME_TIMEOUT) or diff < datetime.timedelta(seconds=0):
TimeTrack.last_check = now
TimeTrack.misses += 1
TimeTrack.cached_time = TimeTrack._fetch_sql_datetime()
TimeTrack.last_check = now
else:
TimeTrack.hits += 1
return TimeTrack.cached_time + (now - TimeTrack.last_check)
the_time = TimeTrack.cached_time + (now - TimeTrack.last_check)
# Keep only cent of second precision
the_time = the_time.replace(microsecond=int(the_time.microsecond / 10000) * 10000)
return the_time
def sql_now() -> datetime.datetime:
@@ -115,7 +120,7 @@ def sql_stamp_seconds() -> int:
Returns:
int: Unix timestamp
"""
return int(mktime(sql_now().timetuple()))
return int(time.mktime(sql_now().timetuple()))
def sql_stamp() -> float:
@@ -124,17 +129,50 @@ def sql_stamp() -> float:
Returns:
float: Unix timestamp
"""
return float(mktime(sql_now().timetuple())) + sql_now().microsecond / 1000000.0
return float(time.mktime(sql_now().timetuple())) + sql_now().microsecond / 1000000.0
def generate_uuid(obj: typing.Any = None) -> str:
"""
Generates a ramdom uuid for models default
"""
return CryptoManager().uuid(obj=obj).lower()
return CryptoManager.manager().uuid(obj=obj).lower()
def process_uuid(uuid: str) -> str:
if isinstance(uuid, bytes):
uuid = uuid.decode('utf8')
return uuid.lower()
def get_my_ip_from_db() -> str:
"""
Gets, from the database, the IP of the current server.
"""
# Mysql query:
# SELECT host FROM information_schema.processlist WHERE ID = CONNECTION_ID();
# Postgres query: SELECT client_addr FROM pg_stat_activity WHERE pid = pg_backend_pid();
# sql server: SELECT client_net_address FROM sys.dm_exec_connections WHERE session_id = @@SPID;
try:
match connection.vendor:
case 'mysql':
query = 'SELECT host FROM information_schema.processlist WHERE ID = CONNECTION_ID();'
case 'postgresql':
query = 'SELECT client_addr FROM pg_stat_activity WHERE pid = pg_backend_pid();'
case 'microsoft':
query = 'SELECT client_net_address FROM sys.dm_exec_connections WHERE session_id = @@SPID;'
case _:
return '0.0.0.0' # If not known, return a default IP
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')
return result.split(':')[0]
except Exception as e:
logger.error('Error getting my IP: %s', e)
return '0.0.0.0'

View File

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

@@ -0,0 +1,379 @@
# 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(
def match_args(
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(
callback(sample, arg_2, argument)
And the literals will be ignored
"""
arg_list = list(arg_list) # ensure it is a list
arg_list = [i for i in arg_list] # ensure it is a list
for pattern, function in args:
if len(arg_list) != len(pattern):
continue
@@ -96,3 +96,4 @@ def match(
# Invoke error callback
error()
return None # In fact, error is expected to raise an exception, so this is never reached

View File

@@ -38,7 +38,6 @@ import ssl
import typing
import datetime
import certifi
import requests
import requests.adapters
import urllib3
@@ -102,20 +101,34 @@ def create_self_signed_cert(ip: str) -> tuple[str, str, str]:
)
def create_client_sslcontext(verify: bool = True) -> ssl.SSLContext:
def create_client_sslcontext(
verify: bool = True, ca_cert_file: str | None = None, ca_cert_data: str | None = None
) -> 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, cafile=certifi.where())
ssl_context = ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH)
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
@@ -165,7 +178,7 @@ def check_certificate_matches_private_key(*, cert: str, key: str) -> bool:
return False
def secure_requests_session(*, verify: 'str|bool' = True) -> 'requests.Session':
def secure_requests_session(*, verify: 'str|bool' = True, proxies: 'dict[str, str]|None' = None) -> 'requests.Session':
'''
Generates a requests.Session object with a custom adapter that uses a custom SSLContext.
This is intended to be used for requests that need to be secure, but not necessarily verified.
@@ -196,7 +209,7 @@ def secure_requests_session(*, verify: 'str|bool' = True) -> 'requests.Session':
# 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) # type: ignore
return super().init_poolmanager(*args, **kwargs) # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
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
@@ -217,10 +230,13 @@ def secure_requests_session(*, verify: 'str|bool' = True) -> 'requests.Session':
# conn_kw = conn.__dict__['conn_kw']
# conn_kw['ssl_context'] = self.ssl_context
super().cert_verify(conn, url, verify, cert) # type: ignore
super().cert_verify(conn, url, verify, cert) # pyright: ignore[reportUnknownMemberType]
session = requests.Session()
session.mount("https://", UDSHTTPAdapter())
if proxies:
session.proxies = proxies
# Add user agent header to session
session.headers.update({"User-Agent": consts.system.USER_AGENT})

View File

@@ -73,10 +73,10 @@ def _get_prov_serv_pool_ids(provider: 'Provider') -> tuple[int, ...]:
return res
_id_retriever: typing.Final[
collections.abc.Mapping[
TYPE_TO_ID_RETRIEVER: typing.Final[
dict[
type[Model],
collections.abc.Mapping[types.stats.CounterType, collections.abc.Callable[[typing.Any], typing.Any]],
dict[types.stats.CounterType, collections.abc.Callable[[typing.Any], typing.Any]],
]
] = {
Provider: {
@@ -102,8 +102,8 @@ _id_retriever: typing.Final[
},
}
_valid_model_for_counterype: typing.Final[
collections.abc.Mapping[types.stats.CounterType, tuple[type[Model], ...]]
VALID_MODEL_FOR_COUNTER_TYPE_DICT: typing.Final[
dict[types.stats.CounterType, tuple[type[Model], ...]]
] = {
types.stats.CounterType.LOAD: (Provider,),
types.stats.CounterType.STORAGE: (Service,),
@@ -115,7 +115,7 @@ _valid_model_for_counterype: typing.Final[
types.stats.CounterType.CACHED: (ServicePool,),
}
_obj_type_from_model: typing.Final[collections.abc.Mapping[type[Model], types.stats.CounterOwnerType]] = {
OBJ_TYPE_FROM_MODEL_DICT: typing.Final[dict[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_counterype.get(counter_type, ()): # pylint: disable
if type_ not in VALID_MODEL_FOR_COUNTER_TYPE_DICT.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[type(obj)], obj.id, counter_type, value, stamp
OBJ_TYPE_FROM_MODEL_DICT[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
"""
type_ = type(obj)
obj_type = type(obj)
read_fnc_tbl = _id_retriever.get(type_)
type_to_id_dct = TYPE_TO_ID_RETRIEVER.get(obj_type)
if not read_fnc_tbl:
logger.error('Type %s has no registered stats', type_)
if not type_to_id_dct:
logger.error('Type %s has no registered stats', obj_type)
return
fnc = read_fnc_tbl.get(counter_type)
id_retriever_fnc = type_to_id_dct.get(counter_type)
if not fnc:
logger.error('Type %s has no registerd stats of type %s', type_, counter_type)
if not id_retriever_fnc:
logger.error('Type %s has no registerd stats of type %s', obj_type, counter_type)
return
if not all:
owner_ids = fnc(obj) # pyright: ignore
owner_ids = id_retriever_fnc(obj) # pyright: ignore
else:
owner_ids = None
for i in StatsManager.manager().enumerate_counters(
_obj_type_from_model[type(obj)],
OBJ_TYPE_FROM_MODEL_DICT[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[type(infer_owner_type_from)]
owner_type = OBJ_TYPE_FROM_MODEL_DICT[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-2021 Virtual Cable S.L.U.
# Copyright (c) 2012-2025 Virtual Cable S.L.U.
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without modification,

View File

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

@@ -93,15 +93,32 @@ def validate_numeric(
def validate_hostname(
hostname: str, max_length: int = 64, domain_allowed: bool = False, field_name: typing.Optional[str] = None
hostname: str, max_length: int = 64, allow_domain: bool = False, field_name: typing.Optional[str] = None
) -> str:
"""
Validates that a hostname is valid
Args:
hostname (str): Hostname to validate
max_length (int, optional): Maximum length of the hostname. Defaults to 64.
domain_allowed (bool, optional): If True, allows domains in the hostname. Defaults to False.
field_name (typing.Optional[str], optional): If present, the name of the field for "Raising" exceptions.
If not present, the exception will be raised with the message "Invalid hostname". Defaults to None.
Returns:
str: The validated hostname
Raises:
exceptions.ValidationException: If value is not valid
"""
hostname = hostname.strip()
field_name = f' (On field {field_name})' if field_name else ''
if len(hostname) > max_length:
raise exceptions.ui.ValidationError(
_('{} is not a valid hostname: maximum host name length exceeded.').format(hostname + field_name)
)
if not domain_allowed:
if not allow_domain:
if '.' in hostname:
raise exceptions.ui.ValidationError(
_('{} is not a valid hostname: (domains not allowed)').format(hostname + field_name)
@@ -118,7 +135,7 @@ def validate_hostname(
def validate_fqdn(fqdn: str, max_length: int = 255, field_name: typing.Optional[str] = None) -> str:
return validate_hostname(fqdn, max_length, domain_allowed=True, field_name=field_name)
return validate_hostname(fqdn, max_length, allow_domain=True, field_name=field_name)
def validate_url(url: str, max_length: int = 1024, field_name: typing.Optional[str] = None) -> str:

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