cherrypy-foundation 1.0.0__py3-none-any.whl → 1.0.0a1__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 (68) hide show
  1. cherrypy_foundation/components/ColorModes.jinja +4 -5
  2. cherrypy_foundation/components/Datatable.jinja +2 -2
  3. cherrypy_foundation/components/Datatable.js +2 -2
  4. cherrypy_foundation/components/Field.jinja +6 -16
  5. cherrypy_foundation/components/Fields.jinja +2 -0
  6. cherrypy_foundation/components/Typeahead.css +1 -6
  7. cherrypy_foundation/components/Typeahead.jinja +2 -2
  8. cherrypy_foundation/components/__init__.py +2 -2
  9. cherrypy_foundation/components/tests/test_static.py +1 -1
  10. cherrypy_foundation/error_page.py +17 -20
  11. cherrypy_foundation/flash.py +15 -17
  12. cherrypy_foundation/form.py +2 -2
  13. cherrypy_foundation/logging.py +2 -2
  14. cherrypy_foundation/passwd.py +2 -2
  15. cherrypy_foundation/plugins/db.py +9 -35
  16. cherrypy_foundation/plugins/ldap.py +38 -46
  17. cherrypy_foundation/plugins/restapi.py +1 -1
  18. cherrypy_foundation/plugins/scheduler.py +84 -208
  19. cherrypy_foundation/plugins/smtp.py +46 -78
  20. cherrypy_foundation/plugins/tests/test_db.py +4 -4
  21. cherrypy_foundation/plugins/tests/test_ldap.py +3 -76
  22. cherrypy_foundation/plugins/tests/test_scheduler.py +50 -58
  23. cherrypy_foundation/plugins/tests/test_smtp.py +7 -40
  24. cherrypy_foundation/tests/__init__.py +0 -72
  25. cherrypy_foundation/tests/test_error_page.py +1 -7
  26. cherrypy_foundation/tests/test_passwd.py +2 -2
  27. cherrypy_foundation/tools/auth.py +38 -59
  28. cherrypy_foundation/tools/auth_mfa.py +88 -89
  29. cherrypy_foundation/tools/errors.py +27 -0
  30. cherrypy_foundation/tools/i18n.py +153 -246
  31. cherrypy_foundation/tools/jinja2.py +13 -29
  32. cherrypy_foundation/tools/ratelimit.py +27 -37
  33. cherrypy_foundation/tools/secure_headers.py +5 -1
  34. cherrypy_foundation/tools/sessions_timeout.py +21 -23
  35. cherrypy_foundation/tools/tests/locales/en/LC_MESSAGES/messages.mo +0 -0
  36. cherrypy_foundation/tools/tests/locales/{de → en}/LC_MESSAGES/messages.po +2 -2
  37. cherrypy_foundation/tools/tests/test_auth.py +4 -21
  38. cherrypy_foundation/tools/tests/test_i18n.py +6 -81
  39. cherrypy_foundation/tools/tests/test_ratelimit.py +2 -2
  40. cherrypy_foundation/url.py +25 -25
  41. cherrypy_foundation/widgets.py +2 -2
  42. cherrypy_foundation-1.0.0a1.dist-info/METADATA +42 -0
  43. {cherrypy_foundation-1.0.0.dist-info → cherrypy_foundation-1.0.0a1.dist-info}/RECORD +46 -65
  44. {cherrypy_foundation-1.0.0.dist-info → cherrypy_foundation-1.0.0a1.dist-info}/WHEEL +1 -1
  45. cherrypy_foundation/components/Flash.jinja +0 -13
  46. cherrypy_foundation/components/LocaleSelection.jinja +0 -13
  47. cherrypy_foundation/components/LocaleSelection.js +0 -26
  48. cherrypy_foundation/plugins/tests/test_scheduler_db.py +0 -107
  49. cherrypy_foundation/sessions.py +0 -93
  50. cherrypy_foundation/tests/templates/test_flash.html +0 -9
  51. cherrypy_foundation/tests/templates/test_form.html +0 -16
  52. cherrypy_foundation/tests/templates/test_url.html +0 -15
  53. cherrypy_foundation/tests/test_flash.py +0 -61
  54. cherrypy_foundation/tests/test_form.py +0 -148
  55. cherrypy_foundation/tests/test_logging.py +0 -78
  56. cherrypy_foundation/tests/test_sessions.py +0 -89
  57. cherrypy_foundation/tests/test_url.py +0 -161
  58. cherrypy_foundation/tools/tests/components/Button.jinja +0 -2
  59. cherrypy_foundation/tools/tests/locales/de/LC_MESSAGES/messages.mo +0 -0
  60. cherrypy_foundation/tools/tests/templates/test_jinja2.html +0 -11
  61. cherrypy_foundation/tools/tests/templates/test_jinjax.html +0 -9
  62. cherrypy_foundation/tools/tests/templates/test_jinjax_i18n.html +0 -22
  63. cherrypy_foundation/tools/tests/test_auth_mfa.py +0 -369
  64. cherrypy_foundation/tools/tests/test_jinja2.py +0 -153
  65. cherrypy_foundation/tools/tests/test_secure_headers.py +0 -200
  66. cherrypy_foundation-1.0.0.dist-info/METADATA +0 -71
  67. {cherrypy_foundation-1.0.0.dist-info → cherrypy_foundation-1.0.0a1.dist-info}/licenses/LICENSE.md +0 -0
  68. {cherrypy_foundation-1.0.0.dist-info → cherrypy_foundation-1.0.0a1.dist-info}/top_level.txt +0 -0
