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.
- cherrypy_foundation/components/ColorModes.jinja +4 -5
- cherrypy_foundation/components/Datatable.jinja +2 -2
- cherrypy_foundation/components/Datatable.js +2 -2
- cherrypy_foundation/components/Field.jinja +6 -16
- cherrypy_foundation/components/Fields.jinja +2 -0
- cherrypy_foundation/components/Typeahead.css +1 -6
- cherrypy_foundation/components/Typeahead.jinja +2 -2
- cherrypy_foundation/components/__init__.py +2 -2
- cherrypy_foundation/components/tests/test_static.py +1 -1
- cherrypy_foundation/error_page.py +17 -20
- cherrypy_foundation/flash.py +15 -17
- cherrypy_foundation/form.py +2 -2
- cherrypy_foundation/logging.py +2 -2
- cherrypy_foundation/passwd.py +2 -2
- cherrypy_foundation/plugins/db.py +9 -35
- cherrypy_foundation/plugins/ldap.py +38 -46
- cherrypy_foundation/plugins/restapi.py +1 -1
- cherrypy_foundation/plugins/scheduler.py +84 -208
- cherrypy_foundation/plugins/smtp.py +46 -78
- cherrypy_foundation/plugins/tests/test_db.py +4 -4
- cherrypy_foundation/plugins/tests/test_ldap.py +3 -76
- cherrypy_foundation/plugins/tests/test_scheduler.py +50 -58
- cherrypy_foundation/plugins/tests/test_smtp.py +7 -40
- cherrypy_foundation/tests/__init__.py +0 -72
- cherrypy_foundation/tests/test_error_page.py +1 -7
- cherrypy_foundation/tests/test_passwd.py +2 -2
- cherrypy_foundation/tools/auth.py +38 -59
- cherrypy_foundation/tools/auth_mfa.py +88 -89
- cherrypy_foundation/tools/errors.py +27 -0
- cherrypy_foundation/tools/i18n.py +153 -246
- cherrypy_foundation/tools/jinja2.py +13 -29
- cherrypy_foundation/tools/ratelimit.py +27 -37
- cherrypy_foundation/tools/secure_headers.py +5 -1
- cherrypy_foundation/tools/sessions_timeout.py +21 -23
- cherrypy_foundation/tools/tests/locales/en/LC_MESSAGES/messages.mo +0 -0
- cherrypy_foundation/tools/tests/locales/{de → en}/LC_MESSAGES/messages.po +2 -2
- cherrypy_foundation/tools/tests/test_auth.py +4 -21
- cherrypy_foundation/tools/tests/test_i18n.py +6 -81
- cherrypy_foundation/tools/tests/test_ratelimit.py +2 -2
- cherrypy_foundation/url.py +25 -25
- cherrypy_foundation/widgets.py +2 -2
- cherrypy_foundation-1.0.0a1.dist-info/METADATA +42 -0
- {cherrypy_foundation-1.0.0.dist-info → cherrypy_foundation-1.0.0a1.dist-info}/RECORD +46 -65
- {cherrypy_foundation-1.0.0.dist-info → cherrypy_foundation-1.0.0a1.dist-info}/WHEEL +1 -1
- cherrypy_foundation/components/Flash.jinja +0 -13
- cherrypy_foundation/components/LocaleSelection.jinja +0 -13
- cherrypy_foundation/components/LocaleSelection.js +0 -26
- cherrypy_foundation/plugins/tests/test_scheduler_db.py +0 -107
- cherrypy_foundation/sessions.py +0 -93
- cherrypy_foundation/tests/templates/test_flash.html +0 -9
- cherrypy_foundation/tests/templates/test_form.html +0 -16
- cherrypy_foundation/tests/templates/test_url.html +0 -15
- cherrypy_foundation/tests/test_flash.py +0 -61
- cherrypy_foundation/tests/test_form.py +0 -148
- cherrypy_foundation/tests/test_logging.py +0 -78
- cherrypy_foundation/tests/test_sessions.py +0 -89
- cherrypy_foundation/tests/test_url.py +0 -161
- cherrypy_foundation/tools/tests/components/Button.jinja +0 -2
- cherrypy_foundation/tools/tests/locales/de/LC_MESSAGES/messages.mo +0 -0
- cherrypy_foundation/tools/tests/templates/test_jinja2.html +0 -11
- cherrypy_foundation/tools/tests/templates/test_jinjax.html +0 -9
- cherrypy_foundation/tools/tests/templates/test_jinjax_i18n.html +0 -22
- cherrypy_foundation/tools/tests/test_auth_mfa.py +0 -369
- cherrypy_foundation/tools/tests/test_jinja2.py +0 -153
- cherrypy_foundation/tools/tests/test_secure_headers.py +0 -200
- cherrypy_foundation-1.0.0.dist-info/METADATA +0 -71
- {cherrypy_foundation-1.0.0.dist-info → cherrypy_foundation-1.0.0a1.dist-info}/licenses/LICENSE.md +0 -0
- {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)
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
34
|
+
def catch_exception(scheduler, func):
|
|
31
35
|
"""
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
37
|
-
|
|
41
|
+
def wrapper(*args, **kwargs):
|
|
42
|
+
ident = object()
|
|
43
|
+
scheduler._running.append(ident)
|
|
38
44
|
try:
|
|
39
|
-
|
|
45
|
+
func(*args, **kwargs)
|
|
40
46
|
finally:
|
|
41
47
|
cherrypy.db.clear_sessions()
|
|
42
|
-
|
|
48
|
+
scheduler._running.remove(ident)
|
|
43
49
|
|
|
44
|
-
|
|
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 =
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
158
|
-
|
|
159
|
-
self.
|
|
160
|
-
|
|
161
|
-
self.
|
|
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
|
|
173
|
-
|
|
174
|
-
self.
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
self.
|
|
181
|
-
self.
|
|
182
|
-
self.
|
|
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
|
|
187
|
-
"""
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
109
|
+
def list_tasks(self):
|
|
192
110
|
"""
|
|
193
|
-
|
|
194
|
-
OR directly on the plugin instance.
|
|
111
|
+
Return list of tasks.
|
|
195
112
|
"""
|
|
196
|
-
|
|
197
|
-
raise RuntimeError("Scheduler not running; cannot add job.")
|
|
113
|
+
return self._scheduler.get_jobs(jobstore='default')
|
|
198
114
|
|
|
199
|
-
|
|
115
|
+
def is_job_running(self):
|
|
116
|
+
return self._running
|
|
200
117
|
|
|
201
|
-
def
|
|
118
|
+
def schedule_job(self, execution_time, job, *args, **kwargs):
|
|
202
119
|
"""
|
|
203
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
replace_existing=True,
|
|
132
|
+
jobstore='scheduled',
|
|
133
|
+
executor='scheduled',
|
|
219
134
|
)
|
|
220
135
|
|
|
221
|
-
def
|
|
136
|
+
def schedule_task(self, task, *args, **kwargs):
|
|
222
137
|
"""
|
|
223
|
-
|
|
224
|
-
the job runs immediately upon being added.
|
|
138
|
+
Add the given task to be execute immediately in background.
|
|
225
139
|
"""
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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(
|
|
232
|
-
misfire_grace_time=None,
|
|
146
|
+
next_run_time=datetime.now(),
|
|
233
147
|
)
|
|
234
148
|
|
|
235
|
-
def
|
|
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
|
-
|
|
249
|
-
if
|
|
250
|
-
|
|
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)
|
|
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
|
-
|
|
127
|
+
self.bus.log('cannot send email because SMTP Server is not configured')
|
|
185
128
|
return
|
|
186
129
|
if not self.email_from:
|
|
187
|
-
|
|
130
|
+
self.bus.log('cannot send email because SMTP From is not configured')
|
|
188
131
|
return
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
145
|
+
self.bus.log('cannot send email because SMTP Server is not configured')
|
|
208
146
|
return
|
|
209
147
|
if not self.email_from:
|
|
210
|
-
|
|
148
|
+
self.bus.log('cannot send email because SMTP From is not configured')
|
|
211
149
|
return
|
|
212
150
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
|
2
|
-
# Copyright (C) 2022-
|
|
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(
|
|
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(
|
|
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)
|
|
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
|
|
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 ..
|
|
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
|