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.
- cherrypy_foundation/components/Field.jinja +14 -6
- cherrypy_foundation/components/Typeahead.css +6 -1
- cherrypy_foundation/components/Typeahead.jinja +2 -2
- cherrypy_foundation/components/__init__.py +1 -1
- cherrypy_foundation/error_page.py +18 -15
- cherrypy_foundation/plugins/db.py +34 -8
- cherrypy_foundation/plugins/ldap.py +28 -22
- cherrypy_foundation/plugins/scheduler.py +197 -84
- cherrypy_foundation/plugins/smtp.py +71 -45
- cherrypy_foundation/plugins/tests/test_db.py +2 -2
- cherrypy_foundation/plugins/tests/test_scheduler.py +58 -50
- cherrypy_foundation/plugins/tests/test_smtp.py +9 -6
- cherrypy_foundation/tests/templates/test_form.html +10 -0
- cherrypy_foundation/tests/test_form.py +119 -0
- cherrypy_foundation/tools/auth.py +28 -11
- cherrypy_foundation/tools/auth_mfa.py +10 -13
- cherrypy_foundation/tools/errors.py +1 -1
- cherrypy_foundation/tools/i18n.py +15 -6
- cherrypy_foundation/tools/jinja2.py +15 -12
- cherrypy_foundation/tools/ratelimit.py +5 -9
- cherrypy_foundation/tools/secure_headers.py +0 -4
- cherrypy_foundation/tools/tests/components/Button.jinja +2 -0
- cherrypy_foundation/tools/tests/templates/test_jinja2.html +10 -0
- cherrypy_foundation/tools/tests/templates/test_jinja2_i18n.html +11 -0
- cherrypy_foundation/tools/tests/templates/test_jinjax.html +8 -0
- cherrypy_foundation/tools/tests/test_auth.py +1 -1
- cherrypy_foundation/tools/tests/test_auth_mfa.py +367 -0
- cherrypy_foundation/tools/tests/test_jinja2.py +123 -0
- {cherrypy_foundation-1.0.0a1.dist-info → cherrypy_foundation-1.0.0a3.dist-info}/METADATA +1 -1
- {cherrypy_foundation-1.0.0a1.dist-info → cherrypy_foundation-1.0.0a3.dist-info}/RECORD +33 -25
- {cherrypy_foundation-1.0.0a1.dist-info → cherrypy_foundation-1.0.0a3.dist-info}/WHEEL +0 -0
- {cherrypy_foundation-1.0.0a1.dist-info → cherrypy_foundation-1.0.0a3.dist-info}/licenses/LICENSE.md +0 -0
- {cherrypy_foundation-1.0.0a1.dist-info → cherrypy_foundation-1.0.0a3.dist-info}/top_level.txt +0 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# Scheduler 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
|
|
@@ -13,147 +13,260 @@
|
|
|
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
|
-
|
|
17
|
-
'''
|
|
18
|
-
Created on Mar. 23, 2021
|
|
19
|
-
|
|
20
|
-
@author: Patrik Dufresne <patrik@ikus-soft.com>
|
|
21
|
-
'''
|
|
16
|
+
import copy
|
|
22
17
|
import logging
|
|
23
|
-
from datetime import datetime
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from functools import wraps
|
|
20
|
+
from threading import Event, RLock
|
|
24
21
|
|
|
25
22
|
import cherrypy
|
|
26
|
-
from apscheduler.
|
|
27
|
-
from apscheduler.jobstores.memory import MemoryJobStore
|
|
23
|
+
from apscheduler.events import EVENT_JOB_ERROR, EVENT_JOB_EXECUTED, EVENT_JOB_SUBMITTED
|
|
28
24
|
from apscheduler.schedulers.background import BackgroundScheduler
|
|
29
25
|
from cherrypy.process.plugins import SimplePlugin
|
|
30
26
|
|
|
31
27
|
logger = logging.getLogger(__name__)
|
|
32
28
|
|
|
33
29
|
|
|
34
|
-
def
|
|
30
|
+
def clear_db_sessions(func):
|
|
35
31
|
"""
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
if any job are still running.
|
|
32
|
+
A decorator that ensures database connections that have become unusable, or are obsolete, are closed
|
|
33
|
+
before and after the job is executed.
|
|
39
34
|
"""
|
|
40
35
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
36
|
+
@wraps(func)
|
|
37
|
+
def func_wrapper(*args, **kwargs):
|
|
38
|
+
cherrypy.db.clear_sessions()
|
|
44
39
|
try:
|
|
45
|
-
func(*args, **kwargs)
|
|
40
|
+
result = func(*args, **kwargs)
|
|
46
41
|
finally:
|
|
47
42
|
cherrypy.db.clear_sessions()
|
|
48
|
-
|
|
43
|
+
return result
|
|
49
44
|
|
|
50
|
-
|
|
51
|
-
return wrapper
|
|
45
|
+
return func_wrapper
|
|
52
46
|
|
|
53
47
|
|
|
54
48
|
class Scheduler(SimplePlugin):
|
|
55
49
|
"""
|
|
56
50
|
Plugins to run Job at fixed time (cronjob) and to schedule task to be run on by one.
|
|
51
|
+
|
|
52
|
+
Configuration:
|
|
53
|
+
- 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
|
|
57
54
|
"""
|
|
58
55
|
|
|
56
|
+
jobstores = {"default": {"type": "memory"}}
|
|
57
|
+
executors = {"default": {"type": "threadpool", "max_workers": 10}}
|
|
58
|
+
job_defaults = {"coalesce": False, "max_instances": 1}
|
|
59
|
+
timezone = None
|
|
60
|
+
|
|
59
61
|
def __init__(self, bus):
|
|
60
62
|
super().__init__(bus)
|
|
61
|
-
self._scheduler =
|
|
62
|
-
|
|
63
|
-
|
|
63
|
+
self._scheduler = None
|
|
64
|
+
# Let keep track of running jobs manually.
|
|
65
|
+
# This is required for unit testing to wait for all running task.
|
|
66
|
+
self._lock = RLock()
|
|
67
|
+
self._event = Event()
|
|
68
|
+
self._submitted_jobs = set()
|
|
69
|
+
self._completed_jobs = set()
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def _running_jobs(self):
|
|
73
|
+
with self._lock:
|
|
74
|
+
return self._submitted_jobs
|
|
75
|
+
|
|
76
|
+
def _track_running_jobs(self, event):
|
|
77
|
+
"""
|
|
78
|
+
This listener keep track of running jobs.
|
|
79
|
+
This is required to implement wait for tasks mostly used during unit testing.
|
|
80
|
+
"""
|
|
81
|
+
with self._lock:
|
|
82
|
+
# Special care must be taken to handle race condition
|
|
83
|
+
# the EVENT_JOB_EXECUTED may get called before EVENT_JOB_SUBMITTED
|
|
84
|
+
if event.code == EVENT_JOB_SUBMITTED:
|
|
85
|
+
if event.job_id in self._completed_jobs:
|
|
86
|
+
self._completed_jobs.remove(event.job_id)
|
|
87
|
+
else:
|
|
88
|
+
self._submitted_jobs.add(event.job_id)
|
|
89
|
+
elif event.code in (EVENT_JOB_EXECUTED, EVENT_JOB_ERROR):
|
|
90
|
+
if event.job_id in self._submitted_jobs:
|
|
91
|
+
self._submitted_jobs.remove(event.job_id)
|
|
92
|
+
else:
|
|
93
|
+
self._completed_jobs.add(event.job_id)
|
|
94
|
+
# Wakeup wait_for_tasks.
|
|
95
|
+
self._event.set()
|
|
96
|
+
|
|
97
|
+
def _drop_table(self, target, conn, **kwargs):
|
|
98
|
+
"""
|
|
99
|
+
Callback listener to drop table.
|
|
100
|
+
This is required because SQLAlchemyJobStore creates the table manually.
|
|
101
|
+
"""
|
|
102
|
+
from sqlalchemy.sql import ddl
|
|
103
|
+
|
|
104
|
+
# Here we need to use private API to loop over all sqlalchemy job store.
|
|
105
|
+
for jobstore in self._scheduler._jobstores.values():
|
|
106
|
+
table = getattr(jobstore, 'jobs_t', None)
|
|
107
|
+
if table is not None:
|
|
108
|
+
conn.execute(ddl.DropTable(table, if_exists=True))
|
|
109
|
+
|
|
110
|
+
def _create_table(self, target, conn, **kwargs):
|
|
111
|
+
"""
|
|
112
|
+
Callback listener to drop table.
|
|
113
|
+
This is required because SQLAlchemyJobStore creates the table manually.
|
|
114
|
+
"""
|
|
115
|
+
from sqlalchemy.sql import ddl
|
|
116
|
+
|
|
117
|
+
# Here we need to use private API to loop over all sqlalchemy job store.
|
|
118
|
+
for jobstore in self._scheduler._jobstores.values():
|
|
119
|
+
table = getattr(jobstore, 'jobs_t', None)
|
|
120
|
+
if table is not None:
|
|
121
|
+
conn.execute(ddl.CreateTable(table, if_not_exists=True))
|
|
122
|
+
|
|
123
|
+
def _db_events(self, listen_or_remove):
|
|
124
|
+
"""
|
|
125
|
+
Listen on database events to create and drop tables as required.
|
|
126
|
+
"""
|
|
127
|
+
assert listen_or_remove in ['listen', 'remove']
|
|
128
|
+
try:
|
|
129
|
+
from sqlalchemy import event
|
|
130
|
+
|
|
131
|
+
if getattr(cherrypy, 'db', False):
|
|
132
|
+
base = cherrypy.db.get_base()
|
|
133
|
+
func = getattr(event, listen_or_remove)
|
|
134
|
+
func(base.metadata, 'after_drop', self._drop_table)
|
|
135
|
+
func(base.metadata, 'after_create', self._create_table)
|
|
136
|
+
except ImportError:
|
|
137
|
+
# Nothing to do if sqlalchemy is not available
|
|
138
|
+
pass
|
|
64
139
|
|
|
65
140
|
def _create_scheduler(self):
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
),
|
|
76
|
-
'scheduled': ThreadPoolExecutor(
|
|
77
|
-
max_workers=1,
|
|
78
|
-
pool_kwargs={'thread_name_prefix': 'scheduler-scheduled'},
|
|
79
|
-
),
|
|
80
|
-
},
|
|
141
|
+
"""
|
|
142
|
+
Create a new APScheduler base on plugin configuration.
|
|
143
|
+
"""
|
|
144
|
+
# Create the scheduler with deepcopy of configuration.
|
|
145
|
+
scheduler = BackgroundScheduler(
|
|
146
|
+
jobstores=copy.deepcopy(self.jobstores),
|
|
147
|
+
executors=copy.deepcopy(self.executors),
|
|
148
|
+
job_defaults=copy.deepcopy(self.job_defaults),
|
|
149
|
+
timezone=self.timezone,
|
|
81
150
|
)
|
|
151
|
+
# Add listener to keep track of running jobs.
|
|
152
|
+
scheduler.add_listener(self._track_running_jobs, (EVENT_JOB_EXECUTED | EVENT_JOB_ERROR | EVENT_JOB_SUBMITTED))
|
|
153
|
+
# Start the scheduler.
|
|
154
|
+
scheduler.start()
|
|
155
|
+
return scheduler
|
|
82
156
|
|
|
83
157
|
def start(self):
|
|
84
|
-
self.bus.log('Start Scheduler
|
|
85
|
-
|
|
86
|
-
self.
|
|
87
|
-
|
|
88
|
-
self.
|
|
158
|
+
self.bus.log('Start Scheduler plugin')
|
|
159
|
+
# Create apscheduler
|
|
160
|
+
self._scheduler = self._create_scheduler()
|
|
161
|
+
# Register drop_table event (when using database)
|
|
162
|
+
self._db_events('listen')
|
|
163
|
+
# Then register new channels
|
|
164
|
+
self.bus.subscribe('scheduler:add_job', self.add_job)
|
|
165
|
+
self.bus.subscribe('scheduler:add_job_daily', self.add_job_daily)
|
|
166
|
+
self.bus.subscribe('scheduler:add_job_now', self.add_job_now)
|
|
167
|
+
self.bus.subscribe('scheduler:remove_job', self.remove_job)
|
|
168
|
+
|
|
169
|
+
# Slightly lower priority to start after database, but before other plugins.
|
|
170
|
+
start.priority = 49
|
|
89
171
|
|
|
90
172
|
def stop(self):
|
|
91
|
-
self.bus.log('Stop Scheduler
|
|
92
|
-
|
|
93
|
-
self.
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
self.
|
|
100
|
-
self.
|
|
101
|
-
self.
|
|
173
|
+
self.bus.log('Stop Scheduler plugin')
|
|
174
|
+
# Unregister drop_table event (when using database)
|
|
175
|
+
self._db_events('remove')
|
|
176
|
+
# Shutdown the scheduler & wait for running task to complete.
|
|
177
|
+
if self._scheduler:
|
|
178
|
+
self._scheduler.shutdown(wait=True)
|
|
179
|
+
self._scheduler = None
|
|
180
|
+
self.bus.unsubscribe('scheduler:add_job', self.add_job)
|
|
181
|
+
self.bus.unsubscribe('scheduler:add_job_daily', self.add_job_daily)
|
|
182
|
+
self.bus.unsubscribe('scheduler:add_job_now', self.add_job_now)
|
|
183
|
+
self.bus.unsubscribe('scheduler:remove_job', self.remove_job)
|
|
102
184
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
"""
|
|
107
|
-
|
|
185
|
+
stop.priority = 51
|
|
186
|
+
|
|
187
|
+
def graceful(self):
|
|
188
|
+
"""Reload of subscribers."""
|
|
189
|
+
self.stop()
|
|
190
|
+
self.start()
|
|
108
191
|
|
|
109
|
-
def
|
|
192
|
+
def add_job(self, func, *args, **kwargs):
|
|
110
193
|
"""
|
|
111
|
-
|
|
194
|
+
Called via engine.publish('scheduler:add_job', ...)
|
|
195
|
+
OR directly on the plugin instance.
|
|
112
196
|
"""
|
|
113
|
-
|
|
197
|
+
if not self._scheduler:
|
|
198
|
+
raise RuntimeError("Scheduler not running; cannot add job.")
|
|
114
199
|
|
|
115
|
-
|
|
116
|
-
return self._running
|
|
200
|
+
return self._scheduler.add_job(func, *args, **kwargs)
|
|
117
201
|
|
|
118
|
-
def
|
|
202
|
+
def add_job_daily(self, execution_time, func, *args, **kwargs):
|
|
119
203
|
"""
|
|
120
|
-
|
|
204
|
+
A convenience wrapper around add_job() that ensures
|
|
205
|
+
the job runs daily.
|
|
121
206
|
"""
|
|
122
|
-
assert hasattr(job, '__call__'), 'job must be callable'
|
|
123
207
|
hour, minute = execution_time.split(':', 2)
|
|
124
|
-
self.
|
|
125
|
-
func=
|
|
126
|
-
name=
|
|
208
|
+
return self.add_job(
|
|
209
|
+
func=func,
|
|
210
|
+
name=getattr(func, '__name__', str(func)),
|
|
127
211
|
args=args,
|
|
128
212
|
kwargs=kwargs,
|
|
129
213
|
trigger='cron',
|
|
130
214
|
hour=hour,
|
|
131
215
|
minute=minute,
|
|
132
|
-
|
|
133
|
-
executor='scheduled',
|
|
216
|
+
misfire_grace_time=None,
|
|
134
217
|
)
|
|
135
218
|
|
|
136
|
-
def
|
|
219
|
+
def add_job_now(self, func, *args, **kwargs):
|
|
137
220
|
"""
|
|
138
|
-
|
|
221
|
+
A convenience wrapper around add_job() that ensures
|
|
222
|
+
the job runs immediately upon being added.
|
|
139
223
|
"""
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
name=task.__name__,
|
|
224
|
+
return self.add_job(
|
|
225
|
+
func=func,
|
|
226
|
+
name=getattr(func, '__name__', str(func)),
|
|
144
227
|
args=args,
|
|
145
228
|
kwargs=kwargs,
|
|
146
|
-
next_run_time=datetime.now(),
|
|
229
|
+
next_run_time=datetime.now(timezone.utc),
|
|
230
|
+
misfire_grace_time=None,
|
|
147
231
|
)
|
|
148
232
|
|
|
149
|
-
def
|
|
233
|
+
def get_jobs(self, jobstore=None):
|
|
234
|
+
"""
|
|
235
|
+
Return list of scheduled jobs.
|
|
236
|
+
"""
|
|
237
|
+
if not self._scheduler:
|
|
238
|
+
raise RuntimeError("Scheduler not running; cannot get jobs.")
|
|
239
|
+
return self._scheduler.get_jobs(jobstore=jobstore)
|
|
240
|
+
|
|
241
|
+
def remove_job(self, job, jobstore=None):
|
|
150
242
|
"""
|
|
151
243
|
Remove the given job from scheduler.
|
|
152
244
|
"""
|
|
153
245
|
# Search for a matching job
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
246
|
+
for j in self._scheduler.get_jobs(jobstore=jobstore):
|
|
247
|
+
if j.func == job:
|
|
248
|
+
self._scheduler.remove_job(job_id=j.id, jobstore=jobstore)
|
|
249
|
+
|
|
250
|
+
def remove_all_jobs(self, jobstore=None):
|
|
251
|
+
"""
|
|
252
|
+
Remove all jobs from scheduler.
|
|
253
|
+
"""
|
|
254
|
+
self._scheduler.remove_all_jobs(jobstore=jobstore)
|
|
255
|
+
|
|
256
|
+
def wait_for_jobs(self, jobstore=None):
|
|
257
|
+
"""
|
|
258
|
+
Used to wait for all running jobs to complete.
|
|
259
|
+
"""
|
|
260
|
+
# Wait until the queue is empty.
|
|
261
|
+
while any(
|
|
262
|
+
job for job in self._scheduler.get_jobs(jobstore=jobstore) if job.next_run_time < datetime.now(timezone.utc)
|
|
263
|
+
):
|
|
264
|
+
self._event.wait(timeout=1)
|
|
265
|
+
self._event.clear()
|
|
266
|
+
# Wait until all jobs are completed.
|
|
267
|
+
while self._running_jobs:
|
|
268
|
+
self._event.wait(timeout=1)
|
|
269
|
+
self._event.clear()
|
|
157
270
|
|
|
158
271
|
|
|
159
272
|
# Register Scheduler plugin
|
|
@@ -100,6 +100,26 @@ 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
|
+
|
|
103
123
|
class SmtpPlugin(SimplePlugin):
|
|
104
124
|
|
|
105
125
|
server = None
|
|
@@ -108,6 +128,32 @@ class SmtpPlugin(SimplePlugin):
|
|
|
108
128
|
encryption = None
|
|
109
129
|
email_from = None
|
|
110
130
|
|
|
131
|
+
def _create_msg(self, subject: str, message: str, to=None, cc=None, bcc=None, reply_to=None, headers={}):
|
|
132
|
+
assert subject
|
|
133
|
+
assert message
|
|
134
|
+
assert to or bcc
|
|
135
|
+
|
|
136
|
+
# Record the MIME types of both parts - text/plain and text/html.
|
|
137
|
+
msg = MIMEMultipart('alternative')
|
|
138
|
+
msg['Subject'] = str(subject)
|
|
139
|
+
msg['From'] = _formataddr(self.email_from)
|
|
140
|
+
if to:
|
|
141
|
+
msg['To'] = _formataddr(to)
|
|
142
|
+
if cc:
|
|
143
|
+
msg['Cc'] = _formataddr(cc)
|
|
144
|
+
if bcc:
|
|
145
|
+
msg['Bcc'] = _formataddr(bcc)
|
|
146
|
+
if reply_to:
|
|
147
|
+
msg['Reply-To'] = _formataddr(reply_to)
|
|
148
|
+
msg['Message-ID'] = email.utils.make_msgid()
|
|
149
|
+
if headers:
|
|
150
|
+
for key, value in headers.items():
|
|
151
|
+
msg[key] = value
|
|
152
|
+
text = _html2plaintext(message)
|
|
153
|
+
msg.attach(MIMEText(text, 'plain', 'utf8'))
|
|
154
|
+
msg.attach(MIMEText(message, 'html', 'utf8'))
|
|
155
|
+
return msg
|
|
156
|
+
|
|
111
157
|
def start(self):
|
|
112
158
|
self.bus.log('Start SMTP plugin')
|
|
113
159
|
self.bus.subscribe("send_mail", self.send_mail)
|
|
@@ -118,70 +164,50 @@ class SmtpPlugin(SimplePlugin):
|
|
|
118
164
|
self.bus.unsubscribe("send_mail", self.send_mail)
|
|
119
165
|
self.bus.unsubscribe("queue_mail", self.queue_mail)
|
|
120
166
|
|
|
167
|
+
def graceful(self):
|
|
168
|
+
"""Reload of subscribers."""
|
|
169
|
+
self.stop()
|
|
170
|
+
self.start()
|
|
171
|
+
|
|
121
172
|
def queue_mail(self, *args, **kwargs):
|
|
122
173
|
"""
|
|
123
174
|
Queue mail to be sent.
|
|
124
175
|
"""
|
|
125
176
|
# Skip sending email if smtp server is not configured.
|
|
126
177
|
if not self.server:
|
|
127
|
-
|
|
178
|
+
cherrypy.log('cannot send email because SMTP Server is not configured', context='SMTP')
|
|
128
179
|
return
|
|
129
180
|
if not self.email_from:
|
|
130
|
-
|
|
181
|
+
cherrypy.log('cannot send email because SMTP From is not configured', context='SMTP')
|
|
131
182
|
return
|
|
132
|
-
self.
|
|
133
|
-
|
|
134
|
-
|
|
183
|
+
msg = self._create_msg(*args, **kwargs)
|
|
184
|
+
self.bus.publish(
|
|
185
|
+
'scheduler:add_job_now',
|
|
186
|
+
_send_message,
|
|
187
|
+
server=self.server,
|
|
188
|
+
encryption=self.encryption,
|
|
189
|
+
username=self.username,
|
|
190
|
+
password=self.password,
|
|
191
|
+
msg=msg,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
def send_mail(self, *args, **kwargs):
|
|
135
195
|
"""
|
|
136
196
|
Reusable method to be called to send email to the user user.
|
|
137
197
|
`user` user object where to send the email.
|
|
138
198
|
"""
|
|
139
|
-
assert subject
|
|
140
|
-
assert message
|
|
141
|
-
assert to or bcc
|
|
142
|
-
|
|
143
199
|
# Skip sending email if smtp server is not configured.
|
|
144
200
|
if not self.server:
|
|
145
|
-
|
|
201
|
+
cherrypy.log('cannot send email because SMTP Server is not configured', context='SMTP')
|
|
146
202
|
return
|
|
147
203
|
if not self.email_from:
|
|
148
|
-
|
|
204
|
+
cherrypy.log('cannot send email because SMTP From is not configured', context='SMTP')
|
|
149
205
|
return
|
|
150
206
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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()
|
|
207
|
+
msg = self._create_msg(*args, **kwargs)
|
|
208
|
+
_send_message(
|
|
209
|
+
server=self.server, encryption=self.encryption, username=self.username, password=self.password, msg=msg
|
|
210
|
+
)
|
|
185
211
|
|
|
186
212
|
|
|
187
213
|
# Register SMTP plugin
|
|
@@ -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
|
# Scheduler 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
|
|
@@ -14,79 +14,87 @@
|
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
@author: Patrik Dufresne <patrik@ikus-soft.com>
|
|
21
|
-
"""
|
|
22
|
-
import importlib.util
|
|
23
|
-
import unittest
|
|
24
|
-
from time import sleep
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from threading import Event
|
|
25
19
|
|
|
26
20
|
import cherrypy
|
|
27
21
|
from cherrypy.test import helper
|
|
28
22
|
|
|
29
|
-
|
|
23
|
+
from .. import scheduler # noqa
|
|
24
|
+
|
|
25
|
+
done = Event()
|
|
30
26
|
|
|
31
|
-
|
|
32
|
-
|
|
27
|
+
|
|
28
|
+
def a_task(*args, **kwargs):
|
|
29
|
+
done.set()
|
|
33
30
|
|
|
34
31
|
|
|
35
|
-
@unittest.skipUnless(HAS_APSCHEDULER, "apscheduler not installed")
|
|
36
32
|
class SchedulerPluginTest(helper.CPWebCase):
|
|
37
33
|
def setUp(self) -> None:
|
|
38
|
-
|
|
34
|
+
done.clear()
|
|
35
|
+
cherrypy.scheduler.remove_all_jobs()
|
|
39
36
|
return super().setUp()
|
|
40
37
|
|
|
38
|
+
def tearDown(self):
|
|
39
|
+
return super().tearDown()
|
|
40
|
+
|
|
41
41
|
@classmethod
|
|
42
42
|
def setup_server(cls):
|
|
43
43
|
pass
|
|
44
44
|
|
|
45
|
-
def
|
|
46
|
-
# Given a
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
45
|
+
def test_add_job(self):
|
|
46
|
+
# Given a scheduled job
|
|
47
|
+
scheduled = cherrypy.engine.publish(
|
|
48
|
+
'scheduler:add_job',
|
|
49
|
+
a_task,
|
|
50
|
+
name='custom_name',
|
|
51
|
+
args=(1, 2, 3),
|
|
52
|
+
kwargs={'foo': 'bar'},
|
|
53
|
+
next_run_time=datetime.now(timezone.utc),
|
|
54
|
+
misfire_grace_time=None,
|
|
55
|
+
)
|
|
56
|
+
self.assertTrue(scheduled)
|
|
57
|
+
self.assertEqual('custom_name', scheduled[0].name)
|
|
58
|
+
# When waiting for all jobs
|
|
59
|
+
cherrypy.scheduler.wait_for_jobs()
|
|
60
|
+
# Then the job is done
|
|
61
|
+
self.assertTrue(done.is_set())
|
|
52
62
|
|
|
53
|
-
|
|
63
|
+
def test_add_job_daily(self):
|
|
64
|
+
# Given a scheduler with a specific number of jobs
|
|
65
|
+
count = len(cherrypy.scheduler.get_jobs())
|
|
66
|
+
# When scheduling a daily job.
|
|
67
|
+
scheduled = cherrypy.engine.publish('scheduler:add_job_daily', '23:00', a_task, 1, 2, 3, foo=1, bar=2)
|
|
68
|
+
# Then the job is scheduled
|
|
54
69
|
self.assertTrue(scheduled)
|
|
55
|
-
self.assertEqual(
|
|
70
|
+
self.assertEqual('a_task', scheduled[0].name)
|
|
71
|
+
# Then the number of jobs increase.
|
|
72
|
+
self.assertEqual(count + 1, len(cherrypy.scheduler.get_jobs()))
|
|
56
73
|
|
|
57
|
-
def
|
|
74
|
+
def test_add_job_now(self):
|
|
58
75
|
# Given a task
|
|
59
|
-
|
|
60
|
-
def a_task(*args, **kwargs):
|
|
61
|
-
self.called = True
|
|
62
|
-
|
|
63
76
|
# When scheduling that task
|
|
64
|
-
scheduled = cherrypy.engine.publish('
|
|
77
|
+
scheduled = cherrypy.engine.publish('scheduler:add_job_now', a_task, 1, 2, 3, foo=1, bar=2)
|
|
65
78
|
self.assertTrue(scheduled)
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
sleep(1)
|
|
79
|
+
# When waiting for all tasks
|
|
80
|
+
cherrypy.scheduler.wait_for_jobs()
|
|
69
81
|
# Then the task get called
|
|
70
|
-
self.assertTrue(
|
|
82
|
+
self.assertTrue(done.is_set())
|
|
71
83
|
|
|
72
|
-
def
|
|
84
|
+
def test_remove_job(self):
|
|
73
85
|
# Given a scheduler with a specific number of jobs
|
|
74
|
-
count = len(cherrypy.scheduler.
|
|
75
|
-
|
|
86
|
+
count = len(cherrypy.scheduler.get_jobs())
|
|
76
87
|
# Given a job schedule every seconds
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
self.assertEqual(count, len(cherrypy.scheduler.
|
|
84
|
-
|
|
85
|
-
def
|
|
88
|
+
cherrypy.engine.publish('scheduler:add_job_daily', '23:00', a_task, 1, 2, 3, foo=1, bar=2)
|
|
89
|
+
# Then number of job increase
|
|
90
|
+
self.assertEqual(count + 1, len(cherrypy.scheduler.get_jobs()))
|
|
91
|
+
# When the job is unscheduled.
|
|
92
|
+
cherrypy.engine.publish('scheduler:remove_job', a_task)
|
|
93
|
+
# Then the number of job decrease.
|
|
94
|
+
self.assertEqual(count, len(cherrypy.scheduler.get_jobs()))
|
|
95
|
+
|
|
96
|
+
def test_remove_job_with_invalid_job(self):
|
|
86
97
|
# Given an unschedule job
|
|
87
|
-
def a_job(*args, **kwargs):
|
|
88
|
-
self.called = True
|
|
89
|
-
|
|
90
98
|
# When unscheduling an invalid job
|
|
91
|
-
cherrypy.engine.publish('
|
|
92
|
-
# Then
|
|
99
|
+
cherrypy.engine.publish('scheduler:remove_job', a_task)
|
|
100
|
+
# Then an error is not raised.
|