@@ -1,5 +1,5 @@
1
1
  # Scheduler plugins for Cherrypy
2
- # Copyright (C) 2026 IKUS Software
2
+ # Copyright (C) 2022-2025 IKUS Software
3
3
  #
4
4
  # This program is free software: you can redistribute it and/or modify
5
5
  # it under the terms of the GNU General Public License as published by
@@ -13,271 +13,147 @@
13
13
  #
14
14
  # You should have received a copy of the GNU General Public License
15
15
  # along with this program. If not, see <https://www.gnu.org/licenses/>.
16
- import copy
16
+
17
+ '''
18
+ Created on Mar. 23, 2021
19
+
20
+ @author: Patrik Dufresne <patrik@ikus-soft.com>
21
+ '''
17
22
  import logging
18
- from datetime import datetime, timezone
19
- from functools import wraps
20
- from threading import Event, RLock
23
+ from datetime import datetime
21
24
 
22
25
  import cherrypy
23
- from apscheduler.events import EVENT_JOB_ERROR, EVENT_JOB_EXECUTED, EVENT_JOB_SUBMITTED
26
+ from apscheduler.executors.pool import ThreadPoolExecutor
27
+ from apscheduler.jobstores.memory import MemoryJobStore
24
28
  from apscheduler.schedulers.background import BackgroundScheduler
25
29
  from cherrypy.process.plugins import SimplePlugin
26
30
 
27
31
  logger = logging.getLogger(__name__)
28
32
 
29
33
 
30
- def clear_db_sessions(func):
34
+ def catch_exception(scheduler, func):
31
35
  """
32
- A decorator that ensures database connections that have become unusable, or are obsolete, are closed
33
- before and after the job is executed.
36
+ Wrapper function to execute job.
37
+ Primarly to handle database connection rollback and also to keep track
38
+ if any job are still running.
34
39
  """
35
40
 
36
- @wraps(func)
37
- def func_wrapper(*args, **kwargs):
41
+ def wrapper(*args, **kwargs):
42
+ ident = object()
43
+ scheduler._running.append(ident)
38
44
  try:
39
- result = func(*args, **kwargs)
45
+ func(*args, **kwargs)
40
46
  finally:
41
47
  cherrypy.db.clear_sessions()
42
- return result
48
+ scheduler._running.remove(ident)
43
49
 
44
- return func_wrapper
50
+ wrapper._func = func
51
+ return wrapper
45
52
 
46
53
 
47
54
  class Scheduler(SimplePlugin):
48
55
  """
49
56
  Plugins to run Job at fixed time (cronjob) and to schedule task to be run on by one.
50
-
51
- Configuration:
52
- - jobstore. Take value 'memory' (default), 'db' to use SQLalchemy database in combination with cherrypy.db. Or any callable function to create an instance of BaseJobStore
53
57
  """
