cherrypy-foundation 1.0.0a1__py3-none-any.whl → 1.0.0a3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. cherrypy_foundation/components/Field.jinja +14 -6
  2. cherrypy_foundation/components/Typeahead.css +6 -1
  3. cherrypy_foundation/components/Typeahead.jinja +2 -2
  4. cherrypy_foundation/components/__init__.py +1 -1
  5. cherrypy_foundation/error_page.py +18 -15
  6. cherrypy_foundation/plugins/db.py +34 -8
  7. cherrypy_foundation/plugins/ldap.py +28 -22
  8. cherrypy_foundation/plugins/scheduler.py +197 -84
  9. cherrypy_foundation/plugins/smtp.py +71 -45
  10. cherrypy_foundation/plugins/tests/test_db.py +2 -2
  11. cherrypy_foundation/plugins/tests/test_scheduler.py +58 -50
  12. cherrypy_foundation/plugins/tests/test_smtp.py +9 -6
  13. cherrypy_foundation/tests/templates/test_form.html +10 -0
  14. cherrypy_foundation/tests/test_form.py +119 -0
  15. cherrypy_foundation/tools/auth.py +28 -11
  16. cherrypy_foundation/tools/auth_mfa.py +10 -13
  17. cherrypy_foundation/tools/errors.py +1 -1
  18. cherrypy_foundation/tools/i18n.py +15 -6
  19. cherrypy_foundation/tools/jinja2.py +15 -12
  20. cherrypy_foundation/tools/ratelimit.py +5 -9
  21. cherrypy_foundation/tools/secure_headers.py +0 -4
  22. cherrypy_foundation/tools/tests/components/Button.jinja +2 -0
  23. cherrypy_foundation/tools/tests/templates/test_jinja2.html +10 -0
  24. cherrypy_foundation/tools/tests/templates/test_jinja2_i18n.html +11 -0
  25. cherrypy_foundation/tools/tests/templates/test_jinjax.html +8 -0
  26. cherrypy_foundation/tools/tests/test_auth.py +1 -1
  27. cherrypy_foundation/tools/tests/test_auth_mfa.py +367 -0
  28. cherrypy_foundation/tools/tests/test_jinja2.py +123 -0
  29. {cherrypy_foundation-1.0.0a1.dist-info → cherrypy_foundation-1.0.0a3.dist-info}/METADATA +1 -1
  30. {cherrypy_foundation-1.0.0a1.dist-info → cherrypy_foundation-1.0.0a3.dist-info}/RECORD +33 -25
  31. {cherrypy_foundation-1.0.0a1.dist-info → cherrypy_foundation-1.0.0a3.dist-info}/WHEEL +0 -0
  32. {cherrypy_foundation-1.0.0a1.dist-info → cherrypy_foundation-1.0.0a3.dist-info}/licenses/LICENSE.md +0 -0
  33. {cherrypy_foundation-1.0.0a1.dist-info → cherrypy_foundation-1.0.0a3.dist-info}/top_level.txt +0 -0
@@ -29,16 +29,24 @@
29
29
  {% set input_class, label_class, floating_supported, label_always_last = bootstrap_class_table.get(field.widget.__class__.__name__) or ('', 'form-label', False, False) %}
30
30
  {% set label_last = (floating and floating_supported) or label_always_last %}
31
31
  {% do attrs.add_class(input_class) %}
