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

300 Commits

Author SHA1 Message Date
Adolfo Gómez García
eb33d95280 Merge remote-tracking branch 'origin/v4.0' 2025-10-03 18:04:41 +02:00
Adolfo Gómez García
cd60b398a9 Fixed sample settings and added more info to log on case of SAML failure 2025-10-03 18:03:50 +02:00
Adolfo Gómez García
4e7f3ca096 Merge remote-tracking branch 'origin/v4.0' 2025-10-01 18:48:08 +02:00
Adolfo Gómez García
979f992b6d Update RDP and X2GO tunnel scripts and signatures with new configurations 2025-10-01 18:47:43 +02:00
Adolfo Gómez García
2a10f34a29 Update site display name from 'OpenUDS' to 'UDS' 2025-09-28 23:14:15 +02:00
Adolfo Gómez García
8c0f6deb96 Refactor test_migrate to remove network dependency for CI compatibility 2025-09-25 22:59:15 +02:00
Adolfo Gómez García
a201741c67 Remove network dependency from test_migrate to ensure CI compatibility 2025-09-25 22:57:08 +02:00
Adolfo Gómez García
48c146d0b1 Fix serialized data in STORAGE_DATA for consistency 2025-09-25 22:48:58 +02:00
Adolfo Gómez García
4f9acc7dc4 Update serialized data in STORAGE_DATA for consistency 2025-09-25 22:42:16 +02:00
Adolfo Gómez García
2b5a62445f Update test migration to remove localhost from IP list for GitHub automation compatibility 2025-09-25 22:34:04 +02:00
Adolfo Gómez García
67bd5fc067 Activate UTC timezone for tests and update MAC range in Proxmox fixtures 2025-09-25 22:14:01 +02:00
Adolfo Gómez García
b628cc2459 Validate MAC range on Proxmox provider configuration 2025-09-25 22:13:50 +02:00
Adolfo Gómez García
cbdacb0311 Enhance MAC range validation to ensure start is lower than end and greater than zero 2025-09-25 22:13:28 +02:00
Adolfo Gómez García
9dc25e96d4 Fix datetime import and ensure timezone awareness for NEVER constant 2025-09-25 22:13:18 +02:00
Adolfo Gómez García
449d59e27e Ensure timezone awareness for database datetime retrieval in TimeTrack class 2025-09-25 22:13:05 +02:00
Adolfo Gómez García
e38616f4d6 Translate the datetime correctly with the timezone. 2025-09-25 21:48:13 +02:00
Adolfo Gómez García
2c5ec79ea4 Fixing up tests after tzaware update 2025-09-25 19:36:47 +02:00
Adolfo Gómez García
4528dbac9a Merge remote-tracking branch 'origin/v4.0' 2025-09-25 19:35:07 +02:00
Adolfo Gómez García
83689dddaa Merge remote-tracking branch 'origin/dev/andres/v4.0' into v4.0 2025-09-25 19:19:19 +02:00
Adolfo Gómez García
fbf088d052 Update script and preload links in admin index.html with new version stamps and integrity hashes 2025-09-25 03:52:03 +02:00
Adolfo Gómez García
2fcefd50cc Ensure USE_TZ is set to True in settings.py.sample for consistent timezone handling 2025-09-25 00:17:27 +02:00
Adolfo Gómez
6fd6de8d31 Merge pull request #133 from Future998/django52support
Django 5.2 LTS support
2025-09-24 17:28:30 +02:00
Adolfo Gómez
b0cb36f93b Merge branch 'master' into django52support 2025-09-24 17:26:18 +02:00
Adolfo Gómez García
2530fd3afc Merge remote-tracking branch 'origin/v4.0' 2025-09-23 23:17:40 +02:00
Adolfo Gómez García
611b54eee0 Update comment for NULL_MAC constant to clarify its purpose 2025-09-22 17:48:40 +02:00
Adolfo Gómez García
dbbe153564 Rename MAC_UNKNOWN constant to NULL_MAC and update references throughout the codebase for consistency 2025-09-22 17:45:14 +02:00
aschumann-virtualcable
2fd157e463 Merge remote-tracking branch 'origin/v4.0' into dev/andres/v4.0 2025-09-22 17:36:09 +02:00
Adolfo Gómez García
e9f795b83b Merge remote-tracking branch 'origin/v4.0' 2025-09-22 17:36:09 +02:00
Alexander Burmatov
18dab9bd2c Fix broken day 2025-09-22 15:38:52 +03:00
Alexander Burmatov
e4c7e6e546 Use UTC timezome as a default
It also useful for GH Actions testing.
2025-09-22 15:37:52 +03:00
Alexander Burmatov
d5fc098193 Fix test jobs 2025-09-22 15:37:52 +03:00
Alexander Burmatov
3cc06433ba Disable unused transports 2025-09-22 15:37:52 +03:00
Alexander Burmatov
b01496728f Use aware datetime instead of naive datetime
Django 5.2+ usually uses aware datetime. It also is a best practice.
We can't compare offset-naive and offset-aware datetimes. And this
causes TypeError in Django 5.2.
2025-09-22 15:37:40 +03:00
Alexander Burmatov
f4a3fee375 Small default value fix 2025-09-22 15:37:40 +03:00
Alexander Burmatov
34e05e450d Set correct default value
As a rule memcached uses this address.
2025-09-22 15:37:40 +03:00
Alexander Burmatov
5cdeae711a Rename enterprise to open
Rename site name and change image.
2025-09-22 15:37:40 +03:00
Alexander Burmatov
889570f4b9 Using USE_TZ explicitly 2025-09-22 15:37:40 +03:00
Alexander Burmatov
d6229b7e31 Removing duplicate dependencies 2025-09-22 15:37:40 +03:00
Alexander Burmatov
98ae77b5d9 Adding missing dependencies 2025-09-22 15:37:28 +03:00
Alexander Burmatov
a57984b2aa Add GH action for testing 2025-09-22 15:37:28 +03:00
Adolfo Gómez García
9b6e578b79 Fix proxy handling in secure_requests_session to check for None instead of truthiness 2025-09-22 15:36:43 +03:00
Adolfo Gómez García
b2aed256bd Enhance TypeInfo and python_type_to_openapi to include metadata descriptions for improved clarity and documentation 2025-09-20 17:36:03 +02:00
Adolfo Gómez García
b0cbee2cd0 Refactor API response handling to remove 404 responses and streamline response generation for single items 2025-09-20 17:00:42 +02:00
Adolfo Gómez García
edd50bf92d Refactor REST API information handling to use typed attributes for better clarity and support for multiple types across various models. 2025-09-20 16:38:10 +02:00
Adolfo Gómez García
00fb79244a Add REST API information to various handlers and models
- Introduced REST_API_INFO class variable to Handler and various ModelHandler subclasses to provide metadata for auto-generated APIs.
- Updated api_helpers to utilize REST_API_INFO for dynamic naming and descriptions.
- Enhanced API response generation functions to include OData parameters and improved request body descriptions.
- Added checks in UserServiceManager to prevent actions on already removed services.
- Cleaned up code formatting and comments for better readability.
2025-09-20 16:17:18 +02:00
Adolfo Gómez García
4cedf057b3 Merge remote-tracking branch 'origin/v4.0' 2025-09-19 16:05:30 +02:00
aschumann-virtualcable
afbd4c5355 Fixes incorrect parameter usage for macOS RDP connections
Updates logic to use the correct macOS-specific custom parameters
instead of Linux parameters when generating RDP connection settings.
Adds type ignore comments to improve compatibility with type checkers
and prevent related runtime issues.
2025-09-19 13:54:36 +02:00
Adolfo Gómez García
85ade4b9fa Refactor UUID generation to prefer uuid7 if available and simplify generate_uuid function 2025-09-18 14:20:58 +02:00
aschumann-virtualcable
e4377b83e4 Corrects Mac RDP file usage and field mapping
Aligns Mac-specific RDP file logic to use the appropriate configuration and updates legacy field naming for better clarity and migration. Ensures Mac connections consistently respect intended custom parameter and file options, reducing potential confusion with Linux settings.
2025-09-18 13:59:48 +02:00
aschumann-virtualcable
6763de2bab Merge remote-tracking branch 'origin/v4.0' into dev/andres/v4.0 2025-09-17 12:35:22 +02:00
Adolfo Gómez García
dd1068f18d Updated admin interface 2025-09-16 18:46:41 +02:00
Adolfo Gómez García
51a0388ff2 Merge remote-tracking branch 'origin/v4.0' 2025-09-16 18:45:49 +02:00
aschumann-virtualcable
f494c706fc Updates RDP signature files for macOS with new parameters 2025-09-15 12:34:00 +02:00
aschumann-virtualcable
76b488dc1d Extends RDP custom parameter support for macOS clients
Unifies logic for applying custom RDP parameters to macOS alongside Windows and Linux, improving compatibility and flexibility for connecting from Apple platforms.

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

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

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

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

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

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

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

Updates the related signature file for integrity validation.
2025-09-12 11:24:31 +02:00
Adolfo Gómez García
455c19fa99 Merge remote-tracking branch 'origin/v4.0' 2025-09-11 17:56:04 +02:00
Adolfo Gómez García
c550e70937 Remove unnecessary import of DynamicUserService for type checking in Xen service 2025-09-11 17:16:33 +02:00
aschumann-virtualcable
e37b345aff Adds support for RDP file custom params on Linux
Enables the use of Windows custom parameters in RDP file generation when specified for Linux targets, aligning Linux behavior with Windows.

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

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

