cherrypy-foundation 1.0.0__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/__init__.py +0 -0
- cherrypy_foundation/components/ColorModes.jinja +70 -0
- cherrypy_foundation/components/Datatable.css +47 -0
- cherrypy_foundation/components/Datatable.jinja +63 -0
- cherrypy_foundation/components/Datatable.js +358 -0
- cherrypy_foundation/components/Field.css +10 -0
- cherrypy_foundation/components/Field.jinja +66 -0
- cherrypy_foundation/components/Field.js +56 -0
- cherrypy_foundation/components/Fields.jinja +4 -0
- cherrypy_foundation/components/Flash.jinja +13 -0
- cherrypy_foundation/components/Icon.jinja +3 -0
- cherrypy_foundation/components/LocaleSelection.jinja +13 -0
- cherrypy_foundation/components/LocaleSelection.js +26 -0
- cherrypy_foundation/components/SideBySideMultiSelect.css +25 -0
- cherrypy_foundation/components/SideBySideMultiSelect.jinja +9 -0
- cherrypy_foundation/components/SideBySideMultiSelect.js +9 -0
- cherrypy_foundation/components/Typeahead.css +55 -0
- cherrypy_foundation/components/Typeahead.jinja +106 -0
- cherrypy_foundation/components/Typeahead.js +8 -0
- cherrypy_foundation/components/__init__.py +51 -0
- cherrypy_foundation/components/tests/__init__.py +0 -0
- cherrypy_foundation/components/tests/test_static.py +90 -0
- cherrypy_foundation/components/vendor/bootstrap-icons/bootstrap-icons.css +2106 -0
- cherrypy_foundation/components/vendor/bootstrap-icons/bootstrap-icons.min.css +5 -0
- cherrypy_foundation/components/vendor/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
- cherrypy_foundation/components/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
- cherrypy_foundation/components/vendor/bootstrap5/css/bootstrap.css +9262 -0
- cherrypy_foundation/components/vendor/bootstrap5/css/bootstrap.css.map +95 -0
- cherrypy_foundation/components/vendor/bootstrap5/css/bootstrap.min.css +6 -0
- cherrypy_foundation/components/vendor/bootstrap5/css/bootstrap.min.css.map +7 -0
- cherrypy_foundation/components/vendor/bootstrap5/js/bootstrap.js +4846 -0
- cherrypy_foundation/components/vendor/bootstrap5/js/bootstrap.js.map +1 -0
- cherrypy_foundation/components/vendor/bootstrap5/js/bootstrap.min.js +7 -0
- cherrypy_foundation/components/vendor/bootstrap5/js/bootstrap.min.js.map +7 -0
- cherrypy_foundation/components/vendor/bootstrap5/js/color-modes.js +80 -0
- cherrypy_foundation/components/vendor/datatables/css/dataTables.dataTables.css +849 -0
- cherrypy_foundation/components/vendor/datatables/css/dataTables.dataTables.min.css +1 -0
- cherrypy_foundation/components/vendor/datatables/images/sort_asc.png +0 -0
- cherrypy_foundation/components/vendor/datatables/images/sort_asc_disabled.png +0 -0
- cherrypy_foundation/components/vendor/datatables/images/sort_both.png +0 -0
- cherrypy_foundation/components/vendor/datatables/images/sort_desc.png +0 -0
- cherrypy_foundation/components/vendor/datatables/images/sort_desc_disabled.png +0 -0
- cherrypy_foundation/components/vendor/datatables/js/dataTables.js +14073 -0
- cherrypy_foundation/components/vendor/datatables/js/dataTables.min.js +4 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Buttons/css/buttons.dataTables.css +556 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Buttons/css/buttons.dataTables.min.css +1 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Buttons/js/buttons.html5.js +1700 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Buttons/js/buttons.html5.min.js +8 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Buttons/js/dataTables.buttons.js +2944 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Buttons/js/dataTables.buttons.min.js +4 -0
- cherrypy_foundation/components/vendor/datatables-extensions/FixedHeader/css/fixedHeader.dataTables.css +13 -0
- cherrypy_foundation/components/vendor/datatables-extensions/FixedHeader/css/fixedHeader.dataTables.min.css +1 -0
- cherrypy_foundation/components/vendor/datatables-extensions/FixedHeader/js/dataTables.fixedHeader.js +1202 -0
- cherrypy_foundation/components/vendor/datatables-extensions/FixedHeader/js/dataTables.fixedHeader.min.js +4 -0
- cherrypy_foundation/components/vendor/datatables-extensions/JSZip/jszip.js +11577 -0
- cherrypy_foundation/components/vendor/datatables-extensions/JSZip/jszip.min.js +13 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Responsive/css/responsive.dataTables.css +194 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Responsive/css/responsive.dataTables.min.css +1 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Responsive/js/dataTables.responsive.js +1861 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Responsive/js/dataTables.responsive.min.js +4 -0
- cherrypy_foundation/components/vendor/datatables-extensions/pdfmake/build/pdfmake.js +75023 -0
- cherrypy_foundation/components/vendor/datatables-extensions/pdfmake/build/pdfmake.min.js +3 -0
- cherrypy_foundation/components/vendor/datatables-extensions/pdfmake/build/vfs_fonts.js +6 -0
- cherrypy_foundation/components/vendor/datatables-extensions/rowgroup/css/rowGroup.dataTables.css +53 -0
- cherrypy_foundation/components/vendor/datatables-extensions/rowgroup/css/rowGroup.dataTables.min.css +1 -0
- cherrypy_foundation/components/vendor/datatables-extensions/rowgroup/js/dataTables.rowGroup.js +485 -0
- cherrypy_foundation/components/vendor/datatables-extensions/rowgroup/js/dataTables.rowGroup.min.js +4 -0
- cherrypy_foundation/components/vendor/jquery/jquery.min.js +2 -0
- cherrypy_foundation/components/vendor/multi/LICENSE +7 -0
- cherrypy_foundation/components/vendor/multi/README.md +109 -0
- cherrypy_foundation/components/vendor/multi/multi.css +95 -0
- cherrypy_foundation/components/vendor/multi/multi.js +328 -0
- cherrypy_foundation/components/vendor/popper/popper.js +1825 -0
- cherrypy_foundation/components/vendor/popper/popper.min.js +6 -0
- cherrypy_foundation/components/vendor/typeahead/jquery.typeahead.min.css +1 -0
- cherrypy_foundation/components/vendor/typeahead/jquery.typeahead.min.js +10 -0
- cherrypy_foundation/error_page.py +94 -0
- cherrypy_foundation/flash.py +50 -0
- cherrypy_foundation/form.py +119 -0
- cherrypy_foundation/logging.py +103 -0
- cherrypy_foundation/passwd.py +65 -0
- cherrypy_foundation/plugins/__init__.py +0 -0
- cherrypy_foundation/plugins/db.py +286 -0
- cherrypy_foundation/plugins/ldap.py +257 -0
- cherrypy_foundation/plugins/restapi.py +74 -0
- cherrypy_foundation/plugins/scheduler.py +287 -0
- cherrypy_foundation/plugins/smtp.py +223 -0
- cherrypy_foundation/plugins/tests/__init__.py +0 -0
- cherrypy_foundation/plugins/tests/test_db.py +118 -0
- cherrypy_foundation/plugins/tests/test_ldap.py +451 -0
- cherrypy_foundation/plugins/tests/test_scheduler.py +100 -0
- cherrypy_foundation/plugins/tests/test_scheduler_db.py +107 -0
- cherrypy_foundation/plugins/tests/test_smtp.py +140 -0
- cherrypy_foundation/sessions.py +93 -0
- cherrypy_foundation/tests/__init__.py +72 -0
- cherrypy_foundation/tests/templates/test_flash.html +9 -0
- cherrypy_foundation/tests/templates/test_form.html +16 -0
- cherrypy_foundation/tests/templates/test_url.html +15 -0
- cherrypy_foundation/tests/test_error_page.py +78 -0
- cherrypy_foundation/tests/test_flash.py +61 -0
- cherrypy_foundation/tests/test_form.py +148 -0
- cherrypy_foundation/tests/test_logging.py +78 -0
- cherrypy_foundation/tests/test_passwd.py +51 -0
- cherrypy_foundation/tests/test_sessions.py +89 -0
- cherrypy_foundation/tests/test_url.py +161 -0
- cherrypy_foundation/tools/__init__.py +0 -0
- cherrypy_foundation/tools/auth.py +263 -0
- cherrypy_foundation/tools/auth_mfa.py +249 -0
- cherrypy_foundation/tools/i18n.py +529 -0
- cherrypy_foundation/tools/jinja2.py +158 -0
- cherrypy_foundation/tools/ratelimit.py +265 -0
- cherrypy_foundation/tools/secure_headers.py +119 -0
- cherrypy_foundation/tools/sessions_timeout.py +167 -0
- cherrypy_foundation/tools/tests/__init__.py +0 -0
- cherrypy_foundation/tools/tests/components/Button.jinja +2 -0
- cherrypy_foundation/tools/tests/locales/de/LC_MESSAGES/messages.mo +0 -0
- cherrypy_foundation/tools/tests/locales/de/LC_MESSAGES/messages.po +15 -0
- cherrypy_foundation/tools/tests/locales/fr/LC_MESSAGES/messages.mo +0 -0
- cherrypy_foundation/tools/tests/locales/fr/LC_MESSAGES/messages.po +15 -0
- cherrypy_foundation/tools/tests/locales/messages.pot +2 -0
- cherrypy_foundation/tools/tests/templates/test_jinja2.html +11 -0
- cherrypy_foundation/tools/tests/templates/test_jinjax.html +9 -0
- cherrypy_foundation/tools/tests/templates/test_jinjax_i18n.html +22 -0
- cherrypy_foundation/tools/tests/test_auth.py +110 -0
- cherrypy_foundation/tools/tests/test_auth_mfa.py +369 -0
- cherrypy_foundation/tools/tests/test_i18n.py +247 -0
- cherrypy_foundation/tools/tests/test_jinja2.py +153 -0
- cherrypy_foundation/tools/tests/test_ratelimit.py +109 -0
- cherrypy_foundation/tools/tests/test_secure_headers.py +200 -0
- cherrypy_foundation/url.py +66 -0
- cherrypy_foundation/widgets.py +48 -0
- cherrypy_foundation-1.0.0.dist-info/METADATA +71 -0
- cherrypy_foundation-1.0.0.dist-info/RECORD +136 -0
- cherrypy_foundation-1.0.0.dist-info/WHEEL +5 -0
- cherrypy_foundation-1.0.0.dist-info/licenses/LICENSE.md +674 -0
- cherrypy_foundation-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
# Scheduler plugins for Cherrypy
|
|
2
|
+
# Copyright (C) 2026 IKUS Software
|
|
3
|
+
#
|
|
4
|
+
# This program is free software: you can redistribute it and/or modify
|
|
5
|
+
# it under the terms of the GNU General Public License as published by
|
|
6
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
# (at your option) any later version.
|
|
8
|
+
#
|
|
9
|
+
# This program is distributed in the hope that it will be useful,
|
|
10
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
# GNU General Public License for more details.
|
|
13
|
+
#
|
|
14
|
+
# You should have received a copy of the GNU General Public License
|
|
15
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
import copy
|
|
17
|
+
import logging
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from functools import wraps
|
|
20
|
+
from threading import Event, RLock
|
|
21
|
+
|
|
22
|
+
import cherrypy
|
|
23
|
+
from apscheduler.events import EVENT_JOB_ERROR, EVENT_JOB_EXECUTED, EVENT_JOB_SUBMITTED
|
|
24
|
+
from apscheduler.schedulers.background import BackgroundScheduler
|
|
25
|
+
from cherrypy.process.plugins import SimplePlugin
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def clear_db_sessions(func):
|
|
31
|
+
"""
|
|
32
|
+
A decorator that ensures database connections that have become unusable, or are obsolete, are closed
|
|
33
|
+
before and after the job is executed.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
@wraps(func)
|
|
37
|
+
def func_wrapper(*args, **kwargs):
|
|
38
|
+
try:
|
|
39
|
+
result = func(*args, **kwargs)
|
|
40
|
+
finally:
|
|
41
|
+
cherrypy.db.clear_sessions()
|
|
42
|
+
return result
|
|
43
|
+
|
|
44
|
+
return func_wrapper
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Scheduler(SimplePlugin):
|
|
48
|
+
"""
|
|
49
|
+
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
|
+
"""
|
|
54
|
+
|
|
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
|
+
def __init__(self, bus):
|
|
61
|
+
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
|
|
138
|
+
|
|
139
|
+
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,
|
|
149
|
+
)
|
|
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
|
+
|
|
156
|
+
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
|
|
170
|
+
|
|
171
|
+
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
|
|
185
|
+
|
|
186
|
+
def graceful(self):
|
|
187
|
+
"""Reload of subscribers."""
|
|
188
|
+
self.stop()
|
|
189
|
+
self.start()
|
|
190
|
+
|
|
191
|
+
def add_job(self, func, *args, **kwargs):
|
|
192
|
+
"""
|
|
193
|
+
Called via engine.publish('scheduler:add_job', ...)
|
|
194
|
+
OR directly on the plugin instance.
|
|
195
|
+
"""
|
|
196
|
+
if not self._scheduler:
|
|
197
|
+
raise RuntimeError("Scheduler not running; cannot add job.")
|
|
198
|
+
|
|
199
|
+
return self._scheduler.add_job(func, *args, **kwargs)
|
|
200
|
+
|
|
201
|
+
def add_job_daily(self, execution_time, func, *args, **kwargs):
|
|
202
|
+
"""
|
|
203
|
+
A convenience wrapper around add_job() that ensures
|
|
204
|
+
the job runs daily.
|
|
205
|
+
"""
|
|
206
|
+
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)),
|
|
211
|
+
args=args,
|
|
212
|
+
kwargs=kwargs,
|
|
213
|
+
trigger='cron',
|
|
214
|
+
hour=hour,
|
|
215
|
+
minute=minute,
|
|
216
|
+
misfire_grace_time=None,
|
|
217
|
+
coalesce=True,
|
|
218
|
+
replace_existing=True,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
def add_job_now(self, func, *args, **kwargs):
|
|
222
|
+
"""
|
|
223
|
+
A convenience wrapper around add_job() that ensures
|
|
224
|
+
the job runs immediately upon being added.
|
|
225
|
+
"""
|
|
226
|
+
return self.add_job(
|
|
227
|
+
func=func,
|
|
228
|
+
name=getattr(func, '__name__', str(func)),
|
|
229
|
+
args=args,
|
|
230
|
+
kwargs=kwargs,
|
|
231
|
+
next_run_time=datetime.now(timezone.utc),
|
|
232
|
+
misfire_grace_time=None,
|
|
233
|
+
)
|
|
234
|
+
|
|
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):
|
|
244
|
+
"""
|
|
245
|
+
Remove the given job from scheduler.
|
|
246
|
+
"""
|
|
247
|
+
# 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()
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
# Register Scheduler plugin
|
|
284
|
+
cherrypy.scheduler = Scheduler(cherrypy.engine)
|
|
285
|
+
cherrypy.scheduler.subscribe()
|
|
286
|
+
|
|
287
|
+
cherrypy.config.namespaces['scheduler'] = lambda key, value: setattr(cherrypy.scheduler, key, value)
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# SMTP Plugins for cherrypy
|
|
2
|
+
# Copyright (C) 2026 IKUS Software
|
|
3
|
+
#
|
|
4
|
+
# This program is free software: you can redistribute it and/or modify
|
|
5
|
+
# it under the terms of the GNU General Public License as published by
|
|
6
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
# (at your option) any later version.
|
|
8
|
+
#
|
|
9
|
+
# This program is distributed in the hope that it will be useful,
|
|
10
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
# GNU General Public License for more details.
|
|
13
|
+
#
|
|
14
|
+
# You should have received a copy of the GNU General Public License
|
|
15
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
|
|
17
|
+
import email.utils
|
|
18
|
+
import re
|
|
19
|
+
import smtplib
|
|
20
|
+
from email.mime.multipart import MIMEMultipart
|
|
21
|
+
from email.mime.text import MIMEText
|
|
22
|
+
from xml.etree.ElementTree import fromstring, tostring
|
|
23
|
+
|
|
24
|
+
import cherrypy
|
|
25
|
+
from cherrypy.process.plugins import SimplePlugin
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _html2plaintext(html, encoding='utf-8'):
|
|
29
|
+
"""From an HTML text, convert the HTML to plain text.
|
|
30
|
+
If @param body_id is provided then this is the tag where the
|
|
31
|
+
body (not necessarily <body>) starts.
|
|
32
|
+
"""
|
|
33
|
+
# (c) Fry-IT, www.fry-it.com, 2007
|
|
34
|
+
# <peter@fry-it.com>
|
|
35
|
+
# download here: http://www.peterbe.com/plog/html2plaintext
|
|
36
|
+
assert isinstance(html, str)
|
|
37
|
+
#   are non-breaking space
|
|
38
|
+
html = html.replace(' ', ' ')
|
|
39
|
+
url_index = []
|
|
40
|
+
try:
|
|
41
|
+
tree = fromstring(html)
|
|
42
|
+
tree = tree.find('body')
|
|
43
|
+
i = 0
|
|
44
|
+
for link in tree.findall('.//a'):
|
|
45
|
+
url = link.get('href')
|
|
46
|
+
if url:
|
|
47
|
+
i += 1
|
|
48
|
+
link.tag = 'span'
|
|
49
|
+
link.text = '%s [%s]' % (link.text, i)
|
|
50
|
+
url_index.append(url)
|
|
51
|
+
html = tostring(tree, encoding=encoding).decode(encoding)
|
|
52
|
+
except Exception:
|
|
53
|
+
# Don't fail if the html is invalid.
|
|
54
|
+
pass
|
|
55
|
+
# \r char is converted into , must remove it
|
|
56
|
+
html = html.replace(' ', '')
|
|
57
|
+
# Remove new line & spaces defined for html formating.
|
|
58
|
+
html = re.sub('\n *', '', html)
|
|
59
|
+
# Replace tags
|
|
60
|
+
html = html.replace('<strong>', '*').replace('</strong>', '*')
|
|
61
|
+
html = html.replace('<b>', '*').replace('</b>', '*')
|
|
62
|
+
html = html.replace('<h3>', '*').replace('</h3>', '*')
|
|
63
|
+
html = html.replace('<h2>', '**').replace('</h2>', '**\n')
|
|
64
|
+
html = html.replace('<h1>', '**').replace('</h1>', '**\n')
|
|
65
|
+
html = html.replace('<em>', '/').replace('</em>', '/')
|
|
66
|
+
html = html.replace('<tr>', '\n')
|
|
67
|
+
html = html.replace('</p>', '\n')
|
|
68
|
+
html = re.sub('<style[^>]*>[^<]*</style>', '', html)
|
|
69
|
+
html = re.sub(r'<br\s*/?>', '\n', html)
|
|
70
|
+
html = re.sub('<[^>]*>', '', html)
|
|
71
|
+
html = re.sub(r'\n+', '\n', html)
|
|
72
|
+
html = html.replace('>', '>')
|
|
73
|
+
html = html.replace('<', '<')
|
|
74
|
+
html = html.replace('&', '&')
|
|
75
|
+
|
|
76
|
+
# strip all lines
|
|
77
|
+
html = '\n'.join([x.strip() for x in html.splitlines()])
|
|
78
|
+
html = html.replace('\n' * 2, '\n')
|
|
79
|
+
|
|
80
|
+
for i, url in enumerate(url_index):
|
|
81
|
+
if i == 0:
|
|
82
|
+
html += '\n\n'
|
|
83
|
+
html += '[%s] %s\n' % (i + 1, url)
|
|
84
|
+
|
|
85
|
+
return html.strip('\n')
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _formataddr(value):
|
|
89
|
+
"""
|
|
90
|
+
Format the given value into a valid email address. Support raw string, tuple or a list of string.
|
|
91
|
+
"""
|
|
92
|
+
if not value:
|
|
93
|
+
return None
|
|
94
|
+
if isinstance(value, str):
|
|
95
|
+
return value
|
|
96
|
+
if isinstance(value, (tuple, list)) and len(value) == 2 and isinstance(value[0], str) and isinstance(value[1], str):
|
|
97
|
+
return email.utils.formataddr(value)
|
|
98
|
+
if not isinstance(value, (tuple, list)):
|
|
99
|
+
raise TypeError('expect a string, a tuple or a list of email address')
|
|
100
|
+
return ', '.join(map(_formataddr, value))
|
|
101
|
+
|
|
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
|
+
class SmtpPlugin(SimplePlugin):
|
|
124
|
+
|
|
125
|
+
server = None
|
|
126
|
+
username = None
|
|
127
|
+
password = None
|
|
128
|
+
encryption = None
|
|
129
|
+
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
|
+
|
|
163
|
+
def start(self):
|
|
164
|
+
self.bus.log('Start SMTP plugin')
|
|
165
|
+
self.bus.subscribe("send_mail", self.send_mail)
|
|
166
|
+
self.bus.subscribe("queue_mail", self.queue_mail)
|
|
167
|
+
|
|
168
|
+
def stop(self):
|
|
169
|
+
self.bus.log('Stop SMTP plugin')
|
|
170
|
+
self.bus.unsubscribe("send_mail", self.send_mail)
|
|
171
|
+
self.bus.unsubscribe("queue_mail", self.queue_mail)
|
|
172
|
+
|
|
173
|
+
def graceful(self):
|
|
174
|
+
"""Reload of subscribers."""
|
|
175
|
+
self.stop()
|
|
176
|
+
self.start()
|
|
177
|
+
|
|
178
|
+
def queue_mail(self, *args, **kwargs):
|
|
179
|
+
"""
|
|
180
|
+
Queue mail to be sent.
|
|
181
|
+
"""
|
|
182
|
+
# Skip sending email if smtp server is not configured.
|
|
183
|
+
if not self.server:
|
|
184
|
+
cherrypy.log('cannot send email because SMTP Server is not configured', context='SMTP')
|
|
185
|
+
return
|
|
186
|
+
if not self.email_from:
|
|
187
|
+
cherrypy.log('cannot send email because SMTP From is not configured', context='SMTP')
|
|
188
|
+
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):
|
|
201
|
+
"""
|
|
202
|
+
Reusable method to be called to send email to the user user.
|
|
203
|
+
`user` user object where to send the email.
|
|
204
|
+
"""
|
|
205
|
+
# Skip sending email if smtp server is not configured.
|
|
206
|
+
if not self.server:
|
|
207
|
+
cherrypy.log('cannot send email because SMTP Server is not configured', context='SMTP')
|
|
208
|
+
return
|
|
209
|
+
if not self.email_from:
|
|
210
|
+
cherrypy.log('cannot send email because SMTP From is not configured', context='SMTP')
|
|
211
|
+
return
|
|
212
|
+
|
|
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
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# Register SMTP plugin
|
|
220
|
+
cherrypy.smtp = SmtpPlugin(cherrypy.engine)
|
|
221
|
+
cherrypy.smtp.subscribe()
|
|
222
|
+
|
|
223
|
+
cherrypy.config.namespaces['smtp'] = lambda key, value: setattr(cherrypy.smtp, key, value)
|
|
File without changes
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# Cherrypy-foundation
|
|
2
|
+
# Copyright (C) 2022-2026 IKUS Software
|
|
3
|
+
#
|
|
4
|
+
# This program is free software: you can redistribute it and/or modify
|
|
5
|
+
# it under the terms of the GNU General Public License as published by
|
|
6
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
# (at your option) any later version.
|
|
8
|
+
#
|
|
9
|
+
# This program is distributed in the hope that it will be useful,
|
|
10
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
# GNU General Public License for more details.
|
|
13
|
+
#
|
|
14
|
+
# You should have received a copy of the GNU General Public License
|
|
15
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
import importlib
|
|
17
|
+
import tempfile
|
|
18
|
+
import unittest
|
|
19
|
+
from urllib.parse import urlencode
|
|
20
|
+
|
|
21
|
+
import cherrypy
|
|
22
|
+
from cherrypy.test import helper
|
|
23
|
+
|
|
24
|
+
HAS_SQLALCHEMY = importlib.util.find_spec("sqlalchemy") is not None
|
|
25
|
+
|
|
26
|
+
if not HAS_SQLALCHEMY:
|
|
27
|
+
pass
|
|
28
|
+
else:
|
|
29
|
+
from sqlalchemy import Column, Integer, String, func
|
|
30
|
+
from sqlalchemy.exc import IntegrityError
|
|
31
|
+
from sqlalchemy.sql.schema import Index
|
|
32
|
+
|
|
33
|
+
from .. import db # noqa
|
|
34
|
+
|
|
35
|
+
Base = cherrypy.db.get_base()
|
|
36
|
+
|
|
37
|
+
class User(Base):
|
|
38
|
+
__tablename__ = 'users'
|
|
39
|
+
id = Column(Integer, primary_key=True)
|
|
40
|
+
username = Column(String)
|
|
41
|
+
|
|
42
|
+
def __repr__(self):
|
|
43
|
+
return f"User(id={self.id}, username='{self.username}')"
|
|
44
|
+
|
|
45
|
+
Index('user_username_unique_ix', func.lower(User.username), unique=True, info='This username already exists.')
|
|
46
|
+
|
|
47
|
+
class Root:
|
|
48
|
+
|
|
49
|
+
@cherrypy.expose
|
|
50
|
+
def index(self):
|
|
51
|
+
return str(User.query.all())
|
|
52
|
+
|
|
53
|
+
@cherrypy.expose
|
|
54
|
+
def add(self, username):
|
|
55
|
+
try:
|
|
56
|
+
User(username=username).add().commit()
|
|
57
|
+
return "OK"
|
|
58
|
+
except IntegrityError as e:
|
|
59
|
+
return str(e)
|
|
60
|
+
|
|
61
|
+
@unittest.skipUnless(HAS_SQLALCHEMY, "sqlalchemy not installed")
|
|
62
|
+
class DbPluginTest(helper.CPWebCase):
|
|
63
|
+
interactive = False
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def setup_class(cls):
|
|
67
|
+
cls.tempdir = tempfile.TemporaryDirectory(prefix='cherrypy-foundation-', suffix='-db-test')
|
|
68
|
+
super().setup_class()
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def teardown_class(cls):
|
|
72
|
+
cls.tempdir.cleanup()
|
|
73
|
+
super().teardown_class()
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def setup_server(cls):
|
|
77
|
+
cherrypy.config.update(
|
|
78
|
+
{
|
|
79
|
+
'db.uri': f"sqlite:///{cls.tempdir.name}/data.db",
|
|
80
|
+
}
|
|
81
|
+
)
|
|
82
|
+
cherrypy.tree.mount(Root(), '/')
|
|
83
|
+
|
|
84
|
+
def setUp(self):
|
|
85
|
+
cherrypy.db.create_all()
|
|
86
|
+
return super().setUp()
|
|
87
|
+
|
|
88
|
+
def tearDown(self):
|
|
89
|
+
cherrypy.db.drop_all()
|
|
90
|
+
return super().tearDown()
|
|
91
|
+
|
|
92
|
+
def test_add_user(self):
|
|
93
|
+
# Given an empty database
|
|
94
|
+
self.assertEqual(0, User.query.count())
|
|
95
|
+
# When calling "/add/" to create a user
|
|
96
|
+
self.getPage('/add', method='POST', body=urlencode({'username': 'myuser'}))
|
|
97
|
+
self.assertStatus(200)
|
|
98
|
+
self.assertInBody('OK')
|
|
99
|
+
# Then user get added to database
|
|
100
|
+
self.assertEqual(1, User.query.count())
|
|
101
|
+
|
|
102
|
+
def test_add_duplicate_user(self):
|
|
103
|
+
# Given a database with a users
|
|
104
|
+
User(username='user1').add().commit()
|
|
105
|
+
# When trying to add another user
|
|
106
|
+
self.getPage('/add', method='POST', body=urlencode({'username': 'user1'}))
|
|
107
|
+
# Then an error get raised
|
|
108
|
+
self.assertStatus(200)
|
|
109
|
+
self.assertInBody('user_username_unique_ix')
|
|
110
|
+
|
|
111
|
+
def test_get_users(self):
|
|
112
|
+
# Given a database with a users
|
|
113
|
+
new_user = User(username='newuser').add().commit()
|
|
114
|
+
# When calling "/"
|
|
115
|
+
self.getPage("/")
|
|
116
|
+
self.assertStatus(200)
|
|
117
|
+
# Then the page include our users
|
|
118
|
+
self.assertInBody(new_user.username)
|