32
- {# Render label an widget #}
33
32
  {# Used for input-group #}
34
33
  {% set prepend = attrs.get('prepend', None) %}
35
34
  {% set append = attrs.get('append', None) %}
36
35
  {% set input_group = prepend or append %}
37
- {% set container_class = attrs.get('container_class', 'col-12') %}
38
- {% do attrs.__delitem__('container_class') %}
39
- <div class="mb-2 form-field {{ container_class }}">
36
+ {# Build container & label specific attributes #}
37
+ {% set container_attrs = attrs.__class__({'class': 'mb-2 form-field'}) %}
38
+ {% set label_attrs = attrs.__class__({'class': label_class}) %}
39
+ {% for key, value in attrs.as_dict.items() %}
40
+ {% if key.startswith('container-') or key.startswith('container_') %}
41
+ {% do container_attrs.set(**{key[10:]: value}) %}
42
+ {% endif %}
43
+ {% if key.startswith('label-') or key.startswith('label_') %}
44
+ {% do label_attrs.set(**{key[6:]: value}) %}
45
+ {% endif %}
46
+ {% endfor %}
47
+ <div {{ container_attrs.as_dict | xmlattr }}>
40
48
  {% if floating and floating_supported %}<div class="form-floating">{% endif %}
41
- {% if not label_last %}{{ field.label(class=label_class) }}{% endif %}
49
+ {% if not label_last %}{{ field.label(**label_attrs.as_dict) }}{% endif %}
42
50
  {% if input_group %}
43
51
  <div class="input-group">
44
52
  {% if prepend %}<span class="input-group-text">{{ prepend }}</span>{% endif %}
@@ -48,7 +56,7 @@
48
56
  {% if append %}<span class="input-group-text">{{ append }}</span>{% endif %}
49
57
  </div>
50
58
  {% endif %}
51
- {% if label_last %}{{ field.label(class=label_class) }}{% endif %}
59
+ {% if label_last %}{{ field.label(**label_attrs.as_dict) }}{% endif %}
52
60
  {% for error in field.errors %}<div class="invalid-feedback">{{ error }}</div>{% endfor %}
53
61
  {% if field.description %}<div class="form-text small test-secondary">{{ field.description }}</div>{% endif %}
54
62
  {% if floating and floating_supported %}</div>{% endif %}
@@ -10,6 +10,11 @@
10
10
  top: 0.5rem;
11
11
  }
12
12
 
13
+ /* Fix lense position */
14
+ .typeahead__button button {
15
+ height: 100%;
16
+ }
17
+
13
18
  /* Fix color for DarkMode */
14
19
  .typeahead__container button,
15
20
  .typeahead__container button.disabled,
@@ -47,4 +52,4 @@
47
52
  .typeahead__list .typeahead__item:not([disabled])>a:hover {
48
53
  background-color: var(--bs-tertiary-bg);
49
54
  color: var(--bs-body-color);
50
- }
55
+ }
@@ -1,5 +1,5 @@
1
1
  {#def
2
- accent=True,
2
+ accent=False,
3
3
  async_result=False,
4
4
  autofocus=False,
5
5
  backdrop=False,
@@ -27,7 +27,7 @@ href=None,
27
27
  loading_animation=True,
28
28
  matcher=None,
29
29
  max_item=8,
30
- max_item_per_group=10,
30
+ max_item_per_group=None,
31
31
  max_length=None,
32
32
  min_length=2,
33
33
  multiselect=None,
@@ -1,4 +1,4 @@
1
- # udb, A web interface to manage IT network
1
+ # Cherrypy Foundation
2
2
  # Copyright (C) 2025 IKUS Software inc.
3
3
  #
4
4
  # This program is free software: you can redistribute it and/or modify
@@ -19,9 +19,6 @@ import logging
19
19
 
20
20
  import cherrypy
21
21
 
22
- # Define the logger
23
- logger = logging.getLogger(__name__)
24
-
25
22
  HTML_ERROR_TEMPLATE = '''<!DOCTYPE html>
26
23
  <html>
27
24
  <head>
@@ -53,39 +50,45 @@ HTML_ERROR_TEMPLATE = '''<!DOCTYPE html>
53
50
  '''
54
51
 
55
52
 
56
- def error_page(**kwargs):
53
+ def error_page(status='', message='', traceback='', version=''):
57
54
  """
58
55
  Error page handler to handle Plain Text (text/plain), Json (application/json) or HTML (text/html) output.
59
56
 
60
57
  If available, uses Jina2 environment to generate error page using `error_page.html`.
61
58
  """
62
59
  # Log server error exception
63
- if kwargs.get('status', '').startswith('500'):
64
- logger.error(
65
- 'error page: %s %s\n%s' % (kwargs.get('status', ''), kwargs.get('message', ''), kwargs.get('traceback', ''))
60
+ if status.startswith('500'):
61
+ cherrypy.log(
62
+ f'error page status={status} message={message}\n{traceback}', context='ERROR-PAGE', severity=logging.WARNING
66
63
  )
67
64
 
68
65
  # Replace message by generic one for 404. Default implementation leak path info.
69
- if kwargs.get('status', '') == '404 Not Found':
70
- kwargs['message'] = 'Nothing matches the given URI'
66
+ if status == '404 Not Found':
67
+ message = 'Nothing matches the given URI'
71
68
 
72
69
  # Check expected response type.
73
70
  mtype = cherrypy.serving.response.headers.get('Content-Type') or cherrypy.tools.accept.callable(
74
71
  ['text/html', 'text/plain', 'application/json']
75
72
  )
76
73
  if mtype == 'text/plain':
77
- return kwargs.get('message')
74
+ return message
78
75
  elif mtype == 'application/json':
79
- return json.dumps({'message': kwargs.get('message', ''), 'status': kwargs.get('status', '')})
76
+ return json.dumps({'message': message, 'status': status})
80
77
  elif mtype == 'text/html':
78
+ context = {'status': status, 'message': message, 'traceback': traceback, 'version': version}
81
79
  if hasattr(cherrypy.tools, 'jinja2'):
82
80
  # Try to build a nice error page with Jinja2 env
83
81
  try:
84
- return cherrypy.tools.jinja2.render_request(template='error_page.html', vars=kwargs)
82
+ return cherrypy.tools.jinja2.render_request(template='error_page.html', context=context)
85
83
  except Exception:
86
- logger.exception('fail to render error page')
84
+ cherrypy.log(
85
+ 'fail to render error page with jinja2',
86
+ context='ERROR-PAGE',
87
+ severity=logging.ERROR,
88
+ traceback=True,
89
+ )
87
90
  # Fallback to built-int HTML error page
88
- return HTML_ERROR_TEMPLATE % kwargs
91
+ return HTML_ERROR_TEMPLATE % context
89
92
 
90
93
  # Fallback to raw error message.
91
- return kwargs.get('message')
94
+ return message
@@ -23,8 +23,6 @@ from sqlalchemy.engine import Engine
23
23
  from sqlalchemy.exc import IntegrityError, SQLAlchemyError
24
24
  from sqlalchemy.orm import declarative_base, scoped_session, sessionmaker
25
25
 
26
- logger = logging.getLogger(__name__)
27
-
28
26
 
29
27
  @event.listens_for(Engine, 'connect')
30
28
  def _set_sqlite_journal_mode_wal(connection, connection_record):
@@ -168,6 +166,10 @@ class SQLA(SimplePlugin):
168
166
  _session = None
169
167
  _engine = None
170
168
 
169
+ @property
170
+ def engine(self):
171
+ return self._engine
172
+
171
173
  def start(self):
172
174
  if self.uri is None:
173
175
  return
@@ -180,14 +182,22 @@ class SQLA(SimplePlugin):
180
182
  self.clear_sessions()
181
183
  # Associate our session to our engine
182
184
  self.get_session().configure(bind=self._engine)
183
- self.bus.log("Database session plugin started.")
185
+ self.bus.log("database session plugin started")
186
+
187
+ # This is slightly lower priority to get database started first.
188
+ start.priority = 45
184
189
 
185
190
  def stop(self):
186
191
  if self._session:
187
192
  self.clear_sessions()
188
193
  if self._engine:
189
194
  self._engine.dispose()
190
- self.bus.log("Database session plugin stopped.")
195
+ self.bus.log("database session plugin stopped")
196
+
197
+ def graceful(self):
198
+ """Reload of subscribers."""
199
+ self.stop()
200
+ self.start()
191
201
 
192
202
  def create_all(self):
193
203
  try:
@@ -242,11 +252,27 @@ class SQLA(SimplePlugin):
242
252
  try:
243
253
  # When terminating, raise an error if objects are not commit.
244
254
  if self._session.dirty or self._session.new or self._session.deleted:
245
- changes = ', '.join([str(_get_model_changes(obj)) for obj in self._session.dirty])
246
- logger.exception(
247
- 'session is dirty, some database object(s) are not commited, this indicate a bug in the application '
248
- 'dirty %s new %s deleted %s' % (changes, self._session.new, self._session.deleted)
255
+ cherrypy.log(
256
+ 'database session dirty; uncommitted objects detected — potential application bug',
257
+ context='DB',
258
+ severity=logging.ERROR,
249
259
  )
260
+ if self._session.dirty:
261
+ changes = ', '.join([str(_get_model_changes(obj)) for obj in self._session.dirty])
262
+ cherrypy.log(
263
+ f'database session dirty_objects={self._session.dirty}', context='DB', severity=logging.ERROR
264
+ )
265
+ cherrypy.log(f'database session pending_changes={changes}', context='DB', severity=logging.ERROR)
266
+ if self._session.new:
267
+ cherrypy.log(
268
+ f'database session new_objects={self._session.new}', context='DB', severity=logging.ERROR
269
+ )
270
+ if self._session.deleted:
271
+ cherrypy.log(
272
+ f'database session deleted_objects={self._session.deleted}',
273
+ context='DB',
274
+ severity=logging.ERROR,
275
+ )
250
276
  raise SQLAlchemyError('session is dirty')
251
277
  finally:
252
278
  self._session.rollback()
@@ -21,8 +21,6 @@ import ldap3
21
21
  from cherrypy.process.plugins import SimplePlugin
22
22
  from ldap3.core.exceptions import LDAPException, LDAPInvalidCredentialsResult
23
23
 
24
- logger = logging.getLogger(__name__)
25
-
26
24
  _safe = ldap3.utils.conv.escape_filter_chars
27
25
 
28
26
 
@@ -126,6 +124,11 @@ class LdapPlugin(SimplePlugin):
126
124
  if hasattr(self, '_pool'):
127
125
  self._pool.unbind()
128
126
 
127
+ def graceful(self):
128
+ """Reload of subscribers."""
129
+ self.stop()
130
+ self.start()
131
+
129
132
  def authenticate(self, username, password):
130
133
  """
131
134
  Check if the given credential as valid according to LDAP.
@@ -148,9 +151,9 @@ class LdapPlugin(SimplePlugin):
148
151
  search_filter = f"(&{self.user_filter}(|{attr_filter}))"
149
152
  response = self._search(conn, search_filter)
150
153
  if not response:
151
- logger.info("user %s not found in LDAP", username)
154
+ cherrypy.log(f"lookup failed username={username} reason=not_found", context='LDAP')
152
155
  return False
153
- logger.info("user %s found in LDAP", username)
156
+ cherrypy.log(f"lookup successful username={username}", context='LDAP')
154
157
  user_dn = response[0]['dn']
155
158
 
156
159
  # Use a separate connection to validate credentials
@@ -163,17 +166,21 @@ class LdapPlugin(SimplePlugin):
163
166
  client_strategy=ldap3.ASYNC,
164
167
  )
165
168
  if not login_conn.bind():
166
- logger.warning("LDAP authentication failed for user %s", username)
169
+ cherrypy.log(
170
+ f'ldap authentication failed username={username} reason=wrong_password',
171
+ context='LDAP',
172
+ severity=logging.WARNING,
173
+ )
167
174
  return False
168
175
 
169
176
  # Get username
170
177
  attrs = response[0]['attributes']
171
178
  new_username = first_attribute(attrs, self.username_attribute)
172
179
  if not new_username:
173
- logger.info(
174
- "user object %s was found but the username attribute %s doesn't exists",
175
- user_dn,
176
- self.username_attribute,
180
+ cherrypy.log(
181
+ f"object missing username attribute user_dn={user_dn} attribute={self.username_attribute}",
182
+ context='LDAP',
183
+ severity=logging.WARNING,
177
184
  )
178
185
  return False
179
186
 
@@ -189,17 +196,15 @@ class LdapPlugin(SimplePlugin):
189
196
  self.group_filter,
190
197
  )
191
198
  # Search LDAP Server for matching groups.
192
- logger.info(
193
- "check if user %s is member of any group %s",
194
- user_value,
195
- ' '.join(self.required_group),
199
+ cherrypy.log(
200
+ f"group check start username={user_value} required_groups={' '.join(self.required_group)}",
201
+ context='LDAP',
196
202
  )
197
203
  response = self._search(conn, group_filter, attributes=['cn'])
198
204
  if not response:
199
- logger.info(
200
- "user %s was found but is not member of any group(s) %s",
201
- user_value,
202
- ' '.join(self.required_group),
205
+ cherrypy.log(
206
+ f"group check failed username={user_value} required_groups={' '.join(self.required_group)}",
207
+ context='LDAP',
203
208
  )
204
209
  return False
205
210
 
@@ -215,7 +220,9 @@ class LdapPlugin(SimplePlugin):
215
220
  except LDAPInvalidCredentialsResult:
216
221
  return False
217
222
  except LDAPException:
218
- logger.exception("can't validate user %s credentials", username)
223
+ cherrypy.log(
224
+ f"unexpected error username={username}", context='LDAP', severity=logging.ERROR, traceback=True
225
+ )
219
226
  return None
220
227
 
221
228
  def search(self, filter, attributes=ldap3.ALL_ATTRIBUTES, search_base=None, paged_size=None):
@@ -228,11 +235,10 @@ class LdapPlugin(SimplePlugin):
228
235
  search_scope = {'base': ldap3.BASE, 'onelevel': ldap3.LEVEL, 'subtree': ldap3.SUBTREE}.get(
229
236
  self.scope, ldap3.SUBTREE
230
237
  )
231
- logger.debug(
232
- "search ldap server: {}/{}?{}?{}".format(self.uri, search_base or self.base_dn, search_scope, filter)
233
- )
238
+ search_base = search_base or self.base_dn
239
+ cherrypy.log(f"search {self.uri}/{search_base}?{search_scope}?{filter}", context='LDAP')
234
240
  msg_id = conn.search(
235
- search_base=search_base or self.base_dn,
241
+ search_base=search_base,
236
242
  search_filter=filter,
237
243
  search_scope=search_scope,
238
244
  time_limit=self.timeout,