54
58
 
55
- jobstores = {"default": {"type": "memory"}}
56
- executors = {"default": {"type": "threadpool", "max_workers": 10}}
57
- job_defaults = {"coalesce": False, "max_instances": 1}
58
- timezone = None
59
-
60
59
  def __init__(self, bus):
61
60
  super().__init__(bus)
62
- self._scheduler = None
63
- # Let keep track of running jobs manually.
64
- # This is required for unit testing to wait for all running task.
65
- self._lock = RLock()
66
- self._event = Event()
67
- self._submitted_jobs = set()
68
- self._completed_jobs = set()
69
-
70
- @property
71
- def _running_jobs(self):
72
- with self._lock:
73
- return self._submitted_jobs
74
-
75
- def _track_running_jobs(self, event):
76
- """
77
- This listener keep track of running jobs.
78
- This is required to implement wait for tasks mostly used during unit testing.
79
- """
80
- with self._lock:
81
- # Special care must be taken to handle race condition
82
- # the EVENT_JOB_EXECUTED may get called before EVENT_JOB_SUBMITTED
83
- if event.code == EVENT_JOB_SUBMITTED:
84
- if event.job_id in self._completed_jobs:
85
- self._completed_jobs.remove(event.job_id)
86
- else:
87
- self._submitted_jobs.add(event.job_id)
88
- elif event.code in (EVENT_JOB_EXECUTED, EVENT_JOB_ERROR):
89
- if event.job_id in self._submitted_jobs:
90
- self._submitted_jobs.remove(event.job_id)
91
- else:
92
- self._completed_jobs.add(event.job_id)
93
- # Wakeup wait_for_tasks.
94
- self._event.set()
95
-
96
- def _drop_table(self, target, conn, **kwargs):
97
- """
98
- Callback listener to drop table.
99
- This is required because SQLAlchemyJobStore creates the table manually.
100
- """
101
- from sqlalchemy.sql import ddl
102
-
103
- # Here we need to use private API to loop over all sqlalchemy job store.
104
- for jobstore in self._scheduler._jobstores.values():
105
- table = getattr(jobstore, 'jobs_t', None)
106
- if table is not None:
107
- conn.execute(ddl.DropTable(table, if_exists=True))
108
-
109
- def _create_table(self, target, conn, **kwargs):
110
- """
111
- Callback listener to drop table.
112
- This is required because SQLAlchemyJobStore creates the table manually.
113
- """
114
- from sqlalchemy.sql import ddl
115
-
116
- # Here we need to use private API to loop over all sqlalchemy job store.
117
- for jobstore in self._scheduler._jobstores.values():
118
- table = getattr(jobstore, 'jobs_t', None)
119
- if table is not None:
120
- conn.execute(ddl.CreateTable(table, if_not_exists=True))
121
-
122
- def _db_events(self, listen_or_remove):
123
- """
124
- Listen on database events to create and drop tables as required.
125
- """
126
- assert listen_or_remove in ['listen', 'remove']
127
- try:
128
- from sqlalchemy import event
129
-
130
- if getattr(cherrypy, 'db', False):
131
- base = cherrypy.db.get_base()
132
- func = getattr(event, listen_or_remove)
133
- func(base.metadata, 'after_drop', self._drop_table)
134
- func(base.metadata, 'after_create', self._create_table)
135
- except ImportError:
136
- # Nothing to do if sqlalchemy is not available
137
- pass
61
+ self._scheduler = self._create_scheduler()
62
+ self._scheduler.start(paused=True)
63
+ self._running = []
138
64
 
139
65
  def _create_scheduler(self):
