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.
Files changed (136) hide show
  1. cherrypy_foundation/__init__.py +0 -0
  2. cherrypy_foundation/components/ColorModes.jinja +70 -0
  3. cherrypy_foundation/components/Datatable.css +47 -0
  4. cherrypy_foundation/components/Datatable.jinja +63 -0
  5. cherrypy_foundation/components/Datatable.js +358 -0
  6. cherrypy_foundation/components/Field.css +10 -0
  7. cherrypy_foundation/components/Field.jinja +66 -0
  8. cherrypy_foundation/components/Field.js +56 -0
  9. cherrypy_foundation/components/Fields.jinja +4 -0
  10. cherrypy_foundation/components/Flash.jinja +13 -0
  11. cherrypy_foundation/components/Icon.jinja +3 -0
  12. cherrypy_foundation/components/LocaleSelection.jinja +13 -0
  13. cherrypy_foundation/components/LocaleSelection.js +26 -0
  14. cherrypy_foundation/components/SideBySideMultiSelect.css +25 -0
  15. cherrypy_foundation/components/SideBySideMultiSelect.jinja +9 -0
  16. cherrypy_foundation/components/SideBySideMultiSelect.js +9 -0
  17. cherrypy_foundation/components/Typeahead.css +55 -0
  18. cherrypy_foundation/components/Typeahead.jinja +106 -0
  19. cherrypy_foundation/components/Typeahead.js +8 -0
  20. cherrypy_foundation/components/__init__.py +51 -0
  21. cherrypy_foundation/components/tests/__init__.py +0 -0
  22. cherrypy_foundation/components/tests/test_static.py +90 -0
  23. cherrypy_foundation/components/vendor/bootstrap-icons/bootstrap-icons.css +2106 -0
  24. cherrypy_foundation/components/vendor/bootstrap-icons/bootstrap-icons.min.css +5 -0
  25. cherrypy_foundation/components/vendor/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
  26. cherrypy_foundation/components/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
  27. cherrypy_foundation/components/vendor/bootstrap5/css/bootstrap.css +9262 -0
  28. cherrypy_foundation/components/vendor/bootstrap5/css/bootstrap.css.map +95 -0
  29. cherrypy_foundation/components/vendor/bootstrap5/css/bootstrap.min.css +6 -0
  30. cherrypy_foundation/components/vendor/bootstrap5/css/bootstrap.min.css.map +7 -0
  31. cherrypy_foundation/components/vendor/bootstrap5/js/bootstrap.js +4846 -0
  32. cherrypy_foundation/components/vendor/bootstrap5/js/bootstrap.js.map +1 -0
  33. cherrypy_foundation/components/vendor/bootstrap5/js/bootstrap.min.js +7 -0
  34. cherrypy_foundation/components/vendor/bootstrap5/js/bootstrap.min.js.map +7 -0
  35. cherrypy_foundation/components/vendor/bootstrap5/js/color-modes.js +80 -0
  36. cherrypy_foundation/components/vendor/datatables/css/dataTables.dataTables.css +849 -0
  37. cherrypy_foundation/components/vendor/datatables/css/dataTables.dataTables.min.css +1 -0
  38. cherrypy_foundation/components/vendor/datatables/images/sort_asc.png +0 -0
  39. cherrypy_foundation/components/vendor/datatables/images/sort_asc_disabled.png +0 -0
  40. cherrypy_foundation/components/vendor/datatables/images/sort_both.png +0 -0
  41. cherrypy_foundation/components/vendor/datatables/images/sort_desc.png +0 -0
  42. cherrypy_foundation/components/vendor/datatables/images/sort_desc_disabled.png +0 -0
  43. cherrypy_foundation/components/vendor/datatables/js/dataTables.js +14073 -0
  44. cherrypy_foundation/components/vendor/datatables/js/dataTables.min.js +4 -0
  45. cherrypy_foundation/components/vendor/datatables-extensions/Buttons/css/buttons.dataTables.css +556 -0
  46. cherrypy_foundation/components/vendor/datatables-extensions/Buttons/css/buttons.dataTables.min.css +1 -0
  47. cherrypy_foundation/components/vendor/datatables-extensions/Buttons/js/buttons.html5.js +1700 -0
  48. cherrypy_foundation/components/vendor/datatables-extensions/Buttons/js/buttons.html5.min.js +8 -0
  49. cherrypy_foundation/components/vendor/datatables-extensions/Buttons/js/dataTables.buttons.js +2944 -0
  50. cherrypy_foundation/components/vendor/datatables-extensions/Buttons/js/dataTables.buttons.min.js +4 -0
  51. cherrypy_foundation/components/vendor/datatables-extensions/FixedHeader/css/fixedHeader.dataTables.css +13 -0
  52. cherrypy_foundation/components/vendor/datatables-extensions/FixedHeader/css/fixedHeader.dataTables.min.css +1 -0
  53. cherrypy_foundation/components/vendor/datatables-extensions/FixedHeader/js/dataTables.fixedHeader.js +1202 -0
  54. cherrypy_foundation/components/vendor/datatables-extensions/FixedHeader/js/dataTables.fixedHeader.min.js +4 -0
  55. cherrypy_foundation/components/vendor/datatables-extensions/JSZip/jszip.js +11577 -0
  56. cherrypy_foundation/components/vendor/datatables-extensions/JSZip/jszip.min.js +13 -0
  57. cherrypy_foundation/components/vendor/datatables-extensions/Responsive/css/responsive.dataTables.css +194 -0
  58. cherrypy_foundation/components/vendor/datatables-extensions/Responsive/css/responsive.dataTables.min.css +1 -0
  59. cherrypy_foundation/components/vendor/datatables-extensions/Responsive/js/dataTables.responsive.js +1861 -0
  60. cherrypy_foundation/components/vendor/datatables-extensions/Responsive/js/dataTables.responsive.min.js +4 -0
  61. cherrypy_foundation/components/vendor/datatables-extensions/pdfmake/build/pdfmake.js +75023 -0
  62. cherrypy_foundation/components/vendor/datatables-extensions/pdfmake/build/pdfmake.min.js +3 -0
  63. cherrypy_foundation/components/vendor/datatables-extensions/pdfmake/build/vfs_fonts.js +6 -0
  64. cherrypy_foundation/components/vendor/datatables-extensions/rowgroup/css/rowGroup.dataTables.css +53 -0
  65. cherrypy_foundation/components/vendor/datatables-extensions/rowgroup/css/rowGroup.dataTables.min.css +1 -0
  66. cherrypy_foundation/components/vendor/datatables-extensions/rowgroup/js/dataTables.rowGroup.js +485 -0
  67. cherrypy_foundation/components/vendor/datatables-extensions/rowgroup/js/dataTables.rowGroup.min.js +4 -0
  68. cherrypy_foundation/components/vendor/jquery/jquery.min.js +2 -0
  69. cherrypy_foundation/components/vendor/multi/LICENSE +7 -0
  70. cherrypy_foundation/components/vendor/multi/README.md +109 -0
  71. cherrypy_foundation/components/vendor/multi/multi.css +95 -0
  72. cherrypy_foundation/components/vendor/multi/multi.js +328 -0
  73. cherrypy_foundation/components/vendor/popper/popper.js +1825 -0
  74. cherrypy_foundation/components/vendor/popper/popper.min.js +6 -0
  75. cherrypy_foundation/components/vendor/typeahead/jquery.typeahead.min.css +1 -0
  76. cherrypy_foundation/components/vendor/typeahead/jquery.typeahead.min.js +10 -0
  77. cherrypy_foundation/error_page.py +94 -0
  78. cherrypy_foundation/flash.py +50 -0
  79. cherrypy_foundation/form.py +119 -0
  80. cherrypy_foundation/logging.py +103 -0
  81. cherrypy_foundation/passwd.py +65 -0
  82. cherrypy_foundation/plugins/__init__.py +0 -0
  83. cherrypy_foundation/plugins/db.py +286 -0
  84. cherrypy_foundation/plugins/ldap.py +257 -0
  85. cherrypy_foundation/plugins/restapi.py +74 -0
  86. cherrypy_foundation/plugins/scheduler.py +287 -0
  87. cherrypy_foundation/plugins/smtp.py +223 -0
  88. cherrypy_foundation/plugins/tests/__init__.py +0 -0
  89. cherrypy_foundation/plugins/tests/test_db.py +118 -0
  90. cherrypy_foundation/plugins/tests/test_ldap.py +451 -0
  91. cherrypy_foundation/plugins/tests/test_scheduler.py +100 -0
  92. cherrypy_foundation/plugins/tests/test_scheduler_db.py +107 -0
  93. cherrypy_foundation/plugins/tests/test_smtp.py +140 -0
  94. cherrypy_foundation/sessions.py +93 -0
  95. cherrypy_foundation/tests/__init__.py +72 -0
  96. cherrypy_foundation/tests/templates/test_flash.html +9 -0
  97. cherrypy_foundation/tests/templates/test_form.html +16 -0
  98. cherrypy_foundation/tests/templates/test_url.html +15 -0
  99. cherrypy_foundation/tests/test_error_page.py +78 -0
  100. cherrypy_foundation/tests/test_flash.py +61 -0
  101. cherrypy_foundation/tests/test_form.py +148 -0
  102. cherrypy_foundation/tests/test_logging.py +78 -0
  103. cherrypy_foundation/tests/test_passwd.py +51 -0
  104. cherrypy_foundation/tests/test_sessions.py +89 -0
  105. cherrypy_foundation/tests/test_url.py +161 -0
  106. cherrypy_foundation/tools/__init__.py +0 -0
  107. cherrypy_foundation/tools/auth.py +263 -0
  108. cherrypy_foundation/tools/auth_mfa.py +249 -0
  109. cherrypy_foundation/tools/i18n.py +529 -0
  110. cherrypy_foundation/tools/jinja2.py +158 -0
  111. cherrypy_foundation/tools/ratelimit.py +265 -0
  112. cherrypy_foundation/tools/secure_headers.py +119 -0
  113. cherrypy_foundation/tools/sessions_timeout.py +167 -0
  114. cherrypy_foundation/tools/tests/__init__.py +0 -0
  115. cherrypy_foundation/tools/tests/components/Button.jinja +2 -0
  116. cherrypy_foundation/tools/tests/locales/de/LC_MESSAGES/messages.mo +0 -0
  117. cherrypy_foundation/tools/tests/locales/de/LC_MESSAGES/messages.po +15 -0
  118. cherrypy_foundation/tools/tests/locales/fr/LC_MESSAGES/messages.mo +0 -0
  119. cherrypy_foundation/tools/tests/locales/fr/LC_MESSAGES/messages.po +15 -0
  120. cherrypy_foundation/tools/tests/locales/messages.pot +2 -0
  121. cherrypy_foundation/tools/tests/templates/test_jinja2.html +11 -0
  122. cherrypy_foundation/tools/tests/templates/test_jinjax.html +9 -0
  123. cherrypy_foundation/tools/tests/templates/test_jinjax_i18n.html +22 -0
  124. cherrypy_foundation/tools/tests/test_auth.py +110 -0
  125. cherrypy_foundation/tools/tests/test_auth_mfa.py +369 -0
  126. cherrypy_foundation/tools/tests/test_i18n.py +247 -0
  127. cherrypy_foundation/tools/tests/test_jinja2.py +153 -0
  128. cherrypy_foundation/tools/tests/test_ratelimit.py +109 -0
  129. cherrypy_foundation/tools/tests/test_secure_headers.py +200 -0
  130. cherrypy_foundation/url.py +66 -0
  131. cherrypy_foundation/widgets.py +48 -0
  132. cherrypy_foundation-1.0.0.dist-info/METADATA +71 -0
  133. cherrypy_foundation-1.0.0.dist-info/RECORD +136 -0
  134. cherrypy_foundation-1.0.0.dist-info/WHEEL +5 -0
  135. cherrypy_foundation-1.0.0.dist-info/licenses/LICENSE.md +674 -0
  136. 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
+ # &#160; are non-breaking space
38
+ html = html.replace('&#160;', ' ')
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 &#13;, must remove it
56
+ html = html.replace('&#13;', '')
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('&gt;', '>')
73
+ html = html.replace('&lt;', '<')
74
+ html = html.replace('&amp;', '&')
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)