Renames and consolidates configuration for using RDP files with Thincast and xfreerdp, streamlines related logic, and enhances RDP file handling in Linux scripts. Improves clarity, maintainability, and user experience for Linux RDP connections.
2025-09-10 18:33:59 +02:00
Adolfo Gómez García
1b93fa8d51 Refactor variable name in blocker decorator for clarity 2025-09-09 19:14:39 +02:00
Adolfo Gómez García
8ec5170f28 Merge remote-tracking branch 'origin/v4.0' 2025-09-09 19:05:19 +02:00
aschumann-virtualcable
34676c817f Enhance Thincast support by updating RDPTransport to conditionally handle 'as_file' and improve logging in direct.py for better debugging. 2025-09-09 11:16:52 +02:00
aschumann-virtualcable
d17224c9cb Merge branch 'dev/andres/v4.0' of github.com:VirtualCable/openuds into dev/andres/v4.0 2025-09-09 10:43:02 +02:00
aschumann-virtualcable
b57b00f3fc Add lnx_thincast_rdp_file field to RDPTransport and BaseRDPTransport for Thincast support 2025-09-09 10:42:40 +02:00
aschumann-virtualcable
f82041da1e Add debug logging for Thincast RDP file processing and update signatures 2025-09-08 13:10:04 +02:00
aschumann-virtualcable
03a837f865 Add Thincast support and improve logging in RDP scripts 2025-09-08 13:08:12 +02:00
Adolfo Gómez García
7d092ca993 Remove ticket stamp update and save logic in invalidate case, replacing it with deletion of the ticket 2025-09-05 18:21:04 +02:00
Adolfo Gómez García
90890ef916 Prevent SSO credential conversion when for_notify is True 2025-09-05 17:50:33 +02:00
Adolfo Gómez García
5d64e37c85 Add for_notify parameter to transport connection methods for SSO differentiation 2025-09-05 17:14:16 +02:00
Adolfo Gómez García
4498e2d90e Add MAC Address column to ServersTokens API response 2025-09-05 17:00:38 +02:00
Adolfo Gómez García
5124a21096 Merge remote-tracking branch 'origin/v4.0' 2025-09-05 16:42:59 +02:00
Adolfo Gómez García
c3b1e8cbe8 Enhance RDP transport with SSO support and update ticket store owner field length 2025-09-05 01:23:15 +02:00
Adolfo Gómez García
6e719eaf78 Remove supports_sso property from UserService and add use_sso checkbox in BaseRDPTransport for SSO integration 2025-09-04 22:26:57 +02:00
Adolfo Gómez García
cf613fa0ab Merge remote-tracking branch 'origin/v4.0' 2025-09-04 22:10:42 +02:00
aschumann-virtualcable
95f0b0ab26 Update tunnel.py.signature with new signature data 2025-09-04 11:59:05 +02:00
aschumann-virtualcable
28433fc33e Add support for Thincast in RDP scripts and improve executable search logic 2025-09-04 11:55:41 +02:00
aschumann-virtualcable
fc4e7414df Update subproject commits for actor and client modules 2025-09-04 11:26:01 +02:00
aschumann-virtualcable
e61cb1f855 Add logging for client discovery in RDP scripts 2025-09-04 11:25:24 +02:00
Adolfo Gómez García
909991a963 Refactor and enhance codebase with various improvements
- Updated `pytest.ini` to ignore additional warnings: `PytestUnraisableExceptionWarning` and `ResourceWarning` for `sqlite3`.
- Added exception handling for `User.DoesNotExist` in `users_groups.py` to raise a `NotFound` exception.
- Modified pattern matching in `__init__.py` to improve clarity and correctness.
- Implemented equality method in `SchemaProperty` class in `api.py` for better comparison of schema properties.
- Enhanced type hints in `user_interface.py` for `as_choices` method to support more input types.
- Fixed key name from `row-style` to `row_style` in `test_users_groups_users.py`.
- Improved readability of assertions in `test_apigen.py` by formatting multi-line assertions.
- Deleted obsolete test file `test_helpdoc.py`.
- Updated imports in `test_gui.py` to include `types` for better type handling.
- Refactored query execution tests in `test_query_filter.py` to improve clarity and maintainability.
- Adjusted assertions in OpenStack helper tests to use object attributes instead of dictionary keys.
- Enhanced test assertions in `test_service_fixed.py` and `test_service_multi.py` for consistency.
- Updated Proxmox helper tests to use `ChoiceItem` type for better type checking.
- Added a new `conftest.py` file to manage database connections and garbage collection during tests.
2025-09-04 04:11:59 +02:00
Adolfo Gómez García
f091e40a2a Merge remote-tracking branch 'origin/v4.0' 2025-09-04 02:08:11 +02:00
Adolfo Gómez García
4322465040 Add support for SSO in UserService and integrate credential token conversion in RDP transport 2025-09-04 01:35:23 +02:00
Adolfo Gómez García
674b29afb9 Refactor choice field definitions to use gui.choice_item for consistency across UI components 2025-09-04 00:54:07 +02:00
Adolfo Gómez García
5ede89c56b Merge remote-tracking branch 'origin/v4.0' 2025-09-04 00:16:47 +02:00
Adolfo Gómez García
192c6ffdac Refactor ticket handling and error responses in REST API for improved clarity and consistency 2025-09-04 00:16:19 +02:00
Adolfo Gómez García
0dbc2d7c5b Add AES-256-GCM encryption method with Base64 encoding for JSON fields 2025-09-02 18:44:14 +02:00
Adolfo Gómez García
e08846b834 Refactor get_component_from_type to improve item schema retrieval and simplify managed object checks 2025-08-28 19:17:31 +02:00
Adolfo Gómez García
d3d97c4579 Refactor response handling in api_paths to include 404 response and update helper function name for clarity 2025-08-28 18:32:21 +02:00
Adolfo Gómez García
e5ac3cef9d Enhance OpenAPI schema generation to support dataclass references in python_type_to_openapi and api_components functions 2025-08-27 21:57:23 +02:00
Adolfo Gómez García
48c18f885f Refactor API response handling to use specific type names for improved clarity and consistency 2025-08-27 21:28:50 +02:00
Adolfo Gómez García
5242013cf1 Refactor choice item handling to use types.ui.ChoiceItem for consistency and improved readability 2025-08-27 19:30:03 +02:00
Adolfo Gómez García
15dd9cdffb Refactor GUI handling to use attribute access instead of dictionary keys for improved readability and consistency 2025-08-27 17:04:40 +02:00
Adolfo Gómez García
7a5f4883ee Add security parameter to api_paths methods and update related components 2025-08-27 00:52:27 +02:00
Adolfo Gómez García
b5ce128f23 Refactor API response handling by introducing gen_response utility and updating api_paths methods to utilize it 2025-08-27 00:14:14 +02:00
Adolfo Gómez García
fc352d9b7d Enhance API response handling and schema definitions in REST components 2025-08-27 00:04:00 +02:00
Adolfo Gómez García
3e2c38d5b1 Add securitySchemes support to Components and update tests accordingly 2025-08-26 20:28:50 +02:00
Adolfo Gómez García
8410894b2d Enhance API path handling by adding tags parameter to api_paths methods across various handlers 2025-08-26 19:54:13 +02:00
Adolfo Gómez García
ec6896952c Enhance API documentation and functionality in REST model handlers 2025-08-26 18:30:46 +02:00
Adolfo Gómez García
e0d1305e76 Refactor REST API components and handlers
- Updated Parameter class in api.py to use Schema type instead of dict.
- Enhanced OpenApiTypeInfo to include nullable and ref attributes.
- Improved python_type_to_openapi function to handle Union types more effectively.
- Added comprehensive path validation in TestApiGenBasic to ensure correct API structure.
- Introduced DetailHandler and ModelHandler classes for better management of RESTful details and models.
- Implemented api_paths helper function to streamline API path generation for model handlers.
- Added extensive documentation and logging for better maintainability and debugging.
2025-08-26 18:14:08 +02:00
Adolfo Gómez García
f987af05a1 Merge remote-tracking branch 'origin/v4.0' 2025-08-25 17:29:31 +02:00
Adolfo Gómez
54f254c6fb Fix the copyright holder name (i'ts one word :S) 2025-08-23 14:25:47 +02:00
Adolfo Gómez García
388e557ca5 Merge remote-tracking branch 'origin/v4.0' 2025-08-22 18:33:30 +02:00
aschumann-virtualcable
1fddc17b75 initial dev enviroment 2025-08-21 18:04:11 +02:00
Adolfo Gómez García
6fddebd08a Update subproject commits for actor and client modules 2025-08-21 16:35:24 +02:00
Adolfo Gómez García
9b247daada Refactor UserInterface: introduce VALID_CONVERSIONS for field type validation and enhance logging for type mismatches in form fields. 2025-08-21 16:35:00 +02:00
Adolfo Gómez García
152683c9f1 Merge remote-tracking branch 'origin/v4.0' 2025-08-19 19:21:21 +02:00
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
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
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
2c11d29974 Merge remote-tracking branch 'origin/v4.0' 2025-08-07 05:37:46 +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
738081bf7d Merge remote-tracking branch 'origin/v4.0' 2025-07-31 16:55:40 +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
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
21f156a930 Merge remote-tracking branch 'origin/v4.0' 2025-07-21 21:58:31 +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
fc964ab96d Merge remote-tracking branch 'origin/v4.0' 2025-07-17 03:30:29 +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
fb7be05ea9 Merge remote-tracking branch 'origin/v4.0' 2025-06-22 04:07:11 +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
474302174b Merge remote-tracking branch 'origin/v4.0' 2025-06-12 18:33:10 +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
225 changed files with 10667 additions and 5097 deletions

63
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,63 @@
name: Test OpenUDS
on:
push:
branches:
- '**'
pull_request:
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: server
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libsasl2-dev \
python3-dev \
libldap2-dev \
libssl-dev \
libmemcached-dev \
zlib1g-dev \
gcc
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Set PYTHONPATH
run: echo "PYTHONPATH=$PWD/src" >> $GITHUB_ENV
- name: Copy Django settings
run: cp src/server/settings.py.sample src/server/settings.py
- name: Generate RSA key and set as environment variable
run: |
openssl genrsa 2048 > private.pem
RSA_KEY=$(awk 'NF {sub(/\r/, ""); printf "%s\\n",$0;}' private.pem)
echo "RSA_KEY=$RSA_KEY" >> $GITHUB_ENV
- name: Patch settings.py with generated RSA key
run: |
sed -i "s|^RSA_KEY = .*|RSA_KEY = '''$RSA_KEY'''|" src/server/settings.py
- name: Create log directory
run: mkdir -p src/log
- name: Run tests with pytest
run: python3 -m pytest

View File

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

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: 3c40cb45f0...1b723fc3b4

2
client

Submodule client updated: 5b044bca34...517f8935a2

28
server/conftest.py Normal file
View File

@@ -0,0 +1,28 @@
import pytest
import gc
from django.db import connections
@pytest.fixture(autouse=True)
def close_all_db_connections():
yield
for conn in connections.all():
try:
conn.close()
except Exception:
pass
def pytest_sessionfinish(session: pytest.Session, exitstatus: pytest.ExitCode) -> None:
"""Al final de toda la suite, cerrar conexiones y forzar GC."""
try:
from django.db import connections
for conn in connections.all():
try:
conn.close()
except Exception:
pass
except ImportError:
pass
gc.collect()

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,11 @@ python_classes =
filterwarnings =
error
ignore:The --rsyncdir command line argument and rsyncdirs config variable are deprecated.:DeprecationWarning
ignore::matplotlib._api.deprecation.MatplotlibDeprecationWarning:pydev
ignore::matplotlib._api.deprecation.MatplotlibDeprecationWarning:pydev
ignore::pytest.PytestUnraisableExceptionWarning
ignore::ResourceWarning:sqlite3
log_format = "%(asctime)s %(levelname)s %(message)s"
log_date_format = "%Y-%m-%d %H:%M:%S"
log_cli = true
log_level = debug

View File

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

View File

@@ -32,7 +32,6 @@
Author: Adolfo Gómez, dkmaster at dkmon dot com
'''
import typing
import collections.abc
import asyncio
import aiohttp
import enum
@@ -151,8 +150,6 @@ async def main():
if options.params is not None:
options.params = json.loads(options.params)
REST_URL = options.url
async with aiohttp.ClientSession() as session:
# request_pools() # Not logged in, this will generate an error
await login(session, options.auth, options.username, options.password)

View File

@@ -14,6 +14,7 @@ BASE_DIR = '/'.join(
) # If used 'relpath' instead of abspath, returns path of "enterprise" instead of "openuds"
DEBUG = True
PROFILING = False
# USE_X_FORWARDED_HOST = True
SECURE_PROXY_SSL_HEADER = (
@@ -32,7 +33,7 @@ DATABASES = {
},
'NAME': 'dbuds', # Or path to database file if using sqlite3.
'USER': 'dbuds', # Not used with sqlite3.
'PASSWORD': 'PASSWOR', # Not used with sqlite3.
'PASSWORD': 'PASSWORD', # Not used with sqlite3.
'HOST': 'localhost', # Set to empty string for localhost. Not used with sqlite3.
'PORT': '3306', # Set to empty string for default. Not used with sqlite3.
# 'CONN_MAX_AGE': 600, # Enable DB Pooling, 10 minutes max connection duration
@@ -59,7 +60,8 @@ DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
# system time zone.
# TIME_SECTION_START
TIME_ZONE = 'Europe/Madrid'
USE_TZ = True
TIME_ZONE = 'UTC'
# TIME_SECTION_END
# Override for gettext so we can use the same syntax as in django
@@ -97,6 +99,8 @@ USE_I18N = True
# calendars according to the current locale
USE_L10N = True
USE_TZ = True
# Absolute filesystem path to the directory that will hold user-uploaded files.
# Example: "/home/media/media.lawrence.com/media/"
MEDIA_ROOT = ''
@@ -150,7 +154,7 @@ CACHES = {
# }
'memory': {
'BACKEND': 'django.core.cache.backends.memcached.PyLibMCCache',
'LOCATION': 'db.dkmon.com:11211',
'LOCATION': '127.0.0.1:11211',
},
}
@@ -194,7 +198,7 @@ SECURE_CIPHERS = (
':ECDHE-ECDSA-CHACHA20-POLY1305'
)
# Min TLS version
SECURE_MIN_TLS_VERSION = '1.2'
# SECURE_MIN_TLS_VERSION = '1.2'
# LDAP CIFHER SUITE can be enforced here. Use GNU TLS cipher suite names in this case
# Debian libldap uses gnutls, and it's my development environment. Continue reading for more info:

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

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

View File

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

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,27 +752,49 @@ class Ticket(ActorV3Action):
Gets an stored ticket
"""
name = 'ticket'
NAME = 'ticket'
def action(self) -> dict[str, typing.Any]:
logger.debug('Args: %s, Params: %s', self._args, self._params)
try:
# Simple check that token exists
Server.objects.get(
token=self._params['token'], type=types.servers.ServerType.ACTOR
) # Not assigned, because only needs check
except Server.DoesNotExist:
raise exceptions.rest.BlockAccess() from None # If too many blocks...
if len(self._args) > 1:
raise exceptions.rest.RequestError('Invalid request')
kind = self._args[0] if len(self._args) == 1 else 'server'
try:
return ActorV3Action.actor_result(TicketStore.get(self._params['ticket'], invalidate=True))
match kind:
case 'server':
# Server tickets are simple applicaitons with parameters
# Enough secure this way (no onwer)
try:
# Simple check that token exists
Server.objects.get(
token=self._params['token'], type=types.servers.ServerType.ACTOR
) # Not assigned, because only needs check
except Server.DoesNotExist:
raise exceptions.rest.BlockAccess() from None # If too many blocks...
return ActorV3Action.actor_result(TicketStore.get(self._params['ticket'], invalidate=True))
case 'userservice':
# Userservice also has owner, to increase security
self.get_userservice() # We just want to check that is valid
return ActorV3Action.actor_result(
TicketStore.get(
uuid=self._params['ticket'], owner=self._params['token'], invalidate=True
)
)
case _:
raise exceptions.rest.RequestError('Invalid request')
except TicketStore.DoesNotExist:
return ActorV3Action.actor_result(error='Invalid ticket')
class Unmanaged(ActorV3Action):
name = 'unmanaged'
NAME = 'unmanaged'
def action(self) -> dict[str, typing.Any]:
"""
@@ -869,7 +880,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 +889,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 +898,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,89 @@ from uds.REST.model import ModelHandler
from .users_groups import Groups, Users
# Not imported at runtime, just for type checking
if typing.TYPE_CHECKING:
from django.db.models import Model
from uds.core.module import Module
from uds.core.module import Module
logger = logging.getLogger(__name__)
@dataclasses.dataclass
class AuthenticatorTypeInfo(types.rest.ExtraTypeInfo):
search_users_supported: bool
search_groups_supported: bool
needs_password: bool
label_username: str
label_groupname: str
label_password: str
create_users_supported: bool
is_external: bool
mfa_data_enabled: bool
mfa_supported: bool
def as_dict(self) -> dict[str, typing.Any]:
return dataclasses.asdict(self)
@dataclasses.dataclass
class AuthenticatorItem(types.rest.ManagedObjectItem[Authenticator]):
numeric_id: int
id: str
name: str
priority: int
tags: list[str]
comments: str
net_filtering: str
networks: list[str]
state: str
mfa_id: str
small_name: str
users_count: int
permission: int
type_info: types.rest.TypeInfo | None
# Enclosed methods under /auth path
class Authenticators(ModelHandler):
model = Authenticator
class Authenticators(ModelHandler[AuthenticatorItem]):
ITEM_TYPE = AuthenticatorItem
MODEL = Authenticator
# Custom get method "search" that requires authenticator id
custom_methods = [('search', True)]
detail = {'users': Users, 'groups': Groups}
save_fields = ['name', 'comments', 'tags', 'priority', 'small_name', 'mfa_id:_', 'state']
CUSTOM_METHODS = [types.rest.ModelCustomMethod('search', True)]
DETAIL = {'users': Users, 'groups': Groups}
FIELDS_TO_SAVE = ['name', 'comments', 'tags', 'priority', 'small_name', 'mfa_id:_', 'state']
table_title = _('Authenticators')
table_fields = [
{'numeric_id': {'title': _('Id'), 'visible': True}},
{'name': {'title': _('Name'), 'visible': True, 'type': 'iconType'}},
{'type_name': {'title': _('Type')}},
{'comments': {'title': _('Comments')}},
{'priority': {'title': _('Priority'), 'type': 'numeric', 'width': '5rem'}},
{'small_name': {'title': _('Label')}},
{'users_count': {'title': _('Users'), 'type': 'numeric', 'width': '1rem'}},
{
'mfa_name': {
'title': _('MFA'),
}
},
{'tags': {'title': _('tags'), 'visible': False}},
]
TABLE = (
ui_utils.TableBuilder(_('Authenticators'))
.numeric_column(name='numeric_id', title=_('Id'), visible=True, width='1rem')
.icon(name='name', title=_('Name'), visible=True)
.text_column(name='type_name', title=_('Type'))
.text_column(name='comments', title=_('Comments'))
.numeric_column(name='priority', title=_('Priority'), width='5rem')
.text_column(name='small_name', title=_('Label'))
.numeric_column(name='users_count', title=_('Users'), width='1rem')
.text_column(name='mfa_name', title=_('MFA'))
.text_column(name='tags', title=_('tags'), visible=False)
.row_style(prefix='row-state-', field='state')
.build()
)
def enum_types(self) -> collections.abc.Iterable[type[auths.Authenticator]]:
# Rest api related information to complete the auto-generated API
REST_API_INFO = types.rest.api.RestApiInfo(
typed=types.rest.api.RestApiInfoGuiType.MULTIPLE_TYPES,
)
@classmethod
def possible_types(cls: type[typing.Self]) -> collections.abc.Iterable[type[auths.Authenticator]]:
return auths.factory().providers().values()
def type_info(self, type_: type['Module']) -> typing.Optional[types.rest.AuthenticatorTypeInfo]:
@classmethod
def extra_type_info(
cls: type[typing.Self], type_: type['Module']
) -> typing.Optional[AuthenticatorTypeInfo]:
if issubclass(type_, auths.Authenticator):
return types.rest.AuthenticatorTypeInfo(
return AuthenticatorTypeInfo(
search_users_supported=type_.search_users != auths.Authenticator.search_users,
search_groups_supported=type_.search_groups != auths.Authenticator.search_groups,
needs_password=type_.needs_password,
@@ -98,95 +143,82 @@ class Authenticators(ModelHandler):
create_users_supported=type_.create_user != auths.Authenticator.create_user,
is_external=type_.external_source,
mfa_data_enabled=type_.mfa_data_enabled,
mfa_supported=type_.provides_mfa(),
mfa_supported=type_.provides_mfa_identifier(),
)
# Not of my type
return None
def get_gui(self, type_: str) -> list[typing.Any]:
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
try:
auth_type = auths.factory().lookup(type_)
auth_type = auths.factory().lookup(for_type)
if auth_type:
# Create a new instance of the authenticator to access to its GUI
with Environment.temporary_environment() as env:
auth_instance = auth_type(env, None)
field = self.add_default_fields(
auth_instance.gui_description(),
['name', 'comments', 'tags', 'priority', 'small_name', 'networks'],
)
self.add_field(
field,
{
'name': 'state',
'value': consts.auth.VISIBLE,
'choices': [
{'id': consts.auth.VISIBLE, 'text': _('Visible')},
{'id': consts.auth.HIDDEN, 'text': _('Hidden')},
{'id': consts.auth.DISABLED, 'text': _('Disabled')},
],
'label': gettext('Access'),
'tooltip': gettext(
'Access type for this transport. Disabled means not only hidden, but also not usable as login method.'
),
'type': types.ui.FieldType.CHOICE,
'order': 107,
'tab': gettext('Display'),
},
)
# If supports mfa, add MFA provider selector field
if auth_type.provides_mfa():
self.add_field(
field,
{
'name': 'mfa_id',
'choices': [gui.choice_item('', str(_('None')))]
+ gui.sorted_choices(
[gui.choice_item(v.uuid, v.name) for v in MFA.objects.all()]
),
'label': gettext('MFA Provider'),
'tooltip': gettext('MFA provider to use for this authenticator'),
'type': types.ui.FieldType.CHOICE,
'order': 108,
'tab': types.ui.Tab.MFA,
},
auth_instance = auth_type(env, None)
gui = (
(
ui_utils.GuiBuilder()
.set_order(100)
.add_stock_field(types.rest.stock.StockField.PRIORITY)
.add_stock_field(types.rest.stock.StockField.NAME)
.add_stock_field(types.rest.stock.StockField.LABEL)
.add_stock_field(types.rest.stock.StockField.NETWORKS)
.add_stock_field(types.rest.stock.StockField.COMMENTS)
.add_stock_field(types.rest.stock.StockField.TAGS)
)
return field
.add_fields(auth_instance.gui_description())
.add_choice(
name='state',
default=consts.auth.VISIBLE,
choices=[
ui.gui.choice_item(consts.auth.VISIBLE, _('Visible')),
ui.gui.choice_item(consts.auth.HIDDEN, _('Hidden')),
ui.gui.choice_item(consts.auth.DISABLED, _('Disabled')),
],
label=gettext('Access'),
)
)
if auth_type.provides_mfa_identifier():
gui.add_choice(
name='mfa_id',
label=gettext('MFA Provider'),
choices=[ui.gui.choice_item('', str(_('None')))]
+ ui.gui.sorted_choices(
[ui.gui.choice_item(v.uuid, v.name) for v in MFA.objects.all()]
),
)
return gui.build()
raise Exception() # Not found
except Exception as e:
logger.info('Type not found: %s', e)
raise exceptions.rest.NotFound('type not found') from e
def item_as_dict(self, item: 'Model') -> dict[str, typing.Any]:
summary = 'summarize' in self._params
logger.info('Authenticator type not found: %s', e)
raise exceptions.rest.NotFound('Authenticator type not found') from e
def get_item(self, item: 'models.Model') -> AuthenticatorItem:
item = ensure.is_instance(item, Authenticator)
v: dict[str, typing.Any] = {
'numeric_id': item.id,
'id': item.uuid,
'name': item.name,
'priority': item.priority,
}
if not summary:
type_ = item.get_type()
v.update(
{
'tags': [tag.tag for tag in typing.cast(collections.abc.Iterable[Tag], item.tags.all())],
'comments': item.comments,
'net_filtering': item.net_filtering,
'networks': [n.uuid for n in item.networks.all()],
'state': item.state,
'mfa_id': item.mfa.uuid if item.mfa else '',
'small_name': item.small_name,
'users_count': item.users.count(),
'type': type_.mod_type(),
'type_name': type_.mod_name(),
'type_info': self.type_as_dict(type_),
'permission': permissions.effective_permissions(self._user, item),
}
)
return v
def post_save(self, item: 'Model') -> None:
return AuthenticatorItem(
numeric_id=item.id,
id=item.uuid,
name=item.name,
priority=item.priority,
tags=[tag.tag for tag in typing.cast(collections.abc.Iterable[Tag], item.tags.all())],
comments=item.comments,
net_filtering=item.net_filtering,
networks=[n.uuid for n in item.networks.all()],
state=item.state,
mfa_id=item.mfa.uuid if item.mfa else '',
small_name=item.small_name,
users_count=item.users.count(),
permission=permissions.effective_permissions(self._user, item),
item=item,
type_info=type(self).as_typeinfo(item.get_type()),
)
def post_save(self, item: 'models.Model') -> None:
item = ensure.is_instance(item, Authenticator)
try:
networks = self._params['networks']
@@ -199,13 +231,17 @@ class Authenticators(ModelHandler):
item.networks.set(Network.objects.filter(uuid__in=networks))
# Custom "search" method
def search(self, item: 'Model') -> list[types.rest.ItemDictType]:
def search(self, item: 'models.Model') -> list[types.auth.SearchResultItem.ItemDict]:
"""
API:
Search for users or groups in this authenticator
"""
item = ensure.is_instance(item, Authenticator)
self.ensure_has_access(item, types.permissions.PermissionType.READ)
self.check_access(item, types.permissions.PermissionType.READ)
try:
type_ = self._params['type']
if type_ not in ('user', 'group'):
raise self.invalid_request_response()
raise exceptions.rest.RequestError(_('Invalid type: {}').format(type_))
term = self._params['term']
@@ -227,7 +263,7 @@ class Authenticators(ModelHandler):
)
)
if search_supported is False:
raise self.not_supported_response()
raise exceptions.rest.NotSupportedError(_('Search not supported'))
if type_ == 'user':
iterable = auth.search_users(term)
@@ -237,13 +273,15 @@ class Authenticators(ModelHandler):
return [i.as_dict() for i in itertools.islice(iterable, limit)]
except Exception as e:
logger.exception('Too many results: %s', e)
return [{'id': _('Too many results...'), 'name': _('Refine your query')}]
return [
types.auth.SearchResultItem(id=_('Too many results...'), name=_('Refine your query')).as_dict()
]
# self.invalidResponseException('{}'.format(e))
def test(self, type_: str) -> typing.Any:
auth_type = auths.factory().lookup(type_)
if not auth_type:
raise self.invalid_request_response(f'Invalid type: {type_}')
raise exceptions.rest.RequestError(_('Invalid type: {}').format(type_))
dct = self._params.copy()
dct['_request'] = self._request
@@ -270,11 +308,9 @@ class Authenticators(ModelHandler):
fields['small_name'] = fields['small_name'].strip().replace(' ', '_')
# And ensure small_name chars are valid [a-zA-Z0-9:-]+
if fields['small_name'] and not re.match(r'^[a-zA-Z0-9:.-]+$', fields['small_name']):
raise self.invalid_request_response(
_('Label must contain only letters, numbers, or symbols: - : .')
)
raise exceptions.rest.RequestError(_('Label must contain only letters, numbers, or symbols: - : .'))
def delete_item(self, item: 'Model') -> None:
def delete_item(self, item: 'models.Model') -> None:
# For every user, remove assigned services (mark them for removal)
item = ensure.is_instance(item, Authenticator)

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

View File

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

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(
@@ -130,7 +130,7 @@ class Client(Handler):
try:
data: dict[str, typing.Any] = TicketStore.get(ticket)
except TicketStore.InvalidTicket:
except TicketStore.DoesNotExist:
return Client.result(error=types.errors.Error.ACCESS_DENIED)
self._request.user = User.objects.get(uuid=data['user'])
@@ -224,7 +224,7 @@ class Client(Handler):
ticket, command = self._args[:2]
try:
data: dict[str, typing.Any] = TicketStore.get(ticket)
except TicketStore.InvalidTicket:
except TicketStore.DoesNotExist:
return Client.result(error=types.errors.Error.ACCESS_DENIED)
self._request.user = User.objects.get(uuid=data['user'])
@@ -282,7 +282,7 @@ class Client(Handler):
}
)
return match(
return match_args(
self._args,
_error, # In case of error, raises RequestError
((), _noargs), # No args, return version

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

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

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

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,40 @@ class MetaPools(ModelHandler):
'transport_grouping',
]
table_title = _('Meta Pools')
table_fields = [
{'name': {'title': _('Name')}},
{'comments': {'title': _('Comments')}},
{
'policy': {
'title': _('Policy'),
'type': 'dict',
'dict': dict(types.pools.LoadBalancingPolicy.enumerate()),
}
},
{
'ha_policy': {
'title': _('HA Policy'),
'type': 'dict',
'dict': dict(types.pools.HighAvailabilityPolicy.enumerate()),
}
},
{'user_services_count': {'title': _('User services'), 'type': 'number'}},
{'user_services_in_preparation': {'title': _('In Preparation')}},
{'visible': {'title': _('Visible'), 'type': 'callback'}},
{'pool_group_name': {'title': _('Pool Group')}},
{'label': {'title': _('Label')}},
{'tags': {'title': _('tags'), 'visible': False}},
TABLE = (
ui_utils.TableBuilder(_('Meta Pools'))
.text_column(name='name', title=_('Name'))
.text_column(name='comments', title=_('Comments'))
.dict_column(
name='policy',
title=_('Policy'),
dct=dict(types.pools.LoadBalancingPolicy.enumerate()),
)
.dict_column(
name='ha_policy',
title=_('HA Policy'),
dct=dict(types.pools.HighAvailabilityPolicy.enumerate()),
)
.numeric_column(name='user_services_count', title=_('User services'))
.numeric_column(name='user_services_in_preparation', title=_('In Preparation'))
.boolean(name='visible', title=_('Visible'))
.text_column(name='pool_group_name', title=_('Pool Group'), width='16em')
.text_column(name='short_name', title=_('Label'))
.text_column(name='tags', title=_('tags'), visible=False)
.build()
)
CUSTOM_METHODS = [
types.rest.ModelCustomMethod('set_fallback_access', True),
types.rest.ModelCustomMethod('get_fallback_access', True),
]
custom_methods = [('setFallbackAccess', True), ('getFallbackAccess', True)]
# Rest api related information to complete the auto-generated API
REST_API_INFO = types.rest.api.RestApiInfo(
typed=types.rest.api.RestApiInfoGuiType.SINGLE_TYPE,
)
def item_as_dict(self, item: 'Model') -> dict[str, typing.Any]:
def get_item(self, item: 'models.Model') -> MetaPoolItem:
item = ensure.is_instance(item, MetaPool)
# if item does not have an associated service, hide it (the case, for example, for a removed service)
# Access from dict will raise an exception, and item will be skipped
@@ -131,126 +158,93 @@ class MetaPools(ModelHandler):
(i.pool.userServices.filter(state=State.PREPARING).count()) for i in all_pools
)
val = {
'id': item.uuid,
'name': item.name,
'short_name': item.short_name,
'tags': [tag.tag for tag in item.tags.all()],
'comments': item.comments,
'thumb': item.image.thumb64 if item.image is not None else DEFAULT_THUMB_BASE64,
'image_id': item.image.uuid if item.image is not None else None,
'servicesPoolGroup_id': pool_group_id,
'pool_group_name': pool_group_name,
'pool_group_thumb': pool_group_thumb,
'user_services_count': userservices_total,
'user_services_in_preparation': userservices_in_preparation,
'visible': item.visible,
'policy': str(item.policy),
'fallbackAccess': item.fallbackAccess,
'permission': permissions.effective_permissions(self._user, item),
'calendar_message': item.calendar_message,
'transport_grouping': item.transport_grouping,
'ha_policy': str(item.ha_policy),
}
return val
return MetaPoolItem(
id=item.uuid,
name=item.name,
short_name=item.short_name,
tags=[tag.tag for tag in item.tags.all()],
comments=item.comments,
thumb=item.image.thumb64 if item.image is not None else DEFAULT_THUMB_BASE64,
image_id=item.image.uuid if item.image is not None else None,
servicesPoolGroup_id=pool_group_id,
pool_group_name=pool_group_name,
pool_group_thumb=pool_group_thumb,
user_services_count=userservices_total,
user_services_in_preparation=userservices_in_preparation,
visible=item.visible,
policy=str(item.policy),
fallbackAccess=item.fallbackAccess,
permission=permissions.effective_permissions(self._user, item),
calendar_message=item.calendar_message,
transport_grouping=item.transport_grouping,
ha_policy=str(item.ha_policy),
)
# Gui related
def get_gui(self, type_: str) -> list[typing.Any]:
local_gui = self.add_default_fields([], ['name', 'comments', 'tags'])
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
for field in [
{
'name': 'short_name',
'type': 'text',
'label': _('Short name'),
'tooltip': _('Short name for user service visualization'),
'required': False,
'length': 32,
'order': 0 - 95,
},
{
'name': 'policy',
'choices': [gui.choice_item(k, str(v)) for k, v in types.pools.LoadBalancingPolicy.enumerate()],
'label': gettext('Load balancing policy'),
'tooltip': gettext('Service pool load balancing policy'),
'type': types.ui.FieldType.CHOICE,
'order': 100,
},
{
'name': 'ha_policy',
'choices': [
gui.choice_item(k, str(v)) for k, v in types.pools.HighAvailabilityPolicy.enumerate()
return (
ui_utils.GuiBuilder()
.add_stock_field(types.rest.stock.StockField.TAGS)
.add_text(
name='short_name',
label=gettext('Short name'),
tooltip=gettext('Short name for user service visualization'),
length=32,
)
.add_stock_field(types.rest.stock.StockField.NAME)
.add_stock_field(types.rest.stock.StockField.COMMENTS)
.set_order(100)
.add_multichoice(
name='policy',
label=gettext('Load balancing policy'),
choices=[ui.gui.choice_item(k, str(v)) for k, v in types.pools.LoadBalancingPolicy.enumerate()],
tooltip=gettext('Service pool load balancing policy'),
)
.add_choice(
name='ha_policy',
label=gettext('HA Policy'),
choices=[
ui.gui.choice_item(k, str(v)) for k, v in types.pools.HighAvailabilityPolicy.enumerate()
],
'label': gettext('HA Policy'),
'tooltip': gettext(
'Service pool High Availability policy. If enabled and a pool fails, it will be restarted in another pool. Enable with care!.'
tooltip=gettext(
'Service pool High Availability policy. If enabled and a pool fails, it will be restarted in another pool. Enable with care!'
),
'type': types.ui.FieldType.CHOICE,
'order': 101,
},
{
'name': 'image_id',
'choices': [gui.choice_image(-1, '--------', DEFAULT_THUMB_BASE64)]
+ gui.sorted_choices(
[gui.choice_image(v.uuid, v.name, v.thumb64) for v in Image.objects.all()]
),
'label': gettext('Associated Image'),
'tooltip': gettext('Image assocciated with this service'),
'type': types.ui.FieldType.IMAGECHOICE,
'order': 120,
'tab': types.ui.Tab.DISPLAY,
},
{
'name': 'servicesPoolGroup_id',
'choices': [gui.choice_image(-1, _('Default'), DEFAULT_THUMB_BASE64)]
+ gui.sorted_choices(
[
gui.choice_image(v.uuid, v.name, v.thumb64)
for v in ServicePoolGroup.objects.all()
]
),
'label': gettext('Pool group'),
'tooltip': gettext('Pool group for this pool (for pool classify on display)'),
'type': types.ui.FieldType.IMAGECHOICE,
'order': 121,
'tab': types.ui.Tab.DISPLAY,
},
{
'name': 'visible',
'value': True,
'label': gettext('Visible'),
'tooltip': gettext('If active, metapool will be visible for users'),
'type': types.ui.FieldType.CHECKBOX,
'order': 123,
'tab': types.ui.Tab.DISPLAY,
},
{
'name': 'calendar_message',
'value': '',
'label': gettext('Calendar access denied text'),
'tooltip': gettext(
'Custom message to be shown to users if access is limited by calendar rules.'
),
'type': types.ui.FieldType.TEXT,
'order': 124,
'tab': types.ui.Tab.DISPLAY,
},
{
'name': 'transport_grouping',
'choices': [
gui.choice_item(k, str(v)) for k, v in types.pools.TransportSelectionPolicy.enumerate()
)
.new_tab(types.ui.Tab.DISPLAY)
.add_image_choice()
.add_image_choice(
name='servicesPoolGroup_id',
label=gettext('Pool group'),
choices=[
ui.gui.choice_image(
x.uuid, x.name, x.image.thumb64 if x.image is not None else DEFAULT_THUMB_BASE64
)
for x in ServicePoolGroup.objects.all()
],
'label': gettext('Transport Selection'),
'tooltip': gettext('Transport selection policy'),
'type': types.ui.FieldType.CHOICE,
'order': 125,
'tab': types.ui.Tab.DISPLAY,
},
]:
self.add_field(local_gui, field)
return local_gui
tooltip=gettext('Pool group for this pool (for pool classify on display)'),
)
.add_checkbox(
name='visible',
label=gettext('Visible'),
tooltip=gettext('If active, metapool will be visible for users'),
default=True,
)
.add_text(
name='calendar_message',
label=gettext('Calendar access denied text'),
tooltip=gettext('Custom message to be shown to users if access is limited by calendar rules.'),
)
.add_choice(
name='transport_grouping', # Transport Selection
label=gettext('Transport Selection'),
choices=[
ui.gui.choice_item(k, str(v)) for k, v in types.pools.TransportSelectionPolicy.enumerate()
],
tooltip=gettext('Transport selection policy'),
)
.build()
)
def pre_save(self, fields: dict[str, typing.Any]) -> None:
# logger.debug(self._params)
@@ -284,13 +278,17 @@ class MetaPools(ModelHandler):
logger.debug('Fields: %s', fields)
def delete_item(self, item: 'Model') -> None:
def delete_item(self, item: 'models.Model') -> None:
item = ensure.is_instance(item, MetaPool)
item.delete()
# Set fallback status
def set_fallback_access(self, item: MetaPool) -> typing.Any:
self.ensure_has_access(item, types.permissions.PermissionType.MANAGEMENT)
"""
API:
Sets the fallback access for a metapool
"""
self.check_access(item, types.permissions.PermissionType.MANAGEMENT)
fallback = self._params.get('fallbackAccess', 'ALLOW')
logger.debug('Setting fallback of %s to %s', item.name, fallback)

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

View File

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

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,69 @@ class Notifiers(ModelHandler):
'enabled',
]
table_title = _('Notifiers')
table_fields = [
{'name': {'title': _('Name'), 'visible': True, 'type': 'iconType'}},
{'type_name': {'title': _('Type')}},
{'level': {'title': _('Level')}},
{'enabled': {'title': _('Enabled')}},
{'comments': {'title': _('Comments')}},
{'tags': {'title': _('tags'), 'visible': False}},
]
TABLE = (
ui_utils.TableBuilder(_('Notifiers'))
.icon(name='name', title=_('Name'))
.text_column(name='type_name', title=_('Type'))
.text_column(name='level', title=_('Level'))
.boolean(name='enabled', title=_('Enabled'))
.text_column(name='comments', title=_('Comments'))
.text_column(name='tags', title=_('Tags'), visible=False)
).build()
def enum_types(self) -> collections.abc.Iterable[type[messaging.Notifier]]:
# Rest api related information to complete the auto-generated API
REST_API_INFO = types.rest.api.RestApiInfo(
typed=types.rest.api.RestApiInfoGuiType.MULTIPLE_TYPES,
)
@classmethod
def possible_types(cls: type[typing.Self]) -> collections.abc.Iterable[type[messaging.Notifier]]:
return messaging.factory().providers().values()
def get_gui(self, type_: str) -> list[typing.Any]:
notifier_type = messaging.factory().lookup(type_)
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
notifier_type = messaging.factory().lookup(for_type)
if not notifier_type:
raise self.invalid_item_response()
raise exceptions.rest.NotFound(_('Notifier type not found: {}').format(for_type))
with Environment.temporary_environment() as env:
notifier = notifier_type(env, None)
local_gui = self.add_default_fields(
notifier.gui_description(), ['name', 'comments', 'tags']
return (
(
ui_utils.GuiBuilder(100)
.add_stock_field(types.rest.stock.StockField.NAME)
.add_stock_field(types.rest.stock.StockField.COMMENTS)
.add_stock_field(types.rest.stock.StockField.TAGS)
)
.add_fields(notifier.gui_description())
.add_choice(
name='level',
choices=[gui.choice_item(i[0], i[1]) for i in LogLevel.interesting()],
label=gettext('Level'),
tooltip=gettext('Level of notifications'),
default=str(LogLevel.ERROR.value),
)
.add_checkbox(
name='enabled',
label=gettext('Enabled'),
tooltip=gettext('If checked, this notifier will be used'),
default=True,
)
.build()
)
for field in [
{
'name': 'level',
'choices': [gui.choice_item(i[0], i[1]) for i in LogLevel.interesting()],
'label': gettext('Level'),
'tooltip': gettext('Level of notifications'),
'type': types.ui.FieldType.CHOICE,
'order': 102,
'default': str(LogLevel.ERROR.value),
},
{
'name': 'enabled',
'label': gettext('Enabled'),
'tooltip': gettext('If checked, this notifier will be used'),
'type': types.ui.FieldType.CHECKBOX,
'order': 103,
'default': True,
}
]:
self.add_field(local_gui, field)
return local_gui
def item_as_dict(self, item: 'Model') -> dict[str, typing.Any]:
def get_item(self, item: 'Model') -> NotifierItem:
item = ensure.is_instance(item, Notifier)
type_ = item.get_type()
return {
'id': item.uuid,
'name': item.name,
'level': str(item.level),
'enabled': item.enabled,
'tags': [tag.tag for tag in item.tags.all()],
'comments': item.comments,
'type': type_.mod_type(),
'type_name': type_.mod_name(),
'permission': permissions.effective_permissions(self._user, item),
}
return NotifierItem(
id=item.uuid,
name=item.name,
level=str(item.level),
enabled=item.enabled,
tags=[tag.tag for tag in item.tags.all()],
comments=item.comments,
type=type_.mod_type(),
type_name=type_.mod_name(),
permission=permissions.effective_permissions(self._user, item),
)

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

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,87 @@ from .services_usage import ServicesUsage
logger = logging.getLogger(__name__)
if typing.TYPE_CHECKING:
from django.db.models import Model
# Helper class for Provider offers
@dataclasses.dataclass
class OfferItem(types.rest.BaseRestItem):
name: str
type: str
description: str
icon: str
class Providers(ModelHandler):
"""
Providers REST handler
"""
@dataclasses.dataclass
class ProviderItem(types.rest.ManagedObjectItem[Provider]):
id: str
name: str
tags: list[str]
services_count: int
user_services_count: int
maintenance_mode: bool
offers: list[OfferItem]
comments: str
permission: types.permissions.PermissionType
model = Provider
detail = {'services': DetailServices, 'usage': ServicesUsage}
custom_methods = [('allservices', False), ('service', False), ('maintenance', True)]
class Providers(ModelHandler[ProviderItem]):
save_fields = ['name', 'comments', 'tags']
MODEL = Provider
DETAIL = {'services': DetailServices, 'usage': ServicesUsage}
table_title = _('Service providers')
# Table info fields
table_fields = [
{'name': {'title': _('Name'), 'type': 'iconType'}},
{'type_name': {'title': _('Type')}},
{'comments': {'title': _('Comments')}},
{'maintenance_state': {'title': _('Status')}},
{'services_count': {'title': _('Services'), 'type': 'numeric'}},
{'user_services_count': {'title': _('User Services'), 'type': 'numeric'}}, # , 'width': '132px'
{'tags': {'title': _('tags'), 'visible': False}},
CUSTOM_METHODS = [
types.rest.ModelCustomMethod('allservices', False),
types.rest.ModelCustomMethod('service', False),
types.rest.ModelCustomMethod('maintenance', True),
]
# Field from where to get "class" and prefix for that class, so this will generate "row-state-A, row-state-X, ....
table_row_style = types.ui.RowStyleInfo(prefix='row-maintenance-', field='maintenance_mode')
def item_as_dict(self, item: 'Model') -> types.rest.ItemDictType:
FIELDS_TO_SAVE = ['name', 'comments', 'tags']
TABLE = (
ui_utils.TableBuilder(_('Service providers'))
.icon(name='name', title=_('Name'))
.text_column(name='type_name', title=_('Type'))
.text_column(name='comments', title=_('Comments'))
.numeric_column(name='services_count', title=_('Services'))
.numeric_column(name='user_services_count', title=_('User Services'))
.text_column(name='tags', title=_('Tags'), visible=False)
.row_style(prefix='row-maintenance-', field='maintenance_mode')
).build()
# Rest api related information to complete the auto-generated API
REST_API_INFO = types.rest.api.RestApiInfo(
typed=types.rest.api.RestApiInfoGuiType.MULTIPLE_TYPES,
)
def get_item(self, item: 'Model') -> ProviderItem:
item = ensure.is_instance(item, Provider)
type_ = item.get_type()
# Icon can have a lot of data (1-2 Kbytes), but it's not expected to have a lot of services providers, and even so, this will work fine
offers = [
{
'name': gettext(t.mod_name()),
'type': t.mod_type(),
'description': gettext(t.description()),
'icon': t.icon64().replace('\n', ''),
}
offers: list[OfferItem] = [
OfferItem(
name=gettext(t.mod_name()),
type=t.mod_type(),
description=gettext(t.description()),
icon=t.icon64().replace('\n', ''),
)
for t in type_.get_provided_services()
]
return {
'id': item.uuid,
'name': item.name,
'tags': [tag.vtag for tag in item.tags.all()],
'services_count': item.services.count(),
'user_services_count': UserService.objects.filter(deployed_service__service__provider=item)
return ProviderItem(
id=item.uuid,
name=item.name,
tags=[tag.vtag for tag in item.tags.all()],
services_count=item.services.count(),
user_services_count=UserService.objects.filter(deployed_service__service__provider=item)
.exclude(state__in=(State.REMOVED, State.ERROR))
.count(),
'maintenance_mode': item.maintenance_mode,
'offers': offers,
'type': type_.mod_type(),
'type_name': type_.mod_name(),
'comments': item.comments,
'permission': permissions.effective_permissions(self._user, item),
}
maintenance_mode=item.maintenance_mode,
offers=offers,
comments=item.comments,
permission=permissions.effective_permissions(self._user, item),
item=item,
)
def validate_delete(self, item: 'Model') -> None:
item = ensure.is_instance(item, Provider)
@@ -118,19 +139,27 @@ class Providers(ModelHandler):
raise exceptions.rest.RequestError(gettext('Can\'t delete providers with services'))
# Types related
def enum_types(self) -> collections.abc.Iterable[type[services.ServiceProvider]]:
@classmethod
def possible_types(cls: type[typing.Self]) -> collections.abc.Iterable[type[services.ServiceProvider]]:
return services.factory().providers().values()
# Gui related
def get_gui(self, type_: str) -> list[typing.Any]:
provider_type = services.factory().lookup(type_)
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
provider_type = services.factory().lookup(for_type)
if provider_type:
with Environment.temporary_environment() as env:
provider = provider_type(env, None)
return self.add_default_fields(provider.gui_description(), ['name', 'comments', 'tags'])
return (
ui_utils.GuiBuilder()
.add_stock_field(types.rest.stock.StockField.NAME)
.add_stock_field(types.rest.stock.StockField.TAGS)
.add_stock_field(types.rest.stock.StockField.COMMENTS)
.add_fields(provider.gui_description(), parent='instance')
).build()
raise exceptions.rest.NotFound('Type not found!')
def allservices(self) -> typing.Generator[types.rest.ItemDictType, None, None]:
def allservices(self) -> typing.Generator[types.rest.BaseRestItem, None, None]:
"""
Custom method that returns "all existing services", no mater who's his daddy :)
"""
@@ -138,33 +167,33 @@ class Providers(ModelHandler):
try:
perm = permissions.effective_permissions(self._user, s)
if perm >= uds.core.types.permissions.PermissionType.READ:
yield DetailServices.service_to_dict(s, perm, True)
yield DetailServices.service_item(s, perm, True)
except Exception:
logger.exception('Passed service cause type is unknown')
def service(self) -> types.rest.ItemDictType:
def service(self) -> types.rest.BaseRestItem:
"""
Custom method that returns a service by its uuid, no matter who's his daddy
"""
try:
service = Service.objects.get(uuid=self._args[1])
self.ensure_has_access(service.provider, uds.core.types.permissions.PermissionType.READ)
self.check_access(service.provider, uds.core.types.permissions.PermissionType.READ)
perm = self.get_permissions(service.provider)
return DetailServices.service_to_dict(service, perm, True)
return DetailServices.service_item(service, perm, True)
except Exception:
# logger.exception('Exception')
return {}
return types.rest.BaseRestItem()
def maintenance(self, item: 'Model') -> types.rest.ItemDictType:
def maintenance(self, item: 'Model') -> types.rest.BaseRestItem:
"""
Custom method that swaps maintenance mode state for a provider
:param item:
"""
item = ensure.is_instance(item, Provider)
self.ensure_has_access(item, uds.core.types.permissions.PermissionType.MANAGEMENT)
self.check_access(item, uds.core.types.permissions.PermissionType.MANAGEMENT)
item.maintenance_mode = not item.maintenance_mode
item.save()
return self.item_as_dict(item)
return self.get_item(item)
def test(self, type_: str) -> str:
from uds.core.environment import Environment
@@ -178,7 +207,8 @@ class Providers(ModelHandler):
with Environment.temporary_environment() as temp_environment:
logger.debug('spType: %s', provider_type)
dct = self._params.copy()
# On 5.0 onwards, instance comes inside "instance" key
dct = self._params.copy()['instance']
dct['_request'] = self._request
test_result = provider_type.test(temp_environment, dct)
return 'ok' if test_result.success else test_result.error

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,42 @@ VALID_PARAMS = (
)
@dataclasses.dataclass
class ReportItem(types.rest.BaseRestItem):
id: str
mime_type: str
encoded: bool
group: str
name: str
description: str
# Enclosed methods under /actor path
class Reports(model.BaseModelHandler):
class Reports(model.BaseModelHandler[ReportItem]):
"""
Processes reports requests
"""
needs_admin = True # By default, staff is lower level needed
ROLE = consts.UserRole.ADMIN
table_title = _('Available reports')
table_fields = [
{'group': {'title': _('Group')}},
{'name': {'title': _('Name')}},
{'description': {'title': _('Description')}},
{'mime_type': {'title': _('Generates')}},
]
# Field from where to get "class" and prefix for that class, so this will generate "row-state-A, row-state-X, ....
table_row_style = types.ui.RowStyleInfo(prefix='row-state-', field='state')
TABLE = (
ui_utils.TableBuilder(_('Available reports'))
.text_column(name='group', title=_('Group'), visible=True)
.text_column(name='name', title=_('Name'), visible=True)
.text_column(name='description', title=_('Description'), visible=True)
.text_column(name='mime_type', title=_('Generates'), visible=True)
.row_style(prefix='row-state-', field='state')
.build()
)
def _locate_report(self, uuid: str, values: typing.Optional[typing.Dict[str, typing.Any]] = None) -> 'Report':
# Rest api related information to complete the auto-generated API
REST_API_INFO = types.rest.api.RestApiInfo(
typed=types.rest.api.RestApiInfoGuiType.MULTIPLE_TYPES,
)
def _locate_report(
self, uuid: str, values: typing.Optional[typing.Dict[str, typing.Any]] = None
) -> 'Report':
found = None
logger.debug('Looking for report %s', uuid)
for i in reports.available_reports:
@@ -85,7 +104,7 @@ class Reports(model.BaseModelHandler):
break
if not found:
raise self.invalid_request_response('Invalid report uuid!')
raise exceptions.rest.NotFound(f'Report not found: {uuid}') from None
return found
@@ -93,21 +112,19 @@ class Reports(model.BaseModelHandler):
logger.debug('method GET for %s, %s', self.__class__.__name__, self._args)
def error() -> typing.NoReturn:
raise self.invalid_request_response()
raise exceptions.rest.RequestError('Invalid report uuid!')
def report_gui(report_id: str) -> typing.Any:
return self.get_gui(report_id)
return match(
return match_args(
self._args,
error,
((), lambda: list(self.get_items())),
((), lambda: list(self.filter_data(self.get_items()))),
((consts.rest.OVERVIEW,), lambda: list(self.get_items())),
(
(consts.rest.TABLEINFO,),
lambda: self.process_table_fields(
str(self.table_title), self.table_fields, self.table_row_style
),
lambda: self.TABLE.as_dict(),
),
((consts.rest.GUI, '<report>'), report_gui),
)
@@ -124,7 +141,7 @@ class Reports(model.BaseModelHandler):
)
if len(self._args) != 1:
raise self.invalid_request_response()
raise exceptions.rest.RequestError('Invalid report uuid!')
report = self._locate_report(self._args[0], self._params)
@@ -142,23 +159,21 @@ class Reports(model.BaseModelHandler):
return data
except Exception as e:
logger.exception('Generating report')
raise self.invalid_request_response(str(e))
raise exceptions.rest.RequestError(str(e)) from e
# Gui related
def get_gui(self, type_: str) -> list[typing.Any]:
report = self._locate_report(type_)
return sorted(report.gui_description(), key=lambda f: f['gui']['order'])
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
report = self._locate_report(for_type)
return sorted(report.gui_description(), key=lambda f: f.gui.order)
# Returns the list of
def get_items(
self, *args: typing.Any, **kwargs: typing.Any
) -> typing.Generator[types.rest.ItemDictType, None, None]:
def get_items(self, *args: typing.Any, **kwargs: typing.Any) -> typing.Generator[ReportItem, None, None]:
for i in reports.available_reports:
yield {
'id': i.get_uuid(),
'mime_type': i.mime_type,
'encoded': i.encoded,
'group': i.translated_group(),
'name': i.translated_name(),
'description': i.translated_description(),
}
yield ReportItem(
id=i.get_uuid(),
mime_type=i.mime_type,
encoded=i.encoded,
group=i.translated_group(),
name=i.translated_name(),
description=i.translated_description(),
)

View File

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

View File

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

View File

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

View File

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

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

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

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

@@ -30,17 +30,17 @@
"""
Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
import datetime
import logging
import typing
from django.utils import timezone
from uds.REST import Handler
from uds import models
from uds.core.managers.crypto import CryptoManager
from uds.core.util.model import process_uuid
from uds.core.util import ensure
from uds.core import exceptions
from uds.core import consts, exceptions
logger = logging.getLogger(__name__)
@@ -89,14 +89,14 @@ class Tickets(Handler):
- servicePool has these groups in it's allowed list
"""
needs_admin = True # By default, staff is lower level needed
ROLE = consts.UserRole.ADMIN
@staticmethod
def result(result: str = '', error: typing.Optional[str] = None) -> dict[str, typing.Any]:
"""
Returns a result for a Ticket request
"""
res = {'result': result, 'date': datetime.datetime.now()}
res = {'result': result, 'date': timezone.localtime()}
if error is not None:
res['error'] = error
return res

View File

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

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

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,7 +249,7 @@ class AssignedService(DetailHandler):
def save_item(self, parent: 'Model', item: typing.Optional[str]) -> typing.Any:
parent = ensure.is_instance(parent, models.ServicePool)
if not item:
raise self.invalid_item_response('Only modify is allowed')
raise exceptions.rest.RequestError('Only modify is allowed')
fields = self.fields_from_params(['auth_id:_', 'user_id:_', 'ip:_'])
userservice = parent.userServices.get(uuid=process_uuid(item))
@@ -251,7 +266,7 @@ class AssignedService(DetailHandler):
.count()
> 0
):
raise self.invalid_response_response(
raise exceptions.rest.RequestError(
f'There is already another user service assigned to {user.pretty_name}'
)
@@ -261,7 +276,7 @@ class AssignedService(DetailHandler):
log_string = f'Changed IP of user service {userservice.friendly_name} to {fields["ip"]} by {self._user.pretty_name}'
userservice.log_ip(fields['ip'])
else:
raise self.invalid_item_response('Invalid fields')
raise exceptions.rest.RequestError('Invalid fields')
# Log change
log.log(parent, types.log.LogLevel.INFO, log_string, types.log.LogSource.ADMIN)
@@ -274,50 +289,51 @@ class AssignedService(DetailHandler):
UserServiceManager.manager().reset(userservice)
class CachedService(AssignedService):
class CachedService(AssignedUserService):
"""
Rest handler for Cached Services, which parent is ServicePool
"""
custom_methods: typing.ClassVar[list[str]] = [] # Remove custom methods from assigned services
CUSTOM_METHODS = [] # Remove custom methods from assigned services
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
def get_items(
self, parent: 'Model', item: typing.Optional[str]
) -> types.rest.ItemsResult['UserServiceItem']:
parent = ensure.is_instance(parent, models.ServicePool)
try:
if not item:
return [
AssignedService.item_as_dict(k, is_cache=True)
for k in parent.cached_users_services()
.all()
.prefetch_related('deployed_service', 'publication')
AssignedUserService.userservice_item(k, is_cache=True)
for k in self.filter_queryset(parent.cached_users_services().all()).prefetch_related(
'deployed_service', 'publication'
)
]
cached_userservice: models.UserService = parent.cached_users_services().get(uuid=process_uuid(item))
return AssignedService.item_as_dict(cached_userservice, is_cache=True)
return AssignedUserService.userservice_item(cached_userservice, is_cache=True)
except models.UserService.DoesNotExist:
raise exceptions.rest.NotFound(_('User service not found')) from None
except Exception as e:
logger.exception('get_items')
raise self.invalid_item_response() from e
logger.error('Error getting user service %s: %s', item, e)
raise exceptions.rest.ResponseError(_('Error getting user service')) from e
def get_title(self, parent: 'Model') -> str:
return _('Cached services')
def get_fields(self, parent: 'Model') -> list[typing.Any]:
def get_table(self, parent: 'Model') -> types.rest.TableInfo:
parent = ensure.is_instance(parent, models.ServicePool)
return [
{'creation_date': {'title': _('Creation date'), 'type': 'datetime'}},
{'revision': {'title': _('Revision')}},
{'unique_id': {'title': 'Unique ID'}},
{'friendly_name': {'title': _('Friendly name')}},
{'state': {'title': _('State'), 'type': 'dict', 'dict': State.literals_dict()}},
] + (
[
{'ip': {'title': _('IP')}},
{'cache_level': {'title': _('Cache level')}},
{'actor_version': {'title': _('Actor version')}},
]
if parent.state != State.LOCKED
else []
table_info = (
ui_utils.TableBuilder(_('Cached Services'))
.datetime_column(name='creation_date', title=_('Creation date'))
.text_column(name='revision', title=_('Revision'))
.text_column(name='unique_id', title='Unique ID')
.text_column(name='ip', title=_('IP'))
.text_column(name='friendly_name', title=_('Friendly name'))
.dict_column(name='state', title=_('State'), dct=State.literals_dict())
)
if parent.state != State.LOCKED:
table_info = table_info.text_column(name='cache_level', title=_('Cache level')).text_column(
name='actor_version', title=_('Actor version')
)
return table_info.build()
def delete_item(self, parent: 'Model', item: str, cache: bool = False) -> None:
return super().delete_item(parent, item, cache=True)
@@ -328,63 +344,57 @@ class CachedService(AssignedService):
userservice = parent.cached_users_services().get(uuid=process_uuid(item))
logger.debug('Getting logs for %s', item)
return log.get_logs(userservice)
except Exception:
raise self.invalid_item_response() from None
except Exception as e:
logger.error('Error getting user service logs for %s: %s', item, e)
raise exceptions.rest.ResponseError(_('Error getting user service logs')) from None
class Groups(DetailHandler):
@dataclasses.dataclass
class GroupItem(types.rest.BaseRestItem):
id: str
auth_id: str
name: str
group_name: str
comments: str
state: str
type: str
auth_name: str
class Groups(DetailHandler[GroupItem]):
"""
Processes the groups detail requests of a Service Pool
"""
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> list['GroupItem']:
parent = typing.cast(typing.Union['models.ServicePool', 'models.MetaPool'], parent)
return [
{
'id': group.uuid,
'auth_id': group.manager.uuid,
'name': group.name,
'group_name': group.pretty_name,
'comments': group.comments,
'state': group.state,
'type': 'meta' if group.is_meta else 'group',
'auth_name': group.manager.name,
}
for group in typing.cast(collections.abc.Iterable[models.Group], parent.assignedGroups.all())
GroupItem(
id=group.uuid,
auth_id=group.manager.uuid,
name=group.name,
group_name=group.pretty_name,
comments=group.comments,
state=group.state,
type='meta' if group.is_meta else 'group',
auth_name=group.manager.name,
)
for group in typing.cast(
collections.abc.Iterable[models.Group], self.filter_queryset(parent.assignedGroups.all())
)
]
def get_title(self, parent: 'Model') -> str:
def get_table(self, parent: 'Model') -> TableInfo:
parent = typing.cast(typing.Union['models.ServicePool', 'models.MetaPool'], parent)
return _('Assigned groups')
def get_fields(self, parent: 'Model') -> list[typing.Any]:
return [
# Note that this field is "self generated" on client table
{
'group_name': {
'title': _('Name'),
'type': 'alphanumeric',
}
},
{'comments': {'title': _('comments')}},
{
'type': {
'title': _('Type'),
# Alphanumeric, default is alphanumeric
}
},
{
'state': {
'title': _('State'),
'type': 'dict',
'dict': State.literals_dict(),
}
},
]
def get_row_style(self, parent: 'Model') -> types.ui.RowStyleInfo:
return types.ui.RowStyleInfo(prefix='row-state-', field='state')
return (
ui_utils.TableBuilder(_('Assigned groups'))
.text_column(name='group_name', title=_('Name'))
.text_column(name='comments', title=_('comments'))
.dict_column(name='state', title=_('State'), dct=State.literals_dict())
.row_style(prefix='row-state-', field='state')
.build()
)
def save_item(self, parent: 'Model', item: typing.Optional[str]) -> typing.Any:
parent = typing.cast(typing.Union['models.ServicePool', 'models.MetaPool'], parent)
@@ -412,44 +422,46 @@ class Groups(DetailHandler):
)
class Transports(DetailHandler):
@dataclasses.dataclass
class TransportItem(types.rest.BaseRestItem):
id: str
name: str
type: dict[str, typing.Any] # TypeInfo
comments: str
priority: int
trans_type: str
class Transports(DetailHandler[TransportItem]):
"""
Processes the transports detail requests of a Service Pool
"""
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> list['TransportItem']:
parent = ensure.is_instance(parent, models.ServicePool)
def get_type(trans: 'models.Transport') -> types.rest.TypeInfoDict:
try:
return self.type_as_dict(trans.get_type())
except Exception: # No type found
raise self.invalid_item_response()
return [
{
'id': i.uuid,
'name': i.name,
'type': get_type(i),
'comments': i.comments,
'priority': i.priority,
'trans_type': _(i.get_type().mod_name()),
}
for i in parent.transports.all()
if get_type(i)
TransportItem(
id=trans.uuid,
name=trans.name,
type=type(self).as_typeinfo(trans.get_type()).as_dict(),
comments=trans.comments,
priority=trans.priority,
trans_type=trans.get_type().mod_name(),
)
for trans in self.filter_queryset(parent.transports.all())
]
def get_title(self, parent: 'Model') -> str:
def get_table(self, parent: 'Model') -> TableInfo:
parent = ensure.is_instance(parent, models.ServicePool)
return _('Assigned transports')
def get_fields(self, parent: 'Model') -> list[typing.Any]:
return [
{'priority': {'title': _('Priority'), 'type': 'numeric', 'width': '6em'}},
{'name': {'title': _('Name')}},
{'trans_type': {'title': _('Type')}},
{'comments': {'title': _('Comments')}},
]
return (
ui_utils.TableBuilder(_('Assigned transports'))
.numeric_column(name='priority', title=_('Priority'), width='6em')
.text_column(name='name', title=_('Name'))
.text_column(name='trans_type', title=_('Type'))
.text_column(name='comments', title=_('Comments'))
.build()
)
def save_item(self, parent: 'Model', item: typing.Optional[str]) -> typing.Any:
parent = ensure.is_instance(parent, models.ServicePool)
@@ -476,12 +488,22 @@ class Transports(DetailHandler):
)
class Publications(DetailHandler):
@dataclasses.dataclass
class PublicationItem(types.rest.BaseRestItem):
id: str
revision: int
publish_date: datetime.datetime
state: str
reason: str
state_date: datetime.datetime
class Publications(DetailHandler[PublicationItem]):
"""
Processes the publications detail requests of a Service Pool
"""
custom_methods = ['publish', 'cancel'] # We provided these custom methods
CUSTOM_METHODS = ['publish', 'cancel'] # We provided these custom methods
def publish(self, parent: 'Model') -> typing.Any:
"""
@@ -496,7 +518,7 @@ class Publications(DetailHandler):
is False
):
logger.debug('Management Permission failed for user %s', self._user)
raise self.access_denied_response()
raise exceptions.rest.AccessDenied(_('Access denied to publish service pool')) from None
logger.debug('Custom "publish" invoked for %s', parent)
parent.publish(change_log) # Can raise exceptions that will be processed on response
@@ -523,7 +545,7 @@ class Publications(DetailHandler):
is False
):
logger.debug('Management Permission failed for user %s', self._user)
raise self.access_denied_response()
raise exceptions.rest.AccessDenied(_('Access denied to cancel service pool publication')) from None
try:
ds = models.ServicePoolPublication.objects.get(uuid=process_uuid(uuid))
@@ -540,65 +562,60 @@ class Publications(DetailHandler):
return self.success()
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> list['PublicationItem']:
parent = ensure.is_instance(parent, models.ServicePool)
return [
{
'id': i.uuid,
'revision': i.revision,
'publish_date': i.publish_date,
'state': i.state,
'reason': State.from_str(i.state).is_errored() and i.get_instance().error_reason() or '',
'state_date': i.state_date,
}
for i in parent.publications.all()
PublicationItem(
id=i.uuid,
revision=i.revision,
publish_date=i.publish_date,
state=i.state,
reason=State.from_str(i.state).is_errored() and i.get_instance().error_reason() or '',
state_date=i.state_date,
)
for i in self.filter_queryset(parent.publications.all())
]
def get_title(self, parent: 'Model') -> str:
def get_table(self, parent: 'Model') -> TableInfo:
parent = ensure.is_instance(parent, models.ServicePool)
return _('Publications')
def get_fields(self, parent: 'Model') -> list[typing.Any]:
return [
{'revision': {'title': _('Revision'), 'type': 'numeric', 'width': '6em'}},
{'publish_date': {'title': _('Publish date'), 'type': 'datetime'}},
{
'state': {
'title': _('State'),
'type': 'dict',
'dict': State.literals_dict(),
}
},
{'reason': {'title': _('Reason')}},
]
def get_row_style(self, parent: 'Model') -> types.ui.RowStyleInfo:
return types.ui.RowStyleInfo(prefix='row-state-', field='state')
return (
ui_utils.TableBuilder(_('Publications'))
.numeric_column(name='revision', title=_('Revision'), width='6em')
.datetime_column(name='publish_date', title=_('Publish date'))
.dict_column(name='state', title=_('State'), dct=State.literals_dict())
.text_column(name='reason', title=_('Reason'))
.row_style(prefix='row-state-', field='state')
).build()
class Changelog(DetailHandler):
@dataclasses.dataclass
class ChangelogItem(types.rest.BaseRestItem):
revision: int
stamp: datetime.datetime
log: str
class Changelog(DetailHandler[ChangelogItem]):
"""
Processes the transports detail requests of a Service Pool
"""
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> list['ChangelogItem']:
parent = ensure.is_instance(parent, models.ServicePool)
return [
{
'revision': i.revision,
'stamp': i.stamp,
'log': i.log,
}
for i in parent.changelog.all()
ChangelogItem(
revision=i.revision,
stamp=i.stamp,
log=i.log,
)
for i in self.filter_queryset(parent.changelog.all())
]
def get_title(self, parent: 'Model') -> str:
def get_table(self, parent: 'Model') -> types.rest.TableInfo:
parent = ensure.is_instance(parent, models.ServicePool)
return _(f'Changelog')
def get_fields(self, parent: 'Model') -> list[typing.Any]:
return [
{'revision': {'title': _('Revision'), 'type': 'numeric', 'width': '6em'}},
{'stamp': {'title': _('Publish date'), 'type': 'datetime'}},
{'log': {'title': _('Comment')}},
]
return (
ui_utils.TableBuilder(_('Changelog'))
.numeric_column(name='revision', title=_('Revision'), width='6em')
.datetime_column(name='stamp', title=_('Publish date'))
.text_column(name='log', title=_('Comment'))
).build()

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'
)
@@ -290,11 +255,17 @@ class Users(DetailHandler):
logger.exception('Saving user on removing error')
user.delete()
except User.DoesNotExist:
raise exceptions.rest.NotFound(_('User not found')) from None
except Exception as e:
logger.error('Error on user removal of %s.%s: %s', parent.name, item, e)
raise self.invalid_item_response() from e
raise exceptions.rest.ResponseError(_('Error removing user')) from e
def services_pools(self, parent: 'Model', item: str) -> list[dict[str, typing.Any]]:
"""
API:
Returns the service pools assigned to a user
"""
parent = ensure.is_instance(parent, Authenticator)
uuid = process_uuid(item)
user = parent.users.get(uuid=process_uuid(uuid))
@@ -315,19 +286,21 @@ class Users(DetailHandler):
return res
def user_services(self, parent: 'Authenticator', item: str) -> list[dict[str, typing.Any]]:
def user_services(self, parent: 'Authenticator', item: str) -> list[UserServiceItem]:
parent = ensure.is_instance(parent, Authenticator)
uuid = process_uuid(item)
user = parent.users.get(uuid=process_uuid(uuid))
res: list[dict[str, typing.Any]] = []
for i in user.userServices.all():
if i.state == State.USABLE:
v = AssignedService.item_as_dict(i)
v['pool'] = i.deployed_service.name
v['pool_id'] = i.deployed_service.uuid
res.append(v)
return res
def item_as_dict(assigned_user_service: 'UserService') -> UserServiceItem:
base = AssignedUserService.userservice_item(assigned_user_service)
base.pool_name = assigned_user_service.deployed_service.name
base.pool_id = assigned_user_service.deployed_service.uuid
return base
return [
item_as_dict(i)
for i in user.userServices.all().prefetch_related('deployed_service').filter(state=State.USABLE)
]
def clean_related(self, parent: 'Authenticator', item: str) -> dict[str, str]:
uuid = process_uuid(item)
@@ -361,101 +334,97 @@ class Users(DetailHandler):
return {'status': 'ok'}
class Groups(DetailHandler):
custom_methods = ['services_pools', 'users']
@dataclasses.dataclass
class GroupItem(types.rest.BaseRestItem):
id: str
name: str
comments: str
state: str
type: str
meta_if_any: bool
skip_mfa: str
groups: list[str] | types.rest.NotRequired = types.rest.NotRequired.field()
pools: list[str] | types.rest.NotRequired = types.rest.NotRequired.field()
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ManyItemsDictType:
class Groups(DetailHandler[GroupItem]):
CUSTOM_METHODS = ['services_pools', 'users']
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ItemsResult['GroupItem']:
parent = ensure.is_instance(parent, Authenticator)
try:
multi = False
if item is None:
multi = True
q = parent.groups.all().order_by('name')
q = self.filter_queryset(parent.groups.all())
else:
q = parent.groups.filter(uuid=process_uuid(item))
res: list[dict[str, typing.Any]] = []
res: list[GroupItem] = []
i = None
for i in q:
val: dict[str, typing.Any] = {
'id': i.uuid,
'name': i.name,
'comments': i.comments,
'state': i.state,
'type': i.is_meta and 'meta' or 'group',
'meta_if_any': i.meta_if_any,
'skip_mfa': i.skip_mfa,
}
val = GroupItem(
id=i.uuid,
name=i.name,
comments=i.comments,
state=i.state,
type=i.is_meta and 'meta' or 'group',
meta_if_any=i.meta_if_any,
skip_mfa=i.skip_mfa,
)
if i.is_meta:
val['groups'] = list(x.uuid for x in i.groups.all().order_by('name'))
val.groups = list(x.uuid for x in i.groups.all().order_by('name'))
res.append(val)
if multi:
return res
if not i:
raise Exception('Item not found')
raise exceptions.rest.NotFound(_('Group not found')) from None
# Add pools field if 1 item only
result = res[0]
result['pools'] = [v.uuid for v in get_service_pools_for_groups([i])]
return result
res[0].pools = [v.uuid for v in get_service_pools_for_groups([i])]
return res[0]
except exceptions.rest.HandlerError:
raise # Re-raise
except Exception as e:
logger.error('Group item not found: %s.%s: %s', parent.name, item, e)
raise self.invalid_item_response() from e
raise exceptions.rest.ResponseError(_('Error getting group')) from e
def get_title(self, parent: 'Model') -> str:
def get_table(self, parent: 'Model') -> types.rest.TableInfo:
parent = ensure.is_instance(parent, Authenticator)
try:
return _('Groups of {0}').format(parent.name)
except Exception:
return _('Current groups')
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 +482,7 @@ class Groups(DetailHandler):
group.save()
return {'id': group.uuid}
except Group.DoesNotExist:
raise self.invalid_item_response() from None
raise exceptions.rest.NotFound(_('Group not found')) from None
except IntegrityError: # Duplicate key probably
raise exceptions.rest.RequestError(_('User already exists (duplicate key error)')) from None
except exceptions.auth.AuthenticatorException as e:
@@ -521,8 +490,8 @@ class Groups(DetailHandler):
except exceptions.rest.RequestError: # pylint: disable=try-except-raise
raise # Re-raise
except Exception as e:
logger.exception('Saving group')
raise self.invalid_request_response() from e
logger.error('Error saving group %s: %s', item, e)
raise exceptions.rest.ResponseError(_('Error saving group')) from e
def delete_item(self, parent: 'Model', item: str) -> None:
parent = ensure.is_instance(parent, Authenticator)
@@ -530,10 +499,13 @@ class Groups(DetailHandler):
group = parent.groups.get(uuid=item)
group.delete()
except Exception:
raise self.invalid_item_response() from None
except exceptions.rest.NotFound:
raise exceptions.rest.NotFound(_('Group not found')) from None
except Exception as e:
logger.error('Error deleting group %s: %s', item, e)
raise exceptions.rest.ResponseError(_('Error deleting group')) from e
def servicesPools(self, parent: 'Model', item: str) -> list[collections.abc.Mapping[str, typing.Any]]:
def services_pools(self, parent: 'Model', item: str) -> list[collections.abc.Mapping[str, typing.Any]]:
parent = ensure.is_instance(parent, Authenticator)
uuid = process_uuid(item)
group = parent.groups.get(uuid=process_uuid(uuid))

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

@@ -32,4 +32,4 @@ Author: Adolfo Gómez, dkmaster at dkmon dot com
# pyright: reportUnusedImport=false
from .base import BaseModelHandler
from .detail import DetailHandler
from .model import ModelHandler
from .master import ModelHandler

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,58 @@ class BaseModelHandler(Handler):
return args
def fill_instance_fields(self, item: 'models.Model', res: dict[str, typing.Any]) -> dict[str, typing.Any]:
"""
For Managed Objects (db element that contains a serialized object), fills a dictionary with the "field" parameters values.
For non managed objects, it does nothing
:param item: Item to extract fields
:param res: Dictionary to "extend" with instance key-values pairs
"""
if isinstance(item, ManagedObjectModel):
i = item.get_instance()
i.init_gui() # Defaults & stuff
res.update(i.get_fields_as_dict())
return res
# Exceptions
def invalid_request_response(self, message: typing.Optional[str] = None) -> exceptions.rest.HandlerError:
"""
Raises an invalid request error with a default translated string
:param message: Custom message to add to exception. If it is None, "Invalid Request" is used
"""
message = message or _('Invalid Request')
return exceptions.rest.RequestError(f'{message} {self.__class__}: {self._args}')
def invalid_response_response(self, message: typing.Optional[str] = None) -> exceptions.rest.HandlerError:
message = 'Invalid response' if message is None else message
return exceptions.rest.ResponseError(message)
def invalid_method_response(self) -> exceptions.rest.HandlerError:
"""
Raises a NotFound exception with translated "Method not found" string to current locale
"""
return exceptions.rest.RequestError(_('Method not found in {}: {}').format(self.__class__, self._args))
def invalid_item_response(self, message: typing.Optional[str] = None) -> exceptions.rest.HandlerError:
"""
Raises a NotFound exception, with location info
"""
message = message or _('Item not found')
return exceptions.rest.NotFound(message)
# raise NotFound('{} {}: {}'.format(message, self.__class__, self._args))
def access_denied_response(self, message: typing.Optional[str] = None) -> exceptions.rest.HandlerError:
return exceptions.rest.AccessDenied(message or _('Access denied'))
def not_supported_response(self, message: typing.Optional[str] = None) -> exceptions.rest.HandlerError:
return exceptions.rest.NotSupportedError(message or _('Operation not supported'))
# Success methods
def success(self) -> str:
"""
Utility method to be invoked for simple methods that returns nothing in fact
Utility method to be invoked for simple methods that returns a simple OK response
"""
logger.debug('Returning success on %s %s', self.__class__, self._args)
return consts.OK
def test(self, type_: str) -> str: # pylint: disable=unused-argument
def test(self, type_: str) -> str:
"""
Invokes a test for an item
"""
logger.debug('Called base test for %s --> %s', self.__class__.__name__, self._params)
raise self.invalid_method_response()
raise exceptions.rest.NotSupportedError(_('Testing not supported'))
@classmethod
def api_components(cls: type[typing.Self]) -> types.rest.api.Components:
"""
Default implementation does not have any component types. (for Api specification purposes)
"""
return types.rest.api.Components()
@classmethod
def api_paths(
cls: type[typing.Self], path: str, tags: list[str], security: str
) -> dict[str, types.rest.api.PathItem]:
"""
Returns the API operations that should be registered
"""
return {}
@typing.final
@staticmethod
def common_components() -> types.rest.api.Components:
"""
Returns a list of common components for the API for ModelHandlers (Model and Detail)
"""
from uds.core.util import api as api_utils
return (
api_utils.api_components(types.rest.TypeInfo)
| api_utils.api_components(types.rest.TableInfo)
| api_utils.api_components(
types.ui.GuiElement,
removable_fields=['value', 'gui.old_field_name', 'gui.value', 'gui.field_name'],
)
)
@typing.final
@staticmethod
def common_paths() -> dict[str, types.rest.api.PathItem]:
"""
Returns a dictionary of common paths for the API for ModelHandlers (Model and Detail)
"""
return {}

View File

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

View File

@@ -0,0 +1,205 @@
# -*- coding: utf-8 -*-
#
# Copyright (c) 2014-2023 Virtual Cable S.L.U.
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without modification,
# are permitted provided that the following conditions are met:
#
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
# * Neither the name of Virtual Cable S.L.U. nor the names of its contributors
# may be used to endorse or promote products derived from this software
# without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
"""
Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
# pylint: disable=too-many-public-methods
import logging
import typing
from django.db import models
from django.utils.translation import gettext as _
from uds.core import consts
from uds.core import types
from uds.core.util import api as api_utils
# Not imported at runtime, just for type checking
if typing.TYPE_CHECKING:
from uds.REST.model.master import DetailHandler
logger = logging.getLogger(__name__)
T = typing.TypeVar('T', bound=models.Model)
def api_paths(
cls: type['DetailHandler[types.rest.T_Item]'], path: str, tags: list[str], security: str
) -> dict[str, types.rest.api.PathItem]:
"""
Returns the API operations that should be registered
"""
name = cls.REST_API_INFO.name if cls.REST_API_INFO.name else path.split('/')[-1].capitalize()
get_tags = tags
put_tags = tags # + ['Create', 'Modify']
# post_tags = tags + ['Create']
delete_tags = tags # + ['Delete']
base_type = next(iter(api_utils.get_generic_types(cls)), None)
if base_type is None:
logger.error('Base type not detected: %s', cls)
return {} # Skip
else:
base_type_name = base_type.__name__
# TODO: Append "custom" methods
api_desc = {
path: types.rest.api.PathItem(
get=types.rest.api.Operation(
summary=f'Get all {name} items',
description=f'Retrieve a list of all {name} items',
parameters=api_utils.gen_odata_parameters(),
responses=api_utils.gen_response(base_type_name, single=False),
tags=get_tags,
security=security,
),
put=types.rest.api.Operation(
summary=f'Creates a new {name} items',
description=f'Update an existing {name} item',
parameters=[],
responses=api_utils.gen_response(base_type_name, single=True),
tags=put_tags,
security=security,
),
),
f'{path}/{{uuid}}': types.rest.api.PathItem(
get=types.rest.api.Operation(
summary=f'Get {name} item by UUID',
description=f'Retrieve a {name} item by UUID',
parameters=api_utils.gen_uuid_parameters(with_odata=True),
responses=api_utils.gen_response(base_type_name, single=True),
tags=get_tags,
security=security,
),
put=types.rest.api.Operation(
summary=f'Update {name} item by UUID',
description=f'Update an existing {name} item by UUID',
parameters=api_utils.gen_uuid_parameters(with_odata=True),
responses=api_utils.gen_response(base_type_name, single=True),
tags=put_tags,
security=security,
),
delete=types.rest.api.Operation(
summary=f'Delete {name} item by UUID',
description=f'Delete a {name} item by UUID',
parameters=api_utils.gen_uuid_parameters(with_odata=True),
responses=api_utils.gen_response(base_type_name, single=True),
tags=delete_tags,
security=security,
),
),
f'{path}/{consts.rest.OVERVIEW}': types.rest.api.PathItem(
get=types.rest.api.Operation(
summary=f'Get overview of {name} items',
description=f'Retrieve an overview of {name} items',
parameters=api_utils.gen_odata_parameters(),
responses=api_utils.gen_response(base_type_name, single=False),
tags=get_tags,
security=security,
)
),
f'{path}/{consts.rest.TABLEINFO}': types.rest.api.PathItem(
get=types.rest.api.Operation(
summary=f'Get table info of {name} items',
description=f'Retrieve table info of {name} items',
parameters=[],
responses=api_utils.gen_response('TableInfo'),
tags=get_tags,
security=security,
)
),
}
if cls.REST_API_INFO.typed.is_single_type():
api_desc[f'{path}/{consts.rest.GUI}'] = types.rest.api.PathItem(
get=types.rest.api.Operation(
summary=f'Get GUI representation of {name} items',
description=f'Retrieve the GUI representation of {name} items',
parameters=[],
responses=api_utils.gen_response('GuiElement', single=False),
tags=get_tags,
security=security,
)
)
if cls.REST_API_INFO.typed.supports_multiple_types():
api_desc.update(
{
f'{path}/{consts.rest.GUI}/{{type}}': types.rest.api.PathItem(
get=types.rest.api.Operation(
summary=f'Get GUI representation of {name} type',
description=f'Retrieve a {name} GUI representation by type',
parameters=[
types.rest.api.Parameter(
name='type',
in_='path',
required=True,
description=f'The type of the {name} GUI representation',
schema=types.rest.api.Schema(type='string'),
)
],
responses=api_utils.gen_response('GuiElement', single=True),
tags=get_tags,
security=security,
)
),
f'{path}/{consts.rest.TYPES}': types.rest.api.PathItem(
get=types.rest.api.Operation(
summary=f'Get types of {name} items',
description=f'Retrieve types of {name} items',
parameters=[],
responses=api_utils.gen_response('TypeInfo', single=False),
tags=get_tags,
security=security,
)
),
f'{path}/{consts.rest.TYPES}/{{type}}': types.rest.api.PathItem(
get=types.rest.api.Operation(
summary=f'Get {name} item by type',
description=f'Retrieve a {name} item by type',
parameters=[
types.rest.api.Parameter(
name='type',
in_='path',
required=True,
description='The type of the item',
schema=types.rest.api.Schema(type='string'),
)
],
responses=api_utils.gen_response('TypeInfo', single=True),
tags=get_tags,
security=security,
)
),
}
)
return api_desc

View File

@@ -33,29 +33,33 @@ Author: Adolfo Gómez, dkmaster at dkmon dot com
import logging
import typing
import abc
import collections.abc
from django.db import IntegrityError, models
from django.db.models import QuerySet
from django.utils.translation import gettext as _
from uds.core import consts
from uds.core import exceptions
from uds.core import types
from uds.core.module import Module
from uds.core.util import log, permissions
from uds.core.util import log, permissions, api as api_utils
from uds.models import ManagedObjectModel, Tag, TaggingMixin
from .base import BaseModelHandler
from ..utils import camel_and_snake_case_from
from uds.REST.model.base import BaseModelHandler
from uds.REST.utils import camel_and_snake_case_from
# Not imported at runtime, just for type checking
if typing.TYPE_CHECKING:
from .detail import DetailHandler
from uds.REST.model.detail import DetailHandler
logger = logging.getLogger(__name__)
T = typing.TypeVar('T', bound=models.Model)
class ModelHandler(BaseModelHandler):
class ModelHandler(BaseModelHandler[types.rest.T_Item], abc.ABC):
"""
Basic Handler for a model
Basically we will need same operations for all models, so we can
@@ -72,90 +76,62 @@ class ModelHandler(BaseModelHandler):
"""
# Authentication related
authenticated = True
needs_staff = True
ROLE = consts.UserRole.STAFF
# Which model does this manage, must be a django model ofc
model: 'typing.ClassVar[type[models.Model]]'
MODEL: 'typing.ClassVar[type[models.Model]]'
# If the model is filtered (for overviews)
model_filter: 'typing.ClassVar[typing.Optional[collections.abc.Mapping[str, typing.Any]]]' = None
FILTER: 'typing.ClassVar[typing.Optional[collections.abc.Mapping[str, typing.Any]]]' = None
# Same, but for exclude
model_exclude: 'typing.ClassVar[typing.Optional[collections.abc.Mapping[str, typing.Any]]]' = None
# By default, filter is empty
fltr: typing.Optional[str] = None
EXCLUDE: 'typing.ClassVar[typing.Optional[collections.abc.Mapping[str, typing.Any]]]' = None
# If this model respond to "custom" methods, we will declare them here
# This is an array of tuples of two items, where first is method and second inticates if method needs parent id (normal behavior is it needs it)
# For example ('services', True) -- > .../id_parent/services
# ('services', False) --> ..../services
custom_methods: typing.ClassVar[list[tuple[str, bool]]] = (
[]
) # If this model respond to "custom" methods, we will declare them here
CUSTOM_METHODS: typing.ClassVar[list[types.rest.ModelCustomMethod]] = []
# If this model has details, which ones
detail: typing.ClassVar[typing.Optional[dict[str, type['DetailHandler']]]] = (
None # Dictionary containing detail routing
)
# Dictionary containing detail routing
DETAIL: typing.ClassVar[typing.Optional[dict[str, type['DetailHandler[typing.Any]']]]] = None
# Fields that are going to be saved directly
# * If a field is in the form "field:default" and field is not present in the request, default will be used
# * If the "default" is the string "None", then the default will be None
# * If the "default" is _ (underscore), then the field will be ignored (not saved) if not present in the request
# Note that these fields has to be present in the model, and they can be "edited" in the pre_save method
save_fields: typing.ClassVar[list[str]] = []
FIELDS_TO_SAVE: typing.ClassVar[list[str]] = []
# Put removable fields before updating
remove_fields: typing.ClassVar[list[str]] = []
EXCLUDED_FIELDS: typing.ClassVar[list[str]] = []
# Table info needed fields and title
table_fields: typing.ClassVar[list[typing.Any]] = []
table_row_style: typing.ClassVar[types.ui.RowStyleInfo] = types.ui.RowStyleInfo.null()
table_title: typing.ClassVar[str] = ''
table_subtitle: typing.ClassVar[str] = ''
TABLE: typing.ClassVar[types.rest.TableInfo] = types.rest.TableInfo.null()
# This methods must be override, depending on what is provided
# Data related
def item_as_dict(self, item: models.Model) -> types.rest.ItemDictType:
"""
Must be overriden by descendants.
Expects the return of an item as a dictionary
"""
return {}
def item_as_dict_overview(self, item: models.Model) -> dict[str, typing.Any]:
"""
Invoked when request is an "overview"
default behavior is return item_as_dict
"""
return self.item_as_dict(item)
# types related
def enum_types(self) -> collections.abc.Iterable[type['Module']]: # override this
# def possible_types(cls: type[typing.Self]) -> collections.abc.Iterable[type[module.Module]]:
@classmethod
def possible_types(cls: type[typing.Self]) -> collections.abc.Iterable[type['Module']]: # override this
"""
Must be overriden by desdencents if they support types
Excpetcs the list of types that the handler supports
"""
return []
def get_types(
self, *args: typing.Any, **kwargs: typing.Any
) -> typing.Generator[types.rest.TypeInfoDict, None, None]:
for type_ in self.enum_types():
yield self.type_as_dict(type_)
def enum_types(self) -> typing.Generator[types.rest.TypeInfo, None, None]:
for type_ in self.possible_types():
yield type(self).as_typeinfo(type_)
def get_type(self, type_: str) -> types.rest.TypeInfoDict:
found = None
for v in self.get_types():
if v['type'] == type_:
found = v
break
def get_type(self, type_: str) -> types.rest.TypeInfo:
for v in self.enum_types():
if v.type == type_:
return v
if found is None:
raise exceptions.rest.NotFound('type not found')
logger.debug('Found type %s', found)
return found
raise exceptions.rest.NotFound('type not found')
# log related
def get_logs(self, item: models.Model) -> list[dict[typing.Any, typing.Any]]:
self.ensure_has_access(item, types.permissions.PermissionType.READ)
self.check_access(item, types.permissions.PermissionType.READ)
try:
return log.get_logs(item)
except Exception as e:
@@ -163,10 +139,13 @@ class ModelHandler(BaseModelHandler):
return []
# gui related
def get_gui(self, type_: str) -> list[typing.Any]:
def get_gui(self, for_type: str) -> list[types.ui.GuiElement]:
return []
# raise self.invalidRequestException()
def get_processed_gui(self, for_type: str) -> list[types.ui.GuiElement]:
return sorted(self.get_gui(for_type), key=lambda f: f.gui.order)
# Delete related, checks if the item can be deleted
# If it can't be so, raises an exception
def validate_delete(self, item: models.Model) -> None:
@@ -192,7 +171,7 @@ class ModelHandler(BaseModelHandler):
def process_detail(self) -> typing.Any:
logger.debug('Processing detail %s for with params %s', self._path, self._params)
try:
item: models.Model = self.model.objects.get(uuid__iexact=self._args[0])
item: models.Model = self.MODEL.objects.get(uuid__iexact=self._args[0])
# If we do not have access to parent to, at least, read...
if self._operation in ('put', 'post', 'delete'):
@@ -206,61 +185,71 @@ class ModelHandler(BaseModelHandler):
self._user,
required_permission,
)
raise self.access_denied_response()
raise exceptions.rest.AccessDenied()
if not self.detail:
raise self.invalid_request_response()
if not self.DETAIL:
raise exceptions.rest.NotFound('Detail not found')
# pylint: disable=unsubscriptable-object
handler_type = self.detail[self._args[1]]
handler_type = self.DETAIL[self._args[1]]
args = list(self._args[2:])
path = self._path + '/' + '/'.join(args[:2])
detail_handler = handler_type(self, path, self._params, *args, parent=item, user=self._user)
detail_handler = handler_type(self, path, self._params, *args, parent_item=item, user=self._user)
method = getattr(detail_handler, self._operation)
return method()
except self.model.DoesNotExist:
raise self.invalid_item_response()
except self.MODEL.DoesNotExist:
raise exceptions.rest.NotFound('Item not found on model {self.MODEL.__name__}')
except (KeyError, AttributeError) as e:
raise self.invalid_method_response() from e
raise exceptions.rest.InvalidMethodError(f'Invalid method {self._operation}') from e
except exceptions.rest.HandlerError:
raise
except Exception as e:
logger.error('Exception processing detail: %s', e)
raise self.invalid_request_response() from e
raise exceptions.rest.RequestError(f'Error processing detail: {e}') from e
# Data related
def get_item(self, item: models.Model) -> types.rest.T_Item:
"""
Must be overriden by descendants.
Expects the return of an item as a dictionary
"""
raise NotImplementedError()
def get_item_summary(self, item: models.Model) -> types.rest.T_Item:
"""
Invoked when request is an "overview"
default behavior is return item_as_dict
"""
return self.get_item(item)
def get_items(
self, *args: typing.Any, **kwargs: typing.Any
) -> typing.Generator[types.rest.ItemDictType, None, None]:
if 'overview' in kwargs:
overview = kwargs['overview']
del kwargs['overview']
self, *, overview: bool = False, query: QuerySet[T] | None = None
) -> typing.Generator[types.rest.T_Item, None, None]:
"""
Get items from the model.
Args:
overview: If True, return a summary of the items.
query: Optional queryset to filter the items. Used to optimize the process for some models
(such as ServicePools)
"""
# Basic model filter
if query:
qs = query
else:
overview = True
qs = self.MODEL.objects.all()
if self.FILTER is not None:
qs = qs.filter(**self.FILTER)
if self.EXCLUDE is not None:
qs = qs.exclude(**self.EXCLUDE)
if 'prefetch' in kwargs:
prefetch = kwargs['prefetch']
logger.debug('Prefetching %s', prefetch)
del kwargs['prefetch']
else:
prefetch = []
qs = self.filter_queryset(qs)
if 'query' in kwargs:
query = kwargs['query'] # We are using a prebuilt query on args
logger.debug('Got query: %s', query)
del kwargs['query']
else:
logger.debug('Args: %s, kwargs: %s', args, kwargs)
query = self.model.objects.filter(*args, **kwargs).prefetch_related(*prefetch)
if self.model_filter is not None:
query = query.filter(**self.model_filter)
if self.model_exclude is not None:
query = query.exclude(**self.model_exclude)
for item in query:
for item in qs:
try:
# Note: Due to this, the response may not have the required elements, but a subset will be returned
if (
permissions.has_access(
self._user,
@@ -270,24 +259,12 @@ class ModelHandler(BaseModelHandler):
is False
):
continue
if overview:
yield self.item_as_dict_overview(item)
else:
res = self.item_as_dict(item)
self.fill_instance_fields(item, res)
yield res
yield self.get_item_summary(item) if overview else self.get_item(item)
except Exception as e: # maybe an exception is thrown to skip an item
logger.debug('Got exception processing item from model: %s', e)
# logger.exception('Exception getting item from {0}'.format(self.model))
def get(self) -> typing.Any:
"""
Wraps real get method so we can process filters if they exists
"""
return self.process_get()
# pylint: disable=too-many-return-statements
def process_get(self) -> typing.Any:
logger.debug('method GET for %s, %s', self.__class__.__name__, self._args)
number_of_args = len(self._args)
@@ -295,10 +272,10 @@ class ModelHandler(BaseModelHandler):
return list(self.get_items(overview=False))
# if has custom methods, look for if this request matches any of them
for cm in self.custom_methods:
for cm in self.CUSTOM_METHODS:
# Convert to snake case
camel_case_name, snake_case_name = camel_and_snake_case_from(cm[0])
if number_of_args > 1 and cm[1] is True: # Method needs parent (existing item)
camel_case_name, snake_case_name = camel_and_snake_case_from(cm.name)
if number_of_args > 1 and cm.needs_parent: # Method needs parent (existing item)
if self._args[1] in (camel_case_name, snake_case_name):
item = None
# Check if operation method exists
@@ -306,9 +283,9 @@ class ModelHandler(BaseModelHandler):
try:
if not operation:
raise Exception() # Operation not found
item = self.model.objects.get(uuid__iexact=self._args[0])
except self.model.DoesNotExist:
raise self.invalid_item_response()
item = self.MODEL.objects.get(uuid__iexact=self._args[0])
except self.MODEL.DoesNotExist:
raise exceptions.rest.NotFound('Item not found') from None
except Exception as e:
logger.error(
'Invalid custom method exception %s/%s/%s: %s',
@@ -317,74 +294,63 @@ class ModelHandler(BaseModelHandler):
self._params,
e,
)
raise self.invalid_method_response()
raise exceptions.rest.ResponseError(
f'Error processing custom method: {self.__class__.__name__}/{self._args}'
) from e
return operation(item)
elif self._args[0] in (snake_case_name, snake_case_name):
operation = getattr(self, snake_case_name) or getattr(self, snake_case_name)
if not operation:
raise self.invalid_method_response()
raise exceptions.rest.InvalidMethodError(f'Invalid method {self._operation}') from None
return operation()
if number_of_args == 1:
if self._args[0] == consts.rest.OVERVIEW:
return list(self.get_items())
if self._args[0] == consts.rest.TYPES:
return list(self.get_types())
if self._args[0] == consts.rest.TABLEINFO:
return self.process_table_fields(
self.table_title,
self.table_fields,
self.table_row_style,
self.table_subtitle,
)
if self._args[0] == consts.rest.GUI:
return self.get_gui('')
match self._args:
case []: # Same as overview, but with all data
return [i.as_dict() for i in self.get_items(overview=False)]
case [consts.rest.OVERVIEW]:
return [i.as_dict() for i in self.get_items()]
case [consts.rest.OVERVIEW, *_fails]:
raise exceptions.rest.RequestError('Invalid overview request') from None
case [consts.rest.TABLEINFO]:
return self.TABLE.as_dict()
case [consts.rest.TABLEINFO, *_fails]:
raise exceptions.rest.RequestError('Invalid table info request') from None
case [consts.rest.TYPES]:
return [i.as_dict() for i in self.enum_types()]
case [consts.rest.TYPES, for_type]:
return self.get_type(for_type).as_dict()
case [consts.rest.TYPES, for_type, *_fails]:
raise exceptions.rest.RequestError('Invalid type request') from None
case [consts.rest.GUI]:
return self.get_processed_gui('')
case [consts.rest.GUI, for_type]:
return self.get_processed_gui(for_type)
case [consts.rest.GUI, for_type, *_fails]:
raise exceptions.rest.RequestError('Invalid GUI request') from None
case _: # Maybe an item or a detail
if number_of_args == 1:
try:
item = self.MODEL.objects.get(uuid__iexact=self._args[0].lower())
self.check_access(item, types.permissions.PermissionType.READ)
return self.get_item(item).as_dict()
except Exception as e:
logger.exception('Got Exception looking for item')
raise exceptions.rest.NotFound('Item not found') from e
elif number_of_args == 2:
if self._args[1] == consts.rest.LOG:
try:
item = self.MODEL.objects.get(uuid__iexact=self._args[0].lower())
return self.get_logs(item)
except Exception as e:
raise exceptions.rest.NotFound('Item not found') from e
# get item ID
try:
item = self.model.objects.get(uuid__iexact=self._args[0].lower())
if self.DETAIL is not None:
return self.process_detail()
self.ensure_has_access(item, types.permissions.PermissionType.READ)
res = self.item_as_dict(item)
self.fill_instance_fields(item, res)
return res
except Exception as e:
logger.exception('Got Exception looking for item')
raise self.invalid_item_response() from e
# nArgs > 1
# Request type info or gui, or detail
if self._args[0] == consts.rest.OVERVIEW:
if number_of_args != 2:
raise self.invalid_request_response()
elif self._args[0] == consts.rest.TYPES:
if number_of_args != 2:
raise self.invalid_request_response()
return self.get_type(self._args[1])
elif self._args[0] == consts.rest.GUI:
if number_of_args != 2:
raise self.invalid_request_response()
gui = self.get_gui(self._args[1])
return sorted(gui, key=lambda f: f['gui']['order'])
elif self._args[1] == consts.rest.LOG:
if number_of_args != 2:
raise self.invalid_request_response()
try:
# DB maybe case sensitive??, anyway, uuids are stored in lowercase
item = self.model.objects.get(uuid__iexact=self._args[0].lower())
return self.get_logs(item)
except Exception as e:
raise self.invalid_item_response() from e
# If has detail and is requesting detail
if self.detail is not None:
return self.process_detail()
raise self.invalid_request_response() # Will not return
raise exceptions.rest.RequestError('Invalid request') from None
def post(self) -> typing.Any:
"""
@@ -396,7 +362,7 @@ class ModelHandler(BaseModelHandler):
if self._args[0] == 'test':
return self.test(self._args[1])
raise self.invalid_method_response() # Will not return
raise exceptions.rest.InvalidMethodError(f'Invalid method {self._operation}') from None
def put(self) -> typing.Any:
"""
@@ -410,21 +376,21 @@ class ModelHandler(BaseModelHandler):
delete_on_error = False
if len(self._args) > 1: # Detail?
if len(self._args) > 1: # Detail (1 arg means ID, more means detail/ID)?
return self.process_detail()
# Here, self.model() indicates an "django model object with default params"
self.ensure_has_access(
self.model(), types.permissions.PermissionType.ALL, root=True
self.check_access(
self.MODEL(), types.permissions.PermissionType.ALL, root=True
) # Must have write permissions to create, modify, etc..
try:
# Extract fields
args = self.fields_from_params(self.save_fields)
args = self.fields_from_params(self.FIELDS_TO_SAVE)
logger.debug('Args: %s', args)
self.pre_save(args)
# If tags is in save fields, treat it "specially"
if 'tags' in self.save_fields:
if 'tags' in self.FIELDS_TO_SAVE:
tags = args['tags']
del args['tags']
else:
@@ -433,12 +399,12 @@ class ModelHandler(BaseModelHandler):
delete_on_error = False
item: models.Model
if not self._args: # create new?
item = self.model.objects.create(**args)
item = self.MODEL.objects.create(**args)
delete_on_error = True
else: # Must have 1 arg
# We have to take care with this case, update will efectively update records on db
item = self.model.objects.get(uuid__iexact=self._args[0].lower())
for v in self.remove_fields:
item = self.MODEL.objects.get(uuid__iexact=self._args[0].lower())
for v in self.EXCLUDED_FIELDS:
if v in args:
del args[v]
# Upadte fields from args
@@ -464,12 +430,14 @@ class ModelHandler(BaseModelHandler):
data_type: typing.Optional[str] = self._params.get('data_type', self._params.get('type'))
if data_type:
item.data_type = data_type
item.data = item.get_instance(self._params).serialize()
# TODO: Currently support parameters outside "instance". Will be removed after tests
item.data = item.get_instance(
self._params['instance'] if 'instance' in self._params else self._params
).serialize()
item.save()
res = self.item_as_dict(item)
self.fill_instance_fields(item, res)
res = self.get_item(item)
except Exception:
logger.exception('Exception on put')
if delete_on_error:
@@ -478,9 +446,9 @@ class ModelHandler(BaseModelHandler):
self.post_save(item)
return res
return res.as_dict()
except self.model.DoesNotExist:
except self.MODEL.DoesNotExist:
raise exceptions.rest.NotFound('Item not found') from None
except IntegrityError: # Duplicate key probably
raise exceptions.rest.RequestError('Element already exists (duplicate key error)') from None
@@ -503,15 +471,15 @@ class ModelHandler(BaseModelHandler):
if len(self._args) != 1:
raise exceptions.rest.RequestError('Delete need one and only one argument')
self.ensure_has_access(
self.model(), types.permissions.PermissionType.ALL, root=True
self.check_access(
self.MODEL(), types.permissions.PermissionType.ALL, root=True
) # Must have write permissions to delete
try:
item = self.model.objects.get(uuid__iexact=self._args[0].lower())
item = self.MODEL.objects.get(uuid__iexact=self._args[0].lower())
self.validate_delete(item)
self.delete_item(item)
except self.model.DoesNotExist:
except self.MODEL.DoesNotExist:
raise exceptions.rest.NotFound('Element do not exists') from None
return consts.OK
@@ -521,3 +489,18 @@ class ModelHandler(BaseModelHandler):
Basic, overridable method for deleting an item
"""
item.delete()
@classmethod
def api_components(cls: type[typing.Self]) -> types.rest.api.Components:
return api_utils.get_component_from_type(cls)
@classmethod
def api_paths(
cls: type[typing.Self], path: str, tags: list[str], security: str
) -> dict[str, types.rest.api.PathItem]:
"""
Returns the API operations that should be registered
"""
from .api_helpers import api_paths
return api_paths(cls, path, tags=tags, security=security)

View File

@@ -0,0 +1,205 @@
# -*- coding: utf-8 -*-
#
# Copyright (c) 2014-2023 Virtual Cable S.L.U.
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without modification,
# are permitted provided that the following conditions are met:
#
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
# * Neither the name of Virtual Cable S.L.U. nor the names of its contributors
# may be used to endorse or promote products derived from this software
# without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
"""
Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
# pylint: disable=too-many-public-methods
import logging
import typing
from django.db import models
from django.utils.translation import gettext as _
from uds.core import consts
from uds.core import types
from uds.core.util import api as api_utils
# Not imported at runtime, just for type checking
if typing.TYPE_CHECKING:
from uds.REST.model.master import ModelHandler
logger = logging.getLogger(__name__)
T = typing.TypeVar('T', bound=models.Model)
def api_paths(
cls: type['ModelHandler[types.rest.T_Item]'], path: str, tags: list[str], security: str
) -> dict[str, types.rest.api.PathItem]:
"""
Returns the API operations that should be registered
"""
name = cls.REST_API_INFO.name if cls.REST_API_INFO.name else cls.MODEL.__name__
get_tags = tags
put_tags = tags # + ['Create', 'Modify']
# post_tags = tags + ['Create']
delete_tags = tags # + ['Delete']
base_type = next(iter(api_utils.get_generic_types(cls)), None)
if base_type is None:
logger.error('Base type not detected: %s', cls)
return {} # Skip
else:
base_type_name = base_type.__name__
api_desc = {
path: types.rest.api.PathItem(
get=types.rest.api.Operation(
summary=f'Get all {name} items',
description=f'Retrieve a list of all {name} items',
parameters=api_utils.gen_odata_parameters(),
responses=api_utils.gen_response(base_type_name, single=False),
tags=get_tags,
security=security,
),
put=types.rest.api.Operation(
summary=f'Creates a new {name} item',
description=f'Creates a new, nonexisting {name} item',
parameters=[],
requestBody=api_utils.gen_request_body(base_type_name, create=True),
responses=api_utils.gen_response(base_type_name, single=True),
tags=put_tags,
security=security,
),
),
f'{path}/{{uuid}}': types.rest.api.PathItem(
get=types.rest.api.Operation(
summary=f'Get {name} item by UUID',
description=f'Retrieve a {name} item by UUID',
parameters=api_utils.gen_uuid_parameters(with_odata=True),
responses=api_utils.gen_response(base_type_name, single=True),
tags=get_tags,
security=security,
),
put=types.rest.api.Operation(
summary=f'Update {name} item by UUID',
description=f'Update an existing {name} item by UUID',
parameters=api_utils.gen_uuid_parameters(with_odata=False),
requestBody=api_utils.gen_request_body(base_type_name, create=False),
responses=api_utils.gen_response(base_type_name, single=True),
tags=put_tags,
security=security,
),
delete=types.rest.api.Operation(
summary=f'Delete {name} item by UUID',
description=f'Delete a {name} item by UUID',
parameters=api_utils.gen_uuid_parameters(with_odata=False),
responses=api_utils.gen_response(base_type_name, single=True),
tags=delete_tags,
security=security,
),
),
f'{path}/{consts.rest.OVERVIEW}': types.rest.api.PathItem(
get=types.rest.api.Operation(
summary=f'Get overview of {name} items',
description=f'Retrieve an overview of {name} items',
parameters=api_utils.gen_odata_parameters(),
responses=api_utils.gen_response(base_type_name, single=False),
tags=get_tags,
security=security,
)
),
f'{path}/{consts.rest.TABLEINFO}': types.rest.api.PathItem(
get=types.rest.api.Operation(
summary=f'Get table info of {name} items',
description=f'Retrieve table info of {name} items',
parameters=[],
responses=api_utils.gen_response('TableInfo', single=True),
tags=get_tags,
security=security,
)
),
}
if cls.REST_API_INFO.typed.is_single_type():
api_desc[f'{path}/{consts.rest.GUI}'] = types.rest.api.PathItem(
get=types.rest.api.Operation(
summary=f'Get GUI representation of {name} items',
description=f'Retrieve the GUI representation of {name} items',
parameters=[],
responses=api_utils.gen_response('GuiElement', single=False),
tags=get_tags,
security=security,
)
)
if cls.REST_API_INFO.typed.supports_multiple_types():
api_desc.update(
{
f'{path}/{consts.rest.GUI}/{{type}}': types.rest.api.PathItem(
get=types.rest.api.Operation(
summary=f'Get GUI representation of {name} type',
description=f'Retrieve a {name} GUI representation by type',
parameters=[
types.rest.api.Parameter(
name='type',
in_='path',
required=True,
description=f'The type of the {name} GUI representation',
schema=types.rest.api.Schema(type='string'),
)
],
responses=api_utils.gen_response('GuiElement', single=True),
tags=get_tags,
security=security,
)
),
f'{path}/{consts.rest.TYPES}': types.rest.api.PathItem(
get=types.rest.api.Operation(
summary=f'Get types of {name} items',
description=f'Retrieve types of {name} items',
parameters=[],
responses=api_utils.gen_response('TypeInfo', single=False),
tags=get_tags,
security=security,
)
),
f'{path}/{consts.rest.TYPES}/{{type}}': types.rest.api.PathItem(
get=types.rest.api.Operation(
summary=f'Get {name} item by type',
description=f'Retrieve a {name} item by type',
parameters=[
types.rest.api.Parameter(
name='type',
in_='path',
required=True,
description='The type of the item',
schema=types.rest.api.Schema(type='string'),
)
],
responses=api_utils.gen_response('TypeInfo', single=True),
tags=get_tags,
security=security,
)
),
},
)
return api_desc

View File

@@ -31,16 +31,16 @@
Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
import collections.abc
import dataclasses
import datetime
import json
import logging
import time
import typing
from django.http import HttpResponse
from django.utils.functional import Promise as DjangoPromise
from uds.core import consts
from uds.core import consts, types
from .utils import to_incremental_json
@@ -65,9 +65,14 @@ class ContentProcessor:
extensions: typing.ClassVar[collections.abc.Iterable[str]] = []
_request: 'HttpRequest'
_odata: 'types.rest.api.ODataParams|None' = None
def __init__(self, request: 'HttpRequest'):
self._request = request
self._odata = None
def set_odata(self, odata: 'types.rest.api.ODataParams') -> None:
self._odata = odata
def process_get_parameters(self) -> dict[str, typing.Any]:
"""
@@ -105,38 +110,63 @@ class ContentProcessor:
yield self.render(obj).encode('utf8')
@staticmethod
def process_for_render(obj: typing.Any) -> typing.Any:
def process_for_render(
obj: typing.Any,
data_transformer: collections.abc.Callable[[dict[str, typing.Any]], dict[str, typing.Any]],
) -> typing.Any:
"""
Helper for renderers. Alters some types so they can be serialized correctly (as we want them to be)
"""
if obj is None or isinstance(obj, (bool, int, float, str)):
return obj
match obj:
case types.rest.BaseRestItem():
return ContentProcessor.process_for_render(obj.as_dict(), data_transformer)
# Dataclass
case None | bool() | int() | float() | str():
return obj
case dict():
return data_transformer(
{
k: ContentProcessor.process_for_render(v, data_transformer)
for k, v in typing.cast(dict[str, typing.Any], obj).items()
if not isinstance(v, types.rest.NotRequired) # Skip
}
)
if isinstance(obj, DjangoPromise):
return str(obj) # This is for translations
case DjangoPromise():
return str(obj) # This is for translations
if isinstance(obj, dict):
return {
k: ContentProcessor.process_for_render(v)
for k, v in typing.cast(dict[str, typing.Any], obj).items()
}
case bytes():
return obj.decode('utf-8')
if isinstance(obj, bytes):
return obj.decode('utf-8')
case collections.abc.Iterable():
return [
ContentProcessor.process_for_render(v, data_transformer)
for v in typing.cast(collections.abc.Iterable[typing.Any], obj)
]
if isinstance(obj, collections.abc.Iterable):
return [
ContentProcessor.process_for_render(v)
for v in typing.cast(collections.abc.Iterable[typing.Any], obj)
]
case datetime.datetime():
return int(obj.timestamp())
if isinstance(obj, (datetime.datetime,)): # Datetime as timestamp
return int(time.mktime(obj.timetuple()))
case datetime.date():
return '{}-{:02d}-{:02d}'.format(obj.year, obj.month, obj.day)
if isinstance(obj, (datetime.date,)): # Date as string
return '{}-{:02d}-{:02d}'.format(obj.year, obj.month, obj.day)
case _:
# Any class with as_dict method shoud be processed
if as_dict := getattr(obj, 'as_dict', None):
try:
obj = as_dict()
return ContentProcessor.process_for_render(obj, data_transformer)
except Exception as e:
# Maybe the as_dict method is not implemented as we expect.. should not happen
logger.warning('Obj has as_dict method but failed to call it: %s', e)
# Will return obj as str in this case, or if it is a dataclass, can return as dict
return str(obj)
if dataclasses.is_dataclass(obj):
# If already has a "as_dict" method, use it, and if not, default
obj = dataclasses.asdict(typing.cast(typing.Any, obj))
return ContentProcessor.process_for_render(obj, data_transformer)
return str(obj)
class MarshallerProcessor(ContentProcessor):
@@ -169,7 +199,11 @@ class MarshallerProcessor(ContentProcessor):
raise ParametersException(str(e))
def render(self, obj: typing.Any) -> str:
return self.marshaller.dumps(ContentProcessor.process_for_render(obj))
def none_transformer(dct: dict[str, typing.Any]) -> dict[str, typing.Any]:
return dct
dct_filter = none_transformer if self._odata is None else self._odata.select_filter
return self.marshaller.dumps(ContentProcessor.process_for_render(obj, dct_filter))
# ---------------

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

@@ -119,19 +119,8 @@ class OAuth2Authenticator(auths.Authenticator):
required=True,
default='code',
choices=[
{'id': v, 'text': v.as_text}
gui.choice_item(v, v.as_text)
for v in oauth2_types.ResponseType
# {'id': 'code', 'text': _('Code (authorization code flow)')},
# {'id': 'pkce', 'text': _('PKCE (authorization code flow with PKCE)')},
# {'id': 'token', 'text': _('Token (implicit flow)')},
# {
# 'id': 'openid+token_id',
# 'text': _('OpenID Connect Token (implicit flow with OpenID Connect)'),
# },
# {
# 'id': 'openid+code',
# 'text': _('OpenID Connect Code (authorization code flow with OpenID Connect)'),
# },
],
tab=types.ui.Tab.ADVANCED,
)

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

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

View File

@@ -33,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,
@@ -124,17 +124,14 @@ def root_user() -> models.User:
# Decorator to make easier protect pages that needs to be logged in
def weblogin_required(
admin: typing.Union[bool, typing.Literal['admin']] = False,
role: typing.Optional[consts.UserRole] = None,
) -> collections.abc.Callable[
[collections.abc.Callable[..., HttpResponse]], collections.abc.Callable[..., HttpResponse]
]:
"""Decorator to set protection to access page
Look for samples at uds.core.web.views
if admin == True, needs admin or staff
if admin == 'admin', needs admin
Args:
admin (bool, optional): If True, needs admin or staff. Is it's "admin" literal, needs admin . Defaults to False (any user).
role (str, optional): If set, needs this role. Defaults to None.
Returns:
collections.abc.Callable[[collections.abc.Callable[..., HttpResponse]], collections.abc.Callable[..., HttpResponse]]: Decorator
@@ -142,10 +139,11 @@ def weblogin_required(
Note:
This decorator is used to protect pages that needs to be logged in.
To protect against ajax calls, use `denyNonAuthenticated` instead
Roles as "inclusive", that is, if you set role to USER, it will allow all users that are not anonymous. (USER, STAFF, ADMIN)
"""
def decorator(
view_func: collections.abc.Callable[..., HttpResponse],
view_func: collections.abc.Callable[..., HttpResponse]
) -> collections.abc.Callable[..., HttpResponse]:
@wraps(view_func)
def _wrapped_view(
@@ -158,8 +156,8 @@ def weblogin_required(
if not request.user or not request.authorized:
return weblogout(request)
if admin in (True, 'admin'):
if request.user.is_staff() is False or (admin == 'admin' and not request.user.is_admin):
if role in (consts.UserRole.ADMIN, consts.UserRole.STAFF):
if request.user.is_staff() is False or (role == consts.UserRole.ADMIN and not request.user.is_admin):
return HttpResponseForbidden(_('Forbidden'))
return view_func(request, *args, **kwargs)
@@ -180,7 +178,7 @@ def is_trusted_ip_forwarder(ip: str) -> bool:
# Decorator to protect pages that needs to be accessed from "trusted sites"
def needs_trusted_source(
view_func: collections.abc.Callable[..., HttpResponse],
view_func: collections.abc.Callable[..., HttpResponse]
) -> collections.abc.Callable[..., HttpResponse]:
"""
Decorator to set protection to access page
@@ -420,7 +418,7 @@ def weblogin(
request.session[consts.auth.SESSION_USER_KEY] = user.id
request.session[consts.auth.SESSION_PASS_KEY] = codecs.encode(
CryptoManager().symmetric_encrypt(password, cookie), "base64"
CryptoManager.manager().symmetric_encrypt(password, cookie), "base64"
).decode() # as str
# Ensures that this user will have access through REST api if logged in through web interface
@@ -432,8 +430,6 @@ def weblogin(
password,
get_language() or '',
request.os.os.name,
user.is_admin,
user.staff_member,
cookie,
)
return True

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,18 +31,21 @@
Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
# pyright: reportUnusedImport=false
import enum
import time
import typing
from datetime import datetime
import datetime
from django.utils.translation import gettext as _
from . import actor, auth, cache, calendar, images, net, os, system, ticket, rest, services, transports, ui
# Date related constants
NEVER: typing.Final[datetime] = datetime(1972, 7, 1)
NEVER: typing.Final[datetime.datetime] = datetime.datetime(1972, 7, 1, tzinfo=datetime.timezone.utc)
NEVER_UNIX: typing.Final[int] = int(time.mktime(NEVER.timetuple()))
# Unknown mac address "magic" value
MAC_UNKNOWN: typing.Final[str] = '00:00:00:00:00:00'
# Null mac address "magic" value
NULL_MAC: typing.Final[str] = '00:00:00:00:00:00'
# REST Related constants
OK: typing.Final[str] = 'ok' # Constant to be returned when result is just "operation complete successfully"
@@ -74,5 +77,62 @@ UNLIMITED: typing.Final[int] = -1
# Constant marking no more names available
NO_MORE_NAMES: typing.Final[str] = 'NO-NAME-ERROR'
# For convenience, same as MAC_UNKNOWN, but different meaning
NO_MORE_MACS: typing.Final[str] = MAC_UNKNOWN
# For convenience, same as NULL_MAC, but different meaning
NO_MORE_MACS: typing.Final[str] = NULL_MAC
class UserRole(enum.StrEnum):
"""
Roles for users
"""
ADMIN = 'admin'
STAFF = 'staff'
USER = 'user'
ANONYMOUS = 'anonymous'
@property
def needs_authentication(self) -> bool:
"""
Checks if this role needs authentication
Returns:
True if this role needs authentication, False otherwise
"""
return self != UserRole.ANONYMOUS
def can_access(self, role: 'UserRole') -> bool:
"""
Checks if this role can access to the requested role
That is, if this role is greater or equal to the requested role
Args:
role: Role to check against
Returns:
True if this role can access to the requested role, False otherwise
"""
ROLE_PRECEDENCE: typing.Final = {
UserRole.ADMIN: 3,
UserRole.STAFF: 2,
UserRole.USER: 1,
UserRole.ANONYMOUS: 0,
}
return ROLE_PRECEDENCE[self] >= ROLE_PRECEDENCE[role]
def as_str(self) -> str:
"""
Returns the string representation of the role
Returns:
The string representation of the role
"""
# _('Admin') or _('Staff member')) or _('User')
return {
UserRole.ADMIN: _('Admin'),
UserRole.STAFF: _('Staff member'),
UserRole.USER: _('User'),
UserRole.ANONYMOUS: _('Anonymous'),
}.get(self, _('Unknown role')) # Default case, should not happen

View File

@@ -39,6 +39,8 @@ MAX_TICKET_VALIDITY_TIME: typing.Final[int] = 60 * 60 * 24 * 7 # 1 week
TUNNEL_TICKET_VALIDITY_TIME: typing.Final[int] = 60 * 60 * 24 * 7 # 1 week
TICKET_SECURED_ONWER: typing.Final[str] = '#SECURE#' # Just a "different" owner. If used anywhere, it's not important (will not fail), but weird enough
# Note that the tunnel ticket will be the the ticket itself + owner, so it will be 48 chars long (Secured or not) (Only valid for tunnel tickets)
TICKET_LENGTH: typing.Final[int] = 40 # Ticket length must much the length of the ticket length on tunnel server!!! (take care with previous note) -
# The old comment about length of ticket, does not apply anymore, because the Owner has been moved to an own field
TICKET_LENGTH: typing.Final[int] = 48 # Ticket length must much the length of the ticket length on tunnel server!!! (take care with previous note) -
LEGACY_TICKET_LENGTH: typing.Final[int] = 40 # Short ticket length - Used for client compatibility
SCRAMBLER_LENGTH: typing.Final[int] = 32 # Scrambler length

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

@@ -33,13 +33,20 @@ import hashlib
import array
import uuid
import codecs
import datetime
import struct
import re
import string
import logging
import typing
import secrets
import base64
uuid7: None|typing.Callable[[], 'uuid.UUID']
try:
from edwh_uuid7 import uuid7 # type: ignore
except ImportError:
uuid7 = None # type: ignore
# For password secrets
from argon2 import PasswordHasher, Type as ArgonType
@@ -49,9 +56,11 @@ from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes, aead
from django.conf import settings
from django.utils import timezone
from uds.core.util import singleton
@@ -306,10 +315,12 @@ class CryptoManager(metaclass=singleton.Singleton):
def uuid(self, obj: typing.Any = None) -> str:
"""Generates an uuid from obj. (lower case)
If obj is None, returns an uuid based on a random string
If obj is None, returns a non-deterministic uuid (preferably uuid7 if available, else uuid4)
"""
if obj is None:
obj = self.random_string()
if obj is None: # Non deterministic, try to use uuid7 if available
if uuid7 is not None:
return str(uuid7())
return str(uuid.uuid4())
elif isinstance(obj, bytes):
obj = obj.decode('utf8') # To string
else:
@@ -318,9 +329,32 @@ class CryptoManager(metaclass=singleton.Singleton):
except Exception:
obj = str(hash(obj)) # Get hash of object
return str(
uuid.uuid5(self._namespace, obj)
).lower() # I believe uuid returns a lowercase uuid always, but in case... :)
return str(uuid.uuid5(self._namespace, obj)) # Uuid is always lower case
# Used to encode fields that will go inside json
def encrypt_field_b64(self, plaintext: str, key_ascii32: str, nonce_seq: int) -> str:
"""
Cipher a `plaintext` with AES-256-GCM using `key_ascii32` (32 bytes ASCII)
and a nonce of 12 bytes with last one being a simple seq, starting at 1.
Args:
plaintext: The plaintext to encrypt.
key_ascii32: The 32 bytes ASCII key to use for encryption.
nonce_seq: The nonce sequence number (1, 2, 3...).
Returns the ciphertext+tag in standard Base64.
"""
key_bytes = key_ascii32.encode("ascii")
if len(key_bytes) != 32:
raise ValueError("The key must be exactly 32 bytes ASCII")
# Nonce is 12 bytes with the last byte = nonce_seq
nonce = bytearray(12)
nonce[-1] = nonce_seq # 1, 2, 3...
# Initialize AES-GCM
aesgcm = aead.AESGCM(key_bytes)
return base64.b64encode(aesgcm.encrypt(bytes(nonce), plaintext.encode("utf-8"), None)).decode()
def random_string(self, length: int = 40, digits: bool = True, punctuation: bool = False) -> str:
base = (
@@ -332,7 +366,7 @@ class CryptoManager(metaclass=singleton.Singleton):
def unique(self) -> str:
return hashlib.sha3_256(
(self.random_string(24, True) + datetime.datetime.now().strftime('%H%M%S%f')).encode()
(self.random_string(24, True) + timezone.localtime().strftime('%H%M%S%f')).encode()
).hexdigest()
def sha(self, value: typing.Union[str, bytes]) -> str:

View File

@@ -38,6 +38,7 @@ from concurrent.futures import ThreadPoolExecutor
from django.db import transaction
from django.db.models import Q
from django.utils.translation import gettext as _
from django.utils import timezone
from uds import models
from uds.core import exceptions, types
@@ -61,7 +62,7 @@ class ServerManager(metaclass=singleton.Singleton):
BASE_PROPERTY_NAME: typing.Final[str] = 'sm_usr_'
# Singleton, can initialize here
last_counters_clean: datetime.datetime = datetime.datetime.now() # This is local to server, so it's ok
last_counters_clean: datetime.datetime = timezone.localtime() # This is local to server, so it's ok
@staticmethod
def manager() -> 'ServerManager':
@@ -71,8 +72,8 @@ class ServerManager(metaclass=singleton.Singleton):
def counter_storage(self) -> typing.Iterator[StorageAsDict]:
with Storage(self.STORAGE_NAME).as_dict(atomic=True, group='counters') as storage:
# If counters are too old, restart them
if datetime.datetime.now() - self.last_counters_clean > self.MAX_COUNTERS_AGE:
self.last_counters_clean = datetime.datetime.now()
if timezone.localtime() - self.last_counters_clean > self.MAX_COUNTERS_AGE:
self.last_counters_clean = timezone.localtime()
storage.clear()
yield storage

View File

@@ -35,6 +35,8 @@ import logging
import time
import typing
from django.utils import timezone
from uds.core import types
from uds.core.util import singleton
from uds.core.util.config import GlobalConfig
@@ -183,6 +185,7 @@ class StatsManager(metaclass=singleton.Singleton):
to = sql_now()
elif isinstance(to, int):
to = datetime.datetime.fromtimestamp(to)
to = timezone.make_aware(to)
if since is None:
if points is None:
@@ -190,6 +193,7 @@ class StatsManager(metaclass=singleton.Singleton):
since = to - datetime.timedelta(seconds=interval_type.seconds() * points)
elif isinstance(since, int):
since = datetime.datetime.fromtimestamp(since)
since = timezone.make_aware(since)
# If points has any value, ensure since..to is points long
if points is not None:

View File

@@ -487,6 +487,12 @@ class UserServiceManager(metaclass=singleton.Singleton):
with transaction.atomic():
userservice = UserService.objects.select_for_update().get(id=userservice.id)
operations_logger.info('Removing userservice %a', userservice.name)
# If already removing or removed, do nothing
if State.from_str(userservice.state) in (State.REMOVING, State.REMOVED):
logger.debug('Userservice %s already removing or removed', userservice.name)
return
if userservice.is_usable() is False and State.from_str(userservice.state).is_removable() is False:
if not forced:
raise OperationException(
@@ -1075,7 +1081,7 @@ class UserServiceManager(metaclass=singleton.Singleton):
)
self.notify_preconnect(
userservice,
transport_instance.get_connection_info(userservice, user, ''),
transport_instance.get_connection_info(userservice, user, '', for_notify=True),
)
trace_logger.info(
'READY on service "%s" for user "%s" with transport "%s" (ip:%s)',

View File

@@ -31,13 +31,13 @@
Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
import codecs
import datetime
import logging
import typing
from weasyprint import HTML, CSS, default_url_fetcher # pyright: ignore[reportUnknownVariableType]
from django.utils.translation import gettext, gettext_noop as _
from django.utils import timezone
from django.template import loader
from uds.core.ui import UserInterface, gui
@@ -178,7 +178,7 @@ class Report(UserInterface):
.replace('{water}', water or 'UDS Report')
.replace(
'{printed}',
_('Printed in {now:%Y, %b %d} at {now:%H:%M}').format(now=datetime.datetime.now()),
_('Printed in {now:%Y, %b %d} at {now:%H:%M}').format(now=timezone.localtime()),
)
)

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
@@ -175,6 +175,8 @@ class Transport(Module):
userservice: typing.Union['models.UserService', 'models.ServicePool'],
user: 'models.User',
password: str,
*,
for_notify: bool = False, # To differentiate SSO from information
) -> types.connections.ConnectionData:
"""
This method must provide information about connection.
@@ -200,7 +202,7 @@ class Transport(Module):
else:
username = self.processed_username(userservice, user)
return types.connections.ConnectionData(
protocol=self.protocol,
protocol=self.PROTOCOL,
username=username,
service_type=types.services.ServiceType.VDI,
password='', # nosec: password is empty string, no password

View File

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

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

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

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,391 @@
# -*- coding: utf-8 -*-
#
# Copyright (c) 2023 Virtual Cable S.L.U.
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without modification,
# are permitted provided that the following conditions are met:
#
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
# * Neither the name of Virtual Cable S.L.U. nor the names of its contributors
# may be used to endorse or promote products derived from this software
# without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
"""
Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
# pyright: reportUnusedImport=false
import abc
import enum
import typing
import dataclasses
from . import stock
from . import actor
from . import api
if typing.TYPE_CHECKING:
from uds.REST.handlers import Handler
from uds.core.module import Module
from uds.models.managed_object_model import ManagedObjectModel
T_Model = typing.TypeVar('T_Model', bound='ManagedObjectModel')
T_Item = typing.TypeVar("T_Item", bound='BaseRestItem')
class NotRequired:
"""
This is a marker class to indicate that a field is not required.
It is used to indicate that a field is optional in the REST API.
"""
def __bool__(self) -> bool:
return False
def __str__(self) -> str:
return 'NotRequired'
# Field generator for dataclasses
@staticmethod
def field() -> typing.Any:
"""
Returns a field that is not required.
This is used to indicate that a field is optional in the REST API.
"""
return dataclasses.field(default_factory=lambda: NotRequired(), repr=False, compare=False)
# This is a named tuple for convenience, and must be
# compatible with tuple[str, bool] (name, needs_parent)
@dataclasses.dataclass
class ModelCustomMethod:
name: str
needs_parent: bool = True
# Note that for this item to work with documentation
# no forward references can be used (that is, do not use quotes around the inner field types)
@dataclasses.dataclass
class BaseRestItem:
def as_dict(self) -> dict[str, typing.Any]:
"""
Returns a dictionary representation of the item.
By default, it returns the dataclass fields as a dictionary.
"""
return dataclasses.asdict(self)
# NOTE: the json processor should take care of converting "sub-items" to valid dictionaries
# (as it already does)
@classmethod
def api_components(cls: type[typing.Self]) -> api.Components:
from uds.core.util import api as api_util # Avoid circular import
return api_util.api_components(cls)
@dataclasses.dataclass
class ManagedObjectItem(BaseRestItem, typing.Generic[T_Model]):
"""
Represents a managed object type, with its name and type.
This is used to represent the type of a managed object in the REST API.
"""
item: T_Model
def as_dict(self) -> dict[str, typing.Any]:
"""
Returns a dictionary representation of the managed object item.
"""
base = super().as_dict()
# Remove the fields that are not needed in the dictionary
base.pop('item')
item = self.item.get_instance()
# item.init_gui() # Defaults & stuff
fields = item.get_fields_as_dict()
# TODO: This will be removed in future versions, as it will be overseed by "instance" key
base.update(fields) # Add fields to dict
base.update(
{
'type': item.mod_type(), # Add type
'type_name': item.mod_name(), # Add type name
'instance': fields, # Future implementation will insert instance fields into "instance" key
}
)
return base
@classmethod
def api_components(cls: type[typing.Self]) -> api.Components:
component = super().api_components()
# Add any additional components specific to this item, that are "type", "type_name" and "instance"
# get reference
schema = component.schemas.get(cls.__name__)
if isinstance(schema, api.Schema):
assert schema is not None, f'Schema for {cls.__name__} not found in components'
# item is not an real field, remove it from components description and required
schema.properties.pop('item', None)
schema.required.remove('item')
# Add the specific fields to the schema
# Note that 'instance' is incomplete, must be completed with item fields
# But as long as python has not "real" generics, we cannot estimate the type of item
schema.properties.update(
{
'type': api.SchemaProperty(type='string'),
'type_name': api.SchemaProperty(type='string'),
'instance': api.SchemaProperty(type='object'),
}
)
schema.required.extend(['type', 'instance']) # type_name is not required
return component
# Alias for get_items return type
ItemsResult: typing.TypeAlias = list[T_Item] | BaseRestItem | typing.Iterator[T_Item]
@dataclasses.dataclass
class TypeInfo:
name: str = dataclasses.field(metadata={'description': 'Name of the type (Human readable)'})
type: str = dataclasses.field(metadata={'description': 'Type name used to identify the type'})
description: str = dataclasses.field(metadata={'description': 'Description for this type'})
icon: str = dataclasses.field(metadata={'description': 'Icon of the type, in base64'})
group: typing.Optional[str] = dataclasses.field(
default=None, metadata={'description': 'Group name used for grouping "similar" types'}
)
extra: 'ExtraTypeInfo|None' = dataclasses.field(
default=None, metadata={'description': 'Extra type info. Depends on specific type.'}
)
def as_dict(self) -> dict[str, typing.Any]:
res: dict[str, typing.Any] = {
'name': self.name,
'type': self.type,
'description': self.description,
'icon': self.icon,
}
# Add optional fields
if self.group:
res['group'] = self.group
if self.extra:
res.update(self.extra.as_dict())
return res
@staticmethod
def null() -> 'TypeInfo':
return TypeInfo(name='', type='', description='', icon='', extra=None)
class ExtraTypeInfo(abc.ABC):
def as_dict(self) -> dict[str, typing.Any]:
return {}
class TableFieldType(enum.StrEnum):
"""
Enum for table field types.
This is used to define the type of a field in a table.
"""
NUMERIC = 'numeric'
ALPHANUMERIC = 'alphanumeric'
BOOLEAN = 'boolean'
DATETIME = 'datetime'
DATETIMESEC = 'datetimesec'
DATE = 'date'
TIME = 'time'
ICON = 'icon'
DICTIONARY = 'dictionary'
IMAGE = 'image'
@dataclasses.dataclass
class TableField:
"""
Represents a field in a table, with its title and type.
This is used to describe the fields of a table in the REST API.
"""
name: str # Name of the field, used as key in the table
title: str # Title of the field
type: TableFieldType = TableFieldType.ALPHANUMERIC # Type of the field, defaults to alphanumeric
visible: bool = True
width: str | None = None # Width of the field, if applicable
dct: dict[typing.Any, typing.Any] | None = None # Dictionary for dictionary fields, if applicable
def as_dict(self) -> dict[str, typing.Any]:
# Only return the fields that are set
res: dict[str | int, typing.Any] = {
'title': self.title,
'type': self.type.value,
'visible': self.visible,
}
if self.dct:
res['dict'] = self.dct
if self.width:
res['width'] = self.width
return {self.name: res} # Return as a dictionary with the field name as key
@dataclasses.dataclass
class RowStyleInfo:
prefix: str
field: str
def as_dict(self) -> dict[str, typing.Any]:
"""Returns a dict with all fields that are not None"""
return dataclasses.asdict(self)
@staticmethod
def null() -> 'RowStyleInfo':
return RowStyleInfo('', '')
@dataclasses.dataclass
class TableInfo:
"""
Represents the table info for a REST API endpoint.
This is used to describe the table fields and row style.
"""
title: str
fields: list[TableField] # List of fields in the table
row_style: 'RowStyleInfo'
subtitle: typing.Optional[str] = None
def as_dict(self) -> dict[str, typing.Any]:
return {
'title': self.title,
'fields': [field.as_dict() for field in self.fields],
'row_style': self.row_style.as_dict(),
'subtitle': self.subtitle or '',
}
@staticmethod
def null() -> 'TableInfo':
"""
Returns a null TableInfo instance, with no fields and an empty title.
"""
return TableInfo(title='', fields=[], row_style=RowStyleInfo.null(), subtitle=None)
@dataclasses.dataclass(frozen=True)
class HandlerNode:
"""
Represents a node on the handler tree for rest services
"""
name: str
handler: typing.Optional[type['Handler']]
parent: typing.Optional['HandlerNode']
children: dict[str, 'HandlerNode']
def __str__(self) -> str:
return f'HandlerNode({self.name}, {self.handler}, {self.children})'
def __repr__(self) -> str:
return str(self)
# Visit all nodes recursively, invoking a callback for each node with the node and path
def visit(
self,
callback: typing.Callable[
['HandlerNode', str, typing.Literal['handler', 'custom_method', 'detail_method'], int], None
],
path: str = '',
level: int = 0,
) -> None:
from uds.REST.model import ModelHandler
if self.handler:
callback(self, path, 'handler', level)
if issubclass(self.handler, ModelHandler):
handler = typing.cast(
type[ModelHandler[typing.Any]], self.handler # pyright: ignore[reportUnknownMemberType]
)
for method in handler.CUSTOM_METHODS:
callback(self, f'{path}/{method.name}' if path else method.name, 'custom_method', level + 1)
for detail_name in handler.DETAIL.keys() if handler.DETAIL else typing.cast(list[str], []):
callback(self, f'{path}/{detail_name}' if path else detail_name, 'detail_method', level + 1)
for child in self.children.values():
child.visit(callback, f'{path}/{child.name}' if path else child.name, level + 1)
def tree(self) -> str:
"""
Returns a string representation of the tree
"""
ret = ''
def _tree(
node: HandlerNode,
path: str,
type_: typing.Literal['handler', 'custom_method', 'detail_method'],
level: int,
) -> None:
nonlocal ret
if not node.handler:
raise ValueError(f'Node {node.name} has no handler, cannot generate tree')
ret += f'{" " * level}* {path} {node.handler.__name__} ({type_})\n'
self.visit(_tree)
return ret
def find_path(self, path: str | list[str]) -> typing.Optional['HandlerNode']:
"""
Returns the node for a given path, or None if not found
"""
if not path or not self.children:
return self
# Remove any trailing '/' to allow some "bogus" paths with trailing slashes
path = path.lstrip('/').split('/') if isinstance(path, str) else path
if path[0] not in self.children:
return None
return self.children[path[0]].find_path(path[1:]) # Recursive call
def full_path(self) -> str:
"""
Returns the full path of this node
"""
if self.name == '' or self.parent is None:
return ''
parent_full_path = self.parent.full_path()
if parent_full_path == '':
return self.name
return f'{parent_full_path}/{self.name}'

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,454 @@
import enum
import typing
import dataclasses
from uds.core import exceptions
if typing.TYPE_CHECKING:
from uds.core.types import ui
# Helper to clean None values from dicts
def _as_dict_without_none(v: typing.Any) -> typing.Any:
if hasattr(v, 'as_dict'):
return _as_dict_without_none(v.as_dict())
elif dataclasses.is_dataclass(v):
return _as_dict_without_none(dataclasses.asdict(typing.cast(typing.Any, v)))
elif isinstance(v, list):
return [_as_dict_without_none(item) for item in typing.cast(list[typing.Any], v) if item is not None]
elif isinstance(v, dict):
return {
k: _as_dict_without_none(val)
for k, val in typing.cast(dict[str, typing.Any], v).items()
if val is not None
}
elif hasattr(v, 'as_dict'):
return v.as_dict()
return v
# This class is used to provide extra information about a handler
# (handler, model, detail, etc.)
# So we can override names or whatever we need
# Types of GUI info that can be provided
class RestApiInfoGuiType(enum.Enum):
SINGLE_TYPE = 0
MULTIPLE_TYPES = 1
UNTYPED = 3
def is_single_type(self) -> bool:
return self == RestApiInfoGuiType.SINGLE_TYPE
def supports_multiple_types(self) -> bool:
return self == RestApiInfoGuiType.MULTIPLE_TYPES
@dataclasses.dataclass
class RestApiInfo:
name: str | None = None
description: str | None = None
# Models can be typed, untyped or :
# - SINGLE_TYPE: the gui returns with no type specified (for example, /gui)
# - MULTI_TYPED: the gui returns with a type specified (for example, /gui/whatever_type)
# - UNTYPED: no gui is provided
typed: 'RestApiInfoGuiType' = RestApiInfoGuiType.UNTYPED
# Parameter
@dataclasses.dataclass
class Parameter:
name: str
in_: str # 'query', 'path', 'header', etc.
required: bool
schema: 'Schema'
description: str | None = None
style: str | None = None
explode: bool | None = None
def as_dict(self) -> dict[str, typing.Any]:
return _as_dict_without_none(
{
'name': self.name,
'in': self.in_,
'required': self.required,
'schema': self.schema.as_dict(),
'description': self.description,
'style': self.style,
'explode': self.explode,
}
)
@dataclasses.dataclass
class Content:
media_type: str
schema: 'SchemaProperty'
def as_dict(self) -> dict[str, typing.Any]:
return _as_dict_without_none(
{
self.media_type: {
'schema': self.schema.as_dict(),
},
}
)
# Request body
@dataclasses.dataclass
class RequestBody:
required: bool
content: Content # e.g. {'application/json': {'schema': {...}}}
description: str | None = None
def as_dict(self) -> dict[str, typing.Any]:
return _as_dict_without_none(
{
'required': self.required,
'content': self.content.as_dict(),
'description': self.description,
}
)
# Response
@dataclasses.dataclass
class Response:
description: str
content: Content | None = None
def as_dict(self) -> dict[str, typing.Any]:
return _as_dict_without_none(
{
'description': self.description,
'content': self.content.as_dict() if self.content else None,
}
)
# Operación (GET, POST, etc.)
@dataclasses.dataclass
class Operation:
summary: str | None = None
description: str | None = None
parameters: list[Parameter] = dataclasses.field(default_factory=list[Parameter])
requestBody: RequestBody | None = None
responses: dict[str, Response] = dataclasses.field(default_factory=dict[str, Response])
security: str | None = None
tags: list[str] = dataclasses.field(default_factory=list[str])
def as_dict(self) -> dict[str, typing.Any]:
data = _as_dict_without_none(
{
'summary': self.summary,
'description': self.description,
'parameters': [param.as_dict() for param in self.parameters],
'requestBody': self.requestBody.as_dict() if self.requestBody else None,
'responses': {k: v.as_dict() for k, v in self.responses.items()},
'tags': self.tags,
}
)
if self.security:
data['security'] = [{self.security: []}]
return data
# Path item
@dataclasses.dataclass
class PathItem:
get: Operation | None = None
post: Operation | None = None
put: Operation | None = None
delete: Operation | None = None
def as_dict(self) -> dict[str, typing.Any]:
return _as_dict_without_none(
{
'get': self.get.as_dict() if self.get else None,
'post': self.post.as_dict() if self.post else None,
'put': self.put.as_dict() if self.put else None,
'delete': self.delete.as_dict() if self.delete else None,
}
)
# Schema property
@dataclasses.dataclass
class SchemaProperty:
type: str | list[str]
format: str | None = None # e.g. 'date-time', 'int32', etc.
description: str | None = None
example: typing.Any | None = None
items: 'SchemaProperty | None' = None # For arrays
additionalProperties: 'SchemaProperty | None' = None # For objects
discriminator: str | None = None # For polymorphic types
enum: list[str | int] | None = None # For enum types
properties: dict[str, 'SchemaProperty'] | None = None
one_of: list['SchemaProperty'] | None = None
def __eq__(self, value: object) -> bool:
if not isinstance(value, SchemaProperty):
return False
return (
self.type == value.type
and self.format == value.format
and self.description == value.description
and self.example == value.example
and self.items == value.items
and self.additionalProperties == value.additionalProperties
and self.discriminator == value.discriminator
and self.enum == value.enum
and self.properties == value.properties
and sorted(self.one_of or [], key=lambda x: x.type)
== sorted(value.one_of or [], key=lambda x: x.type)
)
@staticmethod
def from_field_desc(desc: 'ui.GuiElement') -> 'SchemaProperty|None':
from uds.core.types import ui # avoid circular import
def base_schema() -> 'SchemaProperty|None':
'''Returns the API type for this field type'''
match desc.gui.type:
case ui.FieldType.TEXT:
return SchemaProperty(type='string')
case ui.FieldType.TEXT_AUTOCOMPLETE:
return SchemaProperty(type='string')
case ui.FieldType.NUMERIC:
return SchemaProperty(type='number')
case ui.FieldType.PASSWORD:
return SchemaProperty(type='string')
case ui.FieldType.HIDDEN:
return None
case ui.FieldType.CHOICE:
return SchemaProperty(type='string')
case ui.FieldType.MULTICHOICE:
return SchemaProperty(type='array', items=SchemaProperty(type='string'))
case ui.FieldType.EDITABLELIST:
return SchemaProperty(type='array', items=SchemaProperty(type='string'))
case ui.FieldType.CHECKBOX:
return SchemaProperty(type='boolean')
case ui.FieldType.IMAGECHOICE:
return SchemaProperty(type='string')
case ui.FieldType.DATE:
return SchemaProperty(type='string')
case ui.FieldType.INFO:
return None
case ui.FieldType.TAGLIST:
return SchemaProperty(type='array', items=SchemaProperty(type='string'))
schema = base_schema()
if schema is None:
return None
schema.description = f'{desc.gui.label}.{desc.gui.tooltip}'
return schema
def as_dict(self) -> dict[str, typing.Any]:
val = {
'type': self.type,
'format': self.format,
'description': self.description,
'example': self.example,
'items': self.items.as_dict() if self.items else None,
'additionalProperties': self.additionalProperties.as_dict() if self.additionalProperties else None,
'discriminator': self.discriminator,
'enum': self.enum,
'properties': {k: v.as_dict() for k, v in self.properties.items()} if self.properties else None,
}
# Convert type to oneOf if necesary, and add refs if needed
def one_of_ref(type_: str) -> dict[str, typing.Any]:
if type_.startswith('#'):
return {'$ref': type_}
return {'type': type_}
if self.one_of: # Ignore type, ose one_of values
val['oneOf'] = [i.as_dict() for i in self.one_of]
del val['type']
elif isinstance(self.type, list):
# If one_of is defined, we should not use type, but one_of
val['oneOf'] = [one_of_ref(ref) for ref in self.type]
del val['type']
elif self.type:
del val['type'] # Remove existing type
val.update(one_of_ref(self.type))
return _as_dict_without_none(val)
# Schema
@dataclasses.dataclass
class Schema:
type: str
format: str | None = None
properties: dict[str, SchemaProperty] = dataclasses.field(default_factory=dict[str, SchemaProperty])
required: list[str] = dataclasses.field(default_factory=list[str])
description: str | None = None
minimum: int | None = None
maximum: int | None = None
# For use on generating schemas
def as_dict(self) -> dict[str, typing.Any]:
return _as_dict_without_none(
{
'type': self.type,
'format': self.format,
'properties': {k: v.as_dict() for k, v in self.properties.items()} if self.properties else None,
'required': self.required if self.required else None,
'description': self.description,
'minimum': self.minimum,
'maximum': self.maximum,
}
)
@dataclasses.dataclass
class RelatedSchema:
property: str
mappings: list[tuple[str, str]] # list of (type, ref)
def as_dict(self) -> dict[str, typing.Any]:
return _as_dict_without_none(
{
'oneOf': [{'$ref': i[1]} for i in self.mappings],
'discriminator': {
'propertyName': self.property,
'mapping': {i[0]: i[1] for i in self.mappings},
},
}
)
# Componentes
@dataclasses.dataclass
class Components:
schemas: dict[str, Schema | RelatedSchema] = dataclasses.field(
default_factory=dict[str, Schema | RelatedSchema]
)
securitySchemes: dict[str, typing.Any] = dataclasses.field(default_factory=dict[str, typing.Any])
def as_dict(self) -> dict[str, typing.Any]:
return _as_dict_without_none(
{
'schemas': {k: v.as_dict() for k, v in self.schemas.items()},
'securitySchemes': self.securitySchemes if self.securitySchemes else None,
}
)
def union(self, other: 'Components') -> 'Components':
'''Returns a new Components instance that is the union of this and another Components.'''
new_components = Components()
new_components.schemas = {**self.schemas, **other.schemas}
if other.securitySchemes:
new_components.securitySchemes = {**self.securitySchemes, **other.securitySchemes}
return new_components
# Operator | will union two Components
def __or__(self, other: 'Components') -> 'Components':
return self.union(other)
def is_empty(self) -> bool:
return not self.schemas
# Info general for OpenApi
@dataclasses.dataclass
class Info:
title: str
version: str
description: str | None = None
def as_dict(self) -> dict[str, typing.Any]:
return {
'title': self.title,
'version': self.version,
'description': self.description,
}
# Documento OpenAPI completo
@dataclasses.dataclass
class OpenAPI:
@staticmethod
def _get_system_version() -> Info:
from uds.core.consts import system
return Info(title='UDS API', version=system.VERSION, description='UDS REST API Documentation')
openapi: str = '3.1.0'
info: Info = dataclasses.field(default_factory=lambda: OpenAPI._get_system_version())
paths: dict[str, PathItem] = dataclasses.field(default_factory=dict[str, PathItem])
components: Components = dataclasses.field(default_factory=Components)
def as_dict(self) -> dict[str, typing.Any]:
return _as_dict_without_none(
{
'openapi': self.openapi,
'info': self.info.as_dict(),
'paths': {k: v.as_dict() for k, v in self.paths.items()},
'components': self.components.as_dict(),
}
)
@dataclasses.dataclass
class ODataParams:
"""
OData query parameters converter
"""
filter: str | None = None # $filter=....
start: int | None = None # $skip=... zero based
limit: int | None = None # $top=... defaults to unlimited right now
orderby: list[str] = dataclasses.field(default_factory=list[str]) # $orderby=xxx, yyy asc, zzz desc
select: set[str] = dataclasses.field(default_factory=set[str]) # $select=...
@staticmethod
def from_dict(data: dict[str, typing.Any]) -> 'ODataParams':
try:
# extract order by, split by ',' and replace asc by '' and desc by a '-' stripping text.
# After this, move the - to the beginning when needed
order_fld = typing.cast(str, data.get('$orderby', ''))
order_by = list(
map(
lambda x: f'-{x.rstrip("-")}' if x.endswith('-') else x,
[
item.strip().replace(' asc', '').replace(' desc', '-')
for item in order_fld.split(',')
if item
],
)
)
select_fld = typing.cast(str, data.get('$select', ''))
select = {item.strip() for item in select_fld.split(',') if item}
start = int(data.get('$skip', 0)) if data.get('$skip') is not None else None
limit = int(data.get('$top', 0)) if data.get('$top') is not None else None
return ODataParams(
filter=data.get('$filter'),
start=start,
limit=limit,
orderby=order_by,
select=select,
)
except (ValueError, TypeError):
raise exceptions.rest.RequestError('Invalid OData query parameters')
def select_filter(self, d: dict[str, typing.Any]) -> dict[str, typing.Any]:
"""
Filters a dictionary by the OData parameters.
Args:
d: The dictionary to filter.
Returns:
A new dictionary containing only the keys from the original dictionary that are in the OData select set.
Note:
If the OData select set is empty, all keys are kept.
"""
if not self.select:
return d
return {k: v for k, v in d.items() if k in self.select}

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,172 @@
# -*- coding: utf-8 -*-
#
# Copyright (c) 2025 Virtual Cable S.L.U.
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without modification,
# are permitted provided that the following conditions are met:
#
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
# * Neither the name of Virtual Cable S.L.U. nor the names of its contributors
# may be used to endorse or promote products derived from this software
# without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
"""
Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
import copy
import typing
import enum
from django.utils.translation import gettext_lazy as _
# Avoid circular import by importing ui here insetad of at the top
from ... import ui
class StockField(enum.StrEnum):
"""
This class contains the static fields that are common to all models.
It is used to define the fields that are common to all models in the system.
"""
TAGS = 'tags'
NAME = 'name'
COMMENTS = 'comments'
PRIORITY = 'priority'
LABEL = 'small_name'
NETWORKS = 'networks'
def get_fields(self) -> list['ui.GuiElement']:
"""
Returns the GUI elements for the field.
"""
from uds.models import Network # Import here to avoid circular import
# Get a copy to ensure we do not modify the original
field_gui = [copy.copy(i) for i in _STATIC_FLDS[self]]
# Special cases, as network choices are dynamic
if self.value == self.NETWORKS:
field_gui[0].gui.choices = sorted(
[ui.ChoiceItem(id=x.uuid, text=x.name) for x in Network.objects.all()],
key=lambda x: x.text.lower(),
)
return field_gui
# Note tha Table Builder will update the order, but keep the order here for, maybe, compatibility with older code
# Eventullay, should be removed
_STATIC_FLDS: typing.Final[dict[StockField, list['ui.GuiElement']]] = {
StockField.TAGS: [
ui.GuiElement(
name='tags',
gui=ui.FieldInfo(
label=_('Tags'),
type=ui.FieldType.TAGLIST,
tooltip=_('Tags for this element'),
order=0 - 110,
),
)
],
StockField.NAME: [
ui.GuiElement(
name='name',
gui=ui.FieldInfo(
type=ui.FieldType.TEXT,
required=True,
label=_('Name'),
length=128,
tooltip=_('Name of this element'),
order=0 - 100,
),
)
],
StockField.COMMENTS: [
ui.GuiElement(
name='comments',
gui=ui.FieldInfo(
label=_('Comments'),
type=ui.FieldType.TEXT,
lines=3,
tooltip=_('Comments for this element'),
length=256,
order=0 - 90,
),
)
],
StockField.PRIORITY: [
ui.GuiElement(
name='priority',
gui=ui.FieldInfo(
label=_('Priority'),
type=ui.FieldType.NUMERIC,
required=True,
default=1,
length=4,
tooltip=_('Selects the priority of this element (lower number means higher priority)'),
order=0 - 80,
),
)
],
StockField.LABEL: [
ui.GuiElement(
name='small_name',
gui=ui.FieldInfo(
label=_('Label'),
type=ui.FieldType.TEXT,
required=True,
length=128,
tooltip=_('Label for this element'),
order=0 - 70,
),
)
],
StockField.NETWORKS: [
ui.GuiElement(
name='networks',
gui=ui.FieldInfo(
label=_('Networks'),
type=ui.FieldType.MULTICHOICE,
tooltip=_('Networks associated. If No network selected, will mean "all networks"'),
choices=[], # Will be filled dynamically
order=101,
tab=ui.Tab.ADVANCED,
),
),
ui.GuiElement(
name='net_filtering',
gui=ui.FieldInfo(
label=_('Network Filtering'),
type=ui.FieldType.CHOICE, # Type of network filtering
default='n',
choices=[
ui.ChoiceItem(id='n', text= _('No filtering')),
ui.ChoiceItem(id='a', text= _('Allow selected networks')),
ui.ChoiceItem(id='d', text= _('Deny selected networks')),
],
tooltip=_(
'Type of network filtering. Use "Disabled" to disable origin check, "Allow" to only enable for selected networks or "Deny" to deny from selected networks'
),
order=100, # At end
tab=ui.Tab.ADVANCED,
),
)
],
}

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':
@@ -129,10 +131,20 @@ class Filler(typing.TypedDict):
# Choices
class ChoiceItem(typing.TypedDict):
@dataclasses.dataclass
class ChoiceItem:
id: 'str'
text: str
img: typing.NotRequired[str] # Only for IMAGECHOICE
img: str | None = None # Only for IMAGECHOICE
def as_dict(self) -> dict[str, typing.Any]:
data = {
'id': self.id,
'text': self.text,
}
if self.img:
data['img'] = self.img
return data
ChoicesType = typing.Union[
@@ -149,57 +161,35 @@ class FieldInfo:
type: FieldType
field_name: str = ''
old_field_name: OldFieldNameType = None
readonly: typing.Optional[bool] = None
value: typing.Union[collections.abc.Callable[[], typing.Any], typing.Any] = None
default: typing.Optional[typing.Union[collections.abc.Callable[[], str], str]] = None
required: typing.Optional[bool] = None
length: typing.Optional[int] = None
lines: typing.Optional[int] = None
pattern: typing.Union[FieldPatternType, 'typing.Pattern[str]'] = FieldPatternType.NONE
tab: typing.Union[Tab, str, None] = None
choices: typing.Optional[ChoicesType] = None
min_value: typing.Optional[int] = None
max_value: typing.Optional[int] = None
fills: typing.Optional[Filler] = None
rows: typing.Optional[int] = None
readonly: bool | None = None
value: collections.abc.Callable[[], typing.Any] | typing.Any | None = None
default: collections.abc.Callable[[], str | int | bool] | str | int | bool | None = None
required: bool | None = None
length: int | None = None
lines: int | None = None
pattern: 'FieldPatternType | str | None' = None
tab: Tab | str | None = None
choices: ChoicesType | None = None
min_value: int | None = None
max_value: int | None = None
fills: Filler | None = None
rows: int | None = None
def as_dict(self) -> dict[str, typing.Any]:
"""Returns a dict with all fields that are not None"""
return {k: v for k, v in dataclasses.asdict(self).items() if v is not None}
class GuiElement(typing.TypedDict):
@dataclasses.dataclass
class GuiElement:
name: str
gui: dict[str, list[dict[str, typing.Any]]]
value: typing.Any
# Row styles
@dataclasses.dataclass
class RowStyleInfo:
prefix: str
field: str
gui: FieldInfo
value: typing.Any | None = None
def as_dict(self) -> dict[str, typing.Any]:
"""Returns a dict with all fields that are not None"""
return dataclasses.asdict(self)
@staticmethod
def null() -> 'RowStyleInfo':
return RowStyleInfo('', '')
# Table information
@dataclasses.dataclass
class TableInfo:
fields: list[FieldInfo]
row_style: RowStyleInfo
title: str
subtitle: typing.Optional[str] = None
def as_dict(self) -> dict[str, typing.Any]:
"""Returns a dict with all fields that are not None"""
return dataclasses.asdict(self)
@staticmethod
def null() -> 'TableInfo':
return TableInfo([], RowStyleInfo.null(), '')
return {
'name': self.name,
'gui': self.gui.as_dict(),
'value': self.value,
}

View File

@@ -47,7 +47,7 @@ import abc
from django.conf import settings
from django.utils.translation import gettext
from django.utils.functional import Promise # To recognize lazy translations
from django.utils import timezone
from uds.core import consts, exceptions, types
from uds.core.managers.crypto import UDSK, CryptoManager
@@ -56,12 +56,13 @@ from uds.core.util import modfinder, serializer, validators, ensure
logger = logging.getLogger(__name__)
# To simplify choice parameters declaration of fields
_ChoicesParamType: typing.TypeAlias = typing.Union[
collections.abc.Callable[[], list['types.ui.ChoiceItem']],
collections.abc.Iterable[str | types.ui.ChoiceItem],
dict[str, str],
None,
]
_ChoicesParamType: typing.TypeAlias = collections.abc.Iterable[types.ui.ChoiceItem]|collections.abc.Callable[[], list['types.ui.ChoiceItem']]|None
# typing.Union[
# collections.abc.Callable[[], list['types.ui.ChoiceItem']],
# collections.abc.Iterable[str | types.ui.ChoiceItem],
# dict[str, str],
# None,
# ]
class gui:
@@ -132,22 +133,19 @@ class gui:
"""
if not isinstance(text, (str, Promise)):
text = str(text)
return {
'id': str(id_),
'text': typing.cast(str, text),
} # Cast to avoid mypy error, Promise is at all effects a str
return types.ui.ChoiceItem(id=str(id_), text=typing.cast(str, text))
@staticmethod
def choice_image(id_: typing.Union[str, int], text: str, img: str) -> types.ui.ChoiceItem:
"""
Helper method to create a single choice item with image.
"""
return {'id': str(id_), 'text': str(text), 'img': img}
return types.ui.ChoiceItem(id=str(id_), text=str(text), img=img)
# Helpers
@staticmethod
def as_choices(
vals: _ChoicesParamType,
vals: _ChoicesParamType|dict[str, str]|str|collections.abc.Iterable[str|types.ui.ChoiceItem]|None = None,
) -> typing.Union[collections.abc.Callable[[], list['types.ui.ChoiceItem']], list['types.ui.ChoiceItem']]:
"""
Helper to convert from array of strings (or dictionaries) to the same dict used in choice,
@@ -160,14 +158,10 @@ class gui:
if callable(vals):
return vals
# Helper to convert an item to a dict
def _choice_from_value(val: typing.Union[str, types.ui.ChoiceItem]) -> 'types.ui.ChoiceItem':
if isinstance(val, dict):
if 'id' not in val or 'text' not in val:
raise ValueError(f'Invalid choice dict: {val}')
return gui.choice_item(val['id'], val['text'])
# If val is not a dict, and it has not 'id' and 'text', raise an exception
return gui.choice_item(val, str(val))
def _choice_from_value(val: str | types.ui.ChoiceItem) -> 'types.ui.ChoiceItem':
if isinstance(val, str):
return gui.choice_item(val, val)
return val
# If is a dict
if isinstance(vals, dict):
@@ -188,9 +182,9 @@ class gui:
key: typing.Optional[collections.abc.Callable[[types.ui.ChoiceItem], typing.Any]] = None,
) -> list[types.ui.ChoiceItem]:
if by_id:
key = lambda item: item['id']
key = lambda item: item.id
elif key is None:
key = lambda item: item['text'].lower()
key = lambda item: item.text.casefold()
else:
key = key
return sorted(choices, key=key, reverse=reverse)
@@ -325,7 +319,7 @@ class gui:
value=value,
tab=tab,
)
@property
def field_name(self) -> str:
"""
@@ -389,22 +383,29 @@ class gui:
"""
self._field_info.value = value
def gui_description(self) -> dict[str, typing.Any]:
def gui_description(self) -> types.ui.FieldInfo:
"""
Returns the dictionary with the description of this item.
We copy it, cause we need to translate the label and tooltip fields
and don't want to
alter original values.
"""
data = self._field_info.as_dict()
for i in ('value', 'old_field_name'):
if i in data:
del data[i] # We don't want to send some values on gui_description
data['label'] = gettext(data['label']) if data['label'] else ''
data['tooltip'] = gettext(data['tooltip']) if data['tooltip'] else ''
if 'tab' in data:
data['tab'] = gettext(data['tab']) # Translates tab name
data['default'] = self.default # We need to translate default value
data = copy.copy(self._field_info)
data.value = data.old_field_name = None # We don't want to send some values on gui_description
data.label = gettext(data.label) if data.label else ''
# Translate label and tooltip
data.tooltip = gettext(data.tooltip) if data.tooltip else ''
# And, if tab is set, translate it too
if data.tab:
data.tab = gettext(data.tab) # Translates tab name
# Choices can be a callback, resolve
if callable(data.choices):
data.choices = data.choices()
data.default = self.default
return data
@property
@@ -649,7 +650,7 @@ class gui:
self.field_type = types.ui.FieldType.TEXT_AUTOCOMPLETE
self._field_info.choices = gui.as_choices(choices or [])
def set_choices(self, values: collections.abc.Iterable[typing.Union[str, types.ui.ChoiceItem]]) -> None:
def set_choices(self, values: collections.abc.Iterable[types.ui.ChoiceItem]) -> None:
"""
Set the values for this choice field
"""
@@ -765,7 +766,7 @@ class gui:
def as_datetime(self) -> datetime.datetime:
"""Alias for "value" property, but as datetime.datetime"""
# Convert date to datetime
return datetime.datetime.combine(self.as_date(), datetime.datetime.min.time())
return timezone.make_aware(datetime.datetime.combine(self.as_date(), datetime.datetime.min.time()))
def as_timestamp(self) -> int:
"""Alias for "value" property, but as timestamp"""
@@ -799,11 +800,11 @@ class gui:
def value(self, value: datetime.date | str) -> None:
self._set_value(value)
def gui_description(self) -> dict[str, typing.Any]:
def gui_description(self) -> types.ui.FieldInfo:
fldgui = super().gui_description()
# Convert if needed value and default to string (YYYY-MM-DD)
if 'default' in fldgui:
fldgui['default'] = str(fldgui['default'])
if fldgui.default is not None:
fldgui.default = str(fldgui.default)
return fldgui
class PasswordField(InputField):
@@ -1133,7 +1134,7 @@ class gui:
if fills['callback_name'] not in gui.callbacks:
gui.callbacks[fills['callback_name']] = fnc
def set_choices(self, values: collections.abc.Iterable[typing.Union[str, types.ui.ChoiceItem]]) -> None:
def set_choices(self, values: collections.abc.Iterable[types.ui.ChoiceItem]) -> None:
"""
Set the values for this choice field
"""
@@ -1185,7 +1186,7 @@ class gui:
self._field_info.choices = gui.as_choices(choices or [])
def set_choices(self, values: collections.abc.Iterable[typing.Union[str, types.ui.ChoiceItem]]) -> None:
def set_choices(self, values: collections.abc.Iterable[types.ui.ChoiceItem]) -> None:
"""
Set the values for this choice field
"""
@@ -1275,7 +1276,7 @@ class gui:
self._field_info.choices = gui.as_choices(choices or [])
def set_choices(
self, choices: collections.abc.Iterable[typing.Union[str, types.ui.ChoiceItem]]
self, choices: collections.abc.Iterable[types.ui.ChoiceItem]
) -> None:
"""
Set the values for this choice field
@@ -1523,6 +1524,17 @@ class UserInterface(metaclass=UserInterfaceType):
of this posibility in a near version...
"""
@classmethod
def describe_fields(cls: type[typing.Self]) -> list[types.ui.GuiElement]:
return [
types.ui.GuiElement(
name=key,
gui=val.gui_description(),
value=val.value if val.is_type(types.ui.FieldType.HIDDEN) else None,
)
for key, val in cls._gui_fields_template.items()
]
def get_fields_as_dict(self) -> gui.ValuesDictType:
"""
Returns own data needed for user interaction as a dict of key-names ->
@@ -1636,6 +1648,11 @@ class UserInterface(metaclass=UserInterfaceType):
# Dict of translations from old_field_name to field_name
field_names_translations: dict[str, str] = self._get_fieldname_translations()
# Allowed conversions of type
VALID_CONVERSIONS: typing.Final[dict[types.ui.FieldType, list[types.ui.FieldType]]] = {
types.ui.FieldType.TEXT: [types.ui.FieldType.PASSWORD]
}
# Set all values to defaults ones
for field_name, field in self._all_serializable_fields():
if field.is_type(types.ui.FieldType.HIDDEN) and field.is_serializable() is False:
@@ -1653,17 +1670,20 @@ class UserInterface(metaclass=UserInterfaceType):
if internal_field_type not in FIELD_DECODERS:
logger.warning('Field %s has no decoder', field_name)
continue
if field_type != internal_field_type.name:
# Especial case for text fields converted to password fields
if not (internal_field_type == types.ui.FieldType.PASSWORD and field_type == types.ui.FieldType.TEXT.name):
logger.warning(
'Field %s has different type than expected: %s != %s',
field_name,
field_type,
internal_field_type.name,
)
continue
if valids_for_field := VALID_CONVERSIONS.get(internal_field_type):
if field_type not in [v.name for v in valids_for_field]:
# If the field type is not valid for the internal field type, we log a warning
# and do not include this field in the form
logger.warning(
'Field %s has different type than expected: %s != %s. Not included in form',
field_name,
field_type,
internal_field_type.name,
)
continue
self._gui[field_name].value = FIELD_DECODERS[internal_field_type](field_value)
return False
@@ -1744,11 +1764,11 @@ class UserInterface(metaclass=UserInterfaceType):
for key, val in self._gui.items():
# Only add "value" for hidden fields on gui description. Rest of fields will be filled by client
res.append(
{
'name': key,
'gui': val.gui_description(),
'value': val.value if val.is_type(types.ui.FieldType.HIDDEN) else None,
}
types.ui.GuiElement(
name=key,
gui=val.gui_description(),
value=val.value if val.is_type(types.ui.FieldType.HIDDEN) else None,
)
)
# logger.debug('theGui description: %s', res)
return res
@@ -1791,12 +1811,13 @@ def password_compat_field_decoder(value: str) -> str:
"""
Compatibility function to decode text fields converted to password fields
"""
try:
try:
value = CryptoManager.manager().aes_decrypt(value.encode('utf8'), UDSK, True).decode()
except Exception:
pass
return value
# Dictionaries used to encode/decode fields to be stored on database
FIELDS_ENCODERS: typing.Final[
collections.abc.Mapping[

View File

@@ -0,0 +1,459 @@
import typing
import itertools
import collections.abc
import logging
import dataclasses
import datetime
import enum
import functools
import types as py_types
from uds.core import types
from uds.core.types.rest.api import SchemaProperty
if typing.TYPE_CHECKING:
from uds.REST import model
logger = logging.getLogger(__name__)
def _resolve_forwardref(
ref: typing.Any, globalns: dict[str, typing.Any] | None = None, localns: dict[str, typing.Any] | None = None
):
if isinstance(ref, typing.ForwardRef):
# if not already evaluated, raise an exception
if not ref.__forward_evaluated__:
return None
return ref.__forward_value__
return ref
def get_generic_types(
cls: 'type[model.ModelHandler[typing.Any] | model.DetailHandler[typing.Any]]',
) -> list[type[types.rest.BaseRestItem]]:
"""
Get the generic types of a model handler or detail handler class.
Args:
cls: The class to inspect. (Must be subclass of ModelHandler or DetailHandler)
Note: Normally, for our models, will be or an empty list, or a list with just one element
that is a subclass of BaseRestItem.
Examples:
class Test(ModelHandler[TheType]):
...
if Test is resolvable and TheType is also resolvable, will return
[TheType], else will return []
We use the "list" version just in case, in a future, we have other kind of constructions
with several elements.
"""
base_types: list[type[types.rest.BaseRestItem]] = list(
filter(
lambda x: issubclass(x, types.rest.BaseRestItem), # pyright: ignore[reportUnnecessaryIsInstance]
itertools.chain.from_iterable(
map(
lambda x: [
# Filter out non resolvable forward references of the ARGS, protect against failures
typing.cast(type[typing.Any], _resolve_forwardref(xx))
for xx in typing.get_args(x)
if _resolve_forwardref(xx) is not None
],
[
# Filter out non resolvable forward references of the TYPE, protect against failures
typing.cast(type[typing.Any], _resolve_forwardref(base))
for base in filter(
lambda x: _resolve_forwardref(x) is not None,
[base for base in getattr(cls, '__orig_bases__', [])],
)
],
)
),
)
)
return base_types
def get_component_from_type(
cls: 'type[model.ModelHandler[typing.Any] | model.DetailHandler[typing.Any]]',
) -> types.rest.api.Components:
logger.debug('Getting components from type %s', cls)
base_types = get_generic_types(cls)
all_components = types.rest.api.Components()
for base_type in base_types:
logger.debug('Processing base %s for components %s', base_type, base_type.__bases__)
components = base_type.api_components()
# A reference
item_name = base_type.__name__
# For item schema in components
item_schema = next(filter(lambda x: x[0] == item_name, components.schemas.items()), (None, None))[1]
is_managed_object = issubclass(base_type, types.rest.ManagedObjectItem)
possible_types = cls.possible_types()
refs: list[str] = []
mappings: list[tuple[str, str]] = []
for type_ in possible_types:
type_schema = types.rest.api.Schema(
type='object',
required=[],
description=type_.__doc__ or None,
)
for field in type_.describe_fields():
schema_property = types.rest.api.SchemaProperty.from_field_desc(field)
if schema_property is None:
continue # Skip fields that don't have a schema property
type_schema.properties[field.name] = schema_property
if field.gui.required is True:
type_schema.required.append(field.name)
ref = f'#/components/schemas/{type_.mod_type()}'
refs.append(ref)
mappings.append((f'{type_.mod_type()}', ref))
components.schemas[type_.mod_type()] = type_schema
if is_managed_object and isinstance(item_schema, types.rest.api.Schema):
# item_schema.discriminator = types.rest.api.Discriminator(propertyName='type')
instance_name = f'{item_name}Instance'
item_schema.properties['instance'] = types.rest.api.SchemaProperty(
type=f'#/components/schemas/{instance_name}'
)
instance_comps = types.rest.api.Components(
schemas={instance_name: types.rest.api.RelatedSchema(property='type', mappings=mappings)}
)
all_components = all_components.union(instance_comps)
# Store it
all_components = all_components.union(components)
return all_components
@dataclasses.dataclass(slots=True)
class OpenApiTypeInfo:
type: str
format: str | None = None
ref: bool = False
items: str | None = None # Type of items in array
def as_dict(self) -> dict[str, typing.Any]:
dct: dict[str, typing.Any] = {'type': self.type}
if self.format:
dct['format'] = self.format
if self.items:
dct['items'] = {'type': self.items}
return dct
class OpenApiType(enum.Enum):
OBJECT = OpenApiTypeInfo(type='object')
INTEGER = OpenApiTypeInfo(type='integer', format='int64')
STRING = OpenApiTypeInfo(type='string')
NUMBER = OpenApiTypeInfo(type='number')
BOOLEAN = OpenApiTypeInfo(type='boolean')
NULL = OpenApiTypeInfo(type='null')
DATE_TIME = OpenApiTypeInfo(type='string', format='date-time')
DATE = OpenApiTypeInfo(type='string', format='date')
LIST_STR = OpenApiTypeInfo(type='array', items='string')
LIST_INT = OpenApiTypeInfo(type='array', items='integer')
_OPENAPI_TYPE_MAP: typing.Final[dict[typing.Any, OpenApiType]] = {
int: OpenApiType.INTEGER,
str: OpenApiType.STRING,
float: OpenApiType.NUMBER,
bool: OpenApiType.BOOLEAN,
type(None): OpenApiType.NULL,
datetime.datetime: OpenApiType.DATE_TIME,
datetime.date: OpenApiType.DATE,
list[str]: OpenApiType.LIST_STR,
list[int]: OpenApiType.LIST_INT,
}
def python_type_to_openapi(
py_type: typing.Any, description: str | None = None
) -> 'types.rest.api.SchemaProperty':
"""
Convert a Python type to an OpenAPI 3.1 schema property.
"""
# Partial to add description to schema property if provided
schema_prop = functools.partial(types.rest.api.SchemaProperty, description=description)
origin = typing.get_origin(py_type)
args = typing.get_args(py_type)
# list[...] → array
if origin is list:
item_type = args[0] if args else typing.Any
return schema_prop(type='array', items=python_type_to_openapi(item_type))
# dict[...] → object
elif origin is dict:
value_type = args[1] if len(args) == 2 else typing.Any
return schema_prop(
type='object', additionalProperties=python_type_to_openapi(value_type)
)
# Union[...] → oneOf
# Except if one of them is None, in which case, we must extract it from the list
# and create {'type': xxx, 'nullable': true}
elif origin in {py_types.UnionType, typing.Union}:
# Optional[X] is Union[X, None]
# Note: the casting is because we use "is not", and cannot ad inner types
one_of: list[SchemaProperty] = [
python_type_to_openapi(arg)
for arg in args
if arg is not None
and typing.get_origin(arg) is not typing.cast(typing.Any, collections.abc.Callable)
]
# Remove repeated
one_of = list({item.type: item for item in one_of}.values())
# if only 1, return it directly
if len(one_of) == 1:
return one_of[0]
return schema_prop(
type='not_used',
one_of=one_of,
)
elif origin is typing.Annotated:
return python_type_to_openapi(args[0])
# Literal[...] → enum
elif origin is typing.Literal:
literal_type = typing.cast(type[typing.Any], type(args[0]) if args else str)
return schema_prop(
type=_OPENAPI_TYPE_MAP.get(literal_type, OpenApiType.STRING).value.type, enum=list(args)
)
# Enum classes
# First, IntEnum --> int
elif isinstance(py_type, type) and issubclass(py_type, enum.IntEnum):
return schema_prop(type='integer', enum=[e.value for e in py_type])
# Now, StrEnum --> string
elif isinstance(py_type, type) and issubclass(py_type, enum.StrEnum):
return schema_prop(type='string', enum=[e.value for e in py_type])
# Rest of cases --> enum with first item type setting the type for the field
elif isinstance(py_type, type) and issubclass(py_type, enum.Enum):
try:
sample = next(iter(py_type))
value_type = typing.cast(type[typing.Any], type(sample.value))
openapi_type = _OPENAPI_TYPE_MAP.get(value_type, OpenApiType.STRING)
return schema_prop(type=openapi_type.value.type, enum=[e.value for e in py_type])
except StopIteration:
return schema_prop(type='string')
elif isinstance(py_type, type) and dataclasses.is_dataclass(py_type):
return schema_prop(type=f'#/components/schemas/{py_type.__name__}')
# Simple types
oa_type = _OPENAPI_TYPE_MAP.get(py_type, OpenApiType.OBJECT)
return schema_prop(type=oa_type.value.type, format=oa_type.value.format)
def api_components(
dataclass: typing.Type[typing.Any], *, removable_fields: list[str] | None = None
) -> 'types.rest.api.Components':
from uds.core.util import api as api_util # Avoid circular import
# If not dataclass, raise a ValueError
if not dataclasses.is_dataclass(dataclass):
raise ValueError('Expected a dataclass')
our_removables: set[str] = set()
child_removables: dict[str, list[str]] = {}
for rem_fld in removable_fields or []:
if '.' in rem_fld:
child_name, field = rem_fld.split('.', 1)
if child_name not in child_removables:
child_removables[child_name] = []
child_removables[child_name].append(field)
else:
our_removables.add(rem_fld)
components = types.rest.api.Components()
schema = types.rest.api.Schema(type='object', properties={}, description=None)
# type_hints = typing.get_type_hints(dataclass)
for field in dataclasses.fields(dataclass):
if field.name in our_removables:
continue
description = field.metadata.get('description')
# Check the type, can be a primitive or a complex type
# complexes types accepted are list and dict currently
field_type = field.type # type_hints.get(field.name)
if not field_type:
raise Exception(f'Field {field.name} has no type hint')
args = typing.get_args(field_type)
if args and dataclasses.is_dataclass(args[0]):
# If it's a reference to a dataclass, include the dataclass definition
# care with circular references. Not checked right now, data is our,
# No problem should arise..
components = components | api_components(
typing.cast(type[typing.Any], args[0]), removable_fields=child_removables.get(field.name, [])
)
# If it is a dataclass, get its API components
if dataclasses.is_dataclass(field_type):
components = components | api_components(
typing.cast(type[typing.Any], field_type),
removable_fields=child_removables.get(field.name, []),
)
schema_prop = api_util.python_type_to_openapi(field_type, description=description)
schema.properties[field.name] = schema_prop
if field.default is dataclasses.MISSING and field.default_factory is dataclasses.MISSING:
schema.required.append(field.name)
components.schemas[dataclass.__name__] = schema
return components
def gen_response(
type: str,
single: bool = True,
delete: bool = False,
with_403: bool = True,
) -> dict[str, types.rest.api.Response]:
data: dict[str, types.rest.api.Response]
if not single:
data = {
'200': types.rest.api.Response(
description=f'Successfully retrieved all {type} items',
content=types.rest.api.Content(
media_type='application/json',
schema=types.rest.api.SchemaProperty(
type='array',
items=types.rest.api.SchemaProperty(
type=f'#/components/schemas/{type}',
),
),
),
)
}
else:
data = {
'200': types.rest.api.Response(
description=f'Successfully {"retrieved" if not delete else "deleted"} {type} item',
content=types.rest.api.Content(
media_type='application/json',
schema=types.rest.api.SchemaProperty(
type=f'#/components/schemas/{type}',
),
),
)
}
if single:
data['404'] = types.rest.api.Response(
description=f'{type} item not found',
content=types.rest.api.Content(
media_type='application/json',
schema=types.rest.api.SchemaProperty(
type='object',
properties={
'detail': types.rest.api.SchemaProperty(
type='string',
)
},
),
),
)
if with_403:
data['403'] = types.rest.api.Response(
description='Forbidden. You do not have permission to access this resource with your current role.',
content=types.rest.api.Content(
media_type='application/json',
schema=types.rest.api.SchemaProperty(
type='object',
properties={
'detail': types.rest.api.SchemaProperty(
type='string',
)
},
),
),
)
return data
def gen_request_body(type: str, create: bool = True) -> types.rest.api.RequestBody:
return types.rest.api.RequestBody(
description=f'{"New" if create else "Updated"} {type} item{"s" if not create else ""} to create',
required=True,
content=types.rest.api.Content(
media_type='application/json',
schema=types.rest.api.SchemaProperty(
type=f'#/components/schemas/{type}',
),
),
)
def gen_odata_parameters() -> list[types.rest.api.Parameter]:
return [
types.rest.api.Parameter(
name='$filter',
in_='query',
required=False,
description='Filter items by property values (e.g., $filter=property eq value)',
schema=types.rest.api.Schema(type='string'),
),
types.rest.api.Parameter(
name='$select',
in_='query',
required=False,
description='Select properties to be returned',
schema=types.rest.api.Schema(type='string'),
),
types.rest.api.Parameter(
name='$orderby',
in_='query',
required=False,
description='Order items by property values (e.g., $orderby=property desc)',
schema=types.rest.api.Schema(type='string'),
),
types.rest.api.Parameter(
name='$top',
in_='query',
required=False,
description='Show only the first N items',
schema=types.rest.api.Schema(type='integer', format='int32', minimum=1),
),
types.rest.api.Parameter(
name='$skip',
in_='query',
required=False,
description='Skip the first N items',
schema=types.rest.api.Schema(type='integer', format='int32', minimum=0),
),
]
def gen_uuid_parameters(with_odata: bool) -> list[types.rest.api.Parameter]:
return [
types.rest.api.Parameter(
name='uuid',
in_='path',
required=True,
description='The UUID of the item',
schema=types.rest.api.Schema(type='string', format='uuid'),
)
] + (gen_odata_parameters() if with_odata else [])

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

@@ -39,6 +39,7 @@ import logging
import bitarray
from django.core.cache import caches
from django.utils import timezone
from uds.core.util.model import sql_now
@@ -77,12 +78,14 @@ class CalendarChecker:
data_date = dtime.date()
start = datetime.datetime.combine(data_date, datetime.datetime.min.time())
start = timezone.make_aware(start)
end = datetime.datetime.combine(data_date, datetime.datetime.max.time())
end = timezone.make_aware(end)
for rule in self.calendar.rules.all():
rr = rule.as_rrule()
r_end = datetime.datetime.combine(rule.end, datetime.datetime.max.time()) if rule.end else None
r_end = timezone.make_aware(datetime.datetime.combine(rule.end, datetime.datetime.max.time())) if rule.end else None
duration_in_minutes = rule.duration_as_minutes
frequency_in_minutes = rule.frequency_as_minutes

View File

@@ -4,8 +4,10 @@ import socket
import typing
from django.db import transaction, OperationalError
from django.utils import timezone
from uds import models
from uds.core import consts
from uds.core.util.iface import get_first_iface
from uds.core.util.model import sql_now, get_my_ip_from_db
@@ -20,7 +22,7 @@ class UDSClusterNode(typing.NamedTuple):
hostname: str
ip: str
last_seen: datetime.datetime
mac: str = '00:00:00:00:00:00'
mac: str = consts.NULL_MAC
def as_dict(self) -> dict[str, str]:
"""
@@ -44,7 +46,7 @@ def store_cluster_info() -> None:
"""
iface = get_first_iface()
ip = iface.ip if iface else get_my_ip_from_db()
mac = iface.mac if iface else '00:00:00:00:00:00'
mac = iface.mac if iface else consts.NULL_MAC
try:
hostname = socket.getfqdn() + '|' + ip
@@ -81,8 +83,8 @@ def enumerate_cluster_nodes() -> list[UDSClusterNode]:
UDSClusterNode(
hostname=prop.key.split('|')[0],
ip=prop.key.split('|')[1],
last_seen=datetime.datetime.fromisoformat(prop.value['last_seen']),
mac=prop.value.get('mac', '00:00:00:00:00:00'),
last_seen=timezone.make_aware(datetime.datetime.fromisoformat(prop.value['last_seen'])),
mac=prop.value.get('mac', consts.NULL_MAC),
)
for prop in properties
if 'last_seen' in prop.value and '|' in prop.key

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
"""
@@ -701,7 +701,7 @@ class GlobalConfig:
# Site display name & copyright info
SITE_NAME: Config.Value = Config.section(Config.SectionType.CUSTOM).value(
'Site name',
'UDS Enterprise',
'UDS',
type=Config.FieldType.TEXT,
help=_('Site display name'),
)

View File

@@ -45,7 +45,7 @@ logger = logging.getLogger(__name__)
# FT = typing.TypeVar('FT', bound=collections.abc.Callable[..., typing.Any])
P = typing.ParamSpec('P')
R = typing.TypeVar('R')
R = typing.TypeVar('R', bound=typing.Any, covariant=True) # R is covariant, so we can return a subclass of R
@dataclasses.dataclass
@@ -147,16 +147,16 @@ class _HasConnect(typing.Protocol):
# Keep this, but mypy does not likes it... it's perfect with pyright
# We use pyright for type checking, so we will use this
HasConnect = typing.TypeVar('HasConnect', bound=_HasConnect)
HAS_CONNECT = typing.TypeVar('HAS_CONNECT', bound=_HasConnect)
def ensure_connected(
func: collections.abc.Callable[typing.Concatenate[HasConnect, P], R],
) -> collections.abc.Callable[typing.Concatenate[HasConnect, P], R]:
func: collections.abc.Callable[typing.Concatenate[HAS_CONNECT, P], R],
) -> collections.abc.Callable[typing.Concatenate[HAS_CONNECT, P], R]:
"""This decorator calls "connect" method of the class of the wrapped object"""
@functools.wraps(func)
def connect_and_execute(obj: HasConnect, /, *args: P.args, **kwargs: P.kwargs) -> R:
def connect_and_execute(obj: HAS_CONNECT, /, *args: P.args, **kwargs: P.kwargs) -> R:
# self = typing.cast(_HasConnect, args[0])
obj.connect()
return func(obj, *args, **kwargs)
@@ -177,15 +177,14 @@ def ensure_connected(
# Now, we could use this by creating two decorators, one for the class methods and one for the functions
# But the inheritance problem will still be there, so we will keep the current implementation
# Decorator for caching
# This decorator will cache the result of the function for a given time, and given parameters
def cached(
prefix: typing.Optional[str] = None,
timeout: typing.Union[collections.abc.Callable[[], int], int] = -1,
args: typing.Optional[typing.Union[collections.abc.Iterable[int], int]] = None,
kwargs: typing.Optional[typing.Union[collections.abc.Iterable[str], str]] = None,
key_helper: typing.Optional[collections.abc.Callable[[typing.Any], str]] = None,
prefix: str | None = None,
timeout: collections.abc.Callable[[], int] | int = -1,
args: collections.abc.Iterable[int] | int | None = None,
kwargs: collections.abc.Iterable[str] | str | None = None,
key_helper: collections.abc.Callable[[typing.Any], str] | None = None,
) -> collections.abc.Callable[[collections.abc.Callable[P, R]], collections.abc.Callable[P, R]]:
"""
Decorator that gives us a "quick & clean" caching feature on the database.
@@ -289,6 +288,9 @@ def cached(
data: typing.Any = None
# If misses is 0, we are starting, so we will not try to get from cache
if not kwargs.get('force', False) and effective_timeout > 0 and misses > 0:
if 'force' in kwargs:
# Remove force key
del kwargs['force']
data = cache.get(cache_key, default=consts.cache.CACHE_NOT_FOUND)
if data is not consts.cache.CACHE_NOT_FOUND:
hits += 1
@@ -296,10 +298,6 @@ def cached(
misses += 1
if 'force' in kwargs:
# Remove force key
del kwargs['force']
# Execute the function outside the DB transaction
t = time.thread_time_ns()
data = fnc(*args, **kwargs) # pyright: ignore # For some reason, pyright does not like this line
@@ -340,8 +338,8 @@ def threaded(func: collections.abc.Callable[P, None]) -> collections.abc.Callabl
def blocker(
request_attr: typing.Optional[str] = None,
max_failures: typing.Optional[int] = None,
request_attr: str | None = None,
max_failures: int | None = None,
ignore_block_config: bool = False,
) -> collections.abc.Callable[[collections.abc.Callable[P, R]], collections.abc.Callable[P, R]]:
"""
@@ -375,14 +373,14 @@ def blocker(
except uds.core.exceptions.rest.BlockAccess:
raise exceptions.rest.AccessDenied()
request: typing.Any = getattr(args[0], request_attr or '_request', None)
req: typing.Any | None = getattr(args[0], request_attr or '_request', None)
# No request object, so we can't block
if request is None or getattr(request, 'ip', None) is None:
logger.debug('No request object, so we can\'t block: (value is %s)', request)
if req is None or getattr(req, 'ip', None) is None:
logger.debug('No request object, so we can\'t block: (value is %s)', req)
return f(*args, **kwargs)
request = typing.cast(types.requests.ExtendedHttpRequest, request)
request = typing.cast(types.requests.ExtendedHttpRequest, req)
ip = request.ip
@@ -412,7 +410,7 @@ def blocker(
def profiler(
log_file: typing.Optional[str] = None,
log_file: str | None = None,
) -> collections.abc.Callable[[collections.abc.Callable[P, R]], collections.abc.Callable[P, R]]:
"""
Decorator that will profile the wrapped function and log the results to the provided file
@@ -452,7 +450,7 @@ def retry_on_exception(
retries: int,
*,
wait_seconds: float = 2,
retryable_exceptions: typing.Optional[typing.List[typing.Type[Exception]]] = None,
retryable_exceptions: list[type[Exception]] | None = None,
do_log: bool = False,
) -> collections.abc.Callable[[collections.abc.Callable[P, R]], collections.abc.Callable[P, R]]:
to_retry = retryable_exceptions or [Exception]

View File

@@ -498,8 +498,8 @@ def put_back_to_cache_field(
label=_('Put back to cache'),
tooltip=_('On machine releasy by logout, put it back to cache instead of deleting if possible.'),
choices=[
{'id': 'no', 'text': _('No. Never put it back to cache')},
{'id': 'yes', 'text': _('Yes, try to put it back to cache')},
types.ui.ChoiceItem(id='no', text=_('No. Never put it back to cache')),
types.ui.ChoiceItem(id='yes', text=_('Yes, try to put it back to cache')),
],
tab=tab,
)

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

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

View File

@@ -1,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,29 @@ 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 django.utils import timezone
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
@@ -80,17 +81,20 @@ class TimeTrack:
else 'SELECT CURRENT_TIMESTAMP'
)
cursor.execute(sentence)
date = (cursor.fetchone() or [datetime.datetime.now()])[0]
dt = (cursor.fetchone() or [timezone.localtime()])[0]
else:
date = (
datetime.datetime.now()
dt = (
timezone.localtime()
) # If not know how to get database datetime, returns local datetime (this is fine for sqlite, which is local)
return date
if timezone.is_naive(dt):
dt = timezone.make_aware(dt)
return dt
@staticmethod
def sql_now() -> datetime.datetime:
now = datetime.datetime.now()
now = timezone.localtime()
with TimeTrack.lock:
diff = now - TimeTrack.last_check
# If in last_check is in the future, or more than CACHE_TIME_TIMEOUT seconds ago, we need to refresh
@@ -104,6 +108,7 @@ class TimeTrack:
the_time = TimeTrack.cached_time + (now - TimeTrack.last_check)
# Keep only cent of second precision
the_time = the_time.replace(microsecond=int(the_time.microsecond / 10000) * 10000)
return the_time
@@ -120,7 +125,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:
@@ -129,14 +134,14 @@ 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:
def generate_uuid() -> str:
"""
Generates a ramdom uuid for models default
"""
return CryptoManager.manager().uuid(obj=obj).lower()
return CryptoManager.manager().uuid().lower()
def process_uuid(uuid: str) -> str:
@@ -167,9 +172,9 @@ def get_my_ip_from_db() -> str:
with connection.cursor() as cursor:
cursor.execute(query)
result = cursor.fetchone()
if result:
result = result[0] if isinstance(result[0], str) else result[0].decode('utf8')
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:

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

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