140
- """
141
- Create a new APScheduler base on plugin configuration.
142
- """
143
- # Create the scheduler with deepcopy of configuration.
144
- scheduler = BackgroundScheduler(
145
- jobstores=copy.deepcopy(self.jobstores),
146
- executors=copy.deepcopy(self.executors),
147
- job_defaults=copy.deepcopy(self.job_defaults),
148
- timezone=self.timezone,
66
+ return BackgroundScheduler(
67
+ jobstores={
68
+ 'default': MemoryJobStore(),
69
+ 'scheduled': MemoryJobStore(),
70
+ },
71
+ executors={
72
+ 'default': ThreadPoolExecutor(
73
+ max_workers=1,
74
+ pool_kwargs={'thread_name_prefix': 'scheduler-default'},
75
+ ),
76
+ 'scheduled': ThreadPoolExecutor(
77
+ max_workers=1,
78
+ pool_kwargs={'thread_name_prefix': 'scheduler-scheduled'},
79
+ ),
80
+ },
149
81
  )
150
- # Add listener to keep track of running jobs.
151
- scheduler.add_listener(self._track_running_jobs, (EVENT_JOB_EXECUTED | EVENT_JOB_ERROR | EVENT_JOB_SUBMITTED))
152
- # Start the scheduler.
153
- scheduler.start()
154
- return scheduler
155
82
 
156
83
  def start(self):
157
- self.bus.log('Start Scheduler plugin')
158
- # Create apscheduler
159
- self._scheduler = self._create_scheduler()
160
- # Register drop_table event (when using database)
161
- self._db_events('listen')
162
- # Then register new channels
163
- self.bus.subscribe('scheduler:add_job', self.add_job)
164
- self.bus.subscribe('scheduler:add_job_daily', self.add_job_daily)
165
- self.bus.subscribe('scheduler:add_job_now', self.add_job_now)
166
- self.bus.subscribe('scheduler:remove_job', self.remove_job)
167
-
168
- # Slightly lower priority to start after database, but before other plugins.
169
- start.priority = 49
84
+ self.bus.log('Start Scheduler plugins')
85
+ self._scheduler.resume()
86
+ self.bus.subscribe('schedule_job', self.schedule_job)
87
+ self.bus.subscribe('schedule_task', self.schedule_task)
88
+ self.bus.subscribe('unschedule_job', self.unschedule_job)
170
89
 
171
90
  def stop(self):
172
- self.bus.log('Stop Scheduler plugin')
173
- # Unregister drop_table event (when using database)
174
- self._db_events('remove')
175
- # Shutdown the scheduler & wait for running task to complete.
176
- if self._scheduler:
177
- self._scheduler.shutdown(wait=True)
178
- self._scheduler = None
179
- self.bus.unsubscribe('scheduler:add_job', self.add_job)
180
- self.bus.unsubscribe('scheduler:add_job_daily', self.add_job_daily)
181
- self.bus.unsubscribe('scheduler:add_job_now', self.add_job_now)
182
- self.bus.unsubscribe('scheduler:remove_job', self.remove_job)
183
-
184
- stop.priority = 51
91
+ self.bus.log('Stop Scheduler plugins')
92
+ self._scheduler.pause()
93
+ self.bus.unsubscribe('schedule_job', self.schedule_job)
94
+ self.bus.unsubscribe('unschedule_job', self.unschedule_job)
95
+ self.bus.unsubscribe('schedule_task', self.schedule_task)
96
+
97
+ def exit(self):
98
+ # Shutdown scheduler and create a new one in case the engine get started again.
99
+ self._scheduler.shutdown(wait=True)
100
+ self._scheduler = self._create_scheduler()
101
+ self._scheduler.start(paused=True)
185
102
 
186
- def graceful(self):
187
- """Reload of subscribers."""
188
- self.stop()
189
- self.start()
103
+ def list_jobs(self):
104
+ """
105
+ Return list of scheduled jobs.
106
+ """
107
+ return self._scheduler.get_jobs(jobstore='scheduled')
190
108
 
191
- def add_job(self, func, *args, **kwargs):
109
+ def list_tasks(self):
192
110
  """
193
- Called via engine.publish('scheduler:add_job', ...)
194
- OR directly on the plugin instance.
111
+ Return list of tasks.
195
112
  """
196
- if not self._scheduler:
197
- raise RuntimeError("Scheduler not running; cannot add job.")
113
+ return self._scheduler.get_jobs(jobstore='default')
198
114
 
199
- return self._scheduler.add_job(func, *args, **kwargs)
115
+ def is_job_running(self):
116
+ return self._running
200
117
 
201
- def add_job_daily(self, execution_time, func, *args, **kwargs):
118
+ def schedule_job(self, execution_time, job, *args, **kwargs):
202
119
  """
203
- A convenience wrapper around add_job() that ensures
204
- the job runs daily.
120
+ Add the given scheduled job to the scheduler.
205
121
  """
122
+ assert hasattr(job, '__call__'), 'job must be callable'
206
123
  hour, minute = execution_time.split(':', 2)
207
- return self.add_job(
208
- id=getattr(func, '__name__', str(func)),
209
- func=func,
210
- name=getattr(func, '__name__', str(func)),
124
+ self._scheduler.add_job(
125
+ func=catch_exception(self, job),
126
+ name=job.__name__,
211
127
  args=args,
212
128
  kwargs=kwargs,
213
129
  trigger='cron',
214
130
  hour=hour,
215
131
  minute=minute,
216
- misfire_grace_time=None,
217
- coalesce=True,
218
- replace_existing=True,
132
+ jobstore='scheduled',
133
+ executor='scheduled',
219
134
  )
220
135
 
221
- def add_job_now(self, func, *args, **kwargs):
136
+ def schedule_task(self, task, *args, **kwargs):
222
137
  """
223
- A convenience wrapper around add_job() that ensures
224
- the job runs immediately upon being added.
138
+ Add the given task to be execute immediately in background.
225
139
  """
226
- return self.add_job(
227
- func=func,
228
- name=getattr(func, '__name__', str(func)),
140
+ assert hasattr(task, '__call__'), 'task must be callable'
141
+ self._scheduler.add_job(
142
+ func=catch_exception(self, task),
143
+ name=task.__name__,
229
144
  args=args,
230
145
  kwargs=kwargs,
231
- next_run_time=datetime.now(timezone.utc),
232
- misfire_grace_time=None,
146
+ next_run_time=datetime.now(),
233
147
  )
234
148
 
235
- def get_jobs(self, jobstore=None):
236
- """
237
- Return list of scheduled jobs.
238
- """
239
- if not self._scheduler:
240
- raise RuntimeError("Scheduler not running; cannot get jobs.")
241
- return self._scheduler.get_jobs(jobstore=jobstore)
242
-
243
- def remove_job(self, job, jobstore=None):
149
+ def unschedule_job(self, job):
244
150
  """
245
151
  Remove the given job from scheduler.
246
152
  """
247
153
  # Search for a matching job
248
- return_value = False
249
- if self._scheduler is None:
250
- return return_value
251
- for j in self._scheduler.get_jobs(jobstore=jobstore):
252
- if j.func == job or j.name == job:
253
- self._scheduler.remove_job(job_id=j.id, jobstore=jobstore)
254
- return_value = True
255
- return return_value
256
-
257
- def remove_all_jobs(self, jobstore=None):
258
- """
259
- Remove all jobs from scheduler.
260
- """
261
- if self._scheduler is None:
262
- return
263
- self._scheduler.remove_all_jobs(jobstore=jobstore)
264
-
265
- def wait_for_jobs(self, jobstore=None):
266
- """
267
- Used to wait for all running jobs to complete.
268
- """
269
- if self._scheduler is None:
270
- return
271
- # Wait until the queue is empty.
272
- while any(
273
- job for job in self._scheduler.get_jobs(jobstore=jobstore) if job.next_run_time < datetime.now(timezone.utc)
274
- ):
275
- self._event.wait(timeout=1)
276
- self._event.clear()
277
- # Wait until all jobs are completed.
278
- while self._running_jobs:
279
- self._event.wait(timeout=1)
280
- self._event.clear()
154
+ job_id = next((j.id for j in self._scheduler.get_jobs(jobstore='scheduled') if j.func._func == job), None)
155
+ if job_id:
156
+ self._scheduler.remove_job(job_id=job_id, jobstore='scheduled')
281
157
 
282
158
 
283
159
  # Register Scheduler plugin
@@ -1,5 +1,5 @@
1
1
  # SMTP Plugins for cherrypy
2
- # Copyright (C) 2026 IKUS Software
2
+ # Copyright (C) 2025 IKUS Software
3
3
  #
4
4
  # This program is free software: you can redistribute it and/or modify
5
5
  # it under the terms of the GNU General Public License as published by
@@ -100,26 +100,6 @@ def _formataddr(value):
100
100
  return ', '.join(map(_formataddr, value))
101
101
 
102
102
 
103
- def _send_message(server, encryption, username, password, msg):
104
- """
105
- Send message using SMTP.
106
- """
107
- host, unused, port = server.partition(':')
108
- if encryption == 'ssl':
109
- conn = smtplib.SMTP_SSL(host, port or 465)
110
- else:
111
- conn = smtplib.SMTP(host, port or 25)
112
- try:
113
- if encryption == 'starttls':
114
- conn.starttls()
115
- # Authenticate if required.
116
- if username:
117
- conn.login(username, password)
118
- conn.send_message(msg)
119
- finally:
120
- conn.quit()
121
-
122
-
123
103
  class SmtpPlugin(SimplePlugin):
124
104
 
125
105
  server = None
@@ -127,38 +107,6 @@ class SmtpPlugin(SimplePlugin):
127
107
  password = None
128
108
  encryption = None
129
109
  email_from = None
130
- bcc = None
131
-
132
- def _create_msg(self, subject: str, message: str, to=None, cc=None, bcc=None, reply_to=None, headers={}):
133
- assert subject
134
- assert message
135
- assert to or bcc
136
-
137
- # Record the MIME types of both parts - text/plain and text/html.
138
- msg = MIMEMultipart('alternative')
139
- msg['Subject'] = str(subject)
140
- msg['From'] = _formataddr(self.email_from)
141
- if to:
142
- msg['To'] = _formataddr(to)
143
- if cc:
144
- msg['Cc'] = _formataddr(cc)
145
- bcc_list = []
146
- if self.bcc:
147
- bcc_list.append(_formataddr(self.bcc))
148
- if bcc:
149
- bcc_list.append(_formataddr(bcc))
150
- if bcc_list:
151
- msg['Bcc'] = ', '.join(bcc_list)
152
- if reply_to:
153
- msg['Reply-To'] = _formataddr(reply_to)
154
- msg['Message-ID'] = email.utils.make_msgid()
155
- if headers:
156
- for key, value in headers.items():
157
- msg[key] = value
158
- text = _html2plaintext(message)
159
- msg.attach(MIMEText(text, 'plain', 'utf8'))
160
- msg.attach(MIMEText(message, 'html', 'utf8'))
161
- return msg
162
110
 
163
111
  def start(self):
164
112
  self.bus.log('Start SMTP plugin')
@@ -170,50 +118,70 @@ class SmtpPlugin(SimplePlugin):
170
118
  self.bus.unsubscribe("send_mail", self.send_mail)
171
119
  self.bus.unsubscribe("queue_mail", self.queue_mail)
172
120
 
173
- def graceful(self):
174
- """Reload of subscribers."""
175
- self.stop()
176
- self.start()
177
-
178
121
  def queue_mail(self, *args, **kwargs):
179
122
  """
180
123
  Queue mail to be sent.
181
124
  """
182
125
  # Skip sending email if smtp server is not configured.
183
126
  if not self.server:
184
- cherrypy.log('cannot send email because SMTP Server is not configured', context='SMTP')
127
+ self.bus.log('cannot send email because SMTP Server is not configured')
185
128
  return
186
129
  if not self.email_from:
187
- cherrypy.log('cannot send email because SMTP From is not configured', context='SMTP')
130
+ self.bus.log('cannot send email because SMTP From is not configured')
188
131
  return
189
- msg = self._create_msg(*args, **kwargs)
190
- self.bus.publish(
191
- 'scheduler:add_job_now',
192
- _send_message,
193
- server=self.server,
194
- encryption=self.encryption,
195
- username=self.username,
196
- password=self.password,
197
- msg=msg,
198
- )
199
-
200
- def send_mail(self, *args, **kwargs):
132
+ self.bus.publish('schedule_task', self.send_mail, *args, **kwargs)
133
+
134
+ def send_mail(self, subject: str, message: str, to=None, cc=None, bcc=None, reply_to=None, headers={}):
201
135
  """
202
136
  Reusable method to be called to send email to the user user.
203
137
  `user` user object where to send the email.
204
138
  """
139
+ assert subject
140
+ assert message
141
+ assert to or bcc
142
+
205
143
  # Skip sending email if smtp server is not configured.
206
144
  if not self.server:
207
- cherrypy.log('cannot send email because SMTP Server is not configured', context='SMTP')
145
+ self.bus.log('cannot send email because SMTP Server is not configured')
208
146
  return
209
147
  if not self.email_from:
210
- cherrypy.log('cannot send email because SMTP From is not configured', context='SMTP')
148
+ self.bus.log('cannot send email because SMTP From is not configured')
211
149
  return
212
150
 
213
- msg = self._create_msg(*args, **kwargs)
214
- _send_message(
215
- server=self.server, encryption=self.encryption, username=self.username, password=self.password, msg=msg
216
- )
151
+ # Record the MIME types of both parts - text/plain and text/html.
152
+ msg = MIMEMultipart('alternative')
153
+ msg['Subject'] = str(subject)
154
+ msg['From'] = _formataddr(self.email_from)
155
+ if to:
156
+ msg['To'] = _formataddr(to)
157
+ if cc:
158
+ msg['Cc'] = _formataddr(cc)
159
+ if bcc:
160
+ msg['Bcc'] = _formataddr(bcc)
161
+ if reply_to:
162
+ msg['Reply-To'] = _formataddr(reply_to)
163
+ msg['Message-ID'] = email.utils.make_msgid()
164
+ if headers:
165
+ for key, value in headers.items():
166
+ msg[key] = value
167
+ text = _html2plaintext(message)
168
+ msg.attach(MIMEText(text, 'plain', 'utf8'))
169
+ msg.attach(MIMEText(message, 'html', 'utf8'))
170
+
171
+ host, unused, port = self.server.partition(':')
172
+ if self.encryption == 'ssl':
173
+ conn = smtplib.SMTP_SSL(host, port or 465)
174
+ else:
175
+ conn = smtplib.SMTP(host, port or 25)
176
+ try:
177
+ if self.encryption == 'starttls':
178
+ conn.starttls()
179
+ # Authenticate if required.
180
+ if self.username:
181
+ conn.login(self.username, self.password)
182
+ conn.send_message(msg)
183
+ finally:
184
+ conn.quit()
217
185
 
218
186
 
219
187
  # Register SMTP plugin
@@ -1,5 +1,5 @@
1
- # Cherrypy-foundation
2
- # Copyright (C) 2022-2026 IKUS Software
1
+ # Cherrypy Foundation
2
+ # Copyright (C) 2022-2025 IKUS Software
3
3
  #
4
4
  # This program is free software: you can redistribute it and/or modify
5
5
  # it under the terms of the GNU General Public License as published by
@@ -93,7 +93,7 @@ else:
93
93
  # Given an empty database
94
94
  self.assertEqual(0, User.query.count())
95
95
  # When calling "/add/" to create a user
96
- self.getPage('/add', method='POST', body=urlencode({'username': 'myuser'}))
96
+ self.getPage('/add', method='POST', body=urlencode([('username', 'myuser')]))
97
97
  self.assertStatus(200)
98
98
  self.assertInBody('OK')
99
99
  # Then user get added to database
@@ -103,7 +103,7 @@ else:
103
103
  # Given a database with a users
104
104
  User(username='user1').add().commit()
105
105
  # When trying to add another user
106
- self.getPage('/add', method='POST', body=urlencode({'username': 'user1'}))
106
+ self.getPage('/add', method='POST', body=urlencode([('username', 'user1')]))
107
107
  # Then an error get raised
108
108
  self.assertStatus(200)
109
109
  self.assertInBody('user_username_unique_ix')
@@ -1,5 +1,5 @@
1
1
  # LDAP Plugins for cherrypy
2
- # Copyright (C) 2026 IKUS Software
2
+ # Copyright (C) 2025 IKUS Software
3
3
  #
4
4
  # This program is free software: you can redistribute it and/or modify
5
5
  # it under the terms of the GNU General Public License as published by
@@ -19,13 +19,13 @@ Created on Oct 17, 2015
19
19
  @author: Patrik Dufresne <patrik@ikus-soft.com>
20
20
  """
21
21
  import os
22
- from unittest import TestCase, mock, skipUnless
22
+ from unittest import mock, skipUnless
23
23
 
24
24
  import cherrypy
25
25
  import ldap3
26
26
  from cherrypy.test import helper
27
27
 
28
- from ..ldap import all_attribute, first_attribute # noqa
28
+ from .. import ldap # noqa
29
29
 
30
30
  original_connection = ldap3.Connection
31
31
 
@@ -35,79 +35,6 @@ def mock_ldap_connection(*args, **kwargs):
35
35
  return original_connection(*args, client_strategy=ldap3.MOCK_ASYNC, **kwargs)
36
36
 
37
37
 
38
- class LdapFirstAttributeTest(TestCase):
39
-
40
- def test_no_keys_returns_default(self):
41
- attributes = {"cn": ["John Doe"]}
42
- self.assertIsNone(first_attribute(attributes, None))
43
- self.assertEqual(first_attribute(attributes, [], default="fallback"), "fallback")
44
-
45
- def test_single_key_with_scalar_value(self):
46
- attributes = {"uid": "jdoe"}
47
- self.assertEqual(first_attribute(attributes, "uid"), "jdoe")
48
-
49
- def test_single_key_with_list_value(self):
50
- attributes = {"mail": ["john@example.com", "alt@example.com"]}
51
- self.assertEqual(first_attribute(attributes, "mail"), "john@example.com")
52
-
53
- def test_empty_list_value_is_skipped(self):
54
- attributes = {
55
- "mail": [],
56
- "uid": ["jdoe"],
57
- }
58
- self.assertEqual(first_attribute(attributes, ["mail", "uid"]), "jdoe")
59
-
60
- def test_missing_first_key_uses_next_key(self):
61
- attributes = {"cn": ["John Doe"]}
62
- self.assertEqual(first_attribute(attributes, ["sn", "cn"]), "John Doe")
63
-
64
- def test_all_keys_missing_returns_default(self):
65
- attributes = {"cn": ["John Doe"]}
66
- self.assertEqual(first_attribute(attributes, ["sn", "uid"], default="unknown"), "unknown")
67
-
68
- def test_key_not_found_without_default_returns_none(self):
69
- attributes = {"cn": ["John Doe"]}
70
- self.assertIsNone(first_attribute(attributes, "uid"))
71
-
72
-
73
- class LdapAllAttributeTest(TestCase):
74
-
75
- def test_no_keys_returns_default(self):
76
- attributes = {"cn": ["John Doe"]}
77
- self.assertIsNone(all_attribute(attributes, None))
78
- self.assertEqual(all_attribute(attributes, [], default="fallback"), "fallback")
79
-
80
- def test_single_key_with_scalar_value(self):
81
- attributes = {"uid": "jdoe"}
82
- self.assertEqual(all_attribute(attributes, "uid"), ["jdoe"])
83
-
84
- def test_single_key_with_list_value(self):
85
- attributes = {"mail": ["john@example.com", "alt@example.com"]}
86
- self.assertEqual(all_attribute(attributes, "mail"), ["john@example.com"])
87
-
88
- def test_multiple_keys_collect_values(self):
89
- attributes = {
90
- "uid": "jdoe",
91
- "cn": ["John Doe"],
92
- }
93
- self.assertEqual(all_attribute(attributes, ["uid", "cn"]), ["jdoe", "John Doe"])
94
-
95
- def test_missing_keys_are_skipped(self):
96
- attributes = {"cn": ["John Doe"]}
97
- self.assertEqual(all_attribute(attributes, ["sn", "cn", "uid"]), ["John Doe"])
98
-
99
- def test_all_keys_missing_returns_default(self):
100
- attributes = {"cn": ["John Doe"]}
101
- self.assertEqual(all_attribute(attributes, ["sn", "uid"], default=[]), [])
102
-
103
- def test_empty_list_value_is_appended_as_empty_list(self):
104
- attributes = {
105
- "mail": [],
106
- "uid": "jdoe",
107
- }
108
- self.assertEqual(all_attribute(attributes, ["mail", "uid"]), [[], "jdoe"])
109
-
110
-
111
38
  class LdapPluginTest(helper.CPWebCase):
112
39
 
113
40
  @classmethod