-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathframework.py
More file actions
1892 lines (1486 loc) · 62.3 KB
/
Copy pathframework.py
File metadata and controls
1892 lines (1486 loc) · 62.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
""" The Framework provides a base Morepath application that offers certain
features for applications deriving from it:
* Virtual hosting in conjunction with :mod:`onegov.server`.
* Access to an SQLAlchemy session bound to a specific Postgres schema.
* A cache backed by redis, shared by multiple processes.
* An identity policy with basic rules, permissions and role.
* The ability to serve static files and css/js assets.
Using the framework does not really differ from using Morepath::
from onegov.core.framework import Framework
class MyApplication(Framework):
pass
"""
from __future__ import annotations
import dectate
import hashlib
import inspect
import io
import json
import morepath
import os.path
import random
import sys
import traceback
from base64 import b64encode, urlsafe_b64encode
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives.hashes import SHA256
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from datetime import datetime
from dectate import directive
from functools import cached_property, wraps
from itsdangerous import BadSignature, Signer
from libres.db.models import ORMBase
from morepath import dispatch_method
from morepath.publish import resolve_model, get_view_name
from more.content_security import ContentSecurityApp
from more.content_security import ContentSecurityPolicy
from more.content_security import NONE, SELF, UNSAFE_INLINE
from more.transaction import TransactionApp
from more.transaction.main import transaction_tween_factory
from more.webassets import WebassetsApp
from more.webassets.core import webassets_injector_tween
from more.webassets.tweens import METHODS, CONTENT_TYPES
from reg import ClassIndex
from onegov.core import cache, log, utils
from onegov.core import directives
from onegov.core.crypto import stored_random_token
from onegov.core.datamanager import FileDataManager
from onegov.core.mail import prepare_email
from onegov.core.orm import (
Base, SessionManager, debug, DB_CONNECTION_ERRORS)
from onegov.core.orm.cache import OrmCacheApp
from onegov.core.orm.observer import ScopedPropertyObserver
from onegov.core.request import CoreRequest
from onegov.core.identity import OneGovIdentity as Identity
from onegov.core.utils import batched, PostThread
from onegov.server import Application as ServerApplication
from onegov.server.utils import load_class
from operator import itemgetter
from psycopg import OperationalError as PostgresOperationalError
from purl import URL
from sqlalchemy.exc import OperationalError
from urllib.parse import urlencode
from webob.exc import HTTPConflict, HTTPServiceUnavailable
from typing import overload, Any, Literal, Self, TYPE_CHECKING
if TYPE_CHECKING:
from _typeshed import StrPath
from _typeshed.wsgi import WSGIApplication, WSGIEnvironment, StartResponse
from collections.abc import Callable, Iterable
from email.headerregistry import Address
from fs.base import FS, SubFS
from gettext import GNUTranslations
from morepath.settings import SettingRegistry
from sqlalchemy.orm import Session
from translationstring import _ChameleonTranslate
from webob import Response
from .analytics import AnalyticsProvider
from .layout import Layout
from .mail import Attachment
from .metadata import Metadata
from .security.permissions import Intent
from .types import EmailJsonDict, SequenceOrScalar
class Framework(
TransactionApp,
WebassetsApp,
OrmCacheApp,
ContentSecurityApp,
ServerApplication,
):
""" Baseclass for Morepath OneGov applications. """
request_class: type[CoreRequest[Self]] = CoreRequest
#: holds the database connection string, *if* there is a database connected
dsn: str | None = None
#: holdes the current schema associated with the database connection, set
#: by and derived from :meth:`set_application_id`.
# NOTE: Since this should almost always be set, we pretent it is always
# set to save ourselves the pain of having to check it everywhere
schema: str = None # type:ignore[assignment]
#: framework directives
form = directive(directives.HtmlHandleFormAction)
cronjob = directive(directives.CronjobAction)
analytics_provider = directive(directives.AnalyticsProviderAction)
static_directory = directive(directives.StaticDirectoryAction)
template_variables = directive(directives.TemplateVariablesAction)
replace_setting = directive(directives.ReplaceSettingAction)
replace_setting_section = directive(directives.ReplaceSettingSectionAction)
layout = directive(directives.Layout)
json = directive(directives.ExtendedJsonAction)
#: sets the same-site cookie directive, (may need removal inside iframes)
same_site_cookie_policy: str | None = 'Lax'
#: the request cache is initialised/emptied before each request
request_cache: dict[str, Any]
#: the schema cache stays around for the entire runtime of the
#: application, but is switched, each time the schema changes
# NOTE: This cache should never be used to store ORM objects
# In addition this should generally be backed by a Redis
# cache to make sure the cache is synchronized between
# all processes. Although there may be some cases where
# it makes sense to use this cache on its own
schema_cache: dict[str, Any]
_all_schema_caches: dict[str, Any]
@property
def version(self) -> str:
from onegov.core import __version__
return __version__
if TYPE_CHECKING:
# this avoids us having to ignore a whole bunch of errors
def __call__(
self,
environ: WSGIEnvironment,
start_response: StartResponse
) -> Iterable[bytes]: ...
@morepath.reify # type:ignore[no-redef]
def __call__(self) -> WSGIApplication:
""" Intercept all wsgi calls so we can attach debug tools. """
fn: WSGIApplication = super().__call__
fn = self.with_print_exceptions(fn)
fn = self.with_request_cache(fn)
if getattr(self, 'sql_query_report', False):
fn = self.with_query_report(fn)
if getattr(self, 'profile', False):
fn = self.with_profiler(fn)
if getattr(self, 'with_sentry_middleware', False):
from sentry_sdk.integrations.wsgi import SentryWsgiMiddleware
fn = SentryWsgiMiddleware(fn)
return fn
def with_query_report[**P, T](self, fn: Callable[P, T]) -> Callable[P, T]:
@wraps(fn)
def with_query_report_wrapper(
*args: P.args,
**kwargs: P.kwargs
) -> T:
assert isinstance(self.sql_query_report, str)
with debug.analyze_sql_queries(self.sql_query_report):
return fn(*args, **kwargs)
return with_query_report_wrapper
def with_profiler[**P, T](self, fn: Callable[P, T]) -> Callable[P, T]:
@wraps(fn)
def with_profiler_wrapper(
*args: P.args,
**kwargs: P.kwargs
) -> T:
filename = '{:%Y-%m-%d %H:%M:%S}.profile'.format(datetime.now())
with utils.profile(filename):
return fn(*args, **kwargs)
return with_profiler_wrapper
def with_request_cache[**P, T](self, fn: Callable[P, T]) -> Callable[P, T]:
@wraps(fn)
def with_request_cache_wrapper(
*args: P.args,
**kwargs: P.kwargs
) -> T:
self.clear_request_cache()
return fn(*args, **kwargs)
return with_request_cache_wrapper
def with_print_exceptions[**P, T](
self,
fn: Callable[P, T]
) -> Callable[P, T]:
@wraps(fn)
def with_print_exceptions_wrapper(
*args: P.args,
**kwargs: P.kwargs
) -> T:
try:
return fn(*args, **kwargs)
except Exception:
if getattr(self, 'print_exceptions', False):
print('=' * 80, file=sys.stderr) # ruff:ignore[print]
traceback.print_exc()
raise
return with_print_exceptions_wrapper
def clear_request_cache(self) -> None:
self.request_cache = {}
# FIXME: This is really bad for static type checking, we need to be
# really vigilant to import the actual module in TYPE_CHECKING
# everywhere we use this, so we're not operating on a bunch of
# Any types...
@cached_property
def modules(self) -> utils.Bunch:
""" Provides access to modules used by the Framework class. Those
modules cannot be included at the top because they themselves usually
include the Framework.
Admittelty a bit of a code smell.
"""
from onegov.core import browser_session
from onegov.core import cronjobs
from onegov.core import filestorage
from onegov.core import i18n
from onegov.core import metadata
from onegov.core import security
from onegov.core import theme
from onegov.core.security import rules
return utils.Bunch(
browser_session=browser_session,
cronjobs=cronjobs,
filestorage=filestorage,
i18n=i18n,
security=security,
rules=rules,
theme=theme,
metadata=metadata,
)
@property
def metadata(self) -> Metadata:
return self.modules.metadata.Metadata(self)
@property
def has_database_connection(self) -> bool:
""" onegov.core has good integration for Postgres using SQLAlchemy, but
it doesn't require a connection.
It's possible to have Onegov applications using a different database
or not using one at all.
"""
return self.dsn is not None
@property
def has_filestorage(self) -> bool:
""" True if :attr:`fs` is available. """
return self._global_file_storage is not None
def handle_exception(
self,
exception: BaseException,
environ: WSGIEnvironment,
start_response: StartResponse
) -> Iterable[bytes]:
""" Stops database connection errors from bubbling all the way up
to our exception handling services (sentry.io).
"""
if isinstance(exception, DB_CONNECTION_ERRORS):
return HTTPServiceUnavailable()(environ, start_response)
return super().handle_exception(exception, environ, start_response)
# TODO: Add annotations for the known configuration options?
def configure_application(self, **cfg: Any) -> None:
""" Configures the application. This function calls all methods on
the current class which start with ``configure_``, passing the
configuration as keyword arguments.
The core itself supports the following parameters. Additional
parameters are made available by extra ``configure_`` methods.
:dsn:
The database connection to use. May be None.
See :meth:`onegov.core.orm.session_manager.setup`
:base:
The declarative base class used. By default,
:attr:`onegov.core.orm.Base` is used.
:identity_secure:
True if the identity cookie is only transmitted over https. Only
set this to False during development!
:identity_secret:
A random string used to sign the identity. By default a random
string is generated. The drawback of this is the fact that
users will be logged out every time the application restarts.
So provide your own if you don't want that, but be sure to have
a really long, really random key that you will never share
with anyone!
:redis_url:
The redis url used (default is 'redis://localhost:6379/0').
:file_storage:
The file_storage module to use. See
`<https://docs.pyfilesystem.org/en/latest/filesystems.html>`_
:file_storage_options:
A dictionary of options passed to the ``__init__`` method of the
file_storage class.
The file storage is expected to work as is. For example, if
``fs.osfs.OSFS`` is used, the root_path is expected exist.
The file storage can be shared between different onegov.core
applications. Each application automatically gets its own
namespace inside this space.
:always_compile_theme:
If true, the theme is always compiled - no caching is employed.
:allow_shift_f5_comple:
If true, the theme is recompiled if shift+f5 is done on the
browser (or shift + reload button click).
:csrf_secret:
A random string used to sign the csrf token. Make sure this differs
from ``identity_secret``! The algorithms behind identity_secret and
the csrf protection differ. If the same secret is used we might
leak information about said secret.
By default a random string is generated. The drawback of this is
the fact that users won't be able to submit their forms if the
app is restarted in the background.
So provide your own, but be sure to have a really long, really
random string that you will never share with anyone!
:csrf_time_limit:
The csrf time limit in seconds. Basically the amount of time a
user has to submit a form, from the time it's rendered.
Defaults to 1'200s (20 minutes).
:mail:
A dictionary keyed by e-mail category (i.e. 'marketing',
'transactional') with the following subkeys:
- host: The mail server to send e-mails from.
- port: The port used for the mail server.
- force_tls: True if TLS should be forced.
- username: The mail username
- password: The mail password
- sender: The mail sender
- use_directory: True if a mail directory should be used
- directory: Path to the directory that should be used
:mail_use_directory:
If true, mails are stored in the maildir defined through
``mail_directory``. There, some other process is supposed to
pick up the e-mails and send them.
:mail_directory:
The directory (maildir) where mails are stored if if
``mail_use_directory`` is set to True.
:sql_query_report:
Prints out a report sql queries for each request, unless False.
Valid values are:
* 'summary' (only show the number of queries)
* 'redundant' (show summary and the actual redundant queries)
* 'all' (show summary and all executed queries)
Do not use in production!
:profile:
If true, profiles the request and stores the result in the profiles
folder with the following format: ``YYYY-MM-DD hh:mm:ss.profile``
Do not use in production!
:print_exceptions:
If true, exceptions are printed to stderr. Note that you should
usually configure logging through onegov.server. This is mainly
used for certain unit tests where we use WSGI more directly.
"""
super().configure_application(**cfg)
members = sorted(
inspect.getmembers(self.__class__, callable),
key=itemgetter(0)
)
for n, method in members:
if n.startswith('configure_') and n != 'configure_application':
method(self, **cfg)
def configure_dsn(
self,
*,
dsn: str | None = None,
# FIXME: Use sqlalchemy.orm.DeclarativeBase once we switch to 2.0
base: type[Any] = Base,
**cfg: Any
) -> None:
# certain namespaces are reserved for internal use:
assert self.namespace != 'global'
self.dsn = dsn
if self.dsn:
self.session_manager = SessionManager(self.dsn, base)
# NOTE: We used to only add the ORMBase, when we derived
# from LibresIntegration, however this leads to
# issues when we add a backref from a model derived
# from ORMBase to a model like File, since SQLAlchemy
# will try to load this backref when inspecting
# the state of an instance and fail, because the
# referenced table doesn't exist
self.session_manager.bases.append(ORMBase)
def configure_redis(
self,
*,
redis_url: str = 'redis://127.0.0.1:6379/0',
**cfg: Any
) -> None:
self.redis_url = redis_url
def configure_secrets(
self,
*,
identity_secure: bool = True,
identity_secret: str | None = None,
csrf_secret: str | None = None,
csrf_time_limit: float = 1200,
**cfg: Any
) -> None:
self.identity_secure = identity_secure
# the identity secret is shared between tennants, so we name it
# accordingly - use self.identity_secret to get a secret limited to
# the current tennant
self.unsafe_identity_secret = (
identity_secret
or stored_random_token(self.__class__.__name__, 'identity_secret'))
# same goes for the csrf_secret
self.unsafe_csrf_secret = (
csrf_secret
or stored_random_token(self.__class__.__name__, 'csrf_secret'))
self.csrf_time_limit = int(csrf_time_limit)
# you don't want these keys to be the same, see docstring above
assert self.unsafe_identity_secret != self.unsafe_csrf_secret
# you don't want to use the keys given in the example file
assert (
self.unsafe_identity_secret != 'very-secret-key' # nosec: B105
)
# you don't want to use the keys given in the example file
assert (
self.unsafe_csrf_secret != 'another-very-secret-key' # nosec: B105
)
def configure_yubikey(
self,
*,
yubikey_client_id: str | None = None,
yubikey_secret_key: str | None = None,
**cfg: Any
) -> None:
self.yubikey_client_id = yubikey_client_id
self.yubikey_secret_key = yubikey_secret_key
def configure_mtan_second_factor(
self,
*,
mtan_second_factor_enabled: bool = False,
mtan_automatic_setup: bool = False,
**cfg: Any
) -> None:
self.mtan_second_factor_enabled = mtan_second_factor_enabled
self.mtan_automatic_setup = mtan_automatic_setup
def configure_totp(
self,
*,
totp_enabled: bool = True,
**cfg: Any
) -> None:
self.totp_enabled = totp_enabled
def configure_filestorage(self, **cfg: Any) -> None:
if 'filestorage_object' in cfg:
self._global_file_storage = cfg['filestorage_object']
return
if 'filestorage' in cfg:
filestorage_class = load_class(cfg['filestorage'])
filestorage_options = cfg.get('filestorage_options', {})
# legacy support for pyfilesystem 1.x parameters
if 'dir_mode' in filestorage_options:
filestorage_options['create_mode'] = (
filestorage_options.pop('dir_mode'))
else:
filestorage_class = None
if filestorage_class:
self._global_file_storage = filestorage_class(
**filestorage_options)
else:
self._global_file_storage = None
def configure_debug(
self,
*,
always_compile_theme: bool = False,
allow_shift_f5_compile: bool = False,
sql_query_report: Literal[
False, 'summary', 'redundant', 'all'] = False,
profile: bool = False,
print_exceptions: bool = False,
**cfg: Any
) -> None:
self.always_compile_theme = always_compile_theme
self.allow_shift_f5_compile = allow_shift_f5_compile
self.sql_query_report = sql_query_report
self.profile = profile
self.print_exceptions = print_exceptions
# TODO: Add TypedDict for mail config
def configure_mail(
self,
*,
mail: dict[str, Any] | None = None,
**cfg: Any
) -> None:
self.mail = mail
if self.mail:
assert isinstance(self.mail, dict)
assert 'transactional' in self.mail
assert 'marketing' in self.mail
def configure_sms(
self,
*,
sms_directory: str | None = None, # deprecated
sms: dict[str, Any] | None = None,
**cfg: Any
) -> None:
self.sms = sms or {'directory': sms_directory}
self.sms_directory = self.sms['directory']
def configure_hipchat(
self,
*,
hipchat_token: str | None = None,
hipchat_room_id: str | None = None,
**cfg: Any
) -> None:
self.hipchat_token = hipchat_token
self.hipchat_room_id = hipchat_room_id
def configure_zulip(
self,
*,
zulip_url: str | None = None,
zulip_stream: str | None = None,
zulip_user: str | None = None,
zulip_key: str | None = None,
**cfg: Any
) -> None:
self.zulip_url = zulip_url
self.zulip_stream = zulip_stream
self.zulip_user = zulip_user
self.zulip_key = zulip_key
def configure_content_security_policy(
self,
*,
content_security_policy_enabled: bool = True,
content_security_policy_report_uri: str | None = None,
content_security_policy_report_only: bool = False,
content_security_policy_report_sample_rate: float = 0.0,
content_security_policy_extra_script_src: list[str] | None = None,
**cfg: Any
) -> None:
self.content_security_policy_enabled = content_security_policy_enabled
self.content_security_policy_report_uri = (
content_security_policy_report_uri)
self.content_security_policy_report_only = (
content_security_policy_report_only)
self.content_security_policy_report_sample_rate = (
content_security_policy_report_sample_rate)
self.content_security_policy_extra_script_src = (
content_security_policy_extra_script_src or []
)
def configure_sentry(
self,
*,
sentry_dsn: str | None = None,
**cfg: Any
) -> None:
self.sentry_dsn = sentry_dsn
@property
def is_sentry_supported(self) -> bool:
return getattr(self, 'sentry_dsn', None) and True or False
def configure_analytics_providers(self, **cfg: Any) -> None:
self.analytics_providers_configs = cfg.get('analytics_providers', {})
@cached_property
def available_analytics_providers(self) -> dict[str, AnalyticsProvider]:
return {
name: provider
for name, _provider_cfg in self.analytics_providers_configs.items()
if (cls := self.config.analytics_provider_registry.get(
(provider_cfg := _provider_cfg or {}).get('provider', name)
)) is not None
if (
provider := cls.configure(name=name, **provider_cfg)
) is not None
}
def set_application_id(self, application_id: str) -> None:
""" Set before the request is handled. Gets the schema from the
application id and makes sure it exists, *if* a database connection
is present.
"""
super().set_application_id(application_id)
# replace the dashes in the id with underlines since the schema
# should not include double dashes and IDNA leads to those
#
# then, replace the '/' with a '-' so the only dash left will be
# the dash between namespace and id
self.schema = application_id.replace('-', '_').replace('/', '-')
if not hasattr(self, '_all_schema_caches'):
self._all_schema_caches = {}
self.schema_cache = self._all_schema_caches.setdefault(self.schema, {})
if self.has_database_connection:
ScopedPropertyObserver.enter_scope(self)
self.session_manager.set_current_schema(self.schema)
if not self.is_orm_cache_setup:
self.setup_orm_cache()
def get_cache(
self,
name: str,
expiration_time: float
) -> cache.RedisCacheRegion:
""" Gets a cache bound to this application id. """
return cache.get(
namespace=f'{self.application_id}:{name}',
expiration_time=expiration_time,
redis_url=self.redis_url
)
@property
def session_cache(self) -> cache.RedisCacheRegion:
""" A cache that is kept for a long-ish time. """
day = 60 * 60 * 24
return self.get_cache('sessions', expiration_time=7 * day)
@property
def cache(self) -> cache.RedisCacheRegion:
""" A cache that might be invalidated frequently. """
return self.get_cache('short-term', expiration_time=3600)
@property
def settings(self) -> SettingRegistry:
return self.config.setting_registry
@property
def application_id_hash(self) -> str:
""" The application_id as hash, use this if the application_id can
be read by the user -> this obfuscates things slightly.
"""
# sha-1 should be enough, because even if somebody was able to get
# the cleartext value I honestly couldn't tell you what it could be
# used for...
return hashlib.new( # nosec: B324
'sha1',
self.application_id.encode('utf-8'),
usedforsecurity=False
).hexdigest()
@overload
def object_by_path(
self,
path: str,
with_view_name: Literal[False] = ...
) -> object | None: ...
@overload
def object_by_path(
self,
path: str,
with_view_name: Literal[True]
) -> tuple[object | None, str | None]: ...
def object_by_path(
self,
path: str,
with_view_name: bool = False
) -> object | tuple[object | None, str | None] | None:
""" Takes a path and returns the object associated with it. If a
scheme or a host is passed it is ignored.
Be careful if you use this function with user provided urls, we load
objects here, not views. Therefore no security restrictions apply.
The first use case of this function is to provide a generic copy/paste
functionality. There, we only allow urls to be copied which have been
previously signed by the server.
*Safeguards like this are necessary if the user has the ability to
somehow influence the path*!
"""
request = self.request_class(environ={
'PATH_INFO': URL(path).path(),
'SERVER_NAME': '',
'SERVER_PORT': '',
'SERVER_PROTOCOL': 'https'
}, app=self) # type: ignore[arg-type]
obj = resolve_model(request)
# if there is more than one token unconsumed, this can't be a view
if len(request.unconsumed) > 1:
return (None, None) if with_view_name else None
if with_view_name:
return obj, get_view_name(request.unconsumed) or None
return obj
def permission_by_view(
self,
model: type[object] | object,
view_name: str | None = None
) -> type[Intent]:
""" Returns the permission required for the given model and view_name.
The model may be an instance or a class.
If the view cannot be evaluated, a KeyError is raised.
"""
assert model is not None
model = model if inspect.isclass(model) else model.__class__
predicates = {'name': view_name} if view_name else {}
query = dectate.Query('view').filter(
model=model,
predicates=predicates
)
try:
action, _handler = next(iter(query(self.__class__)))
except (StopIteration, RuntimeError) as exception:
raise KeyError(
'{!r} has no view named {}'.format(model, view_name)
) from exception
assert hasattr(action, 'permission')
return action.permission
@cached_property
def session(self) -> Callable[[], Session]:
""" Alias for self.session_manager.session. """
return self.session_manager.session
def send_marketing_email(
self,
reply_to: Address | str | None = None,
receivers: SequenceOrScalar[Address | str] = (),
cc: SequenceOrScalar[Address | str] = (),
bcc: SequenceOrScalar[Address | str] = (),
subject: str | None = None,
content: str | None = None,
attachments: Iterable[Attachment | StrPath] = (),
headers: dict[str, str] | None = None,
plaintext: str | None = None
) -> None:
""" Sends an e-mail categorised as marketing.
This includes but is not limited to:
* Announcements
* Newsletters
* Promotional E-Mails
When in doubt, send a marketing e-mail. Transactional e-mails are
sacred and should only be used if necessary. This ensures that the
important stuff is reaching our customers!
However, marketing emails will always need to contain an unsubscribe
link in the email body and in a List-Unsubscribe header.
"""
return self.send_email(
reply_to=reply_to,
category='marketing',
receivers=receivers,
cc=cc,
bcc=bcc,
subject=subject,
content=content,
attachments=attachments,
headers=headers,
plaintext=plaintext
)
def send_marketing_email_batch(
self,
prepared_emails: Iterable[EmailJsonDict]
) -> None:
""" Sends an e-mail batch categorised as marketing.
This includes but is not limited to:
* Announcements
* Newsletters
* Promotional E-Mails
When in doubt, send a marketing e-mail. Transactional e-mails are
sacred and should only be used if necessary. This ensures that the
important stuff is reaching our customers!
However, marketing emails will always need to contain an unsubscribe
link in the email body and in a List-Unsubscribe header.
:param prepared_emails: A list of emails prepared using
app.prepare_email
Supplying anything other than stream='marketing' in prepare_email
will be considered an error.
Batches will be split automatically according to API limits.
"""
return self.send_email_batch(prepared_emails, category='marketing')
def send_transactional_email(
self,
reply_to: Address | str | None = None,
receivers: SequenceOrScalar[Address | str] = (),
cc: SequenceOrScalar[Address | str] = (),
bcc: SequenceOrScalar[Address | str] = (),
subject: str | None = None,
content: str | None = None,
attachments: Iterable[Attachment | StrPath] = (),
headers: dict[str, str] | None = None,
plaintext: str | None = None
) -> None:
""" Sends an e-mail categorised as transactional.
This is limited to:
* Welcome emails
* Reset passwords emails
* Notifications
* Weekly digests
* Receipts and invoices
"""
return self.send_email(
reply_to=reply_to,
category='transactional',
receivers=receivers,
cc=cc,
bcc=bcc,
subject=subject,
content=content,
attachments=attachments,
headers=headers,
plaintext=plaintext
)
def send_transactional_email_batch(
self,
prepared_emails: Iterable[EmailJsonDict]
) -> None:
""" Sends an e-mail categorised as transactional.
This is limited to:
* Welcome emails
* Reset passwords emails
* Notifications
* Weekly digests
* Receipts and invoices
:param prepared_emails: A list of emails prepared using
app.prepare_email
Supplying anything other than stream='transactional' in prepare_email
will be considered an error.
Batches will be split automatically according to API limits.
"""
return self.send_email_batch(prepared_emails, category='transactional')
def prepare_email(
self,
reply_to: Address | str | None = None,
category: Literal['marketing', 'transactional'] = 'marketing',
receivers: SequenceOrScalar[Address | str] = (),
cc: SequenceOrScalar[Address | str] = (),
bcc: SequenceOrScalar[Address | str] = (),
subject: str | None = None,
content: str | None = None,
attachments: Iterable[Attachment | StrPath] = (),
headers: dict[str, str] | None = None,
plaintext: str | None = None
) -> EmailJsonDict:
""" Common path for batch and single mail sending. Use this the same
way you would use send_email then pass the prepared emails in a list
or another iterable to the batch send method.
"""
headers = headers or {}
assert reply_to
assert category in ('transactional', 'marketing')
assert self.mail is not None
sender = self.mail[category]['sender']
tenants = self.mail[category].get('tenants', {})
config = tenants.get(self.application_id, {})
sender = config.get('sender') or sender