Compare commits

..

268 Commits

Author SHA1 Message Date
Tim Gröger 83921f3f54 update to version 2.1.0 2024-10-08 13:29:37 +00:00
Tim Gröger bc21aefe93 [feat] add user settings 2024-10-08 13:29:37 +00:00
Tim Gröger 957796e90e Merge branch 'develop' 2024-01-18 16:00:11 +01:00
Tim Gröger 4f20a94f60 fix some func to get balance 2024-01-17 13:04:29 +01:00
Tim Gröger 001ef13014 remove links 2024-01-17 00:20:40 +01:00
Tim Gröger 0ae334620b update dependencies 2024-01-16 22:43:49 +01:00
Tim Gröger 645e2865a6 update dependencies 2024-01-16 22:34:35 +01:00
Tim Gröger bddb11d1b4 update version to 2.0.0 2024-01-16 19:49:56 +01:00
Tim Gröger cab172dc65 fix floor transaction with value which has more ziffers than scale #33 2023-05-17 14:47:40 +02:00
Tim Gröger b40d40644d if birthday is date then take it otherwise parse from string; prettier 2023-05-15 23:52:49 +02:00
Tim Gröger 319889ee43 (user) better avatar cache-control
etag is added to header,
If etag is the same a not modified will be respond
2023-05-12 17:12:36 +02:00
Tim Gröger 4be7cccadb (auth_ldap) add get_last_modified from provider 2023-05-12 17:11:18 +02:00
Tim Gröger 9077c9fd11 (balance) fix notifications
if only author and sender oder receiver exists, create special notifications
2023-05-10 01:12:41 +02:00
Tim Gröger d7428b2ed1 fix add role to user 2023-05-09 21:59:15 +02:00
Tim Gröger 5bab4a7cde fix update ldap, no none types pushed, add more debugging 2023-05-09 21:59:00 +02:00
Tim Gröger d8028c4681 fixed timeout in mailing #30 2023-05-09 21:25:19 +02:00
Tim Gröger 8b15a45902 add docker cmd, more debug, add migrations to package 2023-05-09 21:23:47 +02:00
Tim Gröger ae583a6d18 add black to pyproject.toml 2023-05-09 21:16:17 +02:00
Tim Gröger 193ffeff9d fix reset password
wrong method in userController was executed to get user
2023-05-05 10:00:49 +02:00
Tim Gröger 7eb30b662f fix mail-plugin
this fix load config the right way.
now you can install mail-plugin with
```flaschengeist plugin install mail && flaschengeist plugin enable mail```
2023-05-05 10:00:08 +02:00
Tim Gröger 11204662be (balance) add filter to search user 2023-05-03 14:03:59 +02:00
Tim Gröger cb0795a6ac add ua-parser to pares user-agent 2023-05-03 07:46:50 +02:00
Tim Gröger f7c8ae1037 blacked and add some typings 2023-05-03 06:30:42 +02:00
Tim Gröger e6c143ad92 fix json_encoder for flask 2.x 2023-05-03 06:29:55 +02:00
Tim Gröger 59f5d4529d add ide to gitignore 2023-05-03 06:29:28 +02:00
Tim Gröger f38fb334f1 add get notifications of plugin 2023-05-01 21:53:48 +02:00
Tim Gröger 47442fe211 fix Flask dependencie for #28 2023-04-09 21:15:07 +02:00
Tim Gröger af2c674ce4 fixed most deprecations from flask and sqlalchemy 2023-04-09 20:57:15 +02:00
Tim Gröger cfbb557539 Merge pull request 'feature/migrations, closes #19' (#20) from feature/migrations into develop
Reviewed-on: #20
2023-03-02 05:37:09 +00:00
Tim Gröger ba93345a09 feat(users) fix cli if user get role, that provider is updatet too 2023-02-18 15:48:53 +01:00
Tim Gröger d475f3f8e2 feat(ldap) fix login on ldap 2023-02-18 15:11:42 +01:00
Tim Gröger a50ba403fc feat(ldap) fix sync from ldap 2023-02-17 20:40:27 +01:00
Tim Gröger e0acb80f5d feat(plugin) fix get right instance auf auth_provider 2023-02-17 17:52:20 +01:00
Tim Gröger c5436f22fa feat(ldap) fix get right config 2023-02-17 16:40:54 +01:00
Tim Gröger 7796f45097 feat(db) fix get plugins if no database exists 2023-02-17 15:30:05 +01:00
Tim Gröger 8a656c3c89 Merge branch 'develop' into feature/migrations 2023-02-17 13:15:32 +01:00
Tim Gröger 8a5380d888 update sqlalchmy version. not higher than 2.0 2023-02-17 12:46:27 +01:00
Ferdinand Thiessen 9f729bda6c [plugins] Fix `auth_ldap`, `balance`, and `pricelist` compatibility
Signed-off-by: Ferdinand Thiessen <rpm@fthiessen.de>
2022-08-26 17:05:03 +02:00
Ferdinand Thiessen 9e8117e554 [plugins] Fix scheduler accessing database while unbound from session
Signed-off-by: Ferdinand Thiessen <rpm@fthiessen.de>
2022-08-25 18:45:01 +02:00
Ferdinand Thiessen 88a4dc24f2 [db] Fix automatic migration upgrade for plugins and core
Signed-off-by: Ferdinand Thiessen <rpm@fthiessen.de>
2022-08-25 18:44:21 +02:00
Ferdinand Thiessen 0698327ef5 [core][deps] Use sqlalchemy_utils instead of copy-paste code for merging references
This fixes issues when using SQLite

Signed-off-by: Ferdinand Thiessen <rpm@fthiessen.de>
2022-08-25 17:07:12 +02:00
Ferdinand Thiessen aa8f8f6e64 [core][plugin] Allow blueprints to be set on instance level
This ensures blueprints are read from the plugin instance
instead of the class, allowing custom routes to be added within the
`load()` function.

Signed-off-by: Ferdinand Thiessen <rpm@fthiessen.de>
2022-08-25 17:05:04 +02:00
Ferdinand Thiessen 6ad8cd1728 [cli] Users and roles can be now managed using the cli
Signed-off-by: Ferdinand Thiessen <rpm@fthiessen.de>
2022-08-25 17:04:22 +02:00
Ferdinand Thiessen e2254b71b0 [core][plugin] Unify plugin model and real plugins
Plugins are now extensions of the database model,
allowing plugins to access all their properties.

Signed-off-by: Ferdinand Thiessen <rpm@fthiessen.de>
2022-08-25 15:39:05 +02:00
Ferdinand Thiessen 973b4527df [core] UA parsing: Add backwards compatibility for platform names
Signed-off-by: Ferdinand Thiessen <rpm@fthiessen.de>
2022-08-25 14:55:49 +02:00
Ferdinand Thiessen 4248825af0 Revert future imports for annotations, PEP563 is still defered
Signed-off-by: Ferdinand Thiessen <rpm@fthiessen.de>
2022-08-25 12:06:59 +02:00
Ferdinand Thiessen e22e38b304 Implement custom UA parsing, allowing to update Flask and Werkzeug
Signed-off-by: Ferdinand Thiessen <rpm@fthiessen.de>
2022-08-22 17:18:03 +02:00
Ferdinand Thiessen ee38e46c12 [core] Cleanup + Fix loading migrations of (dis)abled plugins
Signed-off-by: Ferdinand Thiessen <rpm@fthiessen.de>
2022-08-18 22:59:19 +02:00
Ferdinand Thiessen d3530cc15f The enabled state of plugins is now loaded from database rather than config file
Signed-off-by: Ferdinand Thiessen <rpm@fthiessen.de>
2022-08-18 21:22:47 +02:00
Ferdinand Thiessen e41be21c47 Restructure models and database import paths
Signed-off-by: Ferdinand Thiessen <rpm@fthiessen.de>
2022-08-18 19:53:58 +02:00
Ferdinand Thiessen 7f8aa80b0e Update dependencies and increase python version to 3.10
Drop future imports, not needed with python 3.10

Signed-off-by: Ferdinand Thiessen <rpm@fthiessen.de>
2022-08-18 19:45:54 +02:00
Ferdinand Thiessen dc2b949225 [cli] Fix exporting of plugin interfaces
Signed-off-by: Ferdinand Thiessen <rpm@fthiessen.de>
2022-07-31 19:28:11 +02:00
Ferdinand Thiessen fa503fe142 [cli] Added install command to install the database and all plugins
Signed-off-by: Ferdinand Thiessen <rpm@fthiessen.de>
2022-07-31 19:07:40 +02:00
Ferdinand Thiessen f1d6b6a2f2 [plugins][cli] Fix initial migration file + Make sure plugin permissions are installed
Signed-off-by: Ferdinand Thiessen <rpm@fthiessen.de>
2022-07-31 19:07:32 +02:00
Ferdinand Thiessen 573bea2da0 feat(cli): Added CLI command for handling plugins
ci/woodpecker/push/lint Pipeline was successful Details
ci/woodpecker/push/test Pipeline was successful Details
ci/woodpecker/pr/lint Pipeline was successful Details
ci/woodpecker/pr/test Pipeline was successful Details
* Install / Uninstall plugins
* List plugins
2022-03-08 17:22:55 +01:00
Ferdinand Thiessen 4fbd20f78e feat(docs): Add documentation on how to install tables
Also various documentation fixed and improvments
2022-03-08 17:22:55 +01:00
Ferdinand Thiessen 6a35137a27 feat(db): Add database migration support, implements #19
Migrations allow us to keep track of database changes and upgrading databases if needed.

* Add initial migrations for core Flaschengeist
* Add migrations to balance
* Add migrations to pricelist

* Skip plugins with not satisfied dependencies.
2022-03-08 17:22:55 +01:00
Ferdinand Thiessen bf02c0e21f fix(db): Add __repr__ to custom column types, same as done by SQLAlchemy 2022-03-08 17:22:55 +01:00
Tim Gröger e09d563e93 Merge pull request 'feat(plugins): Load metadata from entry points / distribution' (#24) from proposal/metadata2 into develop
ci/woodpecker/push/lint Pipeline was successful Details
ci/woodpecker/push/test Pipeline was successful Details
Reviewed-on: #24
2022-03-08 08:13:52 +00:00
Ferdinand Thiessen 2d4c8ebfd9 fix(plugins): Fix functions using id instead of name property
ci/woodpecker/push/lint Pipeline was successful Details
ci/woodpecker/push/test Pipeline was successful Details
ci/woodpecker/pr/lint Pipeline was successful Details
ci/woodpecker/pr/test Pipeline was successful Details
2022-03-07 14:36:31 +01:00
Ferdinand Thiessen 6f35e17fba feat(plugins): Load metadata from entry points / distribution
ci/woodpecker/push/lint Pipeline was successful Details
ci/woodpecker/push/test Pipeline was successful Details
ci/woodpecker/pr/lint Pipeline was successful Details
ci/woodpecker/pr/test Pipeline was successful Details
2022-02-23 15:20:31 +01:00
Ferdinand Thiessen 2f4472e708 feat(docs): Added more documentation on plugins
ci/woodpecker/push/lint Pipeline was successful Details
ci/woodpecker/push/test Pipeline was successful Details
2022-02-23 15:19:45 +01:00
Ferdinand Thiessen e510c54bd8 chore(clean): Fix codestyle of config.py
ci/woodpecker/push/lint Pipeline was successful Details
ci/woodpecker/push/test Pipeline was successful Details
2022-02-22 11:11:23 +01:00
Ferdinand Thiessen c5db932065 chore(clean): Drop `_module_path` from flaschengeist 2022-02-21 22:24:33 +01:00
Ferdinand Thiessen 1484d678ce feat(security): Enforce secret key for flask application. 2022-02-21 21:03:15 +01:00
Ferdinand Thiessen e82d830410 fix(app): Fix import_name for flask application 2022-02-21 21:02:45 +01:00
Ferdinand Thiessen 760ee9fe36 fix(cli): Fix logging when setting verbosity on the cli 2022-02-21 21:02:15 +01:00
Ferdinand Thiessen 90999bbefb chore(core): Seperated logic from the plugin code, reduces imports 2022-02-13 14:31:55 +01:00
Ferdinand Thiessen fb50ed05be deps: Require at lease python 3.9, fixes #22
continuous-integration/woodpecker the build was successful Details
2021-12-26 15:44:04 +01:00
Ferdinand Thiessen 54a789b772 fix(docs): PIP 21.0+ is required, some minor improvements 2021-12-26 15:42:31 +01:00
Ferdinand Thiessen a6cbc002f6 fix(cli): InterfaceGenerator now works even without a namespace defined
continuous-integration/woodpecker the build failed Details
2021-12-23 02:48:28 +01:00
Ferdinand Thiessen 34ee95c66a feat(cli): Split CLI commands into seperate files 2021-12-23 02:48:02 +01:00
Ferdinand Thiessen 9bcba9c7f9 fix(users): Fix import error inside `set_roles`
continuous-integration/woodpecker the build failed Details
2021-12-22 00:37:52 +01:00
Ferdinand Thiessen 016ed7739a fix(db): Fix Serial column type for SQLite
continuous-integration/woodpecker the build failed Details
2021-12-22 00:36:41 +01:00
Ferdinand Thiessen 702b894f75 feat(tests): Added first unit test for the user controller
continuous-integration/woodpecker the build failed Details
2021-12-22 00:34:32 +01:00
Ferdinand Thiessen 519eac8f25 feat(ci): Added pipeline for tests
continuous-integration/woodpecker the build failed Details
Add all supported, meaning by flaschengeist, python versions.
Use slim image of python instead of alpine, because Pillow does not
provide any wheels for musllibc
2021-12-22 00:29:33 +01:00
Ferdinand Thiessen aaec6b43ae tests: Fix tests for current backend 2021-12-21 22:56:03 +01:00
Ferdinand Thiessen 9b42d2b5b7 fix(docs): Rename README
continuous-integration/woodpecker the build failed Details
2021-12-20 00:56:16 +01:00
Ferdinand Thiessen 22fbb526bb fix(balance): Replace deprecated sqlalchemy functions
continuous-integration/woodpecker the build failed Details
2021-12-19 19:01:48 +01:00
Ferdinand Thiessen d9be9430db docs(misc): Some more documentation fixes 2021-12-19 19:01:27 +01:00
Ferdinand Thiessen 4df7f1cc01 docs(plugins): Some more documentation on the plugin class 2021-12-19 18:58:57 +01:00
Ferdinand Thiessen b8ac6eb462 fix(users): Readd `create` flag on set_roles
continuous-integration/woodpecker the build failed Details
2021-12-19 13:06:36 +01:00
Ferdinand Thiessen 25ba4d21aa feat(balance): Add option to allow active services to debit other users
continuous-integration/woodpecker the build was successful Details
2021-12-18 04:23:53 +01:00
Ferdinand Thiessen e1f919bd20 feat(scheduler): Add function to add scheduled tasks programmatically 2021-12-18 04:19:57 +01:00
Ferdinand Thiessen 691345cf40 fix(roles): Minor fix in set_permissions 2021-12-18 04:19:07 +01:00
Ferdinand Thiessen 1db3391826 fix(roles): Minor documentation + typings 2021-12-18 03:04:37 +01:00
Ferdinand Thiessen 2df5a61ff3 fix(cli): Fix logging level not set on run 2021-12-18 03:03:41 +01:00
Ferdinand Thiessen b8f086b4dd fix(app): Add AuthPlugin to FG_PLUGINS 2021-12-18 02:15:53 +01:00
Ferdinand Thiessen ab093c04bd fix(cli): Defaul logging level should be WARNING
continuous-integration/woodpecker the build was successful Details
2021-12-18 02:12:19 +01:00
Ferdinand Thiessen ee839ce6a3 fix(auth_plain): Fix post_install hook 2021-12-18 02:12:00 +01:00
Ferdinand Thiessen f507cd483d fix(docs): Fix script name in documentation 2021-12-18 02:00:46 +01:00
Ferdinand Thiessen bd371dfcf2 fix(users): Register: validate `mail`, handle duplicated `userid`, only send password mail if `mail` was set
continuous-integration/woodpecker the build was successful Details
2021-12-18 01:56:52 +01:00
Ferdinand Thiessen 9f6aa38925 fix(users): Reduce amount of SELECT queries in set_roles 2021-12-18 01:55:24 +01:00
Ferdinand Thiessen ec7bf39666 fix(roles): Map IntegrityError to BadRequest as this is an client error, no server error. 2021-12-18 01:51:38 +01:00
Ferdinand Thiessen 0a3da51b92 fix(logging): console logger should default to stderr 2021-12-18 01:50:03 +01:00
Ferdinand Thiessen ceba819ca7 fix(db): User.userid should be unique 2021-12-18 01:49:17 +01:00
Ferdinand Thiessen a3ccd6cea1 feat(db): Use named database constraints 2021-12-18 01:48:12 +01:00
Ferdinand Thiessen 53ed2d9d1a chore(package): Only use setup.cfg, drop setup.py 2021-12-18 01:46:35 +01:00
Ferdinand Thiessen ece6893675 feat(cli): Ported CLI to use native click / flask cli 2021-12-18 01:44:06 +01:00
Ferdinand Thiessen 38ebaf0e79 feat(hooks): Some more work on the hooks functions 2021-12-17 14:27:27 +01:00
Ferdinand Thiessen cfac55efe0 fix(app): Add some more debugging 2021-12-17 12:21:53 +01:00
Ferdinand Thiessen 1b80e39648 feat(ci): Added woodpecker CI
continuous-integration/woodpecker the build was successful Details
2021-12-14 15:36:08 +01:00
Tim Gröger dc9f70983b fix sync ldap 2021-12-07 20:48:33 +01:00
Tim Gröger d8db0aae3a fix sync with ldap 2021-12-07 18:35:05 +01:00
Ferdinand Thiessen 348adefb7c feat(scheduler): Scheduler is now a plugin
Scheduler allows to schedule tasks, like cron does, but requires special configuration.
2021-12-06 23:48:05 +01:00
Ferdinand Thiessen dca52b764c fix(plugins): Setting a plugin setting to None removes that setting 2021-12-06 23:44:41 +01:00
Ferdinand Thiessen f6c229d2ef feat(core): Selected authentification plugin is always enabled 2021-12-06 23:44:07 +01:00
Ferdinand Thiessen 653c1c584c fix(cli): Set env variable for debug 2021-12-06 15:32:11 +01:00
Ferdinand Thiessen d8192679e5 chore(cleanup): Drop stuff for unsupported python versions 2021-12-06 15:30:39 +01:00
Ferdinand Thiessen 239faac7dd fix(plugin): Only active users can and should be notified 2021-12-05 22:56:34 +01:00
Ferdinand Thiessen 5819a0637f fix(models): Notification.plugin should be bigger to support FQN as value 2021-12-05 22:56:05 +01:00
Ferdinand Thiessen 5b3f63cd0a fix(roles): Return conflict if role should be deleted but is still in use 2021-12-05 20:50:57 +01:00
Ferdinand Thiessen bac75ca582 fix(users): Fix query for active users 2021-12-03 13:13:48 +01:00
Ferdinand Thiessen 47400f02e9 feat(users): Add deleted attribute to users.
This allows us to filter out deleted users which could not be deleted and
had to be soft-deleted.
Meaning: users which still had foreign keys on the database,
from e.g. disabled plugins.
2021-12-03 12:52:45 +01:00
Tim Gröger f9d9494a36 [fix] add empty install function for userController, fix wrong indention 2021-12-03 09:49:34 +01:00
Ferdinand Thiessen d0674e8876 fix(users): Fix deleting users
Remove all internal references, e.g. sessions, attributes, password reset requests.

Add hook for plugins.

If not deletable remove at least all personal data
2021-12-02 21:27:59 +01:00
Ferdinand Thiessen 50fa39be4f feat(users): Add some more relationships to model 2021-12-02 18:28:32 +01:00
Ferdinand Thiessen 593b8546a2 fix(roles): Ignore name if it did not change 2021-12-01 15:31:48 +01:00
Ferdinand Thiessen e4a10028b7 fix(users): Update hook needs to check existence of display_name as well 2021-12-01 15:19:29 +01:00
Ferdinand Thiessen 45d15b4f88 docs(config): Add some database default values 2021-12-01 15:18:36 +01:00
Ferdinand Thiessen 0ce52de8cd feat(plugins) Plugins use native Image objects as default avatar, but can still implement their own stuff. 2021-11-29 18:15:21 +01:00
Ferdinand Thiessen 06caec86e7 fix(users) Display name should be created when user is created 2021-11-29 11:33:23 +01:00
Ferdinand Thiessen b94319c38f chore(plugins) Split of events plugin 2021-11-28 22:29:12 +01:00
Ferdinand Thiessen 60ba8d4799 fix(core) Fix entry point name 2021-11-28 22:27:20 +01:00
Ferdinand Thiessen 50632eb333 feat(cli) Allow assigning all permissions to one group from cli 2021-11-28 22:23:34 +01:00
Ferdinand Thiessen 2b93404dc0 [core] Add CORS headers 2021-11-28 14:23:08 +01:00
Ferdinand Thiessen a479d0c0ee [models] Add __str__ function for all serialized models (for debug) 2021-11-27 03:05:05 +01:00
Tim Gröger d2ef02c2af [balance] add correct notification 2021-11-27 00:36:28 +01:00
Tim Gröger 079fbafb97 Merge pull request 'feature/events' (#18) from feature/events into develop
Reviewed-on: #18
2021-11-25 17:00:40 +00:00
Ferdinand Thiessen e626239d84 [cleanup] Minor pep8 cleanup 2021-11-25 15:50:12 +01:00
Ferdinand Thiessen aa64c769ef [events] Implemented API endpoint for jobs of the current user 2021-11-25 15:50:12 +01:00
Ferdinand Thiessen 1c091311de [events] Use new pagination responses, drop unused api endpoint 2021-11-25 15:50:12 +01:00
Ferdinand Thiessen 1609d8ae29 [utils] Add util to get pagination filter args from request 2021-11-25 15:50:12 +01:00
ferfissimo 41f625aabc Merge pull request 'feature/pricelist add server pagination for balance' (#17) from feature/pricelist into develop
Reviewed-on: #17
2021-11-25 11:22:50 +00:00
Tim Gröger c3468eea03 [balance] revert user ssp for pull-request 2021-11-25 12:20:43 +01:00
Tim Gröger 2634181d5e [balance] add serverside pagination 2021-11-25 12:20:43 +01:00
Tim Gröger 25b174b1c2 [auth_ldap] fix add displayName when create 2021-11-25 12:20:26 +01:00
Ferdinand Thiessen b4086108e4 [events] Can invite, accept and reject invitations 2021-11-24 21:49:14 +01:00
Tim Gröger eb04d305ab [auth_ldap] fix add displayName when create 2021-11-22 15:38:33 +01:00
Ferdinand Thiessen 471258c886 [events] Default jobs to unlocked state 2021-11-22 15:33:18 +01:00
Ferdinand Thiessen 7cac708309 [clean] PEP8 cleanup 2021-11-22 15:31:53 +01:00
ferfissimo 9b935541b0 Merge pull request 'feature/balance add server side pagination for get balances' (#16) from feature/balance into develop
Reviewed-on: #16
2021-11-22 14:30:41 +00:00
Tim Gröger d17b0a7cab [balance] add sorting of transaction 2021-11-22 15:23:21 +01:00
Tim Gröger ff03325e2b [balance] add get and modify limits for all users 2021-11-22 15:20:24 +01:00
Ferdinand Thiessen 04d5b1e83a [events] Allow locking events 2021-11-21 17:58:28 +01:00
Ferdinand Thiessen 51a3a8dfc8 [events] Respect backup assignment 2021-11-21 17:52:24 +01:00
Tim Gröger d75574e078 [auth_ldap] fix create Users 2021-11-21 15:30:49 +01:00
Tim Gröger 0be31d0bfe [auth_ldap] sync ldap_users to Database 2021-11-21 15:11:37 +01:00
Tim Gröger 26d63b7c7d [users][auth_ldap][auth_plain] delete avatar 2021-11-20 22:58:05 +01:00
Tim Gröger f7f27311db [image] bigger filename size 2021-11-19 22:04:33 +01:00
Tim Gröger 00c9da4ff2 Merge remote-tracking branch 'origin/develop' into develop 2021-11-19 20:11:07 +01:00
Ferdinand Thiessen 795475fe15 [pricelist] Delete old images 2021-11-19 13:32:54 +01:00
Ferdinand Thiessen d00c603697 [events] Allow server side pageination 2021-11-18 23:06:03 +01:00
Ferdinand Thiessen 48933cdf5f [core] Minor fixes 2021-11-18 23:02:03 +01:00
Ferdinand Thiessen 7cb31bf60e gitignore 2021-11-18 12:57:18 +01:00
Ferdinand Thiessen 05dc158719 [cleanup] PEP8 cleanup 2021-11-18 12:56:02 +01:00
Tim Gröger 6535aeab2e Merge remote-tracking branch 'origin/develop' into develop 2021-11-16 21:32:25 +01:00
Ferdinand Thiessen 92183a4235 [logging] Enabled overriding logger config by user config 2021-11-16 21:18:06 +01:00
Ferdinand Thiessen c6c41adb02 [logging] Enabled overriding logger config by user config 2021-11-16 14:07:05 +01:00
Ferdinand Thiessen f1d973b446 [deps] Updated flask requirement 2021-11-16 14:06:31 +01:00
Ferdinand Thiessen 45ed9219a4 [logging] Some cleanup and improved configuring using user config 2021-11-16 13:43:47 +01:00
Tim Gröger 0ef9d18ace [auth_ldap][fix] fix loade correct picture 2021-11-16 11:18:00 +01:00
Tim Gröger ae1bf6c54b [auth_ldap][fix] hash ssha from ldap3 2021-11-15 22:38:49 +01:00
Tim Gröger f205291d6d [pricelist][fix] autodeletion of relationship. drinks can be modified 2021-11-15 20:47:14 +01:00
Ferdinand Thiessen 6a9db1b36a [pricelist] Fix minor issues 2021-11-15 17:05:18 +01:00
Ferdinand Thiessen e3d0014e62 [pricelist] Use Serial database type instead of int for IDs 2021-11-15 16:34:58 +01:00
Ferdinand Thiessen a43441e0c5 [pricelist] Use new image controller 2021-11-15 16:34:35 +01:00
Ferdinand Thiessen a6fe921920 [controller] Add controller for handling uploading images 2021-11-15 16:32:24 +01:00
ferfissimo 42e304cf5f Merge pull request 'feature/pricelist' (#15) from feature/pricelist into develop
Reviewed-on: #15
2021-11-15 09:55:09 +00:00
ferfissimo 55278c8413 Merge branch 'develop' into feature/pricelist 2021-11-15 09:54:52 +00:00
Tim Gröger 57a03a80cc [pricelist] add serverside sorting for pricelist, sorting by name for drinks 2021-11-15 09:19:50 +01:00
Tim Gröger dba60fdab8 [pricelist] serverside pagination and filtering for pricelist 2021-11-14 19:35:11 +01:00
Tim Gröger 1bb7bafa2a [pricelist][fix] better query to send drinks with public prices 2021-11-13 15:57:49 +01:00
Tim Gröger e4b937991b [pricelist] add serverside pagination and filter for receipts 2021-11-13 15:44:06 +01:00
Tim Gröger 526433afba [pricelist] serviceside filtering for ingredients 2021-11-13 15:03:21 +01:00
Ferdinand Thiessen 1b371763ee [events] Fix conflic return code 2021-11-13 14:49:28 +01:00
Tim Gröger 8fb74358e7 [pricelist] add serverside filtering for getDrinks 2021-11-13 13:23:04 +01:00
Tim Gröger 26a00ed6a6 [pricelist] add serverside pagination of drinks 2021-11-12 22:09:16 +01:00
Ferdinand Thiessen 974af80a9b [db] Fix warnings and fix readme 2021-11-12 11:34:42 +01:00
Tim Gröger ff13eefb45 Merge branch 'develop' into feature/pricelist 2021-11-11 19:49:43 +01:00
Tim Gröger 5ef603ee50 Merge pull request '[auth_ldap] Allow more configuration' (#14) from improve_ldap into develop
Reviewed-on: #14
2021-11-11 18:43:17 +00:00
Ferdinand Thiessen 80f06e483b [auth_ldap] modify_role has to be called before the update to change it on the backend 2021-11-11 15:23:11 +01:00
Ferdinand Thiessen f80ad5c420 [auth_ldap] Fix typo in __init__ 2021-11-11 15:23:11 +01:00
Ferdinand Thiessen 4e1799e297 [auth_ldap] Allow more configuration
* Allow configuring the password hash (SSHA, PBKDF2 or Argon2)
* Allow setting custom dn templates for users and groups to e.g. allow "ou=people" or "ou=user"
* Allow setting custom object class for entries
* Stop using deprecated openssl constants
2021-11-11 15:23:11 +01:00
Ferdinand Thiessen f7e07fdade [events] Hotfix: delete an event with registered jobs 2021-11-11 15:22:55 +01:00
Ferdinand Thiessen 3d833fb6af [plugin] Plugins should have an unique ID 2021-11-11 15:22:15 +01:00
Ferdinand Thiessen 2dabd1dd34 [events] Fix deleteing an event 2021-11-11 12:23:45 +01:00
Ferdinand Thiessen cadde543f2 [plugins] Improved handling of plugin loading errors 2021-08-30 14:39:54 +02:00
Tim Gröger 4e46ea1ca3 [balance] add get and modify limits for all users 2021-08-09 10:06:51 +00:00
Tim Gröger 0d1a39f217 [balance] add sorting of transaction 2021-08-09 10:06:51 +00:00
Ferdinand Thiessen 7129469835 [roles] MySQL is caseinsensitive for strings so workaround it for renaming roles 2021-07-29 17:18:01 +02:00
Tim Gröger e7b978ae3c better drink dependency
drink_ingredient has name and cost_per_volume
Flaschengeist/flaschengeist-pricelist#2
2021-06-30 10:45:41 +02:00
Tim Gröger ff1a0544f8 Merge branch 'develop' into feature/pricelist 2021-06-29 20:21:50 +02:00
Ferdinand Thiessen 3fc04c4143 [docs] Moved some devel docs to the wiki 2021-05-27 01:52:45 +02:00
Ferdinand Thiessen 7928c16c07 [db] Try mysqlclient first, maybe the user managed to get it working on Windows 2021-05-27 01:52:30 +02:00
Ferdinand Thiessen 7b5f854d51 [db] Support sqlite and postgresql as engine, fixes #5
mysql / mariadb still is the only tested configuration.
This will break existing databases, as UTF8MB4 is enforced for
mysql (real UTF8).
2021-05-27 01:27:53 +02:00
Ferdinand Thiessen 8696699ecb [run_flaschengeist] Improved export command
Export now supports --no-core flag, if set no core models will
get exported.
Also --plugins was changed to support a list of plugins,
if no list is given the old behavior is taken (export all plugins).
If a list of plugins is given, only those plugins are exported.
2021-05-26 16:47:03 +02:00
groegert 776332d5fe added some hints to ease the installation 2021-05-20 15:37:17 +00:00
Tim Gröger 96e4e73f4b [users] add dynamic shortcuts for users 2021-04-18 23:28:05 +02:00
Tim Gröger 1e5304fe1e Merge branch 'feature/pricelist' into develop 2021-04-17 18:32:49 +02:00
Tim Gröger 3da2ed53d5 [pricelist][#6] save order of pricelist columns for user 2021-04-17 18:27:14 +02:00
Tim Gröger f5624e9a7d [pricelist] add user options pricelist_view 2021-04-15 22:55:41 +02:00
Tim Gröger 2d31cda665 Revert "[pricelist] delete visibleColums"
This reverts commit 2d45c0dab9.

Conflicts:
	flaschengeist/plugins/pricelist/__init__.py
2021-04-15 22:05:34 +02:00
Tim Gröger 2da0bb1683 Merge remote-tracking branch 'origin/develop' into feature/pricelist 2021-04-15 15:36:00 +02:00
Tim Gröger 0630b5183d [pricelist] fix bug set no volumes are set 2021-04-15 15:23:37 +02:00
Tim Gröger 32ad4471c6 [pricelist] black code 2021-04-14 22:43:28 +02:00
Tim Gröger 1d36c3ef6c [pricelist] add more permissions 2021-04-14 22:42:57 +02:00
Tim Gröger 2d45c0dab9 [pricelist] delete visibleColums 2021-04-14 20:11:23 +02:00
Tim Gröger 62948cd591 [picture][fix] any size for thumbnail 2021-04-14 20:03:44 +02:00
Ferdinand Thiessen 03aa7a3231 [roles] controller: Fixed setting permissions 2021-04-04 21:46:51 +02:00
Ferdinand Thiessen 064177542e [core] Added license and added setup information 2021-04-02 06:58:47 +02:00
Tim Gröger 5c688df392 [pricelist][tags] change model of tags, fixed tags on updateDrink 2021-04-01 22:45:28 +02:00
Tim Gröger 15c7a56d56 [pricelist] receipt as list of strings 2021-03-29 22:34:05 +02:00
Tim Gröger bcf1941a81 [pricelist] fix some merge issues 2021-03-29 21:28:48 +02:00
Tim Gröger b2d8431697 [pricelist][fix] add permission to plugin 2021-03-29 20:26:15 +02:00
Tim Gröger 3a4e90f50e Merge remote-tracking branch 'origin/develop' into feature/pricelist 2021-03-29 12:50:04 +02:00
Tim Gröger faf5b0b8d0 [pricelist] add receipts 2021-03-29 12:41:29 +02:00
Ferdinand Thiessen c3aeeea2ce [events] Fixed permissions names 2021-03-29 07:33:20 +02:00
Ferdinand Thiessen 775e775e89 [core][plugin] Added Notifications, restructure plugins 2021-03-29 07:32:58 +02:00
Ferdinand Thiessen 544ae6a3fe [pricelist] Fixed warnings 2021-03-28 23:14:03 +02:00
Tim Gröger e8c9c6e66c [pricelist][drinks] return only public drinkprices if not logged in 2021-03-28 16:41:20 +02:00
Tim Gröger 2ae8bc7e0c [pricelist][picture] delete picture, if not found 2021-03-28 12:47:02 +02:00
Ferdinand Thiessen 3ad1cfa9be [setup] We are using pdoc3 2021-03-28 04:08:35 +02:00
Tim Gröger 6fce88c120 [pricelist] now can delete pictures, add no-image 2021-03-25 23:05:17 +01:00
Tim Gröger 6dbb135621 [pricelist] delete picture (not ready yet) 2021-03-25 20:32:21 +01:00
Ferdinand Thiessen 114e12f151 [setup] Added build documentation target 2021-03-25 13:15:43 +01:00
Ferdinand Thiessen da94acf18c [events] More rename 2021-03-25 01:38:24 +01:00
Ferdinand Thiessen 4ffa5e0e6e [pricelist] Install pillow 2021-03-25 01:20:26 +01:00
Ferdinand Thiessen 05aba1161b [config] Connect with pymysql on Windows 2021-03-25 01:19:37 +01:00
Ferdinand Thiessen 6cf33976b3 [events] Changed routes 2021-03-24 20:47:04 +01:00
Ferdinand Thiessen 7d692c5f68 [chore] cleanup 2021-03-24 18:37:53 +01:00
Ferdinand Thiessen 1550be5da6 [app] Secure plugin loading 2021-03-24 17:09:42 +01:00
Tim Gröger dca890dad9 [pricelist] save and load pictures 2021-03-22 23:17:44 +01:00
Tim Gröger eb1e146da9 [utils] picture creator 2021-03-22 23:17:26 +01:00
Tim Gröger b98bae337d [pricelist] add modify settings, fixed update drinks 2021-03-21 22:06:24 +01:00
Ferdinand Thiessen b60c405f76 [events] Improved event plugin 2021-03-21 00:55:50 +01:00
Ferdinand Thiessen 4cd353cf4e Renamed schedule to events, added recurring events and invites 2021-03-20 17:24:14 +01:00
Tim Gröger e31c5756a6 [pricelist][break] some cleanup, removed unneccessary api 2021-03-20 15:01:18 +01:00
Ferdinand Thiessen e16f4704b5 [setup] Flask-sqlalchemy now supports sqlalchemy 1.4 2021-03-20 12:19:39 +01:00
Ferdinand Thiessen 02959587e1 [plugin] schedule: Allow ID for types (e.g. creating new events) 2021-03-20 01:00:55 +01:00
Ferdinand Thiessen a476e4f5b1 [cleanup] Fixed member names
* Use _name only for protected members (should not be used outside the
class
* Use name_ instead for all members which should not be API exported
2021-03-19 22:40:36 +01:00
Ferdinand Thiessen cb342c07e8 [Models] Fixed optional detection 2021-03-19 18:17:59 +01:00
Ferdinand Thiessen d6475604e9 Merge branch 'pluginify' of groeger-clan.duckdns.org:newgeruecht into pluginify 2021-03-19 02:04:04 +01:00
Tim Gröger 8dc2defe02 [pricelist] persistent save for visible columns in pricecalculation 2021-03-18 22:34:18 +01:00
Tim Gröger 2b35eec0fc [run_flaschengeist] fixed bool definition 2021-03-18 17:27:19 +01:00
Tim Gröger d5ba1f023e [pricelist] fixed circula issue 2021-03-18 17:26:02 +01:00
Ferdinand Thiessen 87f9b0aa48 [setup] Install correct mysql driver 2021-03-18 14:05:28 +01:00
Ferdinand Thiessen fb8371afb9 [chore] Minor cleanup 2021-03-18 13:03:34 +01:00
Ferdinand Thiessen 85f83f46d5 [Script] Enhanced and added future compatibility for API export.
* Python >= 3.9 required for API export.
2021-03-18 13:02:16 +01:00
Ferdinand Thiessen 900b5efff5 [chore] Minor cleanup 2021-03-18 12:20:17 +01:00
Ferdinand Thiessen 466efcf9e7 [System] New annotation format for future compatibility 2021-03-18 11:52:51 +01:00
Ferdinand Thiessen 948c700e46 [setup] Fixed sqlalchemy requirement 2021-03-18 10:18:08 +01:00
Tim Gröger 26e82f02d6 [pricelist] add, modify and delete for extra ingredients 2021-03-17 22:49:54 +01:00
Tim Gröger 642d95b2a5 [pricelist] finish drinks, can add, modify and delete 2021-03-17 21:36:51 +01:00
Tim Gröger 5e0e5edf6f [pricelist] new api to add, delete and modify ingredients 2021-03-16 23:27:54 +01:00
Tim Gröger 0b60c27f32 [pricelist] fixed set volumes 2021-03-16 18:11:04 +01:00
Tim Gröger e26b7b8c96 [pricelist] first step to add, modify and delete volumes 2021-03-15 23:53:21 +01:00
Tim Gröger df1610557f [pricelist] break api, new model 2021-03-15 19:56:51 +01:00
Ferdinand Thiessen c3c35e2a6a [Plugin] auth_ldap: Fixed searching for non existing user 2021-03-14 15:53:14 +01:00
Ferdinand Thiessen 919a31bede [System] Fixed plugin loading 2021-03-14 15:11:09 +01:00
Ferdinand Thiessen 1b29e602f1 [System][Plugin] Added plugin settings 2021-03-14 12:56:04 +01:00
Tim Gröger 5826ec7a00 [fix] ldap user right secret 2021-03-07 13:29:43 +01:00
Ferdinand Thiessen f9ac002b53 [Plugin] auth_plain: Create admin user if installed and no users exist. 2021-02-18 12:58:20 +01:00
Ferdinand Thiessen 251648e9a4 [System] plugins can register a post install routine, for e.g. filling the database. 2021-02-18 12:58:20 +01:00
Ferdinand Thiessen 24418f5bcb [System] Models: Do not export optional, not set, parameters. 2021-02-14 19:11:39 +01:00
Tim Gröger a6a1de19de [pricelist] first commit for pricelist plugin 2021-02-13 14:13:46 +01:00
Tim Gröger 29157dbc03 [config] add port-config for database 2021-02-10 21:21:53 +01:00
Tim Gröger 29630387a5 Merge branch 'release/1.0' 2020-08-06 22:31:33 +02:00
82 changed files with 5571 additions and 2135 deletions

5
.gitignore vendored
View File

@ -67,6 +67,8 @@ instance/
# Sphinx documentation # Sphinx documentation
docs/_build/ docs/_build/
# pdoc
docs/html
# PyBuilder # PyBuilder
target/ target/
@ -120,6 +122,9 @@ dmypy.json
*.swo *.swo
.vscode/ .vscode/
*.log *.log
.fleet/
data/
# config # config
flaschengeist/flaschengeist.toml flaschengeist/flaschengeist.toml

6
.woodpecker/lint.yml Normal file
View File

@ -0,0 +1,6 @@
pipeline:
lint:
image: python:slim
commands:
- pip install black
- black --check --line-length 120 --target-version=py39 .

19
.woodpecker/test.yml Normal file
View File

@ -0,0 +1,19 @@
pipeline:
install:
image: python:${PYTHON}-slim
commands:
- python -m venv --clear venv
- export PATH=venv/bin:$PATH
- python -m pip install --upgrade pip
- pip install -v ".[tests]"
test:
image: python:${PYTHON}-slim
commands:
- export PATH=venv/bin:$PATH
- python -m pytest
matrix:
PYTHON:
- 3.10
- 3.9

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright 2021 Tim Gröger | Flaschengeist Developers
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

116
README.md Normal file
View File

@ -0,0 +1,116 @@
# Flaschengeist
![status-badge](https://ci.os-sc.org/api/badges/Flaschengeist/flaschengeist/status.svg)
This is the backend of the Flaschengeist.
## Installation
### Requirements
- `mysql` or `mariadb`
- maybe `libmariadb` development files[1]
- python 3.9+
- pip 21.0+
*[1] By default Flaschengeist uses mysql as database backend, if you are on Windows Flaschengeist uses `PyMySQL`, but on
Linux / Mac the faster `mysqlclient` is used, if it is not already installed installing from pypi requires the
development files for `libmariadb` to be present on your system.*
### Install python files
It is recommended to upgrade pip to the latest version before installing:
python -m pip install --upgrade pip
Default installation with *mariadb*/*mysql* support:
pip3 install --user ".[mysql]"
or with ldap support
pip3 install --user ".[ldap]"
or if you want to also run the tests:
pip3 install --user ".[ldap,tests]"
You will also need a MySQL driver, by default one of this is installed:
- `mysqlclient` (non Windows)
- `PyMySQL` (on Windows)
#### Hint on MySQL driver on Windows:
If you want to use `mysqlclient` instead of `PyMySQL` (performance?) you have to follow [this guide](https://www.radishlogic.com/coding/python-3/installing-mysqldb-for-python-3-in-windows/)
### Install database
The user needs to have full permissions to the database.
If not you need to create user and database manually do (or similar on Windows):
(
echo "CREATE DATABASE flaschengeist;"
echo "CREATE USER 'flaschengeist'@'localhost' IDENTIFIED BY 'flaschengeist';"
echo "GRANT ALL PRIVILEGES ON flaschengeist.* TO 'flaschengeist'@'localhost';"
echo "FLUSH PRIVILEGES;"
) | sudo mysql
Then you can install the database tables, this will update all tables from core + all enabled plugins.
And also install all enabled plugins:
$ flaschengeist install
*Hint:* To only install the database tables, or upgrade the database after plugins or core are updated later
you can use this command:
$ flaschengeist db upgrade heads
## Plugins
To only upgrade one plugin (for example the `events` plugin):
$ flaschengeist db upgrade events@head
## Configuration
Configuration is done within the a `flaschengeist.toml`file, you can copy the one located inside the module path
(where flaschegeist is installed) or create an empty one and place it inside either:
1. `~/.config/`
2. A custom path and set environment variable `FLASCHENGEIST_CONF`
Uncomment and change at least all the database parameters!
#### CRON
Some functionality used by some plugins rely on regular updates,
but as flaschengeists works as an WSGI app it can not controll when it gets called.
So you have to configure one of the following options to call flaschengeists CRON tasks:
1. Passive Web-CRON: Every time an users calls flaschengeist a task is scheduled (**NOT RECOMMENDED**)
- Pros: No external configuration needed
- Cons: Slower user experience, no guaranteed execution time of tasks
2. Active Web-CRON: You configure a webworker to call `<flaschengeist>/cron`
- Pros: Guaranteed execution interval, no impact on user experience (at least if you do not limit wsgi worker threads)
- Cons: Uses one of the webserver threads while executing
### Run
Flaschengeist provides a CLI, based on the flask CLI, respectivly called `flaschengeist`.
⚠️ When using the CLI for running Flaschengeist, please note that logging will happen as configured,
with the difference of the main logger will be forced to output to `stderr` and the logging level
of the CLI will override the logging level you have configured for the main logger.
$ flaschengeist run
or with debug messages:
$ flaschengeist run --debug
This will run the backend on http://localhost:5000
## Tests
$ pip install '.[test]'
$ pytest
Run with coverage report:
$ coverage run -m pytest
$ coverage report
Or with html output (open `htmlcov/index.html` in a browser):
$ coverage html
## Development
Please refer to our [development wiki](https://flaschengeist.dev/Flaschengeist/flaschengeist/wiki/Development).

View File

@ -0,0 +1,57 @@
# Plugin Development
## File Structure
- your_plugin/
- __init__.py
- ...
- migrations/ (optional)
- ...
- setup.cfg
The basic layout of a plugin is quite simple, you will only need the `setup.cfg` or `setup.py` and
the package containing your plugin code, at lease a `__init__.py` file with your `Plugin` class.
If you use custom database tables you need to provide a `migrations` directory within your package,
see next section.
## Database Tables / Migrations
To allow upgrades of installed plugins, the database is versioned and handled
through [Alembic](https://alembic.sqlalchemy.org/en/latest/index.html) migrations.
Each plugin, which uses custom database tables, is represented as an other base.
So you could simply follow the Alembic tutorial on [how to work with multiple bases](https://alembic.sqlalchemy.org/en/latest/branches.html#creating-a-labeled-base-revision).
A quick overview on how to work with migrations for your plugin:
$ flaschengeist db revision -m "Create my super plugin" \
--head=base --branch-label=myplugin_name --version-path=your/plugin/migrations
This would add a new base named `myplugin_name`, which should be the same as the pypi name of you plugin.
If your tables depend on an other plugin or a specific base version you could of cause add
--depends-on=VERSION
or
--depends-on=other_plugin
### Plugin Removal and Database Tables
As generic downgrades are most often hard to write, your plugin is not required to provide such functionallity.
For Flaschengeist only instable versions provide meaningful downgrade migrations down to the latest stable version.
So this means if you do not provide downgrades you must at lease provide a series of migrations toward removal of
the database tables in case the users wants to delete the plugin.
(base) ----> 1.0 <----> 1.1 <----> 1.2
|
--> removal
After the removal step the database is stamped to to "remove" your
## Useful Hooks
There are some predefined hooks, which might get handy for you.
For more information, please refer to
- `flaschengeist.utils.hook.HookBefore` and
- `flaschengeist.utils.hook.HookAfter`

View File

@ -1,14 +1,9 @@
""" Server-package """Flaschengeist"""
Initialize app, CORS, database and add it to the application.
"""
import logging import logging
import pkg_resources from importlib.metadata import version
from pathlib import Path
from werkzeug.local import LocalProxy
__version__ = pkg_resources.get_distribution("flaschengeist").version __version__ = version("flaschengeist")
_module_path = Path(__file__).parent __pdoc__ = {}
logger: logging.Logger = LocalProxy(lambda: logging.getLogger(__name__)) logger = logging.getLogger(__name__)
__pdoc__["logger"] = "Flaschengeist's logger instance (`werkzeug.local.LocalProxy`)"

View File

@ -0,0 +1,5 @@
from pathlib import Path
alembic_migrations_path = str(Path(__file__).resolve().parent / "migrations")
alembic_script_path = str(Path(__file__).resolve().parent)

View File

@ -0,0 +1,53 @@
# A generic, single database configuration.
# No used by flaschengeist
[alembic]
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
version_path_separator = os
version_locations = %(here)s/migrations
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic,flask_migrate
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[logger_flask_migrate]
level = INFO
handlers =
qualname = flask_migrate
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

View File

@ -0,0 +1,74 @@
import logging
from logging.config import fileConfig
from pathlib import Path
from flask import current_app
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(Path(config.get_main_option("script_location")) / config.config_file_name.split("/")[-1])
logger = logging.getLogger("alembic.env")
config.set_main_option("sqlalchemy.url", str(current_app.extensions["migrate"].db.get_engine().url).replace("%", "%%"))
target_metadata = current_app.extensions["migrate"].db.metadata
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
# this callback is used to prevent an auto-migration from being generated
# when there are no changes to the schema
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
def process_revision_directives(context, revision, directives):
if getattr(config.cmd_opts, "autogenerate", False):
script = directives[0]
if script.upgrade_ops.is_empty():
directives[:] = []
logger.info("No changes in schema detected.")
connectable = current_app.extensions["migrate"].db.get_engine()
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
process_revision_directives=process_revision_directives,
**current_app.extensions["migrate"].configure_args,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@ -0,0 +1,154 @@
"""Initial core db
Revision ID: 20482a003db8
Revises:
Create Date: 2022-08-25 15:13:34.900996
"""
from alembic import op
import sqlalchemy as sa
import flaschengeist
# revision identifiers, used by Alembic.
revision = "20482a003db8"
down_revision = None
branch_labels = ("flaschengeist",)
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"image",
sa.Column("id", flaschengeist.database.types.Serial(), nullable=False),
sa.Column("filename", sa.String(length=255), nullable=False),
sa.Column("mimetype", sa.String(length=127), nullable=False),
sa.Column("thumbnail", sa.String(length=255), nullable=True),
sa.Column("path", sa.String(length=255), nullable=True),
sa.PrimaryKeyConstraint("id", name=op.f("pk_image")),
)
op.create_table(
"plugin",
sa.Column("id", flaschengeist.database.types.Serial(), nullable=False),
sa.Column("name", sa.String(length=127), nullable=False),
sa.Column("version", sa.String(length=30), nullable=False),
sa.Column("enabled", sa.Boolean(), nullable=True),
sa.PrimaryKeyConstraint("id", name=op.f("pk_plugin")),
)
op.create_table(
"role",
sa.Column("id", flaschengeist.database.types.Serial(), nullable=False),
sa.Column("name", sa.String(length=30), nullable=True),
sa.PrimaryKeyConstraint("id", name=op.f("pk_role")),
sa.UniqueConstraint("name", name=op.f("uq_role_name")),
)
op.create_table(
"permission",
sa.Column("name", sa.String(length=30), nullable=True),
sa.Column("id", flaschengeist.database.types.Serial(), nullable=False),
sa.Column("plugin", flaschengeist.database.types.Serial(), nullable=True),
sa.ForeignKeyConstraint(["plugin"], ["plugin.id"], name=op.f("fk_permission_plugin_plugin")),
sa.PrimaryKeyConstraint("id", name=op.f("pk_permission")),
sa.UniqueConstraint("name", name=op.f("uq_permission_name")),
)
op.create_table(
"plugin_setting",
sa.Column("id", flaschengeist.database.types.Serial(), nullable=False),
sa.Column("plugin", flaschengeist.database.types.Serial(), nullable=True),
sa.Column("name", sa.String(length=127), nullable=False),
sa.Column("value", sa.PickleType(), nullable=True),
sa.ForeignKeyConstraint(["plugin"], ["plugin.id"], name=op.f("fk_plugin_setting_plugin_plugin")),
sa.PrimaryKeyConstraint("id", name=op.f("pk_plugin_setting")),
)
op.create_table(
"user",
sa.Column("userid", sa.String(length=30), nullable=False),
sa.Column("display_name", sa.String(length=30), nullable=True),
sa.Column("firstname", sa.String(length=50), nullable=False),
sa.Column("lastname", sa.String(length=50), nullable=False),
sa.Column("deleted", sa.Boolean(), nullable=True),
sa.Column("birthday", sa.Date(), nullable=True),
sa.Column("mail", sa.String(length=60), nullable=True),
sa.Column("id", flaschengeist.database.types.Serial(), nullable=False),
sa.Column("avatar", flaschengeist.database.types.Serial(), nullable=True),
sa.ForeignKeyConstraint(["avatar"], ["image.id"], name=op.f("fk_user_avatar_image")),
sa.PrimaryKeyConstraint("id", name=op.f("pk_user")),
sa.UniqueConstraint("userid", name=op.f("uq_user_userid")),
)
op.create_table(
"notification",
sa.Column("id", flaschengeist.database.types.Serial(), nullable=False),
sa.Column("text", sa.Text(), nullable=True),
sa.Column("data", sa.PickleType(), nullable=True),
sa.Column("time", flaschengeist.database.types.UtcDateTime(), nullable=False),
sa.Column("user", flaschengeist.database.types.Serial(), nullable=False),
sa.Column("plugin", flaschengeist.database.types.Serial(), nullable=False),
sa.ForeignKeyConstraint(["plugin"], ["plugin.id"], name=op.f("fk_notification_plugin_plugin")),
sa.ForeignKeyConstraint(["user"], ["user.id"], name=op.f("fk_notification_user_user")),
sa.PrimaryKeyConstraint("id", name=op.f("pk_notification")),
)
op.create_table(
"password_reset",
sa.Column("user", flaschengeist.database.types.Serial(), nullable=False),
sa.Column("token", sa.String(length=32), nullable=True),
sa.Column("expires", flaschengeist.database.types.UtcDateTime(), nullable=True),
sa.ForeignKeyConstraint(["user"], ["user.id"], name=op.f("fk_password_reset_user_user")),
sa.PrimaryKeyConstraint("user", name=op.f("pk_password_reset")),
)
op.create_table(
"role_x_permission",
sa.Column("role_id", flaschengeist.database.types.Serial(), nullable=True),
sa.Column("permission_id", flaschengeist.database.types.Serial(), nullable=True),
sa.ForeignKeyConstraint(
["permission_id"], ["permission.id"], name=op.f("fk_role_x_permission_permission_id_permission")
),
sa.ForeignKeyConstraint(["role_id"], ["role.id"], name=op.f("fk_role_x_permission_role_id_role")),
)
op.create_table(
"session",
sa.Column("expires", flaschengeist.database.types.UtcDateTime(), nullable=True),
sa.Column("token", sa.String(length=32), nullable=True),
sa.Column("lifetime", sa.Integer(), nullable=True),
sa.Column("browser", sa.String(length=127), nullable=True),
sa.Column("platform", sa.String(length=64), nullable=True),
sa.Column("id", flaschengeist.database.types.Serial(), nullable=False),
sa.Column("user_id", flaschengeist.database.types.Serial(), nullable=True),
sa.ForeignKeyConstraint(["user_id"], ["user.id"], name=op.f("fk_session_user_id_user")),
sa.PrimaryKeyConstraint("id", name=op.f("pk_session")),
sa.UniqueConstraint("token", name=op.f("uq_session_token")),
)
op.create_table(
"user_attribute",
sa.Column("id", flaschengeist.database.types.Serial(), nullable=False),
sa.Column("user", flaschengeist.database.types.Serial(), nullable=False),
sa.Column("name", sa.String(length=30), nullable=True),
sa.Column("value", sa.PickleType(), nullable=True),
sa.ForeignKeyConstraint(["user"], ["user.id"], name=op.f("fk_user_attribute_user_user")),
sa.PrimaryKeyConstraint("id", name=op.f("pk_user_attribute")),
)
op.create_table(
"user_x_role",
sa.Column("user_id", flaschengeist.database.types.Serial(), nullable=True),
sa.Column("role_id", flaschengeist.database.types.Serial(), nullable=True),
sa.ForeignKeyConstraint(["role_id"], ["role.id"], name=op.f("fk_user_x_role_role_id_role")),
sa.ForeignKeyConstraint(["user_id"], ["user.id"], name=op.f("fk_user_x_role_user_id_user")),
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("user_x_role")
op.drop_table("user_attribute")
op.drop_table("session")
op.drop_table("role_x_permission")
op.drop_table("password_reset")
op.drop_table("notification")
op.drop_table("user")
op.drop_table("plugin_setting")
op.drop_table("permission")
op.drop_table("role")
op.drop_table("plugin")
op.drop_table("image")
# ### end Alembic commands ###

View File

@ -0,0 +1,25 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
import flaschengeist
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@ -1,30 +1,36 @@
import pkg_resources import enum
from flask import Flask, current_app import json
from flask import Flask
from flask_cors import CORS from flask_cors import CORS
from datetime import datetime, date from datetime import datetime, date
from flask.json import JSONEncoder, jsonify from flask.json import jsonify
from json import JSONEncoder
from flask.json.provider import JSONProvider
from sqlalchemy.exc import OperationalError from sqlalchemy.exc import OperationalError
from werkzeug.exceptions import HTTPException from werkzeug.exceptions import HTTPException
from . import logger from flaschengeist import logger
from .plugins import AuthPlugin from flaschengeist.controller import pluginController
from flaschengeist.config import config, configure_app from flaschengeist.utils.hook import Hook
from flaschengeist.controller import roleController from flaschengeist.config import configure_app
from flaschengeist.database import db
class CustomJSONEncoder(JSONEncoder): class CustomJSONEncoder(JSONEncoder):
def default(self, o): def default(self, o):
# Check if custom model
try: try:
# Check if custom model
return o.serialize() return o.serialize()
except AttributeError: except AttributeError:
pass pass
if isinstance(o, datetime) or isinstance(o, date): if isinstance(o, datetime) or isinstance(o, date):
return o.isoformat() return o.isoformat()
if isinstance(o, enum.Enum):
# Check if iterable return o.value
try: try:
# Check if iterable
iterable = iter(o) iterable = iter(o)
except TypeError: except TypeError:
pass pass
@ -33,61 +39,65 @@ class CustomJSONEncoder(JSONEncoder):
return JSONEncoder.default(self, o) return JSONEncoder.default(self, o)
def __load_plugins(app): class CustomJSONProvider(JSONProvider):
logger.info("Search for plugins") ensure_ascii: bool = True
sort_keys: bool = True
def dumps(self, obj, **kwargs):
kwargs.setdefault("ensure_ascii", self.ensure_ascii)
kwargs.setdefault("sort_keys", self.sort_keys)
return json.dumps(obj, **kwargs, cls=CustomJSONEncoder)
def loads(self, s: str | bytes, **kwargs):
return json.loads(s, **kwargs)
@Hook("plugins.loaded")
def load_plugins(app: Flask):
app.config["FG_PLUGINS"] = {} app.config["FG_PLUGINS"] = {}
for entry_point in pkg_resources.iter_entry_points("flaschengeist.plugin"):
logger.debug("Found plugin: >{}<".format(entry_point.name)) for plugin in pluginController.get_enabled_plugins():
plugin = None logger.debug(f"Searching for enabled plugin {plugin.name}")
if entry_point.name in config and config[entry_point.name].get("enabled", False): try:
plugin = entry_point.load()(config[entry_point.name]) # Load class
if plugin.blueprint: cls = plugin.entry_point.load()
# plugin = cls.query.get(plugin.id) if plugin.id is not None else plugin
# plugin = db.session.query(cls).get(plugin.id) if plugin.id is not None else plugin
plugin = db.session.get(cls, plugin.id) if plugin.id is not None else plugin
# Custom loading tasks
plugin.load()
# Register blueprint
if hasattr(plugin, "blueprint") and plugin.blueprint is not None:
app.register_blueprint(plugin.blueprint) app.register_blueprint(plugin.blueprint)
logger.info("Load plugin >{}<".format(entry_point.name)) except:
if isinstance(plugin, AuthPlugin): logger.error(
logger.debug("Found authentication plugin: %s", entry_point.name) f"Plugin {plugin.name} was enabled, but could not be loaded due to an error.",
if entry_point.name == config["FLASCHENGEIST"]["auth"]: exc_info=True,
app.config["FG_AUTH_BACKEND"] = plugin )
else:
del plugin
else:
app.config["FG_PLUGINS"][entry_point.name] = plugin
if "FG_AUTH_BACKEND" not in app.config:
logger.error("No authentication plugin configured or authentication plugin not found")
def install_all():
from flaschengeist.database import db
db.create_all()
db.session.commit()
for name, plugin in current_app.config["FG_PLUGINS"].items():
if not plugin:
logger.debug("Skip disabled plugin {}".format(name))
continue continue
logger.info("Install plugin {}".format(name)) logger.info(f"Loaded plugin: {plugin.name}")
plugin.install() app.config["FG_PLUGINS"][plugin.name] = plugin
if plugin.permissions:
roleController.create_permissions(plugin.permissions)
def create_app(test_config=None): def create_app(test_config=None, cli=False):
app = Flask(__name__) app = Flask("flaschengeist")
app.json_encoder = CustomJSONEncoder app.json_provider_class = CustomJSONProvider
app.json = CustomJSONProvider(app)
CORS(app) CORS(app)
with app.app_context(): with app.app_context():
from flaschengeist.database import db from flaschengeist.database import db, migrate
configure_app(app, test_config) configure_app(app, test_config)
db.init_app(app) db.init_app(app)
__load_plugins(app) migrate.init_app(app, db, compare_type=True)
load_plugins(app)
@app.route("/", methods=["GET"]) @app.route("/", methods=["GET"])
def __get_state(): def __get_state():
from . import __version__ as version from . import __version__ as version
return jsonify({"plugins": app.config["FG_PLUGINS"], "version": version}) return jsonify({"plugins": pluginController.get_loaded_plugins(), "version": version})
@app.errorhandler(Exception) @app.errorhandler(Exception)
def handle_exception(e): def handle_exception(e):

View File

@ -0,0 +1,123 @@
import io
import sys
import inspect
import logging
class InterfaceGenerator:
known = []
classes = {}
mapper = {
"str": "string",
"int": "number",
"float": "number",
"date": "Date",
"datetime": "Date",
"NoneType": "null",
"bool": "boolean",
}
def __init__(self, namespace, filename, logger=logging.getLogger()):
self.basename = ""
self.namespace = namespace
self.filename = filename
self.this_type = None
self.logger = logger
def pytype(self, cls):
a = self._pytype(cls)
return a
def _pytype(self, cls):
import typing
origin = typing.get_origin(cls)
arguments = typing.get_args(cls)
if origin is typing.ForwardRef: # isinstance(cls, typing.ForwardRef):
return "", "this" if cls.__forward_arg__ == self.this_type else cls.__forward_arg__
if origin is typing.Union:
if len(arguments) == 2 and arguments[1] is type(None):
return "?", self.pytype(arguments[0])[1]
else:
return "", "|".join([self.pytype(pt)[1] for pt in arguments])
if origin is list:
return "", "Array<{}>".format("|".join([self.pytype(a_type)[1] for a_type in arguments]))
if cls is typing.Any:
return "", "any"
name = cls.__name__ if hasattr(cls, "__name__") else cls if isinstance(cls, str) else None
if name is not None:
if name in self.mapper:
return "", self.mapper[name]
else:
return "", name
self.logger.warning(f"This python version might not detect all types (try >= 3.9). Could not identify >{cls}<")
return "?", "any"
def walker(self, module):
if sys.version_info < (3, 9):
raise RuntimeError("Python >= 3.9 is required to export API")
import typing
if (
inspect.ismodule(module[1])
and module[1].__name__.startswith(self.basename)
and module[1].__name__ not in self.known
):
self.known.append(module[1].__name__)
for cls in inspect.getmembers(module[1], lambda x: inspect.isclass(x) or inspect.ismodule(x)):
self.walker(cls)
elif (
inspect.isclass(module[1])
and module[1].__module__.startswith(self.basename)
and module[0] not in self.classes
and not module[0].startswith("_")
and hasattr(module[1], "__annotations__")
):
self.this_type = module[0]
d = {}
for param, ptype in typing.get_type_hints(module[1], globalns=None, localns=None).items():
if not param.startswith("_") and not param.endswith("_"):
d[param] = self.pytype(ptype)
if len(d) == 1:
key, value = d.popitem()
self.classes[module[0]] = value[1]
else:
self.classes[module[0]] = d
def run(self, models):
self.basename = models.__name__
self.walker(("models", models))
def _write_types(self):
TYPE = "type {name} = {alias};\n"
INTERFACE = "interface {name} {{\n{properties}}}\n"
PROPERTY = "\t{name}{modifier}: {type};\n"
buffer = io.StringIO()
for cls, props in self.classes.items():
if isinstance(props, str):
buffer.write(TYPE.format(name=cls, alias=props))
else:
buffer.write(
INTERFACE.format(
name=cls,
properties="".join(
[PROPERTY.format(name=name, modifier=props[name][0], type=props[name][1]) for name in props]
),
)
)
return buffer
def write(self):
with open(self.filename, "w") if self.filename else sys.stdout as file:
if self.namespace:
file.write(f"declare namespace {self.namespace} {{\n")
for line in self._write_types().getvalue().split("\n"):
file.write(f"\t{line}\n")
file.write("}\n")
else:
file.write(self._write_types().getvalue())

View File

@ -0,0 +1,103 @@
from os import environ
import sys
import click
import logging
from flask.cli import FlaskGroup, with_appcontext
from flaschengeist import logger
from flaschengeist.app import create_app
LOGGING_MIN = 5 # TRACE (custom)
LOGGING_MAX = logging.ERROR
def get_version(ctx, param, value):
if not value or ctx.resilient_parsing:
return
import platform
from werkzeug import __version__ as werkzeug_version
from flask import __version__ as flask_version
from flaschengeist import __version__
click.echo(
f"Python {platform.python_version()}\n"
f"Flask {flask_version}\n"
f"Werkzeug {werkzeug_version}\n"
f"Flaschengeist {__version__}",
color=ctx.color,
)
ctx.exit()
def configure_logger(level):
"""Reconfigure main logger"""
global logger
# Handle TRACE -> meaning enable debug even for werkzeug
if level == 5:
level = 10
logging.getLogger("werkzeug").setLevel(level)
logger.setLevel(level)
environ["FG_LOGGING"] = logging.getLevelName(level)
for h in logger.handlers:
if isinstance(h, logging.StreamHandler) and h.name == "wsgi":
h.setLevel(level)
h.setStream(sys.stderr)
@with_appcontext
def verbosity(ctx, param, value):
"""Callback: Toggle verbosity between ERROR <-> TRACE"""
if not value or ctx.resilient_parsing:
return
configure_logger(LOGGING_MAX - max(LOGGING_MIN, min(value * 10, LOGGING_MAX - LOGGING_MIN)))
@click.group(
cls=FlaskGroup,
add_version_option=False,
add_default_commands=False,
create_app=create_app,
)
@click.option(
"--version",
help="Show the flask version",
expose_value=False,
callback=get_version,
is_flag=True,
is_eager=True,
)
@click.option(
"--verbose",
"-v",
help="Increase logging level",
callback=verbosity,
count=True,
expose_value=False,
)
def cli():
"""Management script for the Flaschengeist application."""
pass
def main(*args, **kwargs):
from .plugin_cmd import plugin
from .export_cmd import export
from .docs_cmd import docs
from .run_cmd import run
from .install_cmd import install
from .docker_cmd import docker
# Override logging level
environ.setdefault("FG_LOGGING", logging.getLevelName(LOGGING_MAX))
cli.add_command(export)
cli.add_command(docs)
cli.add_command(install)
cli.add_command(plugin)
cli.add_command(run)
cli.add_command(docker)
cli(*args, **kwargs)

View File

@ -0,0 +1,54 @@
import click
from click.decorators import pass_context
from flask.cli import with_appcontext
from os import environ
from flaschengeist import logger
from flaschengeist.controller import pluginController
from werkzeug.exceptions import NotFound
import traceback
@click.group()
def docker():
pass
@docker.command()
@with_appcontext
@pass_context
def setup(ctx):
"""Setup flaschengesit in docker container"""
click.echo("Setup docker")
plugins = environ.get("FG_ENABLE_PLUGINS")
if not plugins:
click.secho("no evironment variable is set for 'FG_ENABLE_PLUGINS'", fg="yellow")
click.secho("set 'FG_ENABLE_PLUGINS' to 'auth_ldap', 'mail', 'balance', 'pricelist_old', 'events'")
plugins = ("auth_ldap", "mail", "pricelist_old", "events", "balance")
else:
plugins = plugins.split(" ")
print(plugins)
for name in plugins:
click.echo(f"Installing {name}{'.'*(20-len(name))}", nl=False)
try:
pluginController.install_plugin(name)
except Exception as e:
click.secho(" failed", fg="red")
if logger.getEffectiveLevel() > 10:
ctx.fail(f"[{e.__class__.__name__}] {e}")
else:
ctx.fail(traceback.format_exc())
else:
click.secho(" ok", fg="green")
for name in plugins:
click.echo(f"Enabling {name}{'.'*(20-len(name))}", nl=False)
try:
pluginController.enable_plugin(name)
click.secho(" ok", fg="green")
except NotFound:
click.secho(" not installed / not found", fg="red")

View File

@ -0,0 +1,38 @@
import click
import pathlib
import subprocess
@click.command()
@click.option(
"--output",
"-o",
help="Documentation output path",
default="./docs/html",
type=click.Path(file_okay=False, path_type=pathlib.Path),
)
@click.pass_context
def docs(ctx: click.Context, output: pathlib.Path):
"""Generate and export API documentation using pdoc"""
import pkg_resources
try:
pkg_resources.get_distribution("pdoc>=8.0.1")
except pkg_resources.DistributionNotFound:
click.echo(
f"Error: pdoc was not found, maybe you need to install it. Try:\n" "\n" '$ pip install "pdoc>=8.0.1"\n'
)
ctx.exit(1)
output.mkdir(parents=True, exist_ok=True)
command = [
"python",
"-m",
"pdoc",
"--docformat",
"google",
"--output-directory",
str(output),
"flaschengeist",
]
click.echo(f"Running command: {command}")
subprocess.check_call(command)

View File

@ -0,0 +1,29 @@
import click
from importlib.metadata import entry_points
@click.command()
@click.option("--output", "-o", help="Output file, default is stdout", type=click.Path())
@click.option("--namespace", "-n", help="TS namespace for the interfaces", type=str, show_default=True)
@click.option("--plugin", "-p", help="Also export types for a plugin (even if disabled)", multiple=True, type=str)
@click.option("--no-core", help="Skip models / types from flaschengeist core", is_flag=True)
def export(namespace, output, no_core, plugin):
from flaschengeist import logger, models
from flaschengeist.cli.InterfaceGenerator import InterfaceGenerator
gen = InterfaceGenerator(namespace, output, logger)
if not no_core:
gen.run(models)
if plugin:
for entry_point in entry_points(group="flaschengeist.plugins"):
if len(plugin) == 0 or entry_point.name in plugin:
try:
plugin = entry_point.load()
gen.run(plugin.models)
except:
logger.error(
f"Plugin {entry_point.name} could not be loaded due to an error.",
exc_info=True,
)
continue
gen.write()

View File

@ -0,0 +1,23 @@
import click
from click.decorators import pass_context
from flask.cli import with_appcontext
from flask_migrate import upgrade
from flaschengeist.controller import pluginController
from flaschengeist.utils.hook import Hook
@click.command()
@with_appcontext
@pass_context
@Hook("plugins.installed")
def install(ctx: click.Context):
plugins = pluginController.get_enabled_plugins()
# Install database
upgrade(revision="flaschengeist@head")
# Install plugins
for plugin in plugins:
plugin = pluginController.install_plugin(plugin.name)
pluginController.enable_plugin(plugin.id)

View File

@ -0,0 +1,144 @@
import traceback
import click
from click.decorators import pass_context
from flask import current_app
from flask.cli import with_appcontext
from importlib.metadata import EntryPoint, entry_points
from flaschengeist import logger
from flaschengeist.controller import pluginController
from werkzeug.exceptions import NotFound
@click.group()
def plugin():
pass
@plugin.command()
@click.argument("plugin", nargs=-1, required=True, type=str)
@with_appcontext
@pass_context
def enable(ctx, plugin):
"""Enable one or more plugins"""
for name in plugin:
click.echo(f"Enabling {name}{'.'*(20-len(name))}", nl=False)
try:
pluginController.enable_plugin(name)
click.secho(" ok", fg="green")
except NotFound:
click.secho(" not installed / not found", fg="red")
@plugin.command()
@click.argument("plugin", nargs=-1, required=True, type=str)
@with_appcontext
@pass_context
def disable(ctx, plugin):
"""Disable one or more plugins"""
for name in plugin:
click.echo(f"Disabling {name}{'.'*(20-len(name))}", nl=False)
try:
pluginController.disable_plugin(name)
click.secho(" ok", fg="green")
except NotFound:
click.secho(" not installed / not found", fg="red")
@plugin.command()
@click.argument("plugin", nargs=-1, type=str)
@click.option("--all", help="Install all enabled plugins", is_flag=True)
@with_appcontext
@pass_context
def install(ctx: click.Context, plugin, all):
"""Install one or more plugins"""
all_plugins = entry_points(group="flaschengeist.plugins")
if all:
plugins = [ep.name for ep in all_plugins]
elif len(plugin) > 0:
plugins = plugin
for name in plugin:
if not all_plugins.select(name=name):
ctx.fail(f"Invalid plugin name, could not find >{name}<")
else:
ctx.fail("At least one plugin must be specified, or use `--all` flag.")
for name in plugins:
click.echo(f"Installing {name}{'.'*(20-len(name))}", nl=False)
try:
pluginController.install_plugin(name)
except Exception as e:
click.secho(" failed", fg="red")
if logger.getEffectiveLevel() > 10:
ctx.fail(f"[{e.__class__.__name__}] {e}")
else:
ctx.fail(traceback.format_exc())
else:
click.secho(" ok", fg="green")
@plugin.command()
@click.argument("plugin", nargs=-1, required=True, type=str)
@with_appcontext
@pass_context
def uninstall(ctx: click.Context, plugin):
"""Uninstall one or more plugins"""
plugins = {plg.name: plg for plg in pluginController.get_installed_plugins() if plg.name in plugin}
try:
for name in plugin:
pluginController.disable_plugin(plugins[name])
if (
click.prompt(
"You are going to uninstall:\n\n"
f"\t{', '.join([plugin_name for plugin_name in plugins.keys()])}\n\n"
"Are you sure?",
default="n",
show_choices=True,
type=click.Choice(["y", "N"], False),
).lower()
!= "y"
):
ctx.exit()
click.echo(f"Uninstalling {name}{'.'*(20-len(name))}", nl=False)
pluginController.uninstall_plugin(plugins[name])
click.secho(" ok", fg="green")
except KeyError:
ctx.fail(f"Invalid plugin ID, could not find >{name}<")
@plugin.command()
@click.option("--enabled", "-e", help="List only enabled plugins", is_flag=True)
@click.option("--no-header", "-n", help="Do not show header", is_flag=True)
@with_appcontext
def ls(enabled, no_header):
def plugin_version(p):
if isinstance(p, EntryPoint):
return p.dist.version
return p.version
plugins = entry_points(group="flaschengeist.plugins")
installed_plugins = {plg.name: plg for plg in pluginController.get_installed_plugins()}
loaded_plugins = current_app.config["FG_PLUGINS"].keys()
if not no_header:
print(f"{' '*13}{'name': <20}| version | {' ' * 8} state")
print("-" * 63)
for plugin in plugins:
is_installed = plugin.name in installed_plugins.keys()
is_enabled = is_installed and installed_plugins[plugin.name].enabled
if enabled and is_enabled:
continue
print(f"{plugin.name: <33}|{plugin_version(plugin): >12} | ", end="")
if is_enabled:
if plugin.name in loaded_plugins:
print(click.style(" enabled", fg="green"))
else:
print(click.style("(failed to load)", fg="red"))
elif is_installed:
print(click.style(" disabled", fg="yellow"))
else:
print("not installed")
for name, plugin in installed_plugins.items():
if plugin.enabled and name not in loaded_plugins:
print(f"{name: <33}|{'': >12} |" f"{click.style(' failed to load', fg='red')}")

View File

@ -0,0 +1,37 @@
import click
from os import environ
from flask import current_app
from flask.cli import with_appcontext, run_command
class PrefixMiddleware(object):
def __init__(self, app, prefix=""):
self.app = app
self.prefix = prefix
def __call__(self, environ, start_response):
if environ["PATH_INFO"].startswith(self.prefix):
environ["PATH_INFO"] = environ["PATH_INFO"][len(self.prefix) :]
environ["SCRIPT_NAME"] = self.prefix
return self.app(environ, start_response)
else:
start_response("404", [("Content-Type", "text/plain")])
return ["This url does not belong to the app.".encode()]
@click.command()
@click.option("--host", help="set hostname to listen on", default="127.0.0.1", show_default=True)
@click.option("--port", help="set port to listen on", type=int, default=5000, show_default=True)
@click.option("--debug", help="run in debug mode", is_flag=True)
@with_appcontext
@click.pass_context
def run(ctx, host, port, debug):
"""Run Flaschengeist using a development server."""
from flaschengeist.config import config
current_app.wsgi_app = PrefixMiddleware(current_app.wsgi_app, prefix=config["FLASCHENGEIST"].get("root", ""))
if debug:
environ["FLASK_DEBUG"] = "1"
environ["FLASK_ENV"] = "development"
ctx.invoke(run_command, reload=True, host=host, port=port, debugger=debug)

View File

@ -1,15 +1,15 @@
import os import os
import toml import toml
import logging.config
import collections.abc import collections.abc
from pathlib import Path from pathlib import Path
from logging.config import dictConfig
from werkzeug.middleware.proxy_fix import ProxyFix from werkzeug.middleware.proxy_fix import ProxyFix
from flaschengeist import _module_path, logger
from flaschengeist import logger
# Default config: # Default config:
config = {} config = {"DATABASE": {"engine": "mysql", "port": 3306}}
def update_dict(d, u): def update_dict(d, u):
@ -23,17 +23,17 @@ def update_dict(d, u):
def read_configuration(test_config): def read_configuration(test_config):
global config global config
paths = [_module_path] paths = [Path(__file__).parent]
if not test_config: if not test_config:
paths.append(Path.home() / ".config") paths.append(Path.home() / ".config")
if "FLASCHENGEIST_CONF" in os.environ: if "FLASCHENGEIST_CONF" in os.environ:
paths.append(Path(os.environ.get("FLASCHENGEIST_CONF"))) paths.append(Path(str(os.environ.get("FLASCHENGEIST_CONF"))))
for loc in paths: for loc in paths:
try: try:
with (loc / "flaschengeist.toml").open() as source: with (loc / "flaschengeist.toml").open() as source:
print("Reading config file from >{}<".format(loc)) logger.warning(f"Reading config file from >{loc}<") # default root logger, goes to stderr
update_dict(config, toml.load(source)) update_dict(config, toml.load(source))
except IOError: except IOError:
pass pass
@ -41,39 +41,77 @@ def read_configuration(test_config):
update_dict(config, test_config) update_dict(config, test_config)
def configure_logger():
"""Configure the logger
force_console: Force a console handler
"""
def set_level(level):
# TRACE means even with werkzeug's request traces
if isinstance(level, str) and level.lower() == "trace":
level = "DEBUG"
logger_config["loggers"]["werkzeug"] = {"level": level}
logger_config["loggers"]["flaschengeist"] = {"level": level}
logger_config["handlers"]["wsgi"]["level"] = level
# Read default config
logger_config = toml.load(Path(__file__).parent / "logging.toml")
if "LOGGING" in config:
# Override with user config
update_dict(logger_config, config.get("LOGGING"))
# Check for shortcuts
if "level" in config["LOGGING"]:
set_level(config["LOGGING"]["level"])
# Override logging, used e.g. by CLI
if "FG_LOGGING" in os.environ:
set_level(os.environ.get("FG_LOGGING", "CRITICAL"))
dictConfig(logger_config)
def configure_app(app, test_config=None): def configure_app(app, test_config=None):
global config global config
read_configuration(test_config) read_configuration(test_config)
# Always enable this builtin plugins! configure_logger()
update_dict(config, {"auth": {"enabled": True}, "roles": {"enabled": True}, "users": {"enabled": True}})
logger_config = toml.load(_module_path / "logging.toml")
if "LOGGING" in config:
if "level" in config["LOGGING"]:
logger_config["loggers"]["flaschengeist"] = {"level": config["LOGGING"]["level"]}
if "file" in config["LOGGING"]:
logger_config["root"]["handlers"].append("file")
logger_config["handlers"]["file"]["filename"] = config["LOGGING"]["file"]
path = Path(config["LOGGING"]["file"])
path.parent.mkdir(parents=True, exist_ok=True)
logging.config.dictConfig(logger_config)
if "secret_key" not in config["FLASCHENGEIST"]: if "secret_key" not in config["FLASCHENGEIST"]:
logger.warning("No secret key was configured, please configure one for production systems!") logger.critical("No secret key was configured, please configure one for production systems!")
app.config["SECRET_KEY"] = "0a657b97ef546da90b2db91862ad4e29" raise RuntimeError("No secret key was configured")
else:
app.config["SECRET_KEY"] = config["FLASCHENGEIST"]["secret_key"]
if test_config is None: app.config["SECRET_KEY"] = config["FLASCHENGEIST"]["secret_key"]
app.config["SQLALCHEMY_DATABASE_URI"] = "mysql://{user}:{passwd}@{host}/{database}".format(
user=config["DATABASE"]["user"], if test_config is not None:
passwd=config["DATABASE"]["password"], config["DATABASE"]["engine"] = "sqlite"
host=config["DATABASE"]["host"],
database=config["DATABASE"]["database"], if config["DATABASE"]["engine"] == "mysql":
) engine = "mysql"
try:
# Try mysqlclient first
from MySQLdb import _mysql
except ModuleNotFoundError:
engine += "+pymysql"
options = "?charset=utf8mb4"
elif config["DATABASE"]["engine"] == "postgres":
engine = "postgresql+psycopg2"
options = "?client_encoding=utf8"
elif config["DATABASE"]["engine"] == "sqlite":
engine = "sqlite"
options = ""
host = ""
else: else:
app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite+pysqlite://{config['DATABASE']['file_path']}" logger.error(f"Invalid database engine configured. >{config['DATABASE']['engine']}< is unknown")
raise Exception
if config["DATABASE"]["engine"] in ["mysql", "postgresql"]:
host = "{user}:{password}@{host}:{port}".format(
user=config["DATABASE"]["user"],
password=config["DATABASE"]["password"],
host=config["DATABASE"]["host"],
port=config["DATABASE"]["port"],
)
app.config["SQLALCHEMY_DATABASE_URI"] = f"{engine}://{host}/{config['DATABASE']['database']}{options}"
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
if "root" in config["FLASCHENGEIST"]: if "root" in config["FLASCHENGEIST"]:

View File

@ -0,0 +1 @@
"""Basic controllers for interaction with the Flaschengeist core"""

View File

@ -0,0 +1,64 @@
from datetime import date
from pathlib import Path
from flask import send_file
from PIL import Image as PImage
from werkzeug.utils import secure_filename
from werkzeug.datastructures import FileStorage
from werkzeug.exceptions import NotFound, UnprocessableEntity
from ..models import Image
from ..database import db
from ..config import config
def check_mimetype(mime: str):
return mime in config["FILES"].get("allowed_mimetypes", [])
def send_image(id: int = None, image: Image = None):
if image is None:
image = Image.query.get(id)
if not image:
raise NotFound
return send_file(image.path_, mimetype=image.mimetype_, download_name=image.filename_)
def send_thumbnail(id: int = None, image: Image = None):
if image is None:
image = Image.query.get(id)
if not image:
raise NotFound
if not image.thumbnail_:
with PImage.open(image.open()) as im:
im.thumbnail(tuple(config["FILES"].get("thumbnail_size")))
s = image.path_.split(".")
s.insert(len(s) - 1, "thumbnail")
im.save(".".join(s))
image.thumbnail_ = ".".join(s)
db.session.commit()
return send_file(image.thumbnail_, mimetype=image.mimetype_, download_name=image.filename_)
def upload_image(file: FileStorage):
if not check_mimetype(file.mimetype):
raise UnprocessableEntity
path = Path(config["FILES"].get("data_path")) / str(date.today().year)
path.mkdir(mode=int("0700", 8), parents=True, exist_ok=True)
if file.filename.count(".") < 1:
name = secure_filename(file.filename + "." + file.mimetype.split("/")[-1])
else:
name = secure_filename(file.filename)
img = Image(mimetype_=file.mimetype, filename_=name)
db.session.add(img)
db.session.flush()
try:
img.path_ = str((path / f"{img.id}.{img.filename_.split('.')[-1]}").resolve())
file.save(img.path_)
except:
db.session.delete(img)
raise
finally:
db.session.commit()
return img

View File

@ -1,5 +1,5 @@
from flaschengeist.utils.hook import Hook from ..utils.hook import Hook
from flaschengeist.models.user import User, Role from ..models import User, Role
class Message: class Message:

View File

@ -0,0 +1,172 @@
"""Controller for Plugin logic
Used by plugins for setting and notification functionality.
"""
from typing import Union, List
from flask import current_app
from werkzeug.exceptions import NotFound, BadRequest
from sqlalchemy.exc import OperationalError, ProgrammingError
from flask_migrate import upgrade as database_upgrade
from importlib.metadata import entry_points
from flaschengeist import version as flaschengeist_version
from .. import logger
from ..database import db
from ..utils.hook import Hook
from ..plugins import Plugin, AuthPlugin
from ..models import Notification
__required_plugins = ["users", "roles", "scheduler", "auth"]
def get_authentication_provider():
return [
current_app.config["FG_PLUGINS"][plugin.name]
for plugin in get_loaded_plugins().values()
if isinstance(plugin, AuthPlugin)
]
def get_loaded_plugins(plugin_name: str = None):
"""Get loaded plugin(s)"""
plugins = current_app.config["FG_PLUGINS"]
if plugin_name is not None:
plugins = [plugins[plugin_name]]
return {name: db.session.merge(plugins[name], load=False) for name in plugins}
def get_installed_plugins() -> list[Plugin]:
"""Get all installed plugins"""
return Plugin.query.all()
def get_enabled_plugins() -> list[Plugin]:
"""Get all installed and enabled plugins"""
try:
enabled_plugins = Plugin.query.filter(Plugin.enabled == True).all()
except (OperationalError, ProgrammingError) as e:
logger.error("Could not connect to database or database not initialized! No plugins enabled!")
logger.debug("Can not query enabled plugins", exc_info=True)
# Fake load required plugins so the database can at least be installed
enabled_plugins = [
entry_points(group="flaschengeist.plugins", name=name)[0].load()(
name=name, enabled=True, installed_version=flaschengeist_version
)
for name in __required_plugins
]
return enabled_plugins
def notify(plugin_id: int, user, text: str, data=None):
"""Create a new notification for an user
Args:
plugin_id: ID of the plugin
user: `flaschengeist.models.user.User` to notify
text: Visibile notification text
data: Optional data passed to the notificaton
Returns:
ID of the created `flaschengeist.models.notification.Notification`
Hint: use the data for frontend actions.
"""
if not user.deleted:
n = Notification(text=text, data=data, plugin_id_=plugin_id, user_=user)
db.session.add(n)
db.session.commit()
return n.id
def get_notifications(plugin_id) -> List[Notification]:
"""Get all notifications for a plugin
Args:
plugin_id: ID of the plugin
Returns:
List of `flaschengeist.models.notification.Notification`
"""
return db.session.execute(db.select(Notification).where(Notification.plugin_id_ == plugin_id)).scalars().all()
@Hook("plugins.installed")
def install_plugin(plugin_name: str):
logger.debug(f"Installing plugin {plugin_name}")
entry_point = entry_points(group="flaschengeist.plugins", name=plugin_name)
if not entry_point:
raise NotFound
cls = entry_point[0].load()
plugin: Plugin = cls.query.filter(Plugin.name == plugin_name).one_or_none()
if plugin is None:
plugin = cls(name=plugin_name, installed_version=entry_point[0].dist.version)
db.session.add(plugin)
db.session.commit()
# Custom installation steps
plugin.install()
# Check migrations
directory = entry_point[0].dist.locate_file("")
logger.debug(f"Checking for migrations in {directory}")
for loc in entry_point[0].module.split(".") + ["migrations"]:
directory /= loc
logger.debug(f"Checking for migrations with loc in {directory}")
if directory.exists():
logger.debug(f"Found migrations in {directory}")
database_upgrade(revision=f"{plugin_name}@head")
db.session.commit()
return plugin
@Hook("plugin.uninstalled")
def uninstall_plugin(plugin_id: Union[str, int, Plugin]):
plugin = disable_plugin(plugin_id)
logger.debug(f"Uninstall plugin {plugin.name}")
plugin.uninstall()
db.session.delete(plugin)
db.session.commit()
@Hook("plugins.enabled")
def enable_plugin(plugin_id: Union[str, int]) -> Plugin:
logger.debug(f"Enabling plugin {plugin_id}")
plugin = Plugin.query
if isinstance(plugin_id, str):
plugin = plugin.filter(Plugin.name == plugin_id).one_or_none()
elif isinstance(plugin_id, int):
plugin = plugin.get(plugin_id)
else:
raise TypeError
if plugin is None:
raise NotFound
plugin.enabled = True
db.session.commit()
plugin = plugin.entry_point.load().query.get(plugin.id)
current_app.config["FG_PLUGINS"][plugin.name] = plugin
return plugin
@Hook("plugins.disabled")
def disable_plugin(plugin_id: Union[str, int, Plugin]):
logger.debug(f"Disabling plugin {plugin_id}")
plugin: Plugin = Plugin.query
if isinstance(plugin_id, str):
plugin = plugin.filter(Plugin.name == plugin_id).one_or_none()
elif isinstance(plugin_id, int):
plugin = plugin.get(plugin_id)
elif isinstance(plugin_id, Plugin):
plugin = plugin_id
else:
raise TypeError
if plugin is None:
raise NotFound
if plugin.name in __required_plugins:
raise BadRequest
plugin.enabled = False
db.session.commit()
if plugin.name in current_app.config["FG_PLUGINS"].keys():
del current_app.config["FG_PLUGINS"][plugin.name]
return plugin

View File

@ -1,23 +1,32 @@
from typing import Union
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from werkzeug.exceptions import BadRequest, NotFound from werkzeug.exceptions import BadRequest, Conflict, NotFound
from flaschengeist.models.user import Role, Permission from .. import logger
from flaschengeist.database import db from ..models import Role, Permission
from flaschengeist import logger from ..database import db, case_sensitive
from flaschengeist.utils.hook import Hook from ..utils.hook import Hook
def get_all(): def get_all():
return Role.query.all() return Role.query.all()
def get(role_name): def get(role_name: Union[int, str]) -> Role:
"""Get role by ID or name
Args:
role_name: Name or ID of the role
Returns:
Matching role
Raises:
NotFound
"""
if type(role_name) is int: if type(role_name) is int:
role = Role.query.get(role_name) role = Role.query.get(role_name)
else: else:
role = Role.query.filter(Role.name == role_name).one_or_none() role = Role.query.filter(Role.name == role_name).one_or_none()
if not role: if not role:
raise NotFound raise NotFound("no such role")
return role return role
@ -27,29 +36,20 @@ def get_permissions():
@Hook @Hook
def update_role(role, new_name): def update_role(role, new_name):
if new_name is None: if new_name is None or not isinstance(new_name, str):
try: raise BadRequest("Invalid new name")
logger.debug(f"Hallo, dies ist die {role.serialize()}") else:
db.session.delete(role) if role.name == new_name or db.session.query(db.exists().where(Role.name == case_sensitive(new_name))).scalar():
logger.debug(f"Hallo, dies ist die {role.serialize()}")
db.session.commit()
except IntegrityError:
logger.debug("IntegrityError: Role might still be in use", exc_info=True)
raise BadRequest("Role still in use")
elif role.name != new_name:
if db.session.query(db.exists().where(Role.name == new_name)).scalar():
raise BadRequest("Name already used") raise BadRequest("Name already used")
role.name = new_name role.name = new_name
db.session.commit() db.session.commit()
def set_permissions(role, permissions): def set_permissions(role, permissions):
role.permissions.clear() perms = Permission.query.filter(Permission.name.in_(permissions)).all()
for name in permissions: if len(perms) < len(permissions):
p = Permission.query.filter(Permission.name.in_(permissions)).all() raise BadRequest("Invalid permission name")
if not p or len(p) < len(permissions): role.permissions = list(perms)
raise BadRequest("Invalid permission name >{}<".format(name))
role.permissions.extend(p)
db.session.commit() db.session.commit()
@ -64,14 +64,22 @@ def create_permissions(permissions):
def create_role(name: str, permissions=[]): def create_role(name: str, permissions=[]):
logger.debug(f"Create new role with name: {name}") logger.debug(f"Create new role with name: {name}")
role = Role(name=name) try:
db.session.add(role) role = Role(name=name)
set_permissions(role, permissions) db.session.add(role)
db.session.commit() set_permissions(role, permissions)
logger.debug(f"Created role: {role.serialize()}") db.session.commit()
logger.debug(f"Created role: {role.serialize()}")
except IntegrityError:
raise BadRequest("role already exists")
return role return role
def delete(role): def delete(role):
role.permissions.clear() role.permissions.clear()
update_role(role, None) try:
db.session.delete(role)
db.session.commit()
except IntegrityError:
logger.debug("IntegrityError: Role might still be in use", exc_info=True)
raise Conflict("Role still in use")

View File

@ -1,14 +1,22 @@
import secrets import secrets
from flaschengeist.models.session import Session
from flaschengeist.database import db
from flaschengeist import logger
from werkzeug.exceptions import Forbidden, Unauthorized
from datetime import datetime, timezone from datetime import datetime, timezone
from werkzeug.exceptions import Forbidden, Unauthorized
from ua_parser import user_agent_parser
from .. import logger
from ..models import Session
from ..database import db
lifetime = 1800 lifetime = 1800
def validate_token(token, user_agent, permission): def get_user_agent(request_headers):
return user_agent_parser.Parse(request_headers.get("User-Agent", "") if request_headers else "")
def validate_token(token, request_headers, permission):
"""Verify session """Verify session
Verify a Session and Roles so if the User has permission or not. Verify a Session and Roles so if the User has permission or not.
@ -16,7 +24,7 @@ def validate_token(token, user_agent, permission):
Args: Args:
token: Token to verify. token: Token to verify.
user_agent: User agent of browser to check request_headers: Headers to validate user agent of browser
permission: Permission needed to access restricted routes permission: Permission needed to access restricted routes
Returns: Returns:
A Session for this given Token A Session for this given Token
@ -28,10 +36,14 @@ def validate_token(token, user_agent, permission):
session = Session.query.filter_by(token=token).one_or_none() session = Session.query.filter_by(token=token).one_or_none()
if session: if session:
logger.debug("token found, check if expired or invalid user agent differs") logger.debug("token found, check if expired or invalid user agent differs")
user_agent = get_user_agent(request_headers)
platform = user_agent["os"]["family"]
browser = user_agent["user_agent"]["family"]
if session.expires >= datetime.now(timezone.utc) and ( if session.expires >= datetime.now(timezone.utc) and (
session.browser == user_agent.browser and session.platform == user_agent.platform session.browser == browser and session.platform == platform
): ):
if not permission or session._user.has_permission(permission): if not permission or session.user_.has_permission(permission):
session.refresh() session.refresh()
db.session.commit() db.session.commit()
return session return session
@ -44,24 +56,26 @@ def validate_token(token, user_agent, permission):
raise Unauthorized raise Unauthorized
def create(user, user_agent=None) -> Session: def create(user, request_headers=None) -> Session:
"""Create a Session """Create a Session
Args: Args:
user: For which User is to create a Session user: For which User is to create a Session
user_agent: User agent to identify session request_headers: Headers to validate user agent of browser
Returns: Returns:
Session: A created Token for User Session: A created Token for User
""" """
logger.debug("create access token") logger.debug("create access token")
token_str = secrets.token_hex(16) token_str = secrets.token_hex(16)
user_agent = get_user_agent(request_headers)
logger.debug(f"platform: {user_agent['os']['family']}, browser: {user_agent['user_agent']['family']}")
session = Session( session = Session(
token=token_str, token=token_str,
_user=user, user_=user,
lifetime=lifetime, lifetime=lifetime,
browser=user_agent.browser, platform=user_agent["os"]["family"],
platform=user_agent.platform, browser=user_agent["user_agent"]["family"],
) )
session.refresh() session.refresh()
db.session.add(session) db.session.add(session)
@ -83,13 +97,13 @@ def get_session(token, owner=None):
Session: Token object identified by given token string Session: Token object identified by given token string
""" """
session = Session.query.filter(Session.token == token).one_or_none() session = Session.query.filter(Session.token == token).one_or_none()
if session and (owner and owner != session._user): if session and (owner and owner != session.user_):
raise Forbidden raise Forbidden
return session return session
def get_users_sessions(user): def get_users_sessions(user):
return Session.query.filter(Session._user == user) return Session.query.filter(Session.user_ == user)
def delete_sessions(user): def delete_sessions(user):
@ -98,7 +112,7 @@ def delete_sessions(user):
Args: Args:
user (User): User to delete all sessions for user (User): User to delete all sessions for
""" """
Session.query.filter(Session._user_id == user._id).delete() Session.query.filter(Session.user_.id_ == user.id_).delete()
db.session.commit() db.session.commit()

View File

@ -1,22 +1,43 @@
import re
import secrets import secrets
from flask import current_app import hashlib
from datetime import datetime, timedelta, timezone
from io import BytesIO
from typing import Optional, Union
from flask import make_response
from flask.json import provider
from sqlalchemy import exc
from sqlalchemy_utils import merge_references
from datetime import datetime, timedelta, timezone, date
from flask.helpers import send_file
from werkzeug.exceptions import NotFound, BadRequest, Forbidden from werkzeug.exceptions import NotFound, BadRequest, Forbidden
from flaschengeist import logger from .. import logger
from flaschengeist.config import config from ..config import config
from flaschengeist.database import db from ..database import db
from flaschengeist.utils.hook import Hook from ..models import Notification, User, Role
from flaschengeist.utils.datetime import from_iso_format from ..models.user import _PasswordReset
from flaschengeist.models.user import User, Role, _PasswordReset from ..utils.hook import Hook
from flaschengeist.controller import messageController, sessionController from ..utils.datetime import from_iso_format
from ..controller import (
imageController,
messageController,
pluginController,
sessionController,
)
from ..plugins import AuthPlugin
def __active_users():
"""Return query for not deleted users"""
return User.query.filter(User.deleted == False)
def _generate_password_reset(user): def _generate_password_reset(user):
"""Generate a password reset link for the user""" """Generate a password reset link for the user"""
reset = _PasswordReset.query.get(user._id) reset = _PasswordReset.query.get(user.id_)
if not reset: if not reset:
reset = _PasswordReset(_user_id=user._id) reset = _PasswordReset(_user_id=user.id_)
db.session.add(reset) db.session.add(reset)
expires = datetime.now(tz=timezone.utc) expires = datetime.now(tz=timezone.utc)
@ -29,17 +50,35 @@ def _generate_password_reset(user):
return reset return reset
def get_provider(userid: str) -> AuthPlugin:
return [p for p in pluginController.get_authentication_provider() if p.user_exists(userid)][0]
@Hook
def update_user(user: User, backend: Optional[AuthPlugin] = None):
"""Update user data from backend
This is seperate function to provide a hook"""
if not backend:
backend = get_provider(user.userid)
backend.update_user(user)
if not user.display_name:
user.display_name = "{} {}.".format(user.firstname, user.lastname[0])
db.session.commit()
def login_user(username, password): def login_user(username, password):
logger.info("login user {{ {} }}".format(username)) logger.info("login user {{ {} }}".format(username))
for provider in pluginController.get_authentication_provider():
user = find_user(username) uid = provider.login(username, password)
if not user: if isinstance(uid, str):
logger.debug("User not found in Database.") user = get_user(uid)
user = User(userid=username) if not user:
db.session.add(user) logger.debug("User not found in Database.")
if current_app.config["FG_AUTH_BACKEND"].login(user, password): user = User(userid=uid)
update_user(user) db.session.add(user)
return user update_user(user, provider)
return user
return None return None
@ -72,26 +111,30 @@ def reset_password(token: str, password: str):
db.session.commit() db.session.commit()
@Hook def set_roles(user: User, roles: list[str], create=False):
def update_user(user): """Set roles of user
current_app.config["FG_AUTH_BACKEND"].update_user(user)
if not user.display_name: Args:
user.display_name = "{} {}.".format(user.firstname, user.lastname[0]) user: User to set roles of
db.session.commit() roles: List of role names
create: If set to true, create not existing roles
Raises:
BadRequest if invalid arguments given or not all roles found while *create* is set to false
"""
from .roleController import create_role
if not isinstance(roles, list) and any([not isinstance(r, str) for r in roles]):
raise BadRequest("Invalid role name")
fetched = Role.query.filter(Role.name.in_(roles)).all()
if len(fetched) < len(roles):
if not create:
raise BadRequest("Invalid role name, role not found")
# Create all new roles
fetched += [create_role(role_name) for role_name in roles if not any([role_name == r.name for r in fetched])]
user.roles_ = fetched
def set_roles(user: User, roles: [str], create=False): def modify_user(user: User, password: str, new_password: str = None):
user.roles_.clear()
for role_name in roles:
role = Role.query.filter(Role.name == role_name).one_or_none()
if not role:
if not create:
raise BadRequest("Role not found >{}<".format(role_name))
role = Role(name=role_name)
user.roles_.append(role)
def modify_user(user, password, new_password=None):
"""Modify given user on the backend """Modify given user on the backend
Args: Args:
@ -103,7 +146,8 @@ def modify_user(user, password, new_password=None):
NotImplemented: If backend is not capable of this operation NotImplemented: If backend is not capable of this operation
BadRequest: Password is wrong or other logic issues BadRequest: Password is wrong or other logic issues
""" """
current_app.config["FG_AUTH_BACKEND"].modify_user(user, password, new_password) provider = get_provider(user.userid)
provider.modify_user(user, password, new_password)
if new_password: if new_password:
logger.debug(f"Password changed for user {user.userid}") logger.debug(f"Password changed for user {user.userid}")
@ -117,92 +161,151 @@ def modify_user(user, password, new_password=None):
messageController.send_message(messageController.Message(user, text, subject)) messageController.send_message(messageController.Message(user, text, subject))
def get_users(): def get_users(deleted=False):
return User.query.all() query = __active_users() if not deleted else User.query
return query.all()
def get_user_by_role(role: Role): def get_user_by_role(role: Role):
return User.query.join(User.roles_).filter_by(role_id=role.id).all() return User.query.join(User.roles_).filter_by(role_id=role.id).all()
def get_user(uid): def get_user(uid, deleted=False) -> User:
"""Get an user by userid from database """Get an user by userid from database
Args: Args:
uid: Userid to search for uid: Userid to search for
deleted: Set to true to also search deleted users
Returns: Returns:
User fround User fround
Raises: Raises:
NotFound if not found""" NotFound if not found"""
user = User.query.filter(User.userid == uid).one_or_none() user = (__active_users() if not deleted else User.query).filter(User.userid == uid).one_or_none()
if not user: if not user:
raise NotFound raise NotFound
return user return user
def find_user(uid_mail): @Hook
"""Finding an user by userid or mail in database or auth-backend def delete_user(user: User):
Args:
uid_mail: userid and or mail to search for
Returns:
User if found or None
"""
mail = uid_mail.split("@")
mail = len(mail) == 2 and len(mail[0]) > 0 and len(mail[1]) > 0
query = User.userid == uid_mail
if mail:
query |= User.mail == uid_mail
user = User.query.filter(query).one_or_none()
if user:
update_user(user)
else:
user = current_app.config["FG_AUTH_BACKEND"].find_user(uid_mail, uid_mail if mail else None)
if user:
db.session.add(user)
db.session.commit()
return user
def delete(user):
"""Delete given user""" """Delete given user"""
current_app.config["FG_AUTH_BACKEND"].delete_user(user) # First let the backend delete the user, as this might fail
db.session.delete(user) provider = get_provider(user.userid)
provider.delete_user(user)
# Clear all easy relationships
user.avatar_ = None
user._attributes.clear()
user.roles_.clear()
user.sessions_.clear()
user.reset_requests_.clear()
# Now move all other references to the DELETED_USER
try:
deleted_user = get_user("__deleted_user__", True)
except NotFound:
deleted_user = User(
userid="__deleted_user__",
firstname="USER",
lastname="DELETED",
display_name="DELETED USER",
deleted=True,
)
db.session.add(user)
db.session.flush()
merge_references(user, deleted_user)
db.session.commit() db.session.commit()
# Now try to delete the user for real
try:
db.session.delete(user)
db.session.commit()
except exc.IntegrityError:
logger.error(
"Delete of user failed, there might be ForeignKey contraits from disabled plugins",
exec_info=True,
)
# Remove at least all personal data
user.userid = f"__deleted_user__{user.id_}"
user.display_name = "DELETED USER"
user.firstname = ""
user.lastname = ""
user.deleted = True
user.birthday = None
user.mail = None
db.session.commit()
def register(data): def register(data, passwd=None):
"""Register a new user
Args:
data: dictionary containing valid user properties
passwd: optional a password, default: 16byte random
"""
allowed_keys = User().serialize().keys() allowed_keys = User().serialize().keys()
values = {key: value for key, value in data.items() if key in allowed_keys} values = {key: value for key, value in data.items() if key in allowed_keys}
roles = values.pop("roles", []) roles = values.pop("roles", [])
if "birthday" in values: if "birthday" in data:
values["birthday"] = from_iso_format(values["birthday"]).date() if isinstance(data["birthday"], date):
values["birthday"] = data["birthday"]
else:
values["birthday"] = from_iso_format(data["birthday"]).date()
if "mail" in data and not re.match(r"[^@]+@[^@]+\.[^@]+", data["mail"]):
raise BadRequest("Invalid mail given")
user = User(**values) user = User(**values)
set_roles(user, roles) set_roles(user, roles)
password = secrets.token_urlsafe(16) password = passwd if passwd else secrets.token_urlsafe(16)
current_app.config["FG_AUTH_BACKEND"].create_user(user, password) try:
db.session.add(user) provider = [p for p in pluginController.get_authentication_provider() if p.can_register()][0]
db.session.commit() provider.create_user(user, password)
db.session.add(user)
db.session.commit()
except IndexError as e:
logger.error("No authentication backend, allowing registering new users, found.")
raise e
except exc.IntegrityError:
raise BadRequest("userid already in use")
reset = _generate_password_reset(user) if user.mail:
reset = _generate_password_reset(user)
subject = str(config["MESSAGES"]["welcome_subject"]).format(name=user.display_name, username=user.userid) subject = str(config["MESSAGES"]["welcome_subject"]).format(name=user.display_name, username=user.userid)
text = str(config["MESSAGES"]["welcome_text"]).format( text = str(config["MESSAGES"]["welcome_text"]).format(
name=user.display_name, name=user.display_name,
username=user.userid, username=user.userid,
password_link=f'https://{config["FLASCHENGEIST"]["domain"]}/reset?token={reset.token}', password_link=f'https://{config["FLASCHENGEIST"]["domain"]}/reset?token={reset.token}',
) )
messageController.send_message(messageController.Message(user, text, subject)) messageController.send_message(messageController.Message(user, text, subject))
provider.update_user(user)
return user return user
def load_avatar(user: User): def get_last_modified(user: User):
return current_app.config["FG_AUTH_BACKEND"].get_avatar(user) """Get the last modification date of the user"""
return get_provider(user.userid).get_last_modified(user)
def save_avatar(user, avatar): def load_avatar(user: User, etag: Union[str, None] = None):
current_app.config["FG_AUTH_BACKEND"].set_avatar(user, avatar) if user.avatar_ is not None:
return imageController.send_image(image=user.avatar_)
else:
provider = get_provider(user.userid)
avatar = provider.get_avatar(user)
new_etag = hashlib.md5(avatar.binary).hexdigest()
if new_etag == etag:
return make_response("", 304)
if len(avatar.binary) > 0:
return send_file(BytesIO(avatar.binary), avatar.mimetype, etag=new_etag)
raise NotFound
def save_avatar(user, file):
get_provider(user.userid).set_avatar(user, file)
db.session.commit()
def delete_avatar(user):
get_provider(user.userid).delete_avatar(user)
db.session.commit() db.session.commit()
@ -210,3 +313,15 @@ def persist(user=None):
if user: if user:
db.session.add(user) db.session.add(user)
db.session.commit() db.session.commit()
def get_notifications(user, start=None):
query = Notification.query.filter(Notification.user_id_ == user.id_)
if start is not None:
query = query.filter(Notification.time > start)
return query.order_by(Notification.time).all()
def delete_notification(nid, user):
Notification.query.filter(Notification.id == nid).filter(Notification.user_ == user).delete()
db.session.commit()

View File

@ -1,3 +0,0 @@
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()

View File

@ -0,0 +1,75 @@
import os
from flask_migrate import Migrate, Config
from flask_sqlalchemy import SQLAlchemy
from importlib.metadata import EntryPoint, entry_points, distribution
from sqlalchemy import MetaData
from flaschengeist.alembic import alembic_script_path
from flaschengeist import logger
# from flaschengeist.controller import pluginController
# https://alembic.sqlalchemy.org/en/latest/naming.html
metadata = MetaData(
naming_convention={
"pk": "pk_%(table_name)s",
"ix": "ix_%(table_name)s_%(column_0_name)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
}
)
db = SQLAlchemy(metadata=metadata, session_options={"expire_on_commit": False})
migrate = Migrate()
@migrate.configure
def configure_alembic(config: Config):
"""Alembic configuration hook
Inject all migrations paths into the ``version_locations`` config option.
This includes even disabled plugins, as simply disabling a plugin without
uninstall can break the alembic version management.
"""
# Set main script location
config.set_main_option("script_location", alembic_script_path)
# Set Flaschengeist's migrations
migrations = [config.get_main_option("script_location") + "/migrations"]
# Gather all migration paths
for entry_point in entry_points(group="flaschengeist.plugins"):
try:
directory = entry_point.dist.locate_file("")
for loc in entry_point.module.split(".") + ["migrations"]:
directory /= loc
if directory.exists():
logger.debug(f"Adding migration version path {directory}")
migrations.append(str(directory.resolve()))
except:
logger.warning(f"Could not load migrations of plugin {entry_point.name} for database migration.")
logger.debug("Plugin loading failed", exc_info=True)
# write back seperator (we changed it if neither seperator nor locations were specified)
config.set_main_option("version_path_separator", os.pathsep)
config.set_main_option("version_locations", os.pathsep.join(set(migrations)))
return config
def case_sensitive(s):
"""
Compare string as case sensitive on the database
Args:
s: string to compare
Example:
User.query.filter(User.name == case_sensitive(some_string))
"""
if db.session.bind.dialect.name == "mysql":
from sqlalchemy import func
return func.binary(s)
return s

View File

@ -0,0 +1,97 @@
from importlib import import_module
import datetime
from sqlalchemy import BigInteger, util
from sqlalchemy.dialects import mysql, sqlite
from sqlalchemy.types import DateTime, TypeDecorator
class ModelSerializeMixin:
"""Mixin class used for models to serialize them automatically
Ignores private and protected members as well as members marked as not to publish (name ends with _)
"""
def __is_optional(self, param):
import typing
module = import_module("flaschengeist.models").__dict__
try:
hint = typing.get_type_hints(self.__class__, globalns=module, locals=locals())[param]
if (
typing.get_origin(hint) is typing.Union
and len(typing.get_args(hint)) == 2
and typing.get_args(hint)[1] is type(None)
):
return getattr(self, param) is None
except:
pass
def serialize(self):
"""Serialize class to dict
Returns:
Dict of all not private or protected annotated member variables.
"""
d = {
param: getattr(self, param)
for param in self.__class__.__annotations__
if not param.startswith("_") and not param.endswith("_") and not self.__is_optional(param)
}
if len(d) == 1:
_, value = d.popitem()
return value
return d
def __str__(self) -> str:
return self.serialize().__str__()
class Serial(TypeDecorator):
"""Same as MariaDB Serial used for IDs"""
cache_ok = True
impl = BigInteger().with_variant(mysql.BIGINT(unsigned=True), "mysql").with_variant(sqlite.INTEGER(), "sqlite")
# https://alembic.sqlalchemy.org/en/latest/autogenerate.html?highlight=custom%20column#affecting-the-rendering-of-types-themselves
def __repr__(self) -> str:
return util.generic_repr(self)
class UtcDateTime(TypeDecorator):
"""Almost equivalent to `sqlalchemy.types.DateTime` with
``timezone=True`` option, but it differs from that by:
- Never silently take naive :class:`datetime.datetime`, instead it
always raise :exc:`ValueError` unless time zone aware value.
- :class:`datetime.datetime` value's :attr:`datetime.datetime.tzinfo`
is always converted to UTC.
- Unlike SQLAlchemy's built-in :class:`sqlalchemy.types.DateTime`,
it never return naive :class:`datetime.datetime`, but time zone
aware value, even with SQLite or MySQL.
"""
cache_ok = True
impl = DateTime(timezone=True)
@staticmethod
def current_utc():
return datetime.datetime.now(tz=datetime.timezone.utc)
def process_bind_param(self, value, dialect):
if value is not None:
if not isinstance(value, datetime.datetime):
raise TypeError("expected datetime.datetime, not " + repr(value))
elif value.tzinfo is None:
raise ValueError("naive datetime is disallowed")
return value.astimezone(datetime.timezone.utc)
def process_result_value(self, value, dialect):
if value is not None:
if value.tzinfo is not None:
value = value.astimezone(datetime.timezone.utc)
value = value.replace(tzinfo=datetime.timezone.utc)
return value
# https://alembic.sqlalchemy.org/en/latest/autogenerate.html?highlight=custom%20column#affecting-the-rendering-of-types-themselves
def __repr__(self) -> str:
return util.generic_repr(self)

View File

@ -7,38 +7,61 @@ auth = "auth_plain"
# Enable if you run flaschengeist behind a proxy, e.g. nginx + gunicorn # Enable if you run flaschengeist behind a proxy, e.g. nginx + gunicorn
#proxy = false #proxy = false
# Set root path, prefixes all routes # Set root path, prefixes all routes
#root = /api root = "/api"
# Set secret key # Set secret key
secret_key = "V3ryS3cr3t" secret_key = "V3ryS3cr3t"
# Domain used by frontend # Domain used by frontend
#domain = "flaschengeist.local"
[LOGGING]
# Uncomment to enable logging to a file
#file = "/tmp/flaschengeist-debug.log"
# Logging level, possible: DEBUG INFO WARNING ERROR
level = "WARNING"
[DATABASE] [DATABASE]
# user = "user" # engine = "mysql" (default)
# host = "127.0.0.1" host = "localhost"
# password = "password" user = "flaschengeist"
# database = "database" password = "flaschengeist"
database = "flaschengeist"
[auth_plain] [LOGGING]
enabled = true # You can override all settings from the logging.toml here
# Default: Logging to WSGI stream (commonly stderr)
#[auth_ldap] # Logging level, possible: TRACE DEBUG INFO WARNING ERROR CRITICAL
# enabled = true # On TRACE level additionally every request will get logged
# host = level = "DEBUG"
# port =
# bind_dn = # If you want the logger to log to a file, you could use:
# base_dn = #[LOGGING.handlers.file]
# secret = # class = "logging.handlers.WatchedFileHandler"
# use_ssl = # level = "WARNING"
# admin_dn = # formatter = "extended"
# admin_dn = # encoding = "utf8"
# default_gid = # filename = "flaschengeist.log"
[FILES]
# Path for file / image uploads
data_path = "./data"
# Thumbnail size
thumbnail_size = [192, 192]
# Accepted mimetypes
allowed_mimetypes = [
"image/avif",
"image/jpeg",
"image/png",
"image/webp"
]
[scheduler]
# Possible values are: "passive_web" (default), "active_web" and "system"
# See documentation
# cron = "passive_web"
[auth_ldap]
# Full documentation https://flaschengeist.dev/Flaschengeist/flaschengeist/wiki/plugins_auth_ldap
# host = "localhost"
# port = 389
# base_dn = "dc=example,dc=com"
# root_dn = "cn=Manager,dc=example,dc=com"
# root_secret = "SuperS3cret"
# Uncomment to use secured LDAP (ldaps)
# use_ssl = true
[MESSAGES] [MESSAGES]
welcome_subject = "Welcome to Flaschengeist {name}" welcome_subject = "Welcome to Flaschengeist {name}"
@ -100,3 +123,5 @@ If this was not you, please contact the support.
# enabled = true # enabled = true
# Enable a default limit, will be set if no other limit is set # Enable a default limit, will be set if no other limit is set
# limit = -10.00 # limit = -10.00
# Uncomment to allow active services to debit other users (requires events plugin)
# allow_service_debit = true

View File

@ -1,24 +1,21 @@
# This is the default flaschengeist logger configuration
# If you want to customize it, use the flaschengeist.toml
version = 1 version = 1
disable_existing_loggers = false disable_existing_loggers = false
[formatters] [formatters]
[formatters.simple] [formatters.simple]
format = "%(asctime)s - %(name)s - %(message)s" format = "[%(asctime)s] %(levelname)s - %(message)s"
[formatters.extended] [formatters.extended]
format = "%(asctime)s — %(filename)s - %(funcName)s - %(lineno)d - %(threadName)s - %(name)s — %(levelname)s — %(message)s" format = "[%(asctime)s] %(levelname)s %(filename)s - %(funcName)s - %(lineno)d - %(threadName)s - %(name)s — %(message)s"
[handlers] [handlers]
[handlers.console] [handlers.wsgi]
stream = "ext://flask.logging.wsgi_errors_stream"
class = "logging.StreamHandler" class = "logging.StreamHandler"
level = "DEBUG"
formatter = "simple" formatter = "simple"
stream = "ext://sys.stdout" level = "DEBUG"
[handlers.file]
class = "logging.handlers.WatchedFileHandler"
level = "WARNING"
formatter = "extended"
encoding = "utf8"
filename = "flaschengeist.log"
[loggers] [loggers]
[loggers.werkzeug] [loggers.werkzeug]
@ -26,4 +23,4 @@ disable_existing_loggers = false
[root] [root]
level = "WARNING" level = "WARNING"
handlers = ["console"] handlers = ["wsgi"]

View File

@ -1,58 +1,5 @@
import datetime from .session import *
from sqlalchemy.types import DateTime, TypeDecorator from .user import *
from .plugin import *
from .notification import *
class ModelSerializeMixin: from .image import *
"""Mixin class used for models to serialize them automatically
Ignores private and protected members as well as members marked as not to publish (name ends with _)
"""
def serialize(self):
"""Serialize class to dict
Returns:
Dict of all not private or protected annotated member variables.
"""
d = {
param: getattr(self, param)
for param in self.__class__.__annotations__
if not param.startswith("_") and not param.endswith("_")
}
if len(d) == 1:
key, value = d.popitem()
return value
return d
class UtcDateTime(TypeDecorator):
"""Almost equivalent to `sqlalchemy.types.DateTime` with
``timezone=True`` option, but it differs from that by:
- Never silently take naive :class:`datetime.datetime`, instead it
always raise :exc:`ValueError` unless time zone aware value.
- :class:`datetime.datetime` value's :attr:`datetime.datetime.tzinfo`
is always converted to UTC.
- Unlike SQLAlchemy's built-in :class:`sqlalchemy.types.DateTime`,
it never return naive :class:`datetime.datetime`, but time zone
aware value, even with SQLite or MySQL.
"""
impl = DateTime(timezone=True)
@staticmethod
def current_utc():
return datetime.datetime.now(tz=datetime.timezone.utc)
def process_bind_param(self, value, dialect):
if value is not None:
if not isinstance(value, datetime.datetime):
raise TypeError("expected datetime.datetime, not " + repr(value))
elif value.tzinfo is None:
raise ValueError("naive datetime is disallowed")
return value.astimezone(datetime.timezone.utc)
def process_result_value(self, value, dialect):
if value is not None:
if value.tzinfo is not None:
value = value.astimezone(datetime.timezone.utc)
value = value.replace(tzinfo=datetime.timezone.utc)
return value

View File

@ -0,0 +1,32 @@
from __future__ import annotations # TODO: Remove if python requirement is >= 3.12 (? PEP 563 is defered)
from sqlalchemy import event
from pathlib import Path
from ..database import db
from ..database.types import ModelSerializeMixin, Serial
class Image(db.Model, ModelSerializeMixin):
__allow_unmapped__ = True
__tablename__ = "image"
id: int = db.Column(Serial, primary_key=True)
filename_: str = db.Column("filename", db.String(255), nullable=False)
mimetype_: str = db.Column("mimetype", db.String(127), nullable=False)
thumbnail_: str = db.Column("thumbnail", db.String(255))
path_: str = db.Column("path", db.String(255))
def open(self):
return open(self.path_, "rb")
@event.listens_for(Image, "before_delete")
def clear_file(mapper, connection, target: Image):
if target.path_:
p = Path(target.path_)
if p.exists():
p.unlink()
if target.thumbnail_:
p = Path(target.thumbnail_)
if p.exists():
p.unlink()

View File

@ -0,0 +1,28 @@
from __future__ import annotations # TODO: Remove if python requirement is >= 3.12 (? PEP 563 is defered)
from datetime import datetime
from typing import Any
from ..database import db
from ..database.types import Serial, UtcDateTime, ModelSerializeMixin
class Notification(db.Model, ModelSerializeMixin):
__allow_unmapped__ = True
__tablename__ = "notification"
id: int = db.Column("id", Serial, primary_key=True)
text: str = db.Column(db.Text)
data: Any = db.Column(db.PickleType(protocol=4))
time: datetime = db.Column(UtcDateTime, nullable=False, default=UtcDateTime.current_utc)
user_id_: int = db.Column("user", Serial, db.ForeignKey("user.id"), nullable=False)
plugin_id_: int = db.Column("plugin", Serial, db.ForeignKey("plugin.id"), nullable=False)
user_: User = db.relationship("User")
plugin_: Plugin = db.relationship(
"Plugin", backref=db.backref("notifications_", cascade="all, delete, delete-orphan")
)
plugin: str
@property
def plugin(self) -> str:
return self.plugin_.name

View File

@ -0,0 +1,74 @@
from __future__ import annotations # TODO: Remove if python requirement is >= 3.12 (? PEP 563 is defered)
from typing import Any, List, Dict
from sqlalchemy.orm.collections import attribute_mapped_collection
from ..database import db
from ..database.types import Serial
class PluginSetting(db.Model):
__allow_unmapped__ = True
__tablename__ = "plugin_setting"
id = db.Column("id", Serial, primary_key=True)
plugin_id: int = db.Column("plugin", Serial, db.ForeignKey("plugin.id"))
name: str = db.Column(db.String(127), nullable=False)
value: Any = db.Column(db.PickleType(protocol=4))
class BasePlugin(db.Model):
__allow_unmapped__ = True
__tablename__ = "plugin"
id: int = db.Column("id", Serial, primary_key=True)
name: str = db.Column(db.String(127), nullable=False)
"""Name of the plugin, loaded from distribution"""
installed_version: str = db.Column("version", db.String(30), nullable=False)
"""The latest installed version"""
enabled: bool = db.Column(db.Boolean, default=False)
"""Enabled state of the plugin"""
permissions: List["Permission"] = db.relationship(
"Permission", cascade="all, delete, delete-orphan", back_populates="plugin_", lazy="select"
)
"""Optional list of custom permissions used by the plugin
A good style is to name the permissions with a prefix related to the plugin name,
to prevent clashes with other plugins. E. g. instead of *delete* use *plugin_delete*.
"""
__settings: Dict[str, "PluginSetting"] = db.relationship(
"PluginSetting",
collection_class=attribute_mapped_collection("name"),
cascade="all, delete, delete-orphan",
lazy="subquery",
)
def get_setting(self, name: str, **kwargs):
"""Get plugin setting
Args:
name: string identifying the setting
default: Default value
Returns:
Value stored in database (native python)
Raises:
`KeyError` if no such setting exists in the database
"""
try:
return self.__settings[name].value
except KeyError as e:
if "default" in kwargs:
return kwargs["default"]
raise e
def set_setting(self, name: str, value):
"""Save setting in database
Args:
name: String identifying the setting
value: Value to be stored
"""
if value is None and name in self.__settings.keys():
del self.__settings[name]
else:
setting = self.__settings.setdefault(name, PluginSetting(plugin_id=self.id, name=name, value=None))
setting.value = value

View File

@ -1,10 +1,11 @@
from datetime import datetime, timedelta, timezone from __future__ import annotations # TODO: Remove if python requirement is >= 3.12 (? PEP 563 is defered)
from . import ModelSerializeMixin, UtcDateTime from datetime import datetime, timedelta, timezone
from .user import User
from flaschengeist.database import db
from secrets import compare_digest from secrets import compare_digest
from flaschengeist import logger
from .. import logger
from ..database import db
from ..database.types import ModelSerializeMixin, UtcDateTime, Serial
class Session(db.Model, ModelSerializeMixin): class Session(db.Model, ModelSerializeMixin):
@ -16,21 +17,22 @@ class Session(db.Model, ModelSerializeMixin):
token: String to verify access later. token: String to verify access later.
""" """
__allow_unmapped__ = True
__tablename__ = "session" __tablename__ = "session"
expires: datetime = db.Column(UtcDateTime) expires: datetime = db.Column(UtcDateTime)
token: str = db.Column(db.String(32), unique=True) token: str = db.Column(db.String(32), unique=True)
lifetime: int = db.Column(db.Integer) lifetime: int = db.Column(db.Integer)
browser: str = db.Column(db.String(30)) browser: str = db.Column(db.String(127))
platform: str = db.Column(db.String(30)) platform: str = db.Column(db.String(64))
userid: str = "" userid: str = ""
_id = db.Column("id", db.Integer, primary_key=True) _id = db.Column("id", Serial, primary_key=True)
_user: User = db.relationship("User", back_populates="_sessions") _user_id = db.Column("user_id", Serial, db.ForeignKey("user.id"))
_user_id = db.Column("user_id", db.Integer, db.ForeignKey("user.id")) user_: User = db.relationship("User", back_populates="sessions_")
@property @property
def userid(self): def userid(self):
return self._user.userid return self.user_.userid
def refresh(self): def refresh(self):
"""Update the Timestamp """Update the Timestamp

View File

@ -1,36 +1,43 @@
from flask import url_for from __future__ import (
from typing import Optional annotations,
) # TODO: Remove if python requirement is >= 3.12 (? PEP 563 is defered)
from typing import Optional, Union, List
from datetime import date, datetime from datetime import date, datetime
from sqlalchemy.orm.collections import attribute_mapped_collection from sqlalchemy.orm.collections import attribute_mapped_collection
from ..database import db from ..database import db
from . import ModelSerializeMixin, UtcDateTime from ..database.types import ModelSerializeMixin, UtcDateTime, Serial
association_table = db.Table( association_table = db.Table(
"user_x_role", "user_x_role",
db.Column("user_id", db.Integer, db.ForeignKey("user.id")), db.Column("user_id", Serial, db.ForeignKey("user.id")),
db.Column("role_id", db.Integer, db.ForeignKey("role.id")), db.Column("role_id", Serial, db.ForeignKey("role.id")),
) )
role_permission_association_table = db.Table( role_permission_association_table = db.Table(
"role_x_permission", "role_x_permission",
db.Column("role_id", db.Integer, db.ForeignKey("role.id")), db.Column("role_id", Serial, db.ForeignKey("role.id")),
db.Column("permission_id", db.Integer, db.ForeignKey("permission.id")), db.Column("permission_id", Serial, db.ForeignKey("permission.id")),
) )
class Permission(db.Model, ModelSerializeMixin): class Permission(db.Model, ModelSerializeMixin):
__allow_unmapped__ = True
__tablename__ = "permission" __tablename__ = "permission"
name: str = db.Column(db.String(30), unique=True) name: str = db.Column(db.String(30), unique=True)
_id = db.Column("id", db.Integer, primary_key=True) id_ = db.Column("id", Serial, primary_key=True)
plugin_id_: int = db.Column("plugin", Serial, db.ForeignKey("plugin.id"))
plugin_ = db.relationship("Plugin", lazy="subquery", back_populates="permissions", enable_typechecks=False)
class Role(db.Model, ModelSerializeMixin): class Role(db.Model, ModelSerializeMixin):
__allow_unmapped__ = True
__tablename__ = "role" __tablename__ = "role"
id: int = db.Column(db.Integer, primary_key=True) id: int = db.Column(Serial, primary_key=True)
name: str = db.Column(db.String(30), unique=True) name: str = db.Column(db.String(30), unique=True)
permissions: [Permission] = db.relationship("Permission", secondary=role_permission_association_table) permissions: List[Permission] = db.relationship("Permission", secondary=role_permission_association_table)
class User(db.Model, ModelSerializeMixin): class User(db.Model, ModelSerializeMixin):
@ -40,7 +47,7 @@ class User(db.Model, ModelSerializeMixin):
Attributes: Attributes:
id: Id in Database as Primary Key. id: Id in Database as Primary Key.
uid: User ID used by authentication provider userid: User ID used by authentication provider
display_name: Name to show display_name: Name to show
firstname: Firstname of the User firstname: Firstname of the User
lastname: Lastname of the User lastname: Lastname of the User
@ -48,30 +55,35 @@ class User(db.Model, ModelSerializeMixin):
birthday: Birthday of the user birthday: Birthday of the user
""" """
__allow_unmapped__ = True
__tablename__ = "user" __tablename__ = "user"
userid: str = db.Column(db.String(30), nullable=False) userid: str = db.Column(db.String(30), unique=True, nullable=False)
display_name: str = db.Column(db.String(30)) display_name: str = db.Column(db.String(30))
firstname: str = db.Column(db.String(50), nullable=False) firstname: str = db.Column(db.String(50), nullable=False)
lastname: str = db.Column(db.String(50), nullable=False) lastname: str = db.Column(db.String(50), nullable=False)
mail: str = db.Column(db.String(60), nullable=False) deleted: bool = db.Column(db.Boolean(), default=False)
birthday: Optional[date] = db.Column(db.Date) birthday: Optional[date] = db.Column(db.Date)
roles: [str] = [] mail: str = db.Column(db.String(60))
permissions: Optional[type([str])] = None roles: List[str] = []
avatar_url: Optional[str] = "" permissions: Optional[list[str]] = []
roles_: [Role] = db.relationship("Role", secondary=association_table, cascade="save-update, merge") # Protected stuff for backend use only
_id = db.Column("id", db.Integer, primary_key=True) id_ = db.Column("id", Serial, primary_key=True)
_sessions = db.relationship("Session", back_populates="_user") roles_: List[Role] = db.relationship("Role", secondary=association_table, cascade="save-update, merge")
sessions_: List[Session] = db.relationship("Session", back_populates="user_", cascade="all, delete, delete-orphan")
avatar_: Optional[Image] = db.relationship("Image", cascade="all, delete, delete-orphan", single_parent=True)
reset_requests_: List["_PasswordReset"] = db.relationship("_PasswordReset", cascade="all, delete, delete-orphan")
# Private stuff for internal use
_avatar_id = db.Column("avatar", Serial, db.ForeignKey("image.id"))
_attributes = db.relationship( _attributes = db.relationship(
"_UserAttribute", collection_class=attribute_mapped_collection("name"), cascade="all, delete" "_UserAttribute",
collection_class=attribute_mapped_collection("name"),
cascade="all, delete, delete-orphan",
) )
@property @property
def avatar_url(self): def roles(self) -> List[str]:
return url_for("users.get_avatar", userid=self.userid) if self.userid else None
@property
def roles(self):
return [role.name for role in self.roles_] return [role.name for role in self.roles_]
def set_attribute(self, name, value): def set_attribute(self, name, value):
@ -88,6 +100,10 @@ class User(db.Model, ModelSerializeMixin):
return self._attributes[name].value return self._attributes[name].value
return default return default
def delete_attribute(self, name):
if name in self._attributes:
self._attributes.pop(name)
def get_permissions(self): def get_permissions(self):
return ["user"] + [permission.name for role in self.roles_ for permission in role.permissions] return ["user"] + [permission.name for role in self.roles_ for permission in role.permissions]
@ -96,9 +112,10 @@ class User(db.Model, ModelSerializeMixin):
class _UserAttribute(db.Model, ModelSerializeMixin): class _UserAttribute(db.Model, ModelSerializeMixin):
__allow_unmapped__ = True
__tablename__ = "user_attribute" __tablename__ = "user_attribute"
id = db.Column("id", db.Integer, primary_key=True) id = db.Column("id", Serial, primary_key=True)
user: User = db.Column("user", db.Integer, db.ForeignKey("user.id"), nullable=False) user: User = db.Column("user", Serial, db.ForeignKey("user.id"), nullable=False)
name: str = db.Column(db.String(30)) name: str = db.Column(db.String(30))
value: any = db.Column(db.PickleType(protocol=4)) value: any = db.Column(db.PickleType(protocol=4))
@ -106,9 +123,10 @@ class _UserAttribute(db.Model, ModelSerializeMixin):
class _PasswordReset(db.Model): class _PasswordReset(db.Model):
"""Table containing password reset requests""" """Table containing password reset requests"""
__allow_unmapped__ = True
__tablename__ = "password_reset" __tablename__ = "password_reset"
_user_id: User = db.Column("user", db.Integer, db.ForeignKey("user.id"), primary_key=True) _user_id: User = db.Column("user", Serial, db.ForeignKey("user.id"), primary_key=True)
user: User = db.relationship("User", foreign_keys=[_user_id]) user: User = db.relationship("User", back_populates="reset_requests_", foreign_keys=[_user_id])
token: str = db.Column(db.String(32)) token: str = db.Column(db.String(32))
expires: datetime = db.Column(UtcDateTime) expires: datetime = db.Column(UtcDateTime)

View File

@ -1,49 +1,160 @@
import pkg_resources """Flaschengeist Plugins
from werkzeug.exceptions import MethodNotAllowed, NotFound
from flaschengeist.models.user import _Avatar .. include:: docs/plugin_development.md
"""
from typing import Union, List
from importlib.metadata import entry_points
from werkzeug.exceptions import NotFound
from werkzeug.datastructures import FileStorage
from flaschengeist.models.plugin import BasePlugin
from flaschengeist.models.user import _Avatar, Permission
from flaschengeist.utils.hook import HookBefore, HookAfter from flaschengeist.utils.hook import HookBefore, HookAfter
__all__ = [
"plugins_installed",
"plugins_loaded",
"before_delete_user",
"before_role_updated",
"before_update_user",
"after_role_updated",
"Plugin",
"AuthPlugin",
]
# Documentation hacks, see https://github.com/mitmproxy/pdoc/issues/320
plugins_installed = HookAfter("plugins.installed")
plugins_installed.__doc__ = """Hook decorator for when all plugins are installed
Possible use case would be to populate the database with some presets.
"""
plugins_loaded = HookAfter("plugins.loaded")
plugins_loaded.__doc__ = """Hook decorator for when all plugins are loaded
Possible use case would be to check if a specific other plugin is loaded and change own behavior
Passed args:
- *app:* Current flask app instance (args)
"""
before_role_updated = HookBefore("update_role") before_role_updated = HookBefore("update_role")
"""Hook decorator for when roles are modified before_role_updated.__doc__ = """Hook decorator for when roles are modified
Args:
role: Role object to modify Passed args:
new_name: New name if the name was changed (None if delete) - *role:* `flaschengeist.models.user.Role` to modify
- *new_name:* New name if the name was changed (*None* if delete)
""" """
after_role_updated = HookAfter("update_role") after_role_updated = HookAfter("update_role")
"""Hook decorator for when roles are modified after_role_updated.__doc__ = """Hook decorator for when roles are modified
Args:
role: Role object containing the modified role Passed args:
new_name: New name if the name was changed (None if deleted) - *role:* modified `flaschengeist.models.user.Role`
- *new_name:* New name if the name was changed (*None* if deleted)
""" """
before_update_user = HookBefore("update_user") before_update_user = HookBefore("update_user")
"""Hook decorator, when ever an user update is done, this is called before. before_update_user.__doc__ = """Hook decorator, when ever an user update is done, this is called before.
Args:
user: User object Passed args:
- *user:* `flaschengeist.models.user.User` object
"""
before_delete_user = HookBefore("delete_user")
before_delete_user.__doc__ = """Hook decorator,this is called before an user gets deleted.
Passed args:
- *user:* `flaschengeist.models.user.User` object
""" """
class Plugin: class Plugin(BasePlugin):
"""Base class for all Plugins """Base class for all Plugins
If your class uses custom models add a static property called ``models``"""
def __init__(self, config=None, blueprint=None, permissions=[]): All plugins must derived from this class.
"""Constructor called by create_app
Args: Optional:
config: Dict configuration containing the plugin section - *blueprint*: `flask.Blueprint` providing your routes
blueprint: A flask blueprint containing all plugin routes - *permissions*: List of your custom permissions
permissions: List of permissions of this Plugin - *models*: Your models, used for API export
""" """
self.blueprint = blueprint
self.permissions = permissions blueprint = None
self.version = pkg_resources.get_distribution(self.__module__.split(".")[0]).version """Optional `flask.blueprint` if the plugin uses custom routes"""
models = None
"""Optional module containing the SQLAlchemy models used by the plugin"""
@property
def version(self) -> str:
"""Version of the plugin, loaded from Distribution"""
return self.dist.version
@property
def dist(self):
"""Distribution of this plugin"""
return self.entry_point.dist
@property
def entry_point(self):
ep = tuple(entry_points(group="flaschengeist.plugins", name=self.name))
return ep[0]
def load(self):
"""__init__ like function that is called when the plugin is initially loaded"""
pass
def install(self): def install(self):
"""Installation routine """Installation routine
Is always called with Flask application context
Also called when updating the plugin, compare `version` and `installed_version`.
Is always called with Flask application context,
it is called after the plugin permissions are installed.
""" """
pass pass
def uninstall(self):
"""Uninstall routine
If the plugin has custom database tables, make sure to remove them.
This can be either done by downgrading the plugin *head* to the *base*.
Or use custom migrations for the uninstall and *stamp* some version.
Is always called with Flask application context.
"""
pass
def notify(self, user, text: str, data=None):
"""Create a new notification for an user
Args:
user: `flaschengeist.models.user.User` to notify
text: Visibile notification text
data: Optional data passed to the notificaton
Returns:
ID of the created `flaschengeist.models.notification.Notification`
Hint: use the data for frontend actions.
"""
from ..controller import pluginController
return pluginController.notify(self.id, user, text, data)
@property
def notifications(self) -> List["Notification"]:
"""Get all notifications for this plugin
Returns:
List of `flaschengeist.models.notification.Notification`
"""
from ..controller import pluginController
return pluginController.get_notifications(self.id)
def serialize(self): def serialize(self):
"""Serialize a plugin into a dict """Serialize a plugin into a dict
@ -52,35 +163,53 @@ class Plugin:
""" """
return {"version": self.version, "permissions": self.permissions} return {"version": self.version, "permissions": self.permissions}
def install_permissions(self, permissions: list[str]):
"""Helper for installing a list of strings as permissions
Args:
permissions: List of permissions to install
"""
cur_perm = set(x for x in self.permissions or [])
all_perm = set(permissions)
new_perms = all_perm - cur_perm
_perms = [Permission(name=x, plugin_=self) for x in new_perms]
# self.permissions = list(filter(lambda x: x.name in permissions, self.permissions and isinstance(self.permissions, list) or []))
self.permissions.extend(_perms)
class AuthPlugin(Plugin): class AuthPlugin(Plugin):
def login(self, user, pw): """Base class for all authentification plugins
See also `Plugin`
"""
def login(self, login_name, password) -> Union[bool, str]:
"""Login routine, MUST BE IMPLEMENTED! """Login routine, MUST BE IMPLEMENTED!
Args: Args:
user: User class containing at least the uid login_name: The name the user entered
pw: given password password: The password the user used to log in
Returns: Returns:
Must return False if not found or invalid credentials, True if success Must return False if not found or invalid credentials, otherwise the UID is returned
""" """
raise NotImplemented raise NotImplemented
def update_user(self, user): def update_user(self, user: "User"):
"""If backend is using external data, then update this user instance with external data """If backend is using external data, then update this user instance with external data
Args: Args:
user: User object user: User object
""" """
pass pass
def find_user(self, userid, mail=None): def user_exists(self, userid) -> bool:
"""Find an user by userid or mail """Check if user exists on this backend
Args: Args:
userid: Userid to search userid: Userid to search
mail: If set, mail to search
Returns: Returns:
None or User True or False
""" """
return None raise NotImplemented
def modify_user(self, user, password, new_password=None): def modify_user(self, user, password, new_password=None):
"""If backend is using (writeable) external data, then update the external database with the user provided. """If backend is using (writeable) external data, then update the external database with the user provided.
@ -91,11 +220,14 @@ class AuthPlugin(Plugin):
password: Password (some backends need the current password for changes) if None force edit (admin) password: Password (some backends need the current password for changes) if None force edit (admin)
new_password: If set a password change is requested new_password: If set a password change is requested
Raises: Raises:
NotImplemented: If backend does not support this feature (or no password change)
BadRequest: Logic error, e.g. password is wrong. BadRequest: Logic error, e.g. password is wrong.
Error: Other errors if backend went mad (are not handled and will result in a 500 error) Error: Other errors if backend went mad (are not handled and will result in a 500 error)
""" """
raise NotImplemented pass
def can_register(self):
"""Check if this backend allows to register new users"""
return False
def create_user(self, user, password): def create_user(self, user, password):
"""If backend is using (writeable) external data, then create a new user on the external database. """If backend is using (writeable) external data, then create a new user on the external database.
@ -105,7 +237,7 @@ class AuthPlugin(Plugin):
password: string password: string
""" """
raise MethodNotAllowed raise NotImplementedError
def delete_user(self, user): def delete_user(self, user):
"""If backend is using (writeable) external data, then delete the user from external database. """If backend is using (writeable) external data, then delete the user from external database.
@ -114,11 +246,25 @@ class AuthPlugin(Plugin):
user: User object user: User object
""" """
raise MethodNotAllowed raise NotImplementedError
def get_modified_time(self, user):
"""If backend is using external data, then return the timestamp of the last modification
Args:
user: User object
Returns:
Timestamp of last modification
"""
pass
def get_avatar(self, user) -> _Avatar: def get_avatar(self, user) -> _Avatar:
"""Retrieve avatar for given user (if supported by auth backend) """Retrieve avatar for given user (if supported by auth backend)
Default behavior is to use native Image objects,
so by default this function is never called, as the userController checks
native Image objects first.
Args: Args:
user: User to retrieve the avatar for user: User to retrieve the avatar for
Raises: Raises:
@ -126,13 +272,33 @@ class AuthPlugin(Plugin):
""" """
raise NotFound raise NotFound
def set_avatar(self, user, avatar: _Avatar): def set_avatar(self, user, file: FileStorage):
"""Set the avatar for given user (if supported by auth backend) """Set the avatar for given user (if supported by auth backend)
Default behavior is to use native Image objects stored on the Flaschengeist server
Args: Args:
user: User to set the avatar for user: User to set the avatar for
avatar: Avatar to set file: `werkzeug.datastructures.FileStorage` uploaded by the user
Raises:
MethodNotAllowed: If not supported by Backend
Any valid HTTP exception
"""
# By default save the image to the avatar,
# deleting would happen by unsetting it
from ..controller import imageController
user.avatar_ = imageController.upload_image(file)
def delete_avatar(self, user):
"""Delete the avatar for given user (if supported by auth backend)
Default behavior is to use the imageController and native Image objects.
Args:
user: Uset to delete the avatar for
Raises: Raises:
MethodNotAllowed: If not supported by Backend MethodNotAllowed: If not supported by Backend
""" """
raise MethodNotAllowed user.avatar_ = None

View File

@ -11,15 +11,12 @@ from flaschengeist.utils.HTTP import no_content, created
from flaschengeist.utils.decorators import login_required from flaschengeist.utils.decorators import login_required
from flaschengeist.controller import sessionController, userController from flaschengeist.controller import sessionController, userController
auth_bp = Blueprint("auth", __name__)
class AuthRoutePlugin(Plugin): class AuthRoutePlugin(Plugin):
def __init__(self, conf): blueprint = Blueprint("auth", __name__)
super().__init__(blueprint=auth_bp)
@auth_bp.route("/auth", methods=["POST"]) @AuthRoutePlugin.blueprint.route("/auth", methods=["POST"])
def login(): def login():
"""Login in an user and create a session """Login in an user and create a session
@ -39,20 +36,20 @@ def login():
except (KeyError, ValueError, TypeError): except (KeyError, ValueError, TypeError):
raise BadRequest("Missing parameter(s)") raise BadRequest("Missing parameter(s)")
logger.debug("search user {{ {} }} in database".format(userid)) logger.debug(f"search user {userid} in database")
user = userController.login_user(userid, password) user = userController.login_user(userid, password)
if not user: if not user:
raise Unauthorized raise Unauthorized
session = sessionController.create(user, user_agent=request.user_agent) session = sessionController.create(user, request_headers=request.headers)
logger.debug("token is {{ {} }}".format(session.token)) logger.debug(f"token is {session.token}")
logger.info("User {{ {} }} success login.".format(userid)) logger.info(f"User {userid} logged in.")
# Lets cleanup the DB # Lets cleanup the DB
sessionController.clear_expired() sessionController.clear_expired()
return created(session) return created(session)
@auth_bp.route("/auth", methods=["GET"]) @AuthRoutePlugin.blueprint.route("/auth", methods=["GET"])
@login_required() @login_required()
def get_sessions(current_session, **kwargs): def get_sessions(current_session, **kwargs):
"""Get all valid sessions of current user """Get all valid sessions of current user
@ -62,11 +59,11 @@ def get_sessions(current_session, **kwargs):
Returns: Returns:
A JSON array of `flaschengeist.models.session.Session` or HTTP error A JSON array of `flaschengeist.models.session.Session` or HTTP error
""" """
sessions = sessionController.get_users_sessions(current_session._user) sessions = sessionController.get_users_sessions(current_session.user_)
return jsonify(sessions) return jsonify(sessions)
@auth_bp.route("/auth/<token>", methods=["DELETE"]) @AuthRoutePlugin.blueprint.route("/auth/<token>", methods=["DELETE"])
@login_required() @login_required()
def delete_session(token, current_session, **kwargs): def delete_session(token, current_session, **kwargs):
"""Delete a session aka "logout" """Delete a session aka "logout"
@ -77,7 +74,7 @@ def delete_session(token, current_session, **kwargs):
200 Status (empty) or HTTP error 200 Status (empty) or HTTP error
""" """
logger.debug("Try to delete access token {{ {} }}".format(token)) logger.debug("Try to delete access token {{ {} }}".format(token))
session = sessionController.get_session(token, current_session._user) session = sessionController.get_session(token, current_session.user_)
if not session: if not session:
logger.debug("Token not found in database!") logger.debug("Token not found in database!")
# Return 403 error, so that users can not bruteforce tokens # Return 403 error, so that users can not bruteforce tokens
@ -88,7 +85,7 @@ def delete_session(token, current_session, **kwargs):
return "" return ""
@auth_bp.route("/auth/<token>", methods=["GET"]) @AuthRoutePlugin.blueprint.route("/auth/<token>", methods=["GET"])
@login_required() @login_required()
def get_session(token, current_session, **kwargs): def get_session(token, current_session, **kwargs):
"""Retrieve information about a session """Retrieve information about a session
@ -103,7 +100,7 @@ def get_session(token, current_session, **kwargs):
JSON encoded `flaschengeist.models.session.Session` or HTTP error JSON encoded `flaschengeist.models.session.Session` or HTTP error
""" """
logger.debug("get token {{ {} }}".format(token)) logger.debug("get token {{ {} }}".format(token))
session = sessionController.get_session(token, current_session._user) session = sessionController.get_session(token, current_session.user_)
if not session: if not session:
# Return 403 error, so that users can not bruteforce tokens # Return 403 error, so that users can not bruteforce tokens
# Valid tokens from other users and invalid tokens now are looking the same # Valid tokens from other users and invalid tokens now are looking the same
@ -111,7 +108,7 @@ def get_session(token, current_session, **kwargs):
return jsonify(session) return jsonify(session)
@auth_bp.route("/auth/<token>", methods=["PUT"]) @AuthRoutePlugin.blueprint.route("/auth/<token>", methods=["PUT"])
@login_required() @login_required()
def set_lifetime(token, current_session, **kwargs): def set_lifetime(token, current_session, **kwargs):
"""Set lifetime of a session """Set lifetime of a session
@ -127,7 +124,7 @@ def set_lifetime(token, current_session, **kwargs):
Returns: Returns:
HTTP-204 or HTTP error HTTP-204 or HTTP error
""" """
session = sessionController.get_session(token, current_session._user) session = sessionController.get_session(token, current_session.user_)
if not session: if not session:
# Return 403 error, so that users can not bruteforce tokens # Return 403 error, so that users can not bruteforce tokens
# Valid tokens from other users and invalid tokens now are looking the same # Valid tokens from other users and invalid tokens now are looking the same
@ -136,12 +133,12 @@ def set_lifetime(token, current_session, **kwargs):
lifetime = request.get_json()["value"] lifetime = request.get_json()["value"]
logger.debug(f"set lifetime >{lifetime}< to access token >{token}<") logger.debug(f"set lifetime >{lifetime}< to access token >{token}<")
sessionController.set_lifetime(session, lifetime) sessionController.set_lifetime(session, lifetime)
return jsonify(sessionController.get_session(token, current_session._user)) return jsonify(sessionController.get_session(token, current_session.user_))
except (KeyError, TypeError): except (KeyError, TypeError):
raise BadRequest raise BadRequest
@auth_bp.route("/auth/<token>/user", methods=["GET"]) @AuthRoutePlugin.blueprint.route("/auth/<token>/user", methods=["GET"])
@login_required() @login_required()
def get_assocd_user(token, current_session, **kwargs): def get_assocd_user(token, current_session, **kwargs):
"""Retrieve user owning a session """Retrieve user owning a session
@ -156,19 +153,19 @@ def get_assocd_user(token, current_session, **kwargs):
JSON encoded `flaschengeist.models.user.User` or HTTP error JSON encoded `flaschengeist.models.user.User` or HTTP error
""" """
logger.debug("get token {{ {} }}".format(token)) logger.debug("get token {{ {} }}".format(token))
session = sessionController.get_session(token, current_session._user) session = sessionController.get_session(token, current_session.user_)
if not session: if not session:
# Return 403 error, so that users can not bruteforce tokens # Return 403 error, so that users can not bruteforce tokens
# Valid tokens from other users and invalid tokens now are looking the same # Valid tokens from other users and invalid tokens now are looking the same
raise Forbidden raise Forbidden
return jsonify(session._user) return jsonify(session.user_)
@auth_bp.route("/auth/reset", methods=["POST"]) @AuthRoutePlugin.blueprint.route("/auth/reset", methods=["POST"])
def reset_password(): def reset_password():
data = request.get_json() data = request.get_json()
if "userid" in data: if "userid" in data:
user = userController.find_user(data["userid"]) user = userController.get_user(data["userid"])
if user: if user:
userController.request_reset(user) userController.request_reset(user)
elif "password" in data and "token" in data: elif "password" in data and "token" in data:

View File

@ -1,119 +1,143 @@
"""LDAP Authentication Provider Plugin""" """LDAP Authentication Provider Plugin"""
import io import os
import ssl import ssl
from typing import Optional from PIL import Image
from io import BytesIO
from flask_ldapconn import LDAPConn from flask_ldapconn import LDAPConn
from flask import current_app as app from flask import current_app as app
from ldap3.utils.hashed import hashed from ldap3 import SUBTREE, MODIFY_REPLACE, MODIFY_ADD, MODIFY_DELETE
from ldap3.core.exceptions import LDAPPasswordIsMandatoryError, LDAPBindError from ldap3.core.exceptions import LDAPPasswordIsMandatoryError, LDAPBindError
from ldap3 import SUBTREE, MODIFY_REPLACE, MODIFY_ADD, MODIFY_DELETE, HASHED_SALTED_MD5
from werkzeug.exceptions import BadRequest, InternalServerError, NotFound from werkzeug.exceptions import BadRequest, InternalServerError, NotFound
from werkzeug.datastructures import FileStorage
from datetime import datetime
from flaschengeist import logger from flaschengeist import logger
from flaschengeist.plugins import AuthPlugin, after_role_updated from flaschengeist.config import config
from flaschengeist.models.user import User, Role, _Avatar from flaschengeist.controller import userController
import flaschengeist.controller.userController as userController from flaschengeist.models import User, Role
from flaschengeist.models.user import _Avatar
from flaschengeist.plugins import AuthPlugin, before_role_updated
class AuthLDAP(AuthPlugin): class AuthLDAP(AuthPlugin):
def __init__(self, cfg): def load(self):
super().__init__() self.config = config.get("auth_ldap", None)
config = {"port": 389, "use_ssl": False} if self.config is None:
config.update(cfg) logger.error("auth_ldap was not configured in flaschengeist.toml", exc_info=True)
raise InternalServerError
app.config.update( app.config.update(
LDAP_SERVER=config["host"], LDAP_SERVER=self.config.get("host", "localhost"),
LDAP_PORT=config["port"], LDAP_PORT=self.config.get("port", 389),
LDAP_BINDDN=config["bind_dn"], LDAP_BINDDN=self.config.get("bind_dn", None),
LDAP_SECRET=self.config.get("secret", None),
LDAP_USE_SSL=self.config.get("use_ssl", False),
# That's not TLS, its dirty StartTLS on unencrypted LDAP
LDAP_USE_TLS=False, LDAP_USE_TLS=False,
LDAP_USE_SSL=config["use_ssl"], LDAP_TLS_VERSION=ssl.PROTOCOL_TLS,
LDAP_TLS_VERSION=ssl.PROTOCOL_TLSv1_2,
LDAP_REQUIRE_CERT=ssl.CERT_NONE,
FORCE_ATTRIBUTE_VALUE_AS_LIST=True, FORCE_ATTRIBUTE_VALUE_AS_LIST=True,
) )
if "SECRET" in config: if "ca_cert" in config:
app.config["LDAP_SECRET"] = (config["secret"],) app.config["LDAP_CA_CERTS_FILE"] = self.config["ca_cert"]
else:
# Default is CERT_REQUIRED
app.config["LDAP_REQUIRE_CERT"] = ssl.CERT_OPTIONAL
self.ldap = LDAPConn(app) self.ldap = LDAPConn(app)
self.dn = config["base_dn"] self.base_dn = self.config["base_dn"]
self.default_gid = config["default_gid"] self.search_dn = self.config.get("search_dn", "ou=people,{base_dn}").format(base_dn=self.base_dn)
self.group_dn = self.config.get("group_dn", "ou=group,{base_dn}").format(base_dn=self.base_dn)
self.password_hash = self.config.get("password_hash", "SSHA").upper()
self.object_classes = self.config.get("object_classes", ["inetOrgPerson"])
self.user_attributes: dict = self.config.get("user_attributes", {})
self.dn_template = self.config.get("dn_template")
# TODO: might not be set if modify is called # TODO: might not be set if modify is called
if "admin_dn" in config: self.root_dn = self.config.get("root_dn", None)
self.admin_dn = config["admin_dn"] self.root_secret = self.config.get("root_secret", None)
self.admin_secret = config["admin_secret"]
else:
self.admin_dn = None
@after_role_updated @before_role_updated
def _role_updated(role, new_name): def _role_updated(role, new_name):
logger.debug(f"LDAP: before_role_updated called with ({role}, {new_name})")
self.__modify_role(role, new_name) self.__modify_role(role, new_name)
def login(self, user, password): def login(self, login_name, password):
if not user: if not login_name:
return False return False
return self.ldap.authenticate(user.userid, password, "uid", self.dn) return login_name if self.ldap.authenticate(login_name, password, "uid", self.base_dn) else False
def find_user(self, userid, mail=None): def user_exists(self, userid) -> bool:
attr = self.__find(userid, mail) attr = self.__find(userid, None)
if attr: return attr is not None
user = User(userid=attr["uid"][0])
self.__update(user, attr)
return user
def update_user(self, user): def update_user(self, user):
attr = self.__find(user.userid) attr = self.__find(user.userid)
self.__update(user, attr) self.__update(user, attr)
def create_user(self, user, password): def can_register(self):
if self.admin_dn is None: return self.root_dn is not None
logger.error("admin_dn missing in ldap config!")
raise InternalServerError
def create_user(self, user, password):
try: try:
ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret) ldap_conn = self.ldap.connect(self.root_dn, self.root_secret)
self.ldap.connection.search( attributes = self.user_attributes.copy()
"ou=user,{}".format(self.dn), if "uidNumber" in attributes:
"(uidNumber=*)", self.ldap.connection.search(
SUBTREE, self.search_dn,
attributes=["uidNumber"], "(uidNumber=*)",
SUBTREE,
attributes=["uidNumber"],
)
resp = sorted(
self.ldap.response(),
key=lambda i: i["attributes"]["uidNumber"],
reverse=True,
)
attributes["uidNumber"] = resp[0]["attributes"]["uidNumber"] + 1 if resp else attributes["uidNumber"]
dn = self.dn_template.format(
user=user,
base_dn=self.base_dn,
) )
uid_number = ( if "default_gid" in attributes:
sorted(self.ldap.response(), key=lambda i: i["attributes"]["uidNumber"], reverse=True,)[0][ default_gid = attributes.pop("default_gid")
"attributes" attributes["gidNumber"] = default_gid
]["uidNumber"] if "homeDirectory" in attributes:
+ 1 attributes["homeDirectory"] = attributes.get("homeDirectory").format(
firstname=user.firstname,
lastname=user.lastname,
userid=user.userid,
mail=user.mail,
display_name=user.display_name,
)
attributes.update(
{
"sn": user.lastname,
"givenName": user.firstname,
"uid": user.userid,
"userPassword": self.__hash(password),
"mail": user.mail,
}
) )
dn = f"cn={user.firstname} {user.lastname},ou=user,{self.dn}" if user.display_name:
object_class = [ attributes.update({"displayName": user.display_name})
"inetOrgPerson", ldap_conn.add(dn, self.object_classes, attributes)
"posixAccount",
"person",
"organizationalPerson",
]
attributes = {
"sn": user.firstname,
"givenName": user.lastname,
"gidNumber": self.default_gid,
"homeDirectory": f"/home/{user.userid}",
"loginShell": "/bin/bash",
"uid": user.userid,
"userPassword": hashed(HASHED_SALTED_MD5, password),
"uidNumber": uid_number,
}
ldap_conn.add(dn, object_class, attributes)
self._set_roles(user) self._set_roles(user)
self.update_user(user)
except (LDAPPasswordIsMandatoryError, LDAPBindError): except (LDAPPasswordIsMandatoryError, LDAPBindError):
raise BadRequest raise BadRequest
def modify_user(self, user: User, password=None, new_password=None): def modify_user(self, user: User, password=None, new_password=None):
try: try:
dn = user.get_attribute("DN") dn = user.get_attribute("DN")
logger.debug(f"LDAP: modify_user for user {user.userid} with dn {dn}")
if password: if password:
logger.debug(f"LDAP: modify_user for user {user.userid} with password")
ldap_conn = self.ldap.connect(dn, password) ldap_conn = self.ldap.connect(dn, password)
else: else:
if self.admin_dn is None: logger.debug(f"LDAP: modify_user for user {user.userid} with root_dn")
logger.error("admin_dn missing in ldap config!") if self.root_dn is None:
logger.error("root_dn missing in ldap config!")
raise InternalServerError raise InternalServerError
ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret) ldap_conn = self.ldap.connect(self.root_dn, self.root_secret)
modifier = {} modifier = {}
for name, ldap_name in [ for name, ldap_name in [
("firstname", "givenName"), ("firstname", "givenName"),
@ -122,19 +146,34 @@ class AuthLDAP(AuthPlugin):
("display_name", "displayName"), ("display_name", "displayName"),
]: ]:
if hasattr(user, name): if hasattr(user, name):
modifier[ldap_name] = [(MODIFY_REPLACE, [getattr(user, name)])] attribute = getattr(user, name)
if attribute:
modifier[ldap_name] = [(MODIFY_REPLACE, [getattr(user, name)])]
if new_password: if new_password:
# TODO: Use secure hash! modifier["userPassword"] = [(MODIFY_REPLACE, [self.__hash(new_password)])]
salted_password = hashed(HASHED_SALTED_MD5, new_password) if "userPassword" in modifier:
modifier["userPassword"] = [(MODIFY_REPLACE, [salted_password])] logger.debug(f"LDAP: modify_user for user {user.userid} with password change (can't show >modifier<)")
else:
logger.debug(f"LDAP: modify_user for user {user.userid} with modifier {modifier}")
ldap_conn.modify(dn, modifier) ldap_conn.modify(dn, modifier)
self._set_roles(user) self._set_roles(user)
except (LDAPPasswordIsMandatoryError, LDAPBindError): except (LDAPPasswordIsMandatoryError, LDAPBindError):
raise BadRequest raise BadRequest
def get_modified_time(self, user):
self.ldap.connection.search(
self.search_dn,
"(uid={})".format(user.userid),
SUBTREE,
attributes=["modifyTimestamp"],
)
r = self.ldap.connection.response[0]["attributes"]
modified_time = r["modifyTimestamp"][0]
return datetime.strptime(modified_time, "%Y%m%d%H%M%SZ")
def get_avatar(self, user): def get_avatar(self, user):
self.ldap.connection.search( self.ldap.connection.search(
"ou=user,{}".format(self.dn), self.search_dn,
"(uid={})".format(user.userid), "(uid={})".format(user.userid),
SUBTREE, SUBTREE,
attributes=["jpegPhoto"], attributes=["jpegPhoto"],
@ -144,49 +183,48 @@ class AuthLDAP(AuthPlugin):
if "jpegPhoto" in r and len(r["jpegPhoto"]) > 0: if "jpegPhoto" in r and len(r["jpegPhoto"]) > 0:
avatar = _Avatar() avatar = _Avatar()
avatar.mimetype = "image/jpeg" avatar.mimetype = "image/jpeg"
avatar.binary.extend(r["jpegPhoto"][0]) avatar.binary = bytearray(r["jpegPhoto"][0])
return avatar return avatar
else: else:
raise NotFound raise NotFound
def set_avatar(self, user, avatar: _Avatar): def set_avatar(self, user: User, file: FileStorage):
if self.admin_dn is None: if self.root_dn is None:
logger.error("admin_dn missing in ldap config!") logger.error("root_dn missing in ldap config!")
raise InternalServerError raise InternalServerError
if avatar.mimetype != "image/jpeg": image_bytes = BytesIO()
# Try converting using Pillow (if installed) try:
try: # Make sure converted to RGB, e.g. png support RGBA but jpeg does not
from PIL import Image image = Image.open(file).convert("RGB")
image.save(image_bytes, format="JPEG")
image = Image.open(io.BytesIO(avatar.binary)) except IOError:
image_bytes = io.BytesIO() logger.debug(f"Could not convert avatar from '{file.mimetype}' to JPEG")
image.save(image_bytes, format="JPEG") raise BadRequest("Unsupported image format")
avatar.binary = image_bytes.getvalue()
avatar.mimetype = "image/jpeg"
except ImportError:
logger.debug("Pillow not installed for image conversion")
raise BadRequest("Unsupported image format")
except IOError:
logger.debug(f"Could not convert avatar from '{avatar.mimetype}' to JPEG")
raise BadRequest("Unsupported image format")
dn = user.get_attribute("DN") dn = user.get_attribute("DN")
ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret) ldap_conn = self.ldap.connect(self.root_dn, self.root_secret)
ldap_conn.modify(dn, {"jpegPhoto": [(MODIFY_REPLACE, [avatar.binary])]}) ldap_conn.modify(dn, {"jpegPhoto": [(MODIFY_REPLACE, [image_bytes.getvalue()])]})
def delete_avatar(self, user):
if self.root_dn is None:
logger.error("root_dn missing in ldap config!")
dn = user.get_attribute("DN")
ldap_conn = self.ldap.connect(self.root_dn, self.root_secret)
ldap_conn.modify(dn, {"jpegPhoto": [(MODIFY_REPLACE, [])]})
def __find(self, userid, mail=None): def __find(self, userid, mail=None):
"""Find attributes of an user by uid or mail in LDAP""" """Find attributes of an user by uid or mail in LDAP"""
con = self.ldap.connection con = self.ldap.connection
if not con: if not con:
con = self.ldap.connect(self.admin_dn, self.admin_secret) con = self.ldap.connect(self.root_dn, self.root_secret)
con.search( con.search(
f"ou=user,{self.dn}", self.search_dn,
f"(| (uid={userid})(mail={mail}))" if mail else f"(uid={userid})", f"(| (uid={userid})(mail={mail}))" if mail else f"(uid={userid})",
SUBTREE, SUBTREE,
attributes=["uid", "givenName", "sn", "mail"], attributes=["uid", "givenName", "sn", "mail"],
) )
return con.response[0]["attributes"] return con.response[0]["attributes"] if len(con.response) > 0 else None
def __update(self, user, attr): def __update(self, user, attr):
"""Update an User object with LDAP attributes""" """Update an User object with LDAP attributes"""
@ -203,14 +241,14 @@ class AuthLDAP(AuthPlugin):
def __modify_role( def __modify_role(
self, self,
role: Role, role: Role,
new_name: Optional[str], new_name,
): ):
if self.admin_dn is None: if self.root_dn is None:
logger.error("admin_dn missing in ldap config!") logger.error("root_dn missing in ldap config!")
raise InternalServerError raise InternalServerError
try: try:
ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret) ldap_conn = self.ldap.connect(self.root_dn, self.root_secret)
ldap_conn.search(f"ou=group,{self.dn}", f"(cn={role.name})", SUBTREE, attributes=["cn"]) ldap_conn.search(self.group_dn, f"(cn={role.name})", SUBTREE, attributes=["cn"])
if len(ldap_conn.response) > 0: if len(ldap_conn.response) > 0:
dn = ldap_conn.response[0]["dn"] dn = ldap_conn.response[0]["dn"]
if new_name: if new_name:
@ -218,13 +256,33 @@ class AuthLDAP(AuthPlugin):
else: else:
ldap_conn.delete(dn) ldap_conn.delete(dn)
except (LDAPPasswordIsMandatoryError, LDAPBindError): except LDAPPasswordIsMandatoryError:
raise BadRequest raise BadRequest
except LDAPBindError:
logger.debug(f"Could not bind to LDAP server", exc_info=True)
raise InternalServerError
def __hash(self, password):
if self.password_hash == "ARGON2":
from argon2 import PasswordHasher
return f"{{ARGON2}}{PasswordHasher().hash(password)}"
else:
from hashlib import pbkdf2_hmac, sha1
import base64
salt = os.urandom(16)
if self.password_hash == "PBKDF2":
rounds = 200000
password_hash = base64.b64encode(pbkdf2_hmac("sha512", password.encode("utf-8"), salt, rounds)).decode()
return f"{{PBKDF2-SHA512}}{rounds}${base64.b64encode(salt).decode()}${password_hash}"
else:
return f"{{SSHA}}{base64.b64encode(sha1(password.encode() + salt).digest() + salt).decode()}"
def _get_groups(self, uid): def _get_groups(self, uid):
groups = [] groups = []
self.ldap.connection.search( self.ldap.connection.search(
"ou=group,{}".format(self.dn), self.group_dn,
"(memberUID={})".format(uid), "(memberUID={})".format(uid),
SUBTREE, SUBTREE,
attributes=["cn"], attributes=["cn"],
@ -236,7 +294,7 @@ class AuthLDAP(AuthPlugin):
def _get_all_roles(self): def _get_all_roles(self):
self.ldap.connection.search( self.ldap.connection.search(
f"ou=group,{self.dn}", self.group_dn,
"(cn=*)", "(cn=*)",
SUBTREE, SUBTREE,
attributes=["cn", "gidNumber", "memberUid"], attributes=["cn", "gidNumber", "memberUid"],
@ -245,8 +303,7 @@ class AuthLDAP(AuthPlugin):
def _set_roles(self, user: User): def _set_roles(self, user: User):
try: try:
ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret) ldap_conn = self.ldap.connect(self.root_dn, self.root_secret)
ldap_roles = self._get_all_roles() ldap_roles = self._get_all_roles()
gid_numbers = sorted(ldap_roles, key=lambda i: i["attributes"]["gidNumber"], reverse=True) gid_numbers = sorted(ldap_roles, key=lambda i: i["attributes"]["gidNumber"], reverse=True)
@ -255,7 +312,7 @@ class AuthLDAP(AuthPlugin):
for user_role in user.roles: for user_role in user.roles:
if user_role not in [role["attributes"]["cn"][0] for role in ldap_roles]: if user_role not in [role["attributes"]["cn"][0] for role in ldap_roles]:
ldap_conn.add( ldap_conn.add(
f"cn={user_role},ou=group,{self.dn}", f"cn={user_role},{self.group_dn}",
["posixGroup"], ["posixGroup"],
attributes={"gidNumber": gid_number}, attributes={"gidNumber": gid_number},
) )
@ -271,3 +328,5 @@ class AuthLDAP(AuthPlugin):
except (LDAPPasswordIsMandatoryError, LDAPBindError): except (LDAPPasswordIsMandatoryError, LDAPBindError):
raise BadRequest raise BadRequest
except IndexError as e:
logger.error("Roles in LDAP", exc_info=True)

View File

@ -0,0 +1,47 @@
import click
from flask import current_app
from flask.cli import with_appcontext
from werkzeug.exceptions import NotFound
@click.command(no_args_is_help=True)
@click.option("--sync", is_flag=True, default=False, help="Synchronize users from LDAP -> database")
@click.option("--sync-ldap", is_flag=True, default=False, help="Synchronize users from database -> LDAP")
@with_appcontext
@click.pass_context
def ldap(ctx, sync, sync_ldap):
"""Tools for the LDAP authentification"""
from flaschengeist.controller import userController
from flaschengeist.plugins.auth_ldap import AuthLDAP
if sync:
click.echo("Synchronizing users from LDAP -> database")
from ldap3 import SUBTREE
from flaschengeist.models import User
from flaschengeist.database import db
auth_ldap: AuthLDAP = current_app.config.get("FG_PLUGINS").get("auth_ldap")
if auth_ldap is None or not isinstance(auth_ldap, AuthLDAP):
ctx.fail("auth_ldap plugin not found or not enabled!")
conn = auth_ldap.ldap.connection
if not conn:
conn = auth_ldap.ldap.connect(auth_ldap.root_dn, auth_ldap.root_secret)
conn.search(auth_ldap.search_dn, "(uid=*)", SUBTREE, attributes=["uid", "givenName", "sn", "mail"])
ldap_users_response = conn.response
for ldap_user in ldap_users_response:
uid = ldap_user["attributes"]["uid"][0]
try:
user = userController.get_user(uid)
except NotFound:
user = User(userid=uid)
db.session.add(user)
userController.update_user(user, auth_ldap)
if sync_ldap:
click.echo("Synchronizing users from database -> LDAP")
auth_ldap: AuthLDAP = current_app.config.get("FG_PLUGINS").get("auth_ldap")
if auth_ldap is None or not isinstance(auth_ldap, AuthLDAP):
ctx.fail("auth_ldap plugin not found or not enabled!")
users = userController.get_users()
for user in users:
userController.update_user(user, auth_ldap)

View File

@ -6,16 +6,26 @@ Flaschengeist database (as User attribute)
import os import os
import hashlib import hashlib
import binascii import binascii
from werkzeug.exceptions import BadRequest, NotFound from werkzeug.exceptions import BadRequest
from flaschengeist.plugins import AuthPlugin from flaschengeist.plugins import AuthPlugin
from flaschengeist.models.user import User from flaschengeist.models import User, Role, Permission
from flaschengeist.database import db
from flaschengeist import logger
class AuthPlain(AuthPlugin): class AuthPlain(AuthPlugin):
def login(self, user: User, password: str): def can_register(self):
if user.has_attribute("password"): return True
return AuthPlain._verify_password(user.get_attribute("password"), password)
def login(self, login_name, password):
users: list[User] = (
User.query.filter((User.userid == login_name) | (User.mail == login_name))
.filter(User._attributes.any(name="password"))
.all()
)
for user in users:
if AuthPlain._verify_password(user.get_attribute("password"), password):
return user.userid
return False return False
def modify_user(self, user, password, new_password=None): def modify_user(self, user, password, new_password=None):
@ -24,6 +34,12 @@ class AuthPlain(AuthPlugin):
if new_password: if new_password:
user.set_attribute("password", AuthPlain._hash_password(new_password)) user.set_attribute("password", AuthPlain._hash_password(new_password))
def user_exists(self, userid) -> bool:
return (
db.session.query(User.id_).filter(User.userid == userid, User._attributes.any(name="password")).first()
is not None
)
def create_user(self, user, password): def create_user(self, user, password):
if not user.userid: if not user.userid:
raise BadRequest("userid is missing for new user") raise BadRequest("userid is missing for new user")
@ -33,14 +49,6 @@ class AuthPlain(AuthPlugin):
def delete_user(self, user): def delete_user(self, user):
pass pass
def get_avatar(self, user):
if not user.has_attribute("avatar"):
raise NotFound
return user.get_attribute("avatar")
def set_avatar(self, user, avatar):
user.set_attribute("avatar", avatar)
@staticmethod @staticmethod
def _hash_password(password): def _hash_password(password):
salt = hashlib.sha256(os.urandom(60)).hexdigest().encode("ascii") salt = hashlib.sha256(os.urandom(60)).hexdigest().encode("ascii")
@ -49,7 +57,7 @@ class AuthPlain(AuthPlugin):
return (salt + pass_hash).decode("ascii") return (salt + pass_hash).decode("ascii")
@staticmethod @staticmethod
def _verify_password(stored_password, provided_password): def _verify_password(stored_password: str, provided_password: str):
salt = stored_password[:64] salt = stored_password[:64]
stored_password = stored_password[64:] stored_password = stored_password[64:]
pass_hash = hashlib.pbkdf2_hmac("sha3-512", provided_password.encode("utf-8"), salt.encode("ascii"), 100000) pass_hash = hashlib.pbkdf2_hmac("sha3-512", provided_password.encode("utf-8"), salt.encode("ascii"), 100000)

View File

@ -3,308 +3,87 @@
Extends users plugin with balance functions Extends users plugin with balance functions
""" """
from datetime import datetime, timezone from flask import current_app
from werkzeug.exceptions import NotFound
from flaschengeist.utils.HTTP import no_content from werkzeug.local import LocalProxy
from flask import Blueprint, request, jsonify
from werkzeug.exceptions import Forbidden, BadRequest
from flaschengeist import logger from flaschengeist import logger
from flaschengeist.utils import HTTP from flaschengeist.config import config
from flaschengeist.models.session import Session from flaschengeist.plugins import Plugin, plugins_loaded, before_update_user
from flaschengeist.utils.datetime import from_iso_format from flaschengeist.plugins.scheduler import add_scheduled
from flaschengeist.utils.decorators import login_required
from flaschengeist.controller import userController
from flaschengeist.plugins import Plugin, before_update_user
from . import balance_controller, permissions, models from . import permissions, models
balance_bp = Blueprint("balance", __name__)
def service_debit():
from flaschengeist.database import db
from flaschengeist.models import UtcDateTime
from flaschengeist.models.user import User
from flaschengeist.controller import roleController
from flaschengeist_events.models import Service, Job
role = BalancePlugin.plugin.get_setting("service_role", default=None)
if role is None:
try:
role = roleController.get("__has_service")
except NotFound:
role = roleController.create_role("__has_service", [permissions.DEBIT])
BalancePlugin.plugin.set_setting("service_role", role.id)
else:
role = roleController.get(role)
active_services = (
User.query.join(Service, User.id_ == Service._user_id)
.join(Job, Service.job_)
.filter(Job.start <= UtcDateTime.current_utc(), Job.end.is_(None) | (Job.end >= UtcDateTime.current_utc()))
.distinct(User.id_)
.all()
)
previous_services = BalancePlugin.plugin.get_setting("service_debit", default=[])
logger.debug(f"Found {len(active_services)} users doing their service.")
if len(previous_services) > 0:
previous_services = User.query.filter(User.userid.in_(previous_services)).all()
# Remove not active users
for user in [u for u in previous_services if u not in active_services]:
user.roles_ = [r for r in user.roles_ if r.id != role.id]
# Add active
for user in [u for u in active_services if u not in previous_services]:
if role not in user.roles_:
user.roles_.append(role)
db.session.commit()
BalancePlugin.plugin.set_setting("service_debit", [u.userid for u in active_services])
class BalancePlugin(Plugin): class BalancePlugin(Plugin):
# id = "dev.flaschengeist.balance"
models = models models = models
def __init__(self, config): def install(self):
super().__init__(blueprint=balance_bp, permissions=permissions.permissions) self.install_permissions(permissions.permissions)
def load(self):
from .routes import blueprint
self.blueprint = blueprint
@plugins_loaded
def post_loaded(*args, **kwargs):
if config.get("allow_service_debit", False) and "events" in current_app.config["FG_PLUGINS"]:
add_scheduled(f"{id}.service_debit", service_debit, minutes=1)
@before_update_user @before_update_user
def set_default_limit(user): def set_default_limit(user, *args):
if "limit" in config: from . import balance_controller
limit = config["limit"]
try:
limit = self.get_setting("limit")
logger.debug("Setting default limit of {} to user {}".format(limit, user.userid)) logger.debug("Setting default limit of {} to user {}".format(limit, user.userid))
balance_controller.set_limit(user, limit, override=False) balance_controller.set_limit(user, limit, override=False)
except KeyError:
pass
def install(self): @staticmethod
from flaschengeist.database import db def getPlugin() -> LocalProxy["BalancePlugin"]:
return LocalProxy(lambda: current_app.config["FG_PLUGINS"]["balance"])
db.create_all()
def str2bool(string: str):
if string.lower() in ["true", "yes", "1"]:
return True
elif string.lower() in ["false", "no", "0"]:
return False
raise ValueError
@balance_bp.route("/users/<userid>/balance/shortcuts", methods=["GET", "PUT"])
@login_required()
def get_shortcuts(userid, current_session: Session):
"""Get balance shortcuts of an user
Route: ``/users/<userid>/balance/shortcuts`` | Method: ``GET`` or ``PUT``
POST-data: On ``PUT`` json encoded array of floats
Args:
userid: Userid identifying the user
current_session: Session sent with Authorization Header
Returns:
GET: JSON object containing the shortcuts as float array or HTTP error
PUT: HTTP-created or HTTP error
"""
if userid != current_session._user.userid:
raise Forbidden
user = userController.get_user(userid)
if request.method == "GET":
return jsonify(user.get_attribute("balance_shortcuts", []))
else:
data = request.get_json()
if not isinstance(data, list) or not all(isinstance(n, (int, float)) for n in data):
raise BadRequest
data.sort(reverse=True)
user.set_attribute("balance_shortcuts", data)
userController.persist()
return no_content()
@balance_bp.route("/users/<userid>/balance/limit", methods=["GET"])
@login_required()
def get_limit(userid, current_session: Session):
"""Get limit of an user
Route: ``/users/<userid>/balance/limit`` | Method: ``GET``
Args:
userid: Userid identifying the user
current_session: Session sent with Authorization Header
Returns:
JSON object containing the limit (or Null if no limit set) or HTTP error
"""
user = userController.get_user(userid)
if (user != current_session._user and not current_session._user.has_permission(permissions.SET_LIMIT)) or (
user == current_session._user and not user.has_permission(permissions.SHOW)
):
raise Forbidden
return {"limit": balance_controller.get_limit(user)}
@balance_bp.route("/users/<userid>/balance/limit", methods=["PUT"])
@login_required(permissions.SET_LIMIT)
def set_limit(userid, current_session: Session):
"""Set the limit of an user
Route: ``/users/<userid>/balance/limit`` | Method: ``PUT``
POST-data: ``{limit: float}``
Args:
userid: Userid identifying the user
current_session: Session sent with Authorization Header
Returns:
HTTP-200 or HTTP error
"""
user = userController.get_user(userid)
data = request.get_json()
try:
limit = data["limit"]
except (TypeError, KeyError):
raise BadRequest
balance_controller.set_limit(user, limit)
return HTTP.no_content()
@balance_bp.route("/users/<userid>/balance", methods=["GET"])
@login_required(permission=permissions.SHOW)
def get_balance(userid, current_session: Session):
"""Get balance of user, optionally filtered
Route: ``/users/<userid>/balance`` | Method: ``GET``
GET-parameters: ```{from?: string, to?: string}```
Args:
userid: Userid of user to get balance from
current_session: Session sent with Authorization Header
Returns:
JSON object containing credit, debit and balance or HTTP error
"""
if userid != current_session._user.userid and not current_session._user.has_permission(permissions.SHOW_OTHER):
raise Forbidden
# Might raise NotFound
user = userController.get_user(userid)
start = request.args.get("from")
if start:
start = from_iso_format(start)
else:
start = datetime.fromtimestamp(0, tz=timezone.utc)
end = request.args.get("to")
if end:
end = from_iso_format(end)
else:
end = datetime.now(tz=timezone.utc)
balance = balance_controller.get_balance(user, start, end)
return {"credit": balance[0], "debit": balance[1], "balance": balance[2]}
@balance_bp.route("/users/<userid>/balance/transactions", methods=["GET"])
@login_required(permission=permissions.SHOW)
def get_transactions(userid, current_session: Session):
"""Get transactions of user, optionally filtered
Returns also count of transactions if limit is set (e.g. just count with limit = 0)
Route: ``/users/<userid>/balance/transactions`` | Method: ``GET``
GET-parameters: ```{from?: string, to?: string, limit?: int, offset?: int}```
Args:
userid: Userid of user to get transactions from
current_session: Session sent with Authorization Header
Returns:
JSON Object {transactions: Transaction[], count?: number} or HTTP error
"""
if userid != current_session._user.userid and not current_session._user.has_permission(permissions.SHOW_OTHER):
raise Forbidden
# Might raise NotFound
user = userController.get_user(userid)
start = request.args.get("from")
if start:
start = from_iso_format(start)
end = request.args.get("to")
if end:
end = from_iso_format(end)
show_reversals = request.args.get("showReversals", False)
show_cancelled = request.args.get("showCancelled", True)
limit = request.args.get("limit")
offset = request.args.get("offset")
try:
if limit is not None:
limit = int(limit)
if offset is not None:
offset = int(offset)
if not isinstance(show_reversals, bool):
show_reversals = str2bool(show_reversals)
if not isinstance(show_cancelled, bool):
show_cancelled = str2bool(show_cancelled)
except ValueError:
raise BadRequest
transactions, count = balance_controller.get_transactions(
user, start, end, limit, offset, show_reversal=show_reversals, show_cancelled=show_cancelled
)
return {"transactions": transactions, "count": count}
@balance_bp.route("/users/<userid>/balance", methods=["PUT"])
@login_required()
def change_balance(userid, current_session: Session):
"""Change balance of an user
If ``sender`` is preset in POST-data, the action is handled as a transfer from ``sender`` to user.
Route: ``/users/<userid>/balance`` | Method: ``PUT``
POST-data: ``{amount: float, sender: string}``
Args:
userid: userid identifying user to change balance
current_session: Session sent with Authorization Header
Returns:
JSON encoded transaction (201) or HTTP error
"""
data = request.get_json()
try:
amount = data["amount"]
except (TypeError, KeyError):
raise BadRequest
sender = data.get("sender", None)
user = userController.get_user(userid)
if sender:
sender = userController.get_user(sender)
if sender == user:
raise BadRequest
if (sender == current_session._user and sender.has_permission(permissions.SEND)) or (
sender != current_session._user and current_session._user.has_permission(permissions.SEND_OTHER)
):
return HTTP.created(balance_controller.send(sender, user, amount, current_session._user))
elif (
amount < 0
and (
(user == current_session._user and user.has_permission(permissions.DEBIT_OWN))
or current_session._user.has_permission(permissions.DEBIT)
)
) or (amount > 0 and current_session._user.has_permission(permissions.CREDIT)):
return HTTP.created(balance_controller.change_balance(user, data["amount"], current_session._user))
raise Forbidden
@balance_bp.route("/balance/<int:transaction_id>", methods=["DELETE"])
@login_required()
def reverse_transaction(transaction_id, current_session: Session):
"""Reverse a transaction
Route: ``/balance/<int:transaction_id>`` | Method: ``DELETE``
Args:
transaction_id: Identifier of the transaction
current_session: Session sent with Authorization Header
Returns:
JSON encoded reversal (transaction) (201) or HTTP error
"""
transaction = balance_controller.get_transaction(transaction_id)
if current_session._user.has_permission(permissions.REVERSAL) or (
transaction.sender_ == current_session._user
and (datetime.now(tz=timezone.utc) - transaction.time).total_seconds() < 10
):
reversal = balance_controller.reverse_transaction(transaction, current_session._user)
return HTTP.created(reversal)
raise Forbidden
@balance_bp.route("/balance", methods=["GET"])
@login_required(permission=permissions.SHOW_OTHER)
def get_balances(current_session: Session):
"""Get all balances
Route: ``/balance`` | Method: ``GET``
Args:
current_session: Session sent with Authorization Header
Returns:
JSON Array containing credit, debit and userid for each user or HTTP error
"""
balances = balance_controller.get_balances()
return jsonify([{"userid": u, "credit": v[0], "debit": v[1]} for u, v in balances.items()])

View File

@ -2,20 +2,29 @@
# Haben -> Zugang aufs Konto # Haben -> Zugang aufs Konto
# English: Debit -> from account # English: Debit -> from account
# Credit -> to account # Credit -> to account
from enum import IntEnum
from sqlalchemy import func from sqlalchemy import func, case, and_, or_
from sqlalchemy.ext.hybrid import hybrid_property
from datetime import datetime from datetime import datetime
from werkzeug.exceptions import BadRequest, NotFound, Conflict from werkzeug.exceptions import BadRequest, NotFound, Conflict
from flaschengeist.database import db from flaschengeist.database import db
from flaschengeist.models.user import User from flaschengeist.models.user import User, _UserAttribute
from flaschengeist.app import logger
from .models import Transaction from .models import Transaction
from . import permissions from . import permissions, BalancePlugin
__attribute_limit = "balance_limit" __attribute_limit = "balance_limit"
class NotifyType(IntEnum):
SEND_TO = 0x01
SEND_FROM = 0x02
ADD_FROM = 0x03
SUB_FROM = 0x04
def set_limit(user: User, limit: float, override=True): def set_limit(user: User, limit: float, override=True):
if override or not user.has_attribute(__attribute_limit): if override or not user.has_attribute(__attribute_limit):
user.set_attribute(__attribute_limit, limit) user.set_attribute(__attribute_limit, limit)
@ -27,7 +36,7 @@ def get_limit(user: User) -> float:
def get_balance(user, start: datetime = None, end: datetime = None): def get_balance(user, start: datetime = None, end: datetime = None):
query = db.session.query(func.sum(Transaction.amount)) query = db.session.query(func.sum(Transaction._amount))
if start: if start:
query = query.filter(start <= Transaction.time) query = query.filter(start <= Transaction.time)
if end: if end:
@ -38,27 +47,150 @@ def get_balance(user, start: datetime = None, end: datetime = None):
return credit, debit, credit - debit return credit, debit, credit - debit
def get_balances(start: datetime = None, end: datetime = None): def get_balances(
debit = db.session.query(Transaction.sender_id, func.sum(Transaction.amount)).filter(Transaction.sender_ != None) start: datetime = None,
credit = db.session.query(Transaction.receiver_id, func.sum(Transaction.amount)).filter( end: datetime = None,
Transaction.receiver_ != None limit=None,
offset=None,
descending=None,
sortBy=None,
_filter=None,
):
logger.debug(
f"get_balances(start={start}, end={end}, limit={limit}, offset={offset}, descending={descending}, sortBy={sortBy}, _filter={_filter})"
) )
if start:
debit = debit.filter(start <= Transaction.time)
credit = credit.filter(start <= Transaction.time)
if end:
debit = debit.filter(Transaction.time <= end)
credit = credit.filter(Transaction.time <= end)
debit = debit.group_by(Transaction._sender_id).all() class _User(User):
credit = credit.group_by(Transaction._receiver_id).all() _debit = db.relationship(Transaction, back_populates="sender_", foreign_keys=[Transaction._sender_id])
_credit = db.relationship(
Transaction,
back_populates="receiver_",
foreign_keys=[Transaction._receiver_id],
)
@hybrid_property
def debit(self):
return sum([cred.amount for cred in self._debit])
@debit.expression
def debit(cls):
a = (
db.select(func.sum(Transaction._amount))
.where(cls.id_ == Transaction._sender_id, Transaction._amount)
.scalar_subquery()
)
return case([(a, a)], else_=0)
@hybrid_property
def credit(self):
return sum([cred.amount for cred in self._credit])
@credit.expression
def credit(cls):
b = (
db.select(func.sum(Transaction._amount))
.where(cls.id_ == Transaction._receiver_id, Transaction._amount)
.scalar_subquery()
)
return case([(b, b)], else_=0)
@hybrid_property
def limit(self):
return self.get_attribute("balance_limit", None)
@limit.expression
def limit(cls):
return (
db.select(_UserAttribute.value)
.where(
and_(
cls.id_ == _UserAttribute.user,
_UserAttribute.name == "balance_limit",
)
)
.scalar_subquery()
)
def get_debit(self, start: datetime = None, end: datetime = None):
if start and end:
return sum([deb.amount for deb in self._debit if start <= deb.time and deb.time <= end])
if start:
return sum([deb.amount for deb in self._dedit if start <= deb.time])
if end:
return sum([deb.amount for deb in self._dedit if deb.time <= end])
return self.debit
def get_credit(self, start: datetime = None, end: datetime = None):
if start and end:
return sum([cred.amount for cred in self._credit if start <= cred.time and cred.time <= end])
if start:
return sum([cred.amount for cred in self._credit if start <= cred.time])
if end:
return sum([cred.amount for cred in self._credit if cred.time <= end])
return self.credit
query = _User.query
if start:
q1 = query.join(_User._credit).filter(start <= Transaction.time)
q2 = query.join(_User._debit).filter(start <= Transaction.time)
query = q1.union(q2)
if end:
q1 = query.join(_User._credit).filter(Transaction.time <= end)
q2 = query.join(_User._debit).filter(Transaction.time <= end)
query = q1.union(q2)
if _filter:
query = query.filter(
or_(
_User.firstname.ilike(f"%{_filter.lower()}%"),
_User.lastname.ilike(f"%{_filter.lower()}%"),
)
)
if sortBy == "balance":
if descending:
query = query.order_by(
(_User.credit - _User.debit).desc(),
_User.lastname.asc(),
_User.firstname.asc(),
)
else:
query = query.order_by(
(_User.credit - _User.debit).asc(),
_User.lastname.asc(),
_User.firstname.asc(),
)
elif sortBy == "limit":
if descending:
query = query.order_by(_User.limit.desc(), User.lastname.asc(), User.firstname.asc())
else:
query = query.order_by(_User.limit.asc(), User.lastname.asc(), User.firstname.asc())
elif sortBy == "firstname":
if descending:
query = query.order_by(User.firstname.desc(), User.lastname.desc())
else:
query = query.order_by(User.firstname.asc(), User.lastname.asc())
elif sortBy == "lastname":
if descending:
query = query.order_by(User.lastname.desc(), User.firstname.desc())
else:
query = query.order_by(User.lastname.asc(), User.firstname.asc())
count = None
if limit:
count = query.count()
query = query.limit(limit)
if offset:
query = query.offset(offset)
users = query
all = {} all = {}
for uid, cred in credit:
all[uid] = [cred, 0] for user in users:
for uid, deb in debit: all[user.userid] = [user.get_credit(start, end), 0]
all.setdefault(uid, [0, 0]) all[user.userid][1] = user.get_debit(start, end)
all[uid][1] = deb
return all return all, count
def send(sender: User, receiver, amount: float, author: User): def send(sender: User, receiver, amount: float, author: User):
@ -74,6 +206,7 @@ def send(sender: User, receiver, amount: float, author: User):
Raises: Raises:
BadRequest if amount <= 0 BadRequest if amount <= 0
""" """
logger.debug(f"send(sender={sender}, receiver={receiver}, amount={amount}, author={author})")
if amount <= 0: if amount <= 0:
raise BadRequest raise BadRequest
@ -86,6 +219,49 @@ def send(sender: User, receiver, amount: float, author: User):
transaction = Transaction(sender_=sender, receiver_=receiver, amount=amount, author_=author) transaction = Transaction(sender_=sender, receiver_=receiver, amount=amount, author_=author)
db.session.add(transaction) db.session.add(transaction)
db.session.commit() db.session.commit()
if sender is not None and sender.id_ != author.id_:
if receiver is not None:
BalancePlugin.getPlugin().notify(
sender,
"Neue Transaktion",
{
"type": NotifyType.SEND_FROM,
"receiver_id": receiver.userid,
"author_id": author.userid,
"amount": amount,
},
)
else:
BalancePlugin.getPlugin().notify(
sender,
"Neue Transaktion",
{
"type": NotifyType.SUB_FROM,
"author_id": author.userid,
"amount": amount,
},
)
if receiver is not None and receiver.id_ != author.id_:
if sender is not None:
BalancePlugin.getPlugin().notify(
receiver,
"Neue Transaktion",
{
"type": NotifyType.SEND_TO,
"sender_id": sender.userid,
"amount": amount,
},
)
else:
BalancePlugin.getPlugin().notify(
receiver,
"Neue Transaktion",
{
"type": NotifyType.ADD_FROM,
"author_id": author.userid,
"amount": amount,
},
)
return transaction return transaction
@ -109,7 +285,16 @@ def get_transaction(transaction_id) -> Transaction:
return transaction return transaction
def get_transactions(user, start=None, end=None, limit=None, offset=None, show_reversal=False, show_cancelled=True): def get_transactions(
user,
start=None,
end=None,
limit=None,
offset=None,
show_reversal=False,
show_cancelled=True,
descending=False,
):
count = None count = None
query = Transaction.query.filter((Transaction.sender_ == user) | (Transaction.receiver_ == user)) query = Transaction.query.filter((Transaction.sender_ == user) | (Transaction.receiver_ == user))
if start: if start:
@ -121,7 +306,10 @@ def get_transactions(user, start=None, end=None, limit=None, offset=None, show_r
query = query.filter(Transaction.original_ == None) query = query.filter(Transaction.original_ == None)
if not show_cancelled: if not show_cancelled:
query = query.filter(Transaction.reversal_id.is_(None)) query = query.filter(Transaction.reversal_id.is_(None))
query = query.order_by(Transaction.time) if descending:
query = query.order_by(Transaction.time.desc())
else:
query = query.order_by(Transaction.time)
if limit is not None: if limit is not None:
count = query.count() count = query.count()
query = query.limit(limit) query = query.limit(limit)

View File

@ -0,0 +1,47 @@
"""balance: initial
Revision ID: 98f2733bbe45
Revises:
Create Date: 2022-02-23 14:41:03.089145
"""
from alembic import op
import sqlalchemy as sa
import flaschengeist
# revision identifiers, used by Alembic.
revision = "98f2733bbe45"
down_revision = None
branch_labels = ("balance",)
depends_on = "flaschengeist"
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"balance_transaction",
sa.Column("receiver_id", flaschengeist.models.Serial(), nullable=True),
sa.Column("sender_id", flaschengeist.models.Serial(), nullable=True),
sa.Column("author_id", flaschengeist.models.Serial(), nullable=False),
sa.Column("id", flaschengeist.models.Serial(), nullable=False),
sa.Column("time", flaschengeist.models.UtcDateTime(), nullable=False),
sa.Column("amount", sa.Numeric(precision=5, scale=2, asdecimal=False), nullable=False),
sa.Column("reversal_id", flaschengeist.models.Serial(), nullable=True),
sa.ForeignKeyConstraint(["author_id"], ["user.id"], name=op.f("fk_balance_transaction_author_id_user")),
sa.ForeignKeyConstraint(["receiver_id"], ["user.id"], name=op.f("fk_balance_transaction_receiver_id_user")),
sa.ForeignKeyConstraint(
["reversal_id"],
["balance_transaction.id"],
name=op.f("fk_balance_transaction_reversal_id_balance_transaction"),
),
sa.ForeignKeyConstraint(["sender_id"], ["user.id"], name=op.f("fk_balance_transaction_sender_id_user")),
sa.PrimaryKeyConstraint("id", name=op.f("pk_balance_transaction")),
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("balance_transaction")
# ### end Alembic commands ###

View File

@ -1,31 +1,34 @@
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.ext.hybrid import hybrid_property
from math import floor
from flaschengeist import logger
from flaschengeist.database import db from flaschengeist.database import db
from flaschengeist.models.user import User from flaschengeist.models.user import User
from flaschengeist.models import ModelSerializeMixin, UtcDateTime from flaschengeist.models import ModelSerializeMixin, UtcDateTime, Serial
class Transaction(db.Model, ModelSerializeMixin): class Transaction(db.Model, ModelSerializeMixin):
__allow_unmapped__ = True
__tablename__ = "balance_transaction" __tablename__ = "balance_transaction"
# Protected foreign key properties # Protected foreign key properties
_receiver_id = db.Column("receiver_id", db.Integer, db.ForeignKey("user.id")) _receiver_id = db.Column("receiver_id", Serial, db.ForeignKey("user.id"))
_sender_id = db.Column("sender_id", db.Integer, db.ForeignKey("user.id")) _sender_id = db.Column("sender_id", Serial, db.ForeignKey("user.id"))
_author_id = db.Column("author_id", db.Integer, db.ForeignKey("user.id"), nullable=False) _author_id = db.Column("author_id", Serial, db.ForeignKey("user.id"), nullable=False)
# Public and exported member # Public and exported member
id: int = db.Column("id", db.Integer, primary_key=True) id: int = db.Column("id", Serial, primary_key=True)
time: datetime = db.Column(UtcDateTime, nullable=False, default=UtcDateTime.current_utc) time: datetime = db.Column(UtcDateTime, nullable=False, default=UtcDateTime.current_utc)
amount: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False), nullable=False) _amount: float = db.Column("amount", db.Numeric(precision=5, scale=2, asdecimal=False), nullable=False)
reversal_id: int = db.Column(db.Integer, db.ForeignKey("balance_transaction.id")) reversal_id: Optional[int] = db.Column(Serial, db.ForeignKey("balance_transaction.id"))
amount: float
# Dummy properties used for JSON serialization (userid instead of full user) # Dummy properties used for JSON serialization (userid instead of full user)
sender_id: Optional[str] = "" author_id: Optional[str] = None
receiver_id: Optional[str] = "" sender_id: Optional[str] = None
author_id: Optional[str] = "" original_id: Optional[int] = None
original_id: Optional[int] = -1 receiver_id: Optional[str] = None
# Not exported relationships just in backend only # Not exported relationships just in backend only
sender_: User = db.relationship("User", foreign_keys=[_sender_id]) sender_: User = db.relationship("User", foreign_keys=[_sender_id])
@ -39,7 +42,7 @@ class Transaction(db.Model, ModelSerializeMixin):
@sender_id.expression @sender_id.expression
def sender_id(cls): def sender_id(cls):
return db.select([User.userid]).where(cls._sender_id == User._id).as_scalar() return db.select([User.userid]).where(cls._sender_id == User.id_).scalar_subquery()
@hybrid_property @hybrid_property
def receiver_id(self): def receiver_id(self):
@ -47,7 +50,7 @@ class Transaction(db.Model, ModelSerializeMixin):
@receiver_id.expression @receiver_id.expression
def receiver_id(cls): def receiver_id(cls):
return db.select([User.userid]).where(cls._receiver_id == User._id).as_scalar() return db.select([User.userid]).where(cls._receiver_id == User.id_).scalar_subquery()
@property @property
def author_id(self): def author_id(self):
@ -56,3 +59,14 @@ class Transaction(db.Model, ModelSerializeMixin):
@property @property
def original_id(self): def original_id(self):
return self.original_.id if self.original_ else None return self.original_.id if self.original_ else None
@property
def amount(self):
return self._amount
@amount.setter
def amount(self, value):
self._amount = floor(value * 100) / 100
def __repr__(self):
return f"<Transaction {self.id} {self.amount} {self.time} {self.sender_id} {self.receiver_id} {self.author_id}>"

View File

@ -0,0 +1,338 @@
from datetime import datetime, timezone
from logging import log
from werkzeug.exceptions import Forbidden, BadRequest
from flask import Blueprint, request, jsonify
from flaschengeist.utils import HTTP
from flaschengeist.models.session import Session
from flaschengeist.utils.datetime import from_iso_format
from flaschengeist.utils.decorators import login_required
from flaschengeist.controller import userController
from flaschengeist.app import logger
from . import BalancePlugin, balance_controller, permissions
def str2bool(string: str):
if string.lower() in ["true", "yes", "1"]:
return True
elif string.lower() in ["false", "no", "0"]:
return False
raise ValueError
blueprint = Blueprint("balance", __package__)
@blueprint.route("/users/<userid>/balance/shortcuts", methods=["GET", "PUT"])
@login_required()
def get_shortcuts(userid, current_session: Session):
"""Get balance shortcuts of an user
Route: ``/users/<userid>/balance/shortcuts`` | Method: ``GET`` or ``PUT``
POST-data: On ``PUT`` json encoded array of floats
Args:
userid: Userid identifying the user
current_session: Session sent with Authorization Header
Returns:
GET: JSON object containing the shortcuts as float array or HTTP error
PUT: HTTP-created or HTTP error
"""
if userid != current_session.user_.userid:
raise Forbidden
user = userController.get_user(userid)
if request.method == "GET":
return jsonify(user.get_attribute("balance_shortcuts", []))
else:
data = request.get_json()
if not isinstance(data, list) or not all(isinstance(n, (int, float)) for n in data):
raise BadRequest
data.sort(reverse=True)
user.set_attribute("balance_shortcuts", data)
userController.persist()
return HTTP.no_content()
@blueprint.route("/users/<userid>/balance/limit", methods=["GET"])
@login_required()
def get_limit(userid, current_session: Session):
"""Get limit of an user
Route: ``/users/<userid>/balance/limit`` | Method: ``GET``
Args:
userid: Userid identifying the user
current_session: Session sent with Authorization Header
Returns:
JSON object containing the limit (or Null if no limit set) or HTTP error
"""
user = userController.get_user(userid)
if (user != current_session.user_ and not current_session.user_.has_permission(permissions.SET_LIMIT)) or (
user == current_session.user_ and not user.has_permission(permissions.SHOW)
):
raise Forbidden
return {"limit": balance_controller.get_limit(user)}
@blueprint.route("/users/<userid>/balance/limit", methods=["PUT"])
@login_required(permissions.SET_LIMIT)
def set_limit(userid, current_session: Session):
"""Set the limit of an user
Route: ``/users/<userid>/balance/limit`` | Method: ``PUT``
POST-data: ``{limit: float}``
Args:
userid: Userid identifying the user
current_session: Session sent with Authorization Header
Returns:
HTTP-200 or HTTP error
"""
user = userController.get_user(userid)
data = request.get_json()
try:
limit = data["limit"]
except (TypeError, KeyError):
raise BadRequest
balance_controller.set_limit(user, limit)
return HTTP.no_content()
@blueprint.route("/users/balance/limit", methods=["GET", "PUT"])
@login_required(permission=permissions.SET_LIMIT)
def limits(current_session: Session):
"""Get, Modify limit of all users
Args:
current_ession: Session sent with Authorization Header
Returns:
JSON encoded array of userid with limit or HTTP-error
"""
users = userController.get_users()
if request.method == "GET":
return jsonify([{"userid": user.userid, "limit": user.get_attribute("balance_limit")} for user in users])
data = request.get_json()
try:
limit = data["limit"]
except (TypeError, KeyError):
raise BadRequest
for user in users:
balance_controller.set_limit(user, limit)
return HTTP.no_content()
@blueprint.route("/users/<userid>/balance", methods=["GET"])
@login_required(permission=permissions.SHOW)
def get_balance(userid, current_session: Session):
"""Get balance of user, optionally filtered
Route: ``/users/<userid>/balance`` | Method: ``GET``
GET-parameters: ``{from?: string, to?: string}``
Args:
userid: Userid of user to get balance from
current_session: Session sent with Authorization Header
Returns:
JSON object containing credit, debit and balance or HTTP error
"""
if userid != current_session.user_.userid and not current_session.user_.has_permission(permissions.SHOW_OTHER):
raise Forbidden
# Might raise NotFound
user = userController.get_user(userid)
start = request.args.get("from")
if start:
start = from_iso_format(start)
else:
start = datetime.fromtimestamp(0, tz=timezone.utc)
end = request.args.get("to")
if end:
end = from_iso_format(end)
else:
end = datetime.now(tz=timezone.utc)
balance = balance_controller.get_balance(user, start, end)
logger.debug(f"Balance of {user.userid} from {start} to {end}: {balance}")
return {"credit": balance[0], "debit": balance[1], "balance": balance[2]}
@blueprint.route("/users/<userid>/balance/transactions", methods=["GET"])
@login_required(permission=permissions.SHOW)
def get_transactions(userid, current_session: Session):
"""Get transactions of user, optionally filtered
Returns also count of transactions if limit is set (e.g. just count with limit = 0)
Route: ``/users/<userid>/balance/transactions`` | Method: ``GET``
GET-parameters: ``{from?: string, to?: string, limit?: int, offset?: int}``
Args:
userid: Userid of user to get transactions from
current_session: Session sent with Authorization Header
Returns:
JSON Object {transactions: Transaction[], count?: number} or HTTP error
"""
if userid != current_session.user_.userid and not current_session.user_.has_permission(permissions.SHOW_OTHER):
raise Forbidden
# Might raise NotFound
user = userController.get_user(userid)
start = request.args.get("from")
if start:
start = from_iso_format(start)
end = request.args.get("to")
if end:
end = from_iso_format(end)
show_reversals = request.args.get("showReversals", False)
show_cancelled = request.args.get("showCancelled", True)
limit = request.args.get("limit")
offset = request.args.get("offset")
descending = request.args.get("descending", False)
try:
if limit is not None:
limit = int(limit)
if offset is not None:
offset = int(offset)
if not isinstance(show_reversals, bool):
show_reversals = str2bool(show_reversals)
if not isinstance(show_cancelled, bool):
show_cancelled = str2bool(show_cancelled)
if not isinstance(descending, bool):
descending = str2bool(descending)
except ValueError:
raise BadRequest
transactions, count = balance_controller.get_transactions(
user,
start,
end,
limit,
offset,
show_reversal=show_reversals,
show_cancelled=show_cancelled,
descending=descending,
)
logger.debug(f"transactions: {transactions}")
return {"transactions": transactions, "count": count}
@blueprint.route("/users/<userid>/balance", methods=["PUT"])
@login_required()
def change_balance(userid, current_session: Session):
"""Change balance of an user
If ``sender`` is preset in POST-data, the action is handled as a transfer from ``sender`` to user.
Route: ``/users/<userid>/balance`` | Method: ``PUT``
POST-data: ``{amount: float, sender: string}``
Args:
userid: userid identifying user to change balance
current_session: Session sent with Authorization Header
Returns:
JSON encoded transaction (201) or HTTP error
"""
data = request.get_json()
try:
amount = data["amount"]
except (TypeError, KeyError):
raise BadRequest
sender = data.get("sender", None)
user = userController.get_user(userid)
if sender:
sender = userController.get_user(sender)
if sender == user:
raise BadRequest
if (sender == current_session.user_ and sender.has_permission(permissions.SEND)) or (
sender != current_session.user_ and current_session.user_.has_permission(permissions.SEND_OTHER)
):
return HTTP.created(balance_controller.send(sender, user, amount, current_session.user_))
elif (
amount < 0
and (
(user == current_session.user_ and user.has_permission(permissions.DEBIT_OWN))
or current_session.user_.has_permission(permissions.DEBIT)
)
) or (amount > 0 and current_session.user_.has_permission(permissions.CREDIT)):
return HTTP.created(balance_controller.change_balance(user, data["amount"], current_session.user_))
raise Forbidden
@blueprint.route("/balance/<int:transaction_id>", methods=["DELETE"])
@login_required()
def reverse_transaction(transaction_id, current_session: Session):
"""Reverse a transaction
Route: ``/balance/<int:transaction_id>`` | Method: ``DELETE``
Args:
transaction_id: Identifier of the transaction
current_session: Session sent with Authorization Header
Returns:
JSON encoded reversal (transaction) (201) or HTTP error
"""
transaction = balance_controller.get_transaction(transaction_id)
if current_session.user_.has_permission(permissions.REVERSAL) or (
transaction.sender_ == current_session.user_
and (datetime.now(tz=timezone.utc) - transaction.time).total_seconds() < 10
):
reversal = balance_controller.reverse_transaction(transaction, current_session.user_)
return HTTP.created(reversal)
raise Forbidden
@blueprint.route("/balance", methods=["GET"])
@login_required(permission=permissions.SHOW_OTHER)
def get_balances(current_session: Session):
"""Get all balances
Route: ``/balance`` | Method: ``GET``
Args:
current_session: Session sent with Authorization Header
Returns:
JSON Array containing credit, debit and userid for each user or HTTP error
"""
limit = request.args.get("limit", type=int)
offset = request.args.get("offset", type=int)
descending = request.args.get("descending", False, type=bool)
sortBy = request.args.get("sortBy", type=str)
_filter = request.args.get("filter", None, type=str)
logger.debug(f"request.args: {request.args}")
balances, count = balance_controller.get_balances(
limit=limit,
offset=offset,
descending=descending,
sortBy=sortBy,
_filter=_filter,
)
return jsonify(
{
"balances": [{"userid": u, "credit": v[0], "debit": v[1]} for u, v in balances.items()],
"count": count,
}
)

View File

@ -1,31 +1,37 @@
import smtplib import smtplib
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from werkzeug.exceptions import InternalServerError
from flaschengeist import logger from flaschengeist import logger
from flaschengeist.models.user import User from flaschengeist.models import User
from flaschengeist.plugins import Plugin
from flaschengeist.utils.hook import HookAfter from flaschengeist.utils.hook import HookAfter
from flaschengeist.controller import userController from flaschengeist.controller import userController
from flaschengeist.controller.messageController import Message from flaschengeist.controller.messageController import Message
from flaschengeist.config import config
from . import Plugin
class MailMessagePlugin(Plugin): class MailMessagePlugin(Plugin):
def __init__(self, config): def load(self):
super().__init__() self.config = config.get("mail", None)
self.server = config["SERVER"] if self.config is None:
self.port = config["PORT"] logger.error("mail was not configured in flaschengeist.toml")
self.user = config["USER"] raise InternalServerError
self.password = config["PASSWORD"] self.server = self.config["SERVER"]
self.crypt = config["CRYPT"] self.port = self.config["PORT"]
self.mail = config["MAIL"] self.user = self.config["USER"]
self.password = self.config["PASSWORD"]
self.crypt = self.config["CRYPT"]
self.mail = self.config["MAIL"]
@HookAfter("send_message") @HookAfter("send_message")
def dummy_send(msg): def dummy_send(msg, *args, **kwargs):
logger.info(f"(dummy_send) Sending message to {msg.receiver}")
self.send_mail(msg) self.send_mail(msg)
def send_mail(self, msg: Message): def send_mail(self, msg: Message):
logger.debug(f"Sending mail to {msg.receiver} with subject {msg.subject}")
if isinstance(msg.receiver, User): if isinstance(msg.receiver, User):
if not msg.receiver.mail: if not msg.receiver.mail:
logger.warning("Could not send Mail, mail missing: {}".format(msg.receiver)) logger.warning("Could not send Mail, mail missing: {}".format(msg.receiver))
@ -39,9 +45,8 @@ class MailMessagePlugin(Plugin):
mail["To"] = ", ".join(recipients) mail["To"] = ", ".join(recipients)
mail["Subject"] = msg.subject mail["Subject"] = msg.subject
mail.attach(MIMEText(msg.message)) mail.attach(MIMEText(msg.message))
if not hasattr(self, "smtp"): with self.__connect() as smtp:
self.__connect() smtp.sendmail(self.mail, recipients, mail.as_string())
self.smtp.sendmail(self.mail, recipients, mail.as_string())
def __connect(self): def __connect(self):
if self.crypt == "SSL": if self.crypt == "SSL":
@ -52,3 +57,4 @@ class MailMessagePlugin(Plugin):
else: else:
raise ValueError("Invalid CRYPT given") raise ValueError("Invalid CRYPT given")
self.smtp.login(self.user, self.password) self.smtp.login(self.user, self.password)
return self.smtp

View File

@ -1,21 +0,0 @@
"""Orders plugin"""
from flask import Blueprint
from flaschengeist.plugins import Plugin
from . import models
order_bp = Blueprint("orders", __name__)
class OrdersPlugin(Plugin):
models = models
def __init__(self, config):
super().__init__(blueprint=order_bp)
def install(self):
from flaschengeist.database import db
db.create_all()

View File

@ -1,49 +0,0 @@
import datetime
from sqlalchemy.orm import relationship
from flaschengeist.models import ModelSerializeMixin, UtcDateTime
from flaschengeist.database import db
class Product(db.Model, ModelSerializeMixin):
__tablename__ = "orders_product"
id: int = db.Column(db.Integer, primary_key=True)
name: str = db.Column(db.String(255))
price: float = db.Column(db.Numeric(precision=19, scale=3, asdecimal=False))
tax_rate: float = db.Column(db.Float)
class Bill(db.Model, ModelSerializeMixin):
__tablename__ = "orders_bill"
id: int = db.Column(db.Integer, primary_key=True)
time: datetime.datetime = db.Column(UtcDateTime, nullable=False)
payment = ...
payed = db.Column(db.Boolean)
cancelled = db.Column(db.Integer, db.ForeignKey("orders_bill.id")) # Cancelled by
orders = relationship("Order", back_populates="bill")
class Order(db.Model):
__tablename__ = "orders_order"
id: int = db.Column(db.Integer, primary_key=True)
bill = ...
product = ...
amount = ...
price = ...
tax_rate = ...
discount = ...
userid: str = ""
value: float = db.Column(db.Numeric(precision=3, scale=2, asdecimal=False), nullable=False)
_job_id = db.Column("job_id", db.Integer, db.ForeignKey("schedule_job.id"), nullable=False, primary_key=True)
_user_id = db.Column("user_id", db.Integer, db.ForeignKey("user.id"), nullable=False, primary_key=True)
user_: User = db.relationship("User")
job_ = db.relationship("Job")
@property
def userid(self):
return self.user_.userid

View File

@ -0,0 +1,753 @@
"""Pricelist plugin"""
from flask import Blueprint, jsonify, request
from werkzeug.exceptions import BadRequest, Forbidden, NotFound, Unauthorized
from flaschengeist import logger
from flaschengeist.controller import userController
from flaschengeist.controller.imageController import send_image, send_thumbnail
from flaschengeist.plugins import Plugin
from flaschengeist.utils.decorators import login_required, extract_session
from flaschengeist.utils.HTTP import no_content
from . import models
from . import pricelist_controller, permissions
class PriceListPlugin(Plugin):
models = models
blueprint = Blueprint("pricelist", __name__, url_prefix="/pricelist")
def install(self):
self.install_permissions(permissions.permissions)
def load(self):
config = {"discount": 0}
config.update(config)
@PriceListPlugin.blueprint.route("/drink-types", methods=["GET"])
@PriceListPlugin.blueprint.route("/drink-types/<int:identifier>", methods=["GET"])
def get_drink_types(identifier=None):
"""Get DrinkType(s)
Route: ``/pricelist/drink-types`` | Method: ``GET``
Route: ``/pricelist/drink-types/<identifier>`` | Method: ``GET``
Args:
identifier: If querying a spicific DrinkType
Returns:
JSON encoded (list of) DrinkType(s) or HTTP-error
"""
if identifier is None:
result = pricelist_controller.get_drink_types()
else:
result = pricelist_controller.get_drink_type(identifier)
return jsonify(result)
@PriceListPlugin.blueprint.route("/drink-types", methods=["POST"])
@login_required(permission=permissions.CREATE_TYPE)
def new_drink_type(current_session):
"""Create new DrinkType
Route ``/pricelist/drink-types`` | Method: ``POST``
POST-data: ``{name: string}``
Args:
current_session: Session sent with Authorization Header
Returns:
JSON encoded DrinkType or HTTP-error
"""
data = request.get_json()
if "name" not in data:
raise BadRequest
drink_type = pricelist_controller.create_drink_type(data["name"])
return jsonify(drink_type)
@PriceListPlugin.blueprint.route("/drink-types/<int:identifier>", methods=["PUT"])
@login_required(permission=permissions.EDIT_TYPE)
def update_drink_type(identifier, current_session):
"""Modify DrinkType
Route ``/pricelist/drink-types/<identifier>`` | METHOD ``PUT``
POST-data: ``{name: string}``
Args:
identifier: Identifier of DrinkType
current_session: Session sent with Authorization Header
Returns:
JSON encoded DrinkType or HTTP-error
"""
data = request.get_json()
if "name" not in data:
raise BadRequest
drink_type = pricelist_controller.rename_drink_type(identifier, data["name"])
return jsonify(drink_type)
@PriceListPlugin.blueprint.route("/drink-types/<int:identifier>", methods=["DELETE"])
@login_required(permission=permissions.DELETE_TYPE)
def delete_drink_type(identifier, current_session):
"""Delete DrinkType
Route: ``/pricelist/drink-types/<identifier>`` | Method: ``DELETE``
Args:
identifier: Identifier of DrinkType
current_session: Session sent with Authorization Header
Returns:
HTTP-NoContent or HTTP-error
"""
pricelist_controller.delete_drink_type(identifier)
return no_content()
@PriceListPlugin.blueprint.route("/tags", methods=["GET"])
@PriceListPlugin.blueprint.route("/tags/<int:identifier>", methods=["GET"])
def get_tags(identifier=None):
"""Get Tag(s)
Route: ``/pricelist/tags`` | Method: ``GET``
Route: ``/pricelist/tags/<identifier>`` | Method: ``GET``
Args:
identifier: Identifier of Tag
Returns:
JSON encoded (list of) Tag(s) or HTTP-error
"""
if identifier:
result = pricelist_controller.get_tag(identifier)
else:
result = pricelist_controller.get_tags()
return jsonify(result)
@PriceListPlugin.blueprint.route("/tags", methods=["POST"])
@login_required(permission=permissions.CREATE_TAG)
def new_tag(current_session):
"""Create Tag
Route: ``/pricelist/tags`` | Method: ``POST``
POST-data: ``{name: string, color: string}``
Args:
current_session: Session sent with Authorization Header
Returns:
JSON encoded Tag or HTTP-error
"""
data = request.get_json()
drink_type = pricelist_controller.create_tag(data)
return jsonify(drink_type)
@PriceListPlugin.blueprint.route("/tags/<int:identifier>", methods=["PUT"])
@login_required(permission=permissions.EDIT_TAG)
def update_tag(identifier, current_session):
"""Modify Tag
Route: ``/pricelist/tags/<identifier>`` | Methods: ``PUT``
POST-data: ``{name: string, color: string}``
Args:
identifier: Identifier of Tag
current_session: Session sent with Authorization Header
Returns:
JSON encoded Tag or HTTP-error
"""
data = request.get_json()
tag = pricelist_controller.update_tag(identifier, data)
return jsonify(tag)
@PriceListPlugin.blueprint.route("/tags/<int:identifier>", methods=["DELETE"])
@login_required(permission=permissions.DELETE_TAG)
def delete_tag(identifier, current_session):
"""Delete Tag
Route: ``/pricelist/tags/<identifier>`` | Methods: ``DELETE``
Args:
identifier: Identifier of Tag
current_session: Session sent with Authorization Header
Returns:
HTTP-NoContent or HTTP-error
"""
pricelist_controller.delete_tag(identifier)
return no_content()
@PriceListPlugin.blueprint.route("/drinks", methods=["GET"])
@PriceListPlugin.blueprint.route("/drinks/<int:identifier>", methods=["GET"])
def get_drinks(identifier=None):
"""Get Drink(s)
Route: ``/pricelist/drinks`` | Method: ``GET``
Route: ``/pricelist/drinks/<identifier>`` | Method: ``GET``
Args:
identifier: Identifier of Drink
Returns:
JSON encoded (list of) Drink(s) or HTTP-error
"""
public = True
try:
extract_session()
public = False
except Unauthorized:
public = True
if identifier:
result = pricelist_controller.get_drink(identifier, public=public)
return jsonify(result)
else:
limit = request.args.get("limit")
offset = request.args.get("offset")
search_name = request.args.get("search_name")
search_key = request.args.get("search_key")
ingredient = request.args.get("ingredient", type=bool)
receipt = request.args.get("receipt", type=bool)
try:
if limit is not None:
limit = int(limit)
if offset is not None:
offset = int(offset)
if ingredient is not None:
ingredient = bool(ingredient)
if receipt is not None:
receipt = bool(receipt)
except ValueError:
raise BadRequest
drinks, count = pricelist_controller.get_drinks(
public=public,
limit=limit,
offset=offset,
search_name=search_name,
search_key=search_key,
ingredient=ingredient,
receipt=receipt,
)
mop = drinks.copy()
logger.debug(f"GET drink {drinks}, {count}")
# return jsonify({"drinks": drinks, "count": count})
return jsonify({"drinks": drinks, "count": count})
@PriceListPlugin.blueprint.route("/list", methods=["GET"])
def get_pricelist():
"""Get Priclist
Route: ``/pricelist/list`` | Method: ``GET``
Returns:
JSON encoded list of DrinkPrices or HTTP-KeyError
"""
public = True
try:
extract_session()
public = False
except Unauthorized:
public = True
limit = request.args.get("limit")
offset = request.args.get("offset")
search_name = request.args.get("search_name")
search_key = request.args.get("search_key")
ingredient = request.args.get("ingredient", type=bool)
receipt = request.args.get("receipt", type=bool)
descending = request.args.get("descending", type=bool)
sortBy = request.args.get("sortBy")
try:
if limit is not None:
limit = int(limit)
if offset is not None:
offset = int(offset)
if ingredient is not None:
ingredient = bool(ingredient)
if receipt is not None:
receipt = bool(receipt)
if descending is not None:
descending = bool(descending)
except ValueError:
raise BadRequest
pricelist, count = pricelist_controller.get_pricelist(
public=public,
limit=limit,
offset=offset,
search_name=search_name,
search_key=search_key,
descending=descending,
sortBy=sortBy,
)
logger.debug(f"GET pricelist {pricelist}, {count}")
return jsonify({"pricelist": pricelist, "count": count})
@PriceListPlugin.blueprint.route("/drinks/search/<string:name>", methods=["GET"])
def search_drinks(name):
"""Search Drink
Route: ``/pricelist/drinks/search/<name>`` | Method: ``GET``
Args:
name: Name to search
Returns:
JSON encoded list of Drinks or HTTP-error
"""
public = True
try:
extract_session()
public = False
except Unauthorized:
public = True
return jsonify(pricelist_controller.get_drinks(name, public=public))
@PriceListPlugin.blueprint.route("/drinks", methods=["POST"])
@login_required(permission=permissions.CREATE)
def create_drink(current_session):
"""Create Drink
Route: ``/pricelist/drinks`` | Method: ``POST``
POST-data :
``{
article_id?: string
cost_per_package?: float,
cost_per_volume?: float,
name: string,
package_size?: number,
receipt?: list[string],
tags?: list[Tag],
type: DrinkType,
uuid?: string,
volume?: float,
volumes?: list[
{
ingredients?: list[{
id: int
drink_ingredient?: {
ingredient_id: int,
volume: float
},
extra_ingredient?: {
id: number,
}
}],
prices?: list[
{
price: float
public: boolean
}
],
volume: float
}
]
}``
Args:
current_session: Session sent with Authorization Header
Returns:
JSON encoded Drink or HTTP-error
"""
data = request.get_json()
return jsonify(pricelist_controller.set_drink(data))
@PriceListPlugin.blueprint.route("/drinks/<int:identifier>", methods=["PUT"])
@login_required(permission=permissions.EDIT)
def update_drink(identifier, current_session):
"""Modify Drink
Route: ``/pricelist/drinks/<identifier>`` | Method: ``PUT``
POST-data :
``{
article_id?: string
cost_per_package?: float,
cost_per_volume?: float,
name: string,
package_size?: number,
receipt?: list[string],
tags?: list[Tag],
type: DrinkType,
uuid?: string,
volume?: float,
volumes?: list[
{
ingredients?: list[{
id: int
drink_ingredient?: {
ingredient_id: int,
volume: float
},
extra_ingredient?: {
id: number,
}
}],
prices?: list[
{
price: float
public: boolean
}
],
volume: float
}
]
}``
Args:
identifier: Identifier of Drink
current_session: Session sent with Authorization Header
Returns:
JSON encoded Drink or HTTP-error
"""
data = request.get_json()
logger.debug(f"update drink {data}")
return jsonify(pricelist_controller.update_drink(identifier, data))
@PriceListPlugin.blueprint.route("/drinks/<int:identifier>", methods=["DELETE"])
@login_required(permission=permissions.DELETE)
def delete_drink(identifier, current_session):
"""Delete Drink
Route: ``/pricelist/drinks/<identifier>`` | Method: ``DELETE``
Args:
identifier: Identifier of Drink
current_session: Session sent with Authorization Header
Returns:
HTTP-NoContent or HTTP-error
"""
pricelist_controller.delete_drink(identifier)
return no_content()
@PriceListPlugin.blueprint.route("/prices/<int:identifier>", methods=["DELETE"])
@login_required(permission=permissions.DELETE_PRICE)
def delete_price(identifier, current_session):
"""Delete Price
Route: ``/pricelist/prices/<identifier>`` | Methods: ``DELETE``
Args:
identifier: Identiefer of Price
current_session: Session sent with Authorization Header
Returns:
HTTP-NoContent or HTTP-error
"""
pricelist_controller.delete_price(identifier)
return no_content()
@PriceListPlugin.blueprint.route("/volumes/<int:identifier>", methods=["DELETE"])
@login_required(permission=permissions.DELETE_VOLUME)
def delete_volume(identifier, current_session):
"""Delete DrinkPriceVolume
Route: ``/pricelist/volumes/<identifier>`` | Method: ``DELETE``
Args:
identifier: Identifier of DrinkPriceVolume
current_session: Session sent with Authorization Header
Returns:
HTTP-NoContent or HTTP-error
"""
pricelist_controller.delete_volume(identifier)
return no_content()
@PriceListPlugin.blueprint.route("/ingredients/extraIngredients", methods=["GET"])
@login_required()
def get_extra_ingredients(current_session):
"""Get ExtraIngredients
Route: ``/pricelist/ingredients/extraIngredients`` | Method: ``GET``
Args:
current_session: Session sent with Authorization Header
Returns:
JSON encoded list of ExtraIngredients or HTTP-error
"""
return jsonify(pricelist_controller.get_extra_ingredients())
@PriceListPlugin.blueprint.route("/ingredients/<int:identifier>", methods=["DELETE"])
@login_required(permission=permissions.DELETE_INGREDIENTS_DRINK)
def delete_ingredient(identifier, current_session):
"""Delete Ingredient
Route: ``/pricelist/ingredients/<identifier>`` | Method: ``DELETE``
Args:
identifier: Identifier of Ingredient
current_session: Session sent with Authorization Header
Returns:
HTTP-NoContent or HTTP-error
"""
pricelist_controller.delete_ingredient(identifier)
return no_content()
@PriceListPlugin.blueprint.route("/ingredients/extraIngredients", methods=["POST"])
@login_required(permission=permissions.EDIT_INGREDIENTS)
def set_extra_ingredient(current_session):
"""Create ExtraIngredient
Route: ``/pricelist/ingredients/extraIngredients`` | Method: ``POST``
POST-data: ``{ name: string, price: float }``
Args:
current_session: Session sent with Authorization Header
Returns:
JSON encoded ExtraIngredient or HTTP-error
"""
data = request.get_json()
return jsonify(pricelist_controller.set_extra_ingredient(data))
@PriceListPlugin.blueprint.route("/ingredients/extraIngredients/<int:identifier>", methods=["PUT"])
@login_required(permission=permissions.EDIT_INGREDIENTS)
def update_extra_ingredient(identifier, current_session):
"""Modify ExtraIngredient
Route: ``/pricelist/ingredients/extraIngredients`` | Method: ``PUT``
POST-data: ``{ name: string, price: float }``
Args:
identifier: Identifier of ExtraIngredient
current_session: Session sent with Authorization Header
Returns:
JSON encoded ExtraIngredient or HTTP-error
"""
data = request.get_json()
return jsonify(pricelist_controller.update_extra_ingredient(identifier, data))
@PriceListPlugin.blueprint.route("/ingredients/extraIngredients/<int:identifier>", methods=["DELETE"])
@login_required(permission=permissions.DELETE_INGREDIENTS)
def delete_extra_ingredient(identifier, current_session):
"""Delete ExtraIngredient
Route: ``/pricelist/ingredients/extraIngredients`` | Method: ``DELETE``
Args:
identifier: Identifier of ExtraIngredient
current_session: Session sent with Authorization Header
Returns:
HTTP-NoContent or HTTP-error
"""
pricelist_controller.delete_extra_ingredient(identifier)
return no_content()
@PriceListPlugin.blueprint.route("/settings/min_prices", methods=["GET"])
@login_required()
def get_pricelist_settings_min_prices(current_session):
"""Get MinPrices
Route: ``/pricelist/settings/min_prices`` | Method: ``GET``
Args:
current_session: Session sent with Authorization Header
Returns:
JSON encoded list of MinPrices
"""
# TODO: Handle if no prices are set!
try:
min_prices = PriceListPlugin.plugin.get_setting("min_prices")
except KeyError:
min_prices = []
return jsonify(min_prices)
@PriceListPlugin.blueprint.route("/settings/min_prices", methods=["POST"])
@login_required(permission=permissions.EDIT_MIN_PRICES)
def post_pricelist_settings_min_prices(current_session):
"""Create MinPrices
Route: ``/pricelist/settings/min_prices`` | Method: ``POST``
POST-data: ``list[int]``
Args:
current_session: Session sent with Authorization Header
Returns:
HTTP-NoContent or HTTP-error
"""
data = request.get_json()
if not isinstance(data, list) or not all(isinstance(n, int) for n in data):
raise BadRequest
data.sort()
PriceListPlugin.plugin.set_setting("min_prices", data)
return no_content()
@PriceListPlugin.blueprint.route("/users/<userid>/pricecalc_columns", methods=["GET", "PUT"])
@login_required()
def get_columns(userid, current_session):
"""Get pricecalc_columns of an user
Route: ``/users/<userid>/pricelist/pricecac_columns`` | Method: ``GET`` or ``PUT``
POST-data: On ``PUT`` json encoded array of floats
Args:
userid: Userid identifying the user
current_session: Session sent with Authorization Header
Returns:
GET: JSON object containing the shortcuts as float array or HTTP error
PUT: HTTP-created or HTTP error
"""
if userid != current_session.user_.userid:
raise Forbidden
user = userController.get_user(userid)
if request.method == "GET":
return jsonify(user.get_attribute("pricecalc_columns", []))
else:
data = request.get_json()
if not isinstance(data, list) or not all(isinstance(n, str) for n in data):
raise BadRequest
data.sort(reverse=True)
user.set_attribute("pricecalc_columns", data)
userController.persist()
return no_content()
@PriceListPlugin.blueprint.route("/users/<userid>/pricecalc_columns_order", methods=["GET", "PUT"])
@login_required()
def get_columns_order(userid, current_session):
"""Get pricecalc_columns_order of an user
Route: ``/users/<userid>/pricelist/pricecac_columns_order`` | Method: ``GET`` or ``PUT``
POST-data: On ``PUT`` json encoded array of floats
Args:
userid: Userid identifying the user
current_session: Session sent with Authorization Header
Returns:
GET: JSON object containing the shortcuts as object array or HTTP error
PUT: HTTP-created or HTTP error
"""
if userid != current_session.user_.userid:
raise Forbidden
user = userController.get_user(userid)
if request.method == "GET":
return jsonify(user.get_attribute("pricecalc_columns_order", []))
else:
data = request.get_json()
if not isinstance(data, list) or not all(isinstance(n, str) for mop in data for n in mop.values()):
raise BadRequest
user.set_attribute("pricecalc_columns_order", data)
userController.persist()
return no_content()
@PriceListPlugin.blueprint.route("/users/<userid>/pricelist", methods=["GET", "PUT"])
@login_required()
def get_priclist_setting(userid, current_session):
"""Get pricelistsetting of an user
Route: ``/pricelist/user/<userid>/pricelist`` | Method: ``GET`` or ``PUT``
POST-data: on ``PUT`` ``{value: boolean}``
Args:
userid: Userid identifying the user
current_session: Session sent wth Authorization Header
Returns:
GET: JSON object containing the value as boolean or HTTP-error
PUT: HTTP-NoContent or HTTP-error
"""
if userid != current_session.user_.userid:
raise Forbidden
user = userController.get_user(userid)
if request.method == "GET":
return jsonify(user.get_attribute("pricelist_view", {"value": False}))
else:
data = request.get_json()
if not isinstance(data, dict) or not "value" in data or not isinstance(data["value"], bool):
raise BadRequest
user.set_attribute("pricelist_view", data)
userController.persist()
return no_content()
@PriceListPlugin.blueprint.route("/drinks/<int:identifier>/picture", methods=["POST", "DELETE"])
@login_required(permission=permissions.EDIT)
def set_picture(identifier, current_session):
"""Get, Create, Delete Drink Picture
Route: ``/pricelist/<identifier>/picture`` | Method: ``GET,POST,DELETE``
POST-data: (if remaining) ``Form-Data: mime: 'image/*'``
Args:
identifier: Identifier of Drink
current_session: Session sent with Authorization
Returns:
Picture or HTTP-error
"""
if request.method == "DELETE":
pricelist_controller.delete_drink_picture(identifier)
return no_content()
file = request.files.get("file")
if file:
return jsonify(pricelist_controller.save_drink_picture(identifier, file))
else:
raise BadRequest
@PriceListPlugin.blueprint.route("/drinks/<int:identifier>/picture", methods=["GET"])
# @headers({"Cache-Control": "private, must-revalidate"})
def _get_picture(identifier):
"""Get Picture
Args:
identifier: Identifier of Drink
Returns:
Picture or HTTP-error
"""
drink = pricelist_controller.get_drink(identifier)
if drink.has_image:
if request.args.get("thumbnail"):
return send_thumbnail(image=drink.image_)
return send_image(image=drink.image_)
raise NotFound

View File

@ -0,0 +1,141 @@
"""pricelist: initial
Revision ID: 58ab9b6a8839
Revises:
Create Date: 2022-02-23 14:45:30.563647
"""
from alembic import op
import sqlalchemy as sa
import flaschengeist
# revision identifiers, used by Alembic.
revision = "58ab9b6a8839"
down_revision = None
branch_labels = ("pricelist",)
depends_on = "flaschengeist"
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"drink_extra_ingredient",
sa.Column("id", flaschengeist.models.Serial(), nullable=False),
sa.Column("name", sa.String(length=30), nullable=False),
sa.Column("price", sa.Numeric(precision=5, scale=2, asdecimal=False), nullable=True),
sa.PrimaryKeyConstraint("id", name=op.f("pk_drink_extra_ingredient")),
sa.UniqueConstraint("name", name=op.f("uq_drink_extra_ingredient_name")),
)
op.create_table(
"drink_tag",
sa.Column("id", flaschengeist.models.Serial(), nullable=False),
sa.Column("name", sa.String(length=30), nullable=False),
sa.Column("color", sa.String(length=7), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_drink_tag")),
sa.UniqueConstraint("name", name=op.f("uq_drink_tag_name")),
)
op.create_table(
"drink_type",
sa.Column("id", flaschengeist.models.Serial(), nullable=False),
sa.Column("name", sa.String(length=30), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_drink_type")),
sa.UniqueConstraint("name", name=op.f("uq_drink_type_name")),
)
op.create_table(
"drink",
sa.Column("id", flaschengeist.models.Serial(), nullable=False),
sa.Column("article_id", sa.String(length=64), nullable=True),
sa.Column("package_size", sa.Integer(), nullable=True),
sa.Column("name", sa.String(length=60), nullable=False),
sa.Column("volume", sa.Numeric(precision=5, scale=2, asdecimal=False), nullable=True),
sa.Column("cost_per_volume", sa.Numeric(precision=5, scale=3, asdecimal=False), nullable=True),
sa.Column("cost_per_package", sa.Numeric(precision=5, scale=3, asdecimal=False), nullable=True),
sa.Column("receipt", sa.PickleType(), nullable=True),
sa.Column("type_id", flaschengeist.models.Serial(), nullable=True),
sa.Column("image_id", flaschengeist.models.Serial(), nullable=True),
sa.ForeignKeyConstraint(["image_id"], ["image.id"], name=op.f("fk_drink_image_id_image")),
sa.ForeignKeyConstraint(["type_id"], ["drink_type.id"], name=op.f("fk_drink_type_id_drink_type")),
sa.PrimaryKeyConstraint("id", name=op.f("pk_drink")),
)
op.create_table(
"drink_ingredient",
sa.Column("id", flaschengeist.models.Serial(), nullable=False),
sa.Column("volume", sa.Numeric(precision=5, scale=2, asdecimal=False), nullable=False),
sa.Column("ingredient_id", flaschengeist.models.Serial(), nullable=True),
sa.ForeignKeyConstraint(["ingredient_id"], ["drink.id"], name=op.f("fk_drink_ingredient_ingredient_id_drink")),
sa.PrimaryKeyConstraint("id", name=op.f("pk_drink_ingredient")),
)
op.create_table(
"drink_price_volume",
sa.Column("id", flaschengeist.models.Serial(), nullable=False),
sa.Column("drink_id", flaschengeist.models.Serial(), nullable=True),
sa.Column("volume", sa.Numeric(precision=5, scale=2, asdecimal=False), nullable=True),
sa.ForeignKeyConstraint(["drink_id"], ["drink.id"], name=op.f("fk_drink_price_volume_drink_id_drink")),
sa.PrimaryKeyConstraint("id", name=op.f("pk_drink_price_volume")),
)
op.create_table(
"drink_x_tag",
sa.Column("drink_id", flaschengeist.models.Serial(), nullable=True),
sa.Column("tag_id", flaschengeist.models.Serial(), nullable=True),
sa.ForeignKeyConstraint(["drink_id"], ["drink.id"], name=op.f("fk_drink_x_tag_drink_id_drink")),
sa.ForeignKeyConstraint(["tag_id"], ["drink_tag.id"], name=op.f("fk_drink_x_tag_tag_id_drink_tag")),
)
op.create_table(
"drink_x_type",
sa.Column("drink_id", flaschengeist.models.Serial(), nullable=True),
sa.Column("type_id", flaschengeist.models.Serial(), nullable=True),
sa.ForeignKeyConstraint(["drink_id"], ["drink.id"], name=op.f("fk_drink_x_type_drink_id_drink")),
sa.ForeignKeyConstraint(["type_id"], ["drink_type.id"], name=op.f("fk_drink_x_type_type_id_drink_type")),
)
op.create_table(
"drink_ingredient_association",
sa.Column("id", flaschengeist.models.Serial(), nullable=False),
sa.Column("volume_id", flaschengeist.models.Serial(), nullable=True),
sa.Column("_drink_ingredient_id", flaschengeist.models.Serial(), nullable=True),
sa.Column("_extra_ingredient_id", flaschengeist.models.Serial(), nullable=True),
sa.ForeignKeyConstraint(
["_drink_ingredient_id"],
["drink_ingredient.id"],
name=op.f("fk_drink_ingredient_association__drink_ingredient_id_drink_ingredient"),
),
sa.ForeignKeyConstraint(
["_extra_ingredient_id"],
["drink_extra_ingredient.id"],
name=op.f("fk_drink_ingredient_association__extra_ingredient_id_drink_extra_ingredient"),
),
sa.ForeignKeyConstraint(
["volume_id"],
["drink_price_volume.id"],
name=op.f("fk_drink_ingredient_association_volume_id_drink_price_volume"),
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_drink_ingredient_association")),
)
op.create_table(
"drink_price",
sa.Column("id", flaschengeist.models.Serial(), nullable=False),
sa.Column("price", sa.Numeric(precision=5, scale=2, asdecimal=False), nullable=True),
sa.Column("volume_id", flaschengeist.models.Serial(), nullable=True),
sa.Column("public", sa.Boolean(), nullable=True),
sa.Column("description", sa.String(length=30), nullable=True),
sa.ForeignKeyConstraint(
["volume_id"], ["drink_price_volume.id"], name=op.f("fk_drink_price_volume_id_drink_price_volume")
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_drink_price")),
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("drink_price")
op.drop_table("drink_ingredient_association")
op.drop_table("drink_x_type")
op.drop_table("drink_x_tag")
op.drop_table("drink_price_volume")
op.drop_table("drink_ingredient")
op.drop_table("drink")
op.drop_table("drink_type")
op.drop_table("drink_tag")
op.drop_table("drink_extra_ingredient")
# ### end Alembic commands ###

View File

@ -0,0 +1,180 @@
from __future__ import annotations # TODO: Remove if python requirement is >= 3.12 (? PEP 563 is defered)
from typing import Optional
from flaschengeist.database import db
from flaschengeist.database.types import ModelSerializeMixin, Serial
from flaschengeist.models import Image
drink_tag_association = db.Table(
"drink_x_tag",
db.Column("drink_id", Serial, db.ForeignKey("drink.id")),
db.Column("tag_id", Serial, db.ForeignKey("drink_tag.id")),
)
drink_type_association = db.Table(
"drink_x_type",
db.Column("drink_id", Serial, db.ForeignKey("drink.id")),
db.Column("type_id", Serial, db.ForeignKey("drink_type.id")),
)
class Tag(db.Model, ModelSerializeMixin):
"""
Tag
"""
__tablename__ = "drink_tag"
id: int = db.Column("id", Serial, primary_key=True)
name: str = db.Column(db.String(30), nullable=False, unique=True)
color: str = db.Column(db.String(7), nullable=False)
class DrinkType(db.Model, ModelSerializeMixin):
"""
DrinkType
"""
__tablename__ = "drink_type"
id: int = db.Column("id", Serial, primary_key=True)
name: str = db.Column(db.String(30), nullable=False, unique=True)
class DrinkPrice(db.Model, ModelSerializeMixin):
"""
PriceFromVolume
"""
__tablename__ = "drink_price"
id: int = db.Column("id", Serial, primary_key=True)
price: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False))
volume_id_ = db.Column("volume_id", Serial, db.ForeignKey("drink_price_volume.id"))
volume: "DrinkPriceVolume" = None
_volume: "DrinkPriceVolume" = db.relationship("DrinkPriceVolume", back_populates="_prices", join_depth=1)
public: bool = db.Column(db.Boolean, default=True)
description: Optional[str] = db.Column(db.String(30))
def __repr__(self):
return f"DrinkPric({self.id},{self.price},{self.public},{self.description})"
class ExtraIngredient(db.Model, ModelSerializeMixin):
"""
ExtraIngredient
"""
__tablename__ = "drink_extra_ingredient"
id: int = db.Column("id", Serial, primary_key=True)
name: str = db.Column(db.String(30), unique=True, nullable=False)
price: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False))
class DrinkIngredient(db.Model, ModelSerializeMixin):
"""
Drink Ingredient
"""
__tablename__ = "drink_ingredient"
id: int = db.Column("id", Serial, primary_key=True)
volume: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False), nullable=False)
ingredient_id: int = db.Column(Serial, db.ForeignKey("drink.id"))
cost_per_volume: float
name: str
_drink_ingredient: Drink = db.relationship("Drink")
@property
def cost_per_volume(self):
return self._drink_ingredient.cost_per_volume if self._drink_ingredient else None
@property
def name(self):
return self._drink_ingredient.name if self._drink_ingredient else None
class Ingredient(db.Model, ModelSerializeMixin):
"""
Ingredient Associationtable
"""
__tablename__ = "drink_ingredient_association"
id: int = db.Column("id", Serial, primary_key=True)
volume_id = db.Column(Serial, db.ForeignKey("drink_price_volume.id"))
drink_ingredient: Optional[DrinkIngredient] = db.relationship(DrinkIngredient, cascade="all,delete")
extra_ingredient: Optional[ExtraIngredient] = db.relationship(ExtraIngredient)
_drink_ingredient_id = db.Column(Serial, db.ForeignKey("drink_ingredient.id"))
_extra_ingredient_id = db.Column(Serial, db.ForeignKey("drink_extra_ingredient.id"))
class MinPrices(ModelSerializeMixin):
"""
MinPrices
"""
percentage: float
price: float
class DrinkPriceVolume(db.Model, ModelSerializeMixin):
"""
Drink Volumes and Prices
"""
__tablename__ = "drink_price_volume"
id: int = db.Column("id", Serial, primary_key=True)
drink_id = db.Column(Serial, db.ForeignKey("drink.id"))
drink: "Drink" = None
_drink: "Drink" = db.relationship("Drink", back_populates="_volumes")
volume: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False))
min_prices: list[MinPrices] = []
# ingredients: list[Ingredient] = []
prices: list[DrinkPrice] = []
_prices: list[DrinkPrice] = db.relationship(
DrinkPrice, back_populates="_volume", cascade="all,delete,delete-orphan"
)
ingredients: list[Ingredient] = db.relationship(
"Ingredient",
foreign_keys=Ingredient.volume_id,
cascade="all,delete,delete-orphan",
)
def __repr__(self):
return f"DrinkPriceVolume({self.id},{self.drink_id},{self.volume},{self.prices})"
class Drink(db.Model, ModelSerializeMixin):
"""
DrinkPrice
"""
__tablename__ = "drink"
id: int = db.Column("id", Serial, primary_key=True)
article_id: Optional[str] = db.Column(db.String(64))
package_size: Optional[int] = db.Column(db.Integer)
name: str = db.Column(db.String(60), nullable=False)
volume: Optional[float] = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False))
cost_per_volume: Optional[float] = db.Column(db.Numeric(precision=5, scale=3, asdecimal=False))
cost_per_package: Optional[float] = db.Column(db.Numeric(precision=5, scale=3, asdecimal=False))
has_image: bool = False
receipt: Optional[list[str]] = db.Column(db.PickleType(protocol=4))
_type_id = db.Column("type_id", Serial, db.ForeignKey("drink_type.id"))
_image_id = db.Column("image_id", Serial, db.ForeignKey("image.id"))
image_: Image = db.relationship("Image", cascade="all, delete", foreign_keys=[_image_id])
tags: Optional[list[Tag]] = db.relationship("Tag", secondary=drink_tag_association, cascade="save-update, merge")
type: Optional[DrinkType] = db.relationship("DrinkType", foreign_keys=[_type_id])
volumes: list[DrinkPriceVolume] = []
_volumes: list[DrinkPriceVolume] = db.relationship(
DrinkPriceVolume, back_populates="_drink", cascade="all,delete,delete-orphan"
)
def __repr__(self):
return f"Drink({self.id},{self.name},{self.volumes})"
@property
def has_image(self):
return self.image_ is not None

View File

@ -0,0 +1,37 @@
CREATE = "drink_create"
"""Can create drinks"""
EDIT = "drink_edit"
"""Can edit drinks"""
DELETE = "drink_delete"
"""Can delete drinks"""
CREATE_TAG = "drink_tag_create"
"""Can create and edit Tags"""
EDIT_PRICE = "edit_price"
DELETE_PRICE = "delete_price"
EDIT_VOLUME = "edit_volume"
DELETE_VOLUME = "delete_volume"
EDIT_INGREDIENTS_DRINK = "edit_ingredients_drink"
DELETE_INGREDIENTS_DRINK = "delete_ingredients_drink"
EDIT_INGREDIENTS = "edit_ingredients"
DELETE_INGREDIENTS = "delete_ingredients"
EDIT_TAG = "drink_tag_edit"
DELETE_TAG = "drink_tag_delete"
CREATE_TYPE = "drink_type_create"
EDIT_TYPE = "drink_type_edit"
DELETE_TYPE = "drink_type_delete"
EDIT_MIN_PRICES = "edit_min_prices"
permissions = [value for key, value in globals().items() if not key.startswith("_")]

View File

@ -0,0 +1,540 @@
from werkzeug.exceptions import BadRequest, NotFound
from sqlalchemy.exc import IntegrityError
from flaschengeist import logger
from flaschengeist.database import db
from flaschengeist.utils.decorators import extract_session
from .models import (
Drink,
DrinkPrice,
Ingredient,
Tag,
DrinkType,
DrinkPriceVolume,
DrinkIngredient,
ExtraIngredient,
)
from .permissions import EDIT_VOLUME, EDIT_PRICE, EDIT_INGREDIENTS_DRINK
import flaschengeist.controller.imageController as image_controller
def update():
db.session.commit()
def get_tags():
return Tag.query.all()
def get_tag(identifier):
if isinstance(identifier, int):
ret = Tag.query.get(identifier)
elif isinstance(identifier, str):
ret = Tag.query.filter(Tag.name == identifier).one_or_none()
else:
logger.debug("Invalid identifier type for Tag")
raise BadRequest
if ret is None:
raise NotFound
return ret
def create_tag(data):
try:
if "id" in data:
data.pop("id")
allowed_keys = Tag().serialize().keys()
values = {key: value for key, value in data.items() if key in allowed_keys}
tag = Tag(**values)
db.session.add(tag)
update()
return tag
except IntegrityError:
raise BadRequest("Name already exists")
def update_tag(identifier, data):
tag = get_tag(identifier)
allowed_keys = Tag().serialize().keys()
values = {key: value for key, value in data.items() if key in allowed_keys}
for key, value in values.items():
setattr(tag, key, value)
try:
update()
except IntegrityError:
raise BadRequest("Name already exists")
def delete_tag(identifier):
tag = get_tag(identifier)
db.session.delete(tag)
try:
update()
except IntegrityError:
raise BadRequest("Tag still in use")
def get_drink_types():
return DrinkType.query.all()
def get_drink_type(identifier):
if isinstance(identifier, int):
ret = DrinkType.query.get(identifier)
elif isinstance(identifier, str):
ret = DrinkType.query.filter(Tag.name == identifier).one_or_none()
else:
logger.debug("Invalid identifier type for DrinkType")
raise BadRequest
if ret is None:
raise NotFound
return ret
def create_drink_type(name):
try:
drink_type = DrinkType(name=name)
db.session.add(drink_type)
update()
return drink_type
except IntegrityError:
raise BadRequest("Name already exists")
def rename_drink_type(identifier, new_name):
drink_type = get_drink_type(identifier)
drink_type.name = new_name
try:
update()
except IntegrityError:
raise BadRequest("Name already exists")
return drink_type
def delete_drink_type(identifier):
drink_type = get_drink_type(identifier)
db.session.delete(drink_type)
try:
update()
except IntegrityError:
raise BadRequest("DrinkType still in use")
def _create_public_drink(drink):
_volumes = []
for volume in drink.volumes:
_prices = []
for price in volume.prices:
price: DrinkPrice
if price.public:
_prices.append(price)
volume.prices = _prices
if len(volume.prices) > 0:
_volumes.append(volume)
drink.volumes = _volumes
if len(drink.volumes) > 0:
return drink
return None
def get_drinks(
name=None,
public=False,
limit=None,
offset=None,
search_name=None,
search_key=None,
ingredient=False,
receipt=None,
):
count = None
if name:
query = Drink.query.filter(Drink.name.contains(name))
else:
query = Drink.query
if ingredient:
query = query.filter(Drink.cost_per_volume >= 0)
if receipt:
query = query.filter(Drink._volumes.any(DrinkPriceVolume.ingredients != None))
if public:
query = query.filter(Drink._volumes.any(DrinkPriceVolume._prices.any(DrinkPrice.public)))
if search_name:
if search_key == "name":
query = query.filter(Drink.name.contains(search_name))
elif search_key == "article_id":
query = query.filter(Drink.article_id.contains(search_name))
elif search_key == "drink_type":
query = query.filter(Drink.type.has(DrinkType.name.contains(search_name)))
elif search_key == "tags":
query = query.filter(Drink.tags.any(Tag.name.contains(search_name)))
else:
query = query.filter(
(Drink.name.contains(search_name))
| (Drink.article_id.contains(search_name))
| (Drink.type.has(DrinkType.name.contains(search_name)))
| (Drink.tags.any(Tag.name.contains(search_name)))
)
query = query.order_by(Drink.name.asc())
if limit is not None:
count = query.count()
query = query.limit(limit)
if offset is not None:
query = query.offset(offset)
drinks = query.all()
for drink in drinks:
for volume in drink._volumes:
volume.prices = volume._prices
drink.volumes = drink._volumes
return drinks, count
def get_pricelist(
public=False,
limit=None,
offset=None,
search_name=None,
search_key=None,
sortBy=None,
descending=False,
):
count = None
query = DrinkPrice.query
if public:
query = query.filter(DrinkPrice.public)
if search_name:
if search_key == "name":
query = query.filter(DrinkPrice._volume.has(DrinkPriceVolume._drink.has(Drink.name.contains(search_name))))
if search_key == "type":
query = query.filter(
DrinkPrice._volume.has(
DrinkPriceVolume._drink.has(Drink.type.has(DrinkType.name.contains(search_name)))
)
)
if search_key == "tags":
query = query.filter(
DrinkPrice._volume.has(DrinkPriceVolume._drink.has(Drink.tags.any(Tag.name.conaitns(search_name))))
)
if search_key == "volume":
query = query.filter(DrinkPrice._volume.has(DrinkPriceVolume.volume == float(search_name)))
if search_key == "price":
query = query.filter(DrinkPrice.price == float(search_name))
if search_key == "description":
query = query.filter(DrinkPrice.description.contains(search_name))
else:
try:
search_name = float(search_name)
query = query.filter(
(DrinkPrice._volume.has(DrinkPriceVolume.volume == float(search_name)))
| (DrinkPrice.price == float(search_name))
)
except:
query = query.filter(
(DrinkPrice._volume.has(DrinkPriceVolume._drink.has(Drink.name.contains(search_name))))
| (
DrinkPrice._volume.has(
DrinkPriceVolume._drink.has(Drink.type.has(DrinkType.name.contains(search_name)))
)
)
| (
DrinkPrice._volume.has(
DrinkPriceVolume._drink.has(Drink.tags.any(Tag.name.contains(search_name)))
)
)
| (DrinkPrice.description.contains(search_name))
)
if sortBy == "type":
query = (
query.join(DrinkPrice._volume)
.join(DrinkPriceVolume._drink)
.join(Drink.type)
.order_by(DrinkType.name.desc() if descending else DrinkType.name.asc())
)
elif sortBy == "volume":
query = query.join(DrinkPrice._volume).order_by(
DrinkPriceVolume.volume.desc() if descending else DrinkPriceVolume.volume.asc()
)
elif sortBy == "price":
query = query.order_by(DrinkPrice.price.desc() if descending else DrinkPrice.price.asc())
else:
query = (
query.join(DrinkPrice._volume)
.join(DrinkPriceVolume._drink)
.order_by(Drink.name.desc() if descending else Drink.name.asc())
)
if limit is not None:
count = query.count()
query = query.limit(limit)
if offset is not None:
query = query.offset(offset)
prices = query.all()
for price in prices:
price._volume.drink = price._volume._drink
price.volume = price._volume
return prices, count
def get_drink(identifier, public=False):
drink = None
if isinstance(identifier, int):
drink = Drink.query.get(identifier)
elif isinstance(identifier, str):
drink = Drink.query.filter(Tag.name == identifier).one_or_none()
else:
raise BadRequest("Invalid identifier type for Drink")
if drink is None:
raise NotFound
if public:
return _create_public_drink(drink)
for volume in drink._volumes:
volume.prices = volume._prices
drink.volumes = drink._volumes
return drink
def set_drink(data):
return update_drink(-1, data)
def update_drink(identifier, data):
try:
session = extract_session()
if "id" in data:
data.pop("id")
volumes = data.pop("volumes") if "volumes" in data else None
tags = []
if "tags" in data:
_tags = data.pop("tags")
if isinstance(_tags, list):
for _tag in _tags:
if isinstance(_tag, dict) and "id" in _tag:
tags.append(get_tag(_tag["id"]))
drink_type = data.pop("type")
if isinstance(drink_type, dict) and "id" in drink_type:
drink_type = drink_type["id"]
drink_type = get_drink_type(drink_type)
if identifier == -1:
drink = Drink()
db.session.add(drink)
else:
drink = get_drink(identifier)
for key, value in data.items():
if hasattr(drink, key) and key != "has_image":
setattr(drink, key, value if value != "" else None)
if drink_type:
drink.type = drink_type
if volumes is not None and session.user_.has_permission(EDIT_VOLUME):
drink._volumes = []
drink._volumes = set_volumes(volumes)
if len(tags) > 0:
drink.tags = tags
db.session.commit()
for volume in drink._volumes:
volume.prices = volume._prices
drink.volumes = drink._volumes
return drink
except (NotFound, KeyError):
raise BadRequest
def set_volumes(volumes):
retVal = []
if not isinstance(volumes, list):
raise BadRequest
for volume in volumes:
retVal.append(set_volume(volume))
return retVal
def delete_drink(identifier):
drink = get_drink(identifier)
db.session.delete(drink)
db.session.commit()
def get_volume(identifier):
return DrinkPriceVolume.query.get(identifier)
def get_volumes(drink_id=None):
if drink_id:
return DrinkPriceVolume.query.filter(DrinkPriceVolume.drink_id == drink_id).all()
return DrinkPriceVolume.query.all()
def set_volume(data):
session = extract_session()
allowed_keys = DrinkPriceVolume().serialize().keys()
values = {key: value for key, value in data.items() if key in allowed_keys}
prices = None
ingredients = None
if "prices" in values:
prices = values.pop("prices")
if "ingredients" in values:
ingredients = values.pop("ingredients")
values.pop("id", None)
volume = DrinkPriceVolume(**values)
db.session.add(volume)
if prices and session.user_.has_permission(EDIT_PRICE):
set_prices(prices, volume)
if ingredients and session.user_.has_permission(EDIT_INGREDIENTS_DRINK):
set_ingredients(ingredients, volume)
return volume
def set_prices(prices, volume):
if isinstance(prices, list):
_prices = []
for _price in prices:
price = set_price(_price)
_prices.append(price)
volume._prices = _prices
def set_ingredients(ingredients, volume):
if isinstance(ingredients, list):
_ingredietns = []
for _ingredient in ingredients:
ingredient = set_ingredient(_ingredient)
_ingredietns.append(ingredient)
volume.ingredients = _ingredietns
def delete_volume(identifier):
volume = get_volume(identifier)
db.session.delete(volume)
db.session.commit()
def get_price(identifier):
if isinstance(identifier, int):
return DrinkPrice.query.get(identifier)
raise NotFound
def get_prices(volume_id=None):
if volume_id:
return DrinkPrice.query.filter(DrinkPrice.volume_id_ == volume_id).all()
return DrinkPrice.query.all()
def set_price(data):
allowed_keys = list(DrinkPrice().serialize().keys())
allowed_keys.append("description")
logger.debug(f"allowed_key {allowed_keys}")
values = {key: value for key, value in data.items() if key in allowed_keys}
values.pop("id", -1)
price = DrinkPrice(**values)
db.session.add(price)
return price
def delete_price(identifier):
price = get_price(identifier)
db.session.delete(price)
db.session.commit()
def set_drink_ingredient(data):
allowed_keys = DrinkIngredient().serialize().keys()
values = {key: value for key, value in data.items() if key in allowed_keys}
if "cost_per_volume" in values:
values.pop("cost_per_volume")
if "name" in values:
values.pop("name")
values.pop("id", -1)
drink_ingredient = DrinkIngredient(**values)
db.session.add(drink_ingredient)
return drink_ingredient
def get_ingredient(identifier):
return Ingredient.query.get(identifier)
def set_ingredient(data):
drink_ingredient_value = None
extra_ingredient_value = None
if "drink_ingredient" in data:
drink_ingredient_value = data.pop("drink_ingredient")
if "extra_ingredient" in data:
extra_ingredient_value = data.pop("extra_ingredient")
data.pop("id", -1)
ingredient = Ingredient(**data)
db.session.add(ingredient)
if drink_ingredient_value:
ingredient.drink_ingredient = set_drink_ingredient(drink_ingredient_value)
if extra_ingredient_value:
if "id" in extra_ingredient_value:
ingredient.extra_ingredient = get_extra_ingredient(extra_ingredient_value.get("id"))
return ingredient
def delete_ingredient(identifier):
ingredient = get_ingredient(identifier)
if ingredient.drink_ingredient:
db.session.delete(ingredient.drink_ingredient)
db.session.delete(ingredient)
db.session.commit()
def get_extra_ingredients():
return ExtraIngredient.query.all()
def get_extra_ingredient(identifier):
return ExtraIngredient.query.get(identifier)
def set_extra_ingredient(data):
allowed_keys = ExtraIngredient().serialize().keys()
if "id" in data:
data.pop("id")
values = {key: value for key, value in data.items() if key in allowed_keys}
extra_ingredient = ExtraIngredient(**values)
db.session.add(extra_ingredient)
db.session.commit()
return extra_ingredient
def update_extra_ingredient(identifier, data):
allowed_keys = ExtraIngredient().serialize().keys()
if "id" in data:
data.pop("id")
values = {key: value for key, value in data.items() if key in allowed_keys}
extra_ingredient = get_extra_ingredient(identifier)
if extra_ingredient:
for key, value in values.items():
setattr(extra_ingredient, key, value)
db.session.commit()
return extra_ingredient
def delete_extra_ingredient(identifier):
extra_ingredient = get_extra_ingredient(identifier)
db.session.delete(extra_ingredient)
db.session.commit()
def save_drink_picture(identifier, file):
drink = delete_drink_picture(identifier)
drink.image_ = image_controller.upload_image(file)
db.session.commit()
return drink
def delete_drink_picture(identifier):
drink = get_drink(identifier)
if drink.image_:
db.session.delete(drink.image_)
drink.image_ = None
db.session.commit()
return drink

View File

@ -5,24 +5,23 @@ Provides routes used to configure roles and permissions of users / roles.
from werkzeug.exceptions import BadRequest from werkzeug.exceptions import BadRequest
from flask import Blueprint, request, jsonify from flask import Blueprint, request, jsonify
from http.client import NO_CONTENT
from flaschengeist.plugins import Plugin from flaschengeist.plugins import Plugin
from flaschengeist.utils.decorators import login_required
from flaschengeist.controller import roleController from flaschengeist.controller import roleController
from flaschengeist.utils.HTTP import created from flaschengeist.utils.HTTP import created, no_content
from flaschengeist.utils.decorators import login_required
roles_bp = Blueprint("roles", __name__) from . import permissions
_permission_edit = "roles_edit"
_permission_delete = "roles_delete"
class RolesPlugin(Plugin): class RolesPlugin(Plugin):
def __init__(self, config): blueprint = Blueprint("roles", __name__)
super().__init__(config, roles_bp, permissions=[_permission_edit, _permission_delete])
def install(self):
self.install_permissions(permissions.permissions)
@roles_bp.route("/roles", methods=["GET"]) @RolesPlugin.blueprint.route("/roles", methods=["GET"])
@login_required() @login_required()
def list_roles(current_session): def list_roles(current_session):
"""List all existing roles """List all existing roles
@ -39,8 +38,8 @@ def list_roles(current_session):
return jsonify(roles) return jsonify(roles)
@roles_bp.route("/roles", methods=["POST"]) @RolesPlugin.blueprint.route("/roles", methods=["POST"])
@login_required(permission=_permission_edit) @login_required(permission=permissions.EDIT)
def create_role(current_session): def create_role(current_session):
"""Create new role """Create new role
@ -62,7 +61,7 @@ def create_role(current_session):
return created(roleController.create_role(data["name"], permissions)) return created(roleController.create_role(data["name"], permissions))
@roles_bp.route("/roles/permissions", methods=["GET"]) @RolesPlugin.blueprint.route("/roles/permissions", methods=["GET"])
@login_required() @login_required()
def list_permissions(current_session): def list_permissions(current_session):
"""List all existing permissions """List all existing permissions
@ -79,7 +78,7 @@ def list_permissions(current_session):
return jsonify(permissions) return jsonify(permissions)
@roles_bp.route("/roles/<role_name>", methods=["GET"]) @RolesPlugin.blueprint.route("/roles/<role_name>", methods=["GET"])
@login_required() @login_required()
def get_role(role_name, current_session): def get_role(role_name, current_session):
"""Get role by name """Get role by name
@ -97,8 +96,8 @@ def get_role(role_name, current_session):
return jsonify(role) return jsonify(role)
@roles_bp.route("/roles/<int:role_id>", methods=["PUT"]) @RolesPlugin.blueprint.route("/roles/<int:role_id>", methods=["PUT"])
@login_required(permission=_permission_edit) @login_required(permission=permissions.EDIT)
def edit_role(role_id, current_session): def edit_role(role_id, current_session):
"""Edit role, rename and / or set permissions """Edit role, rename and / or set permissions
@ -118,13 +117,13 @@ def edit_role(role_id, current_session):
data = request.get_json() data = request.get_json()
if "permissions" in data: if "permissions" in data:
roleController.set_permissions(role, data["permissions"]) roleController.set_permissions(role, data["permissions"])
if "name" in data: if "name" in data and data["name"] != role.name:
roleController.update_role(role, data["name"]) roleController.update_role(role, data["name"])
return "", NO_CONTENT return no_content()
@roles_bp.route("/roles/<int:role_id>", methods=["DELETE"]) @RolesPlugin.blueprint.route("/roles/<int:role_id>", methods=["DELETE"])
@login_required(permission=_permission_delete) @login_required(permission=permissions.DELETE)
def delete_role(role_id, current_session): def delete_role(role_id, current_session):
"""Delete role """Delete role
@ -135,8 +134,8 @@ def delete_role(role_id, current_session):
current_session: Session sent with Authorization Header current_session: Session sent with Authorization Header
Returns: Returns:
HTTP-204 or HTTP error HTTP-204 or HTTP error (HTTP-409 Conflict if role still in use)
""" """
role = roleController.get(role_id) role = roleController.get(role_id)
roleController.delete(role) roleController.delete(role)
return "", NO_CONTENT return no_content()

View File

@ -0,0 +1,7 @@
EDIT = "roles_edit"
"""Can edit roles, assign permissions to roles and change names"""
DELETE = "roles_delete"
"""Can delete roles"""
permissions = [value for key, value in globals().items() if not key.startswith("_")]

View File

@ -1,415 +0,0 @@
"""Schedule plugin
Provides duty schedule / duty roster functions
"""
from datetime import datetime, timedelta, timezone
from http.client import NO_CONTENT
from flask import Blueprint, request, jsonify
from werkzeug.exceptions import BadRequest, NotFound, Forbidden
from flaschengeist.plugins import Plugin
from flaschengeist.models.session import Session
from flaschengeist.utils.decorators import login_required
from flaschengeist.utils.datetime import from_iso_format
from flaschengeist.controller import userController
from . import event_controller, permissions
from . import models
from ...utils.HTTP import no_content
schedule_bp = Blueprint("schedule", __name__, url_prefix="/schedule")
class SchedulePlugin(Plugin):
models = models
def __init__(self, config):
super().__init__(
blueprint=schedule_bp,
permissions=permissions.permissions,
)
@schedule_bp.route("/event-types", methods=["GET"])
@schedule_bp.route("/event-types/<int:identifier>", methods=["GET"])
@login_required()
def get_event_types(current_session, identifier=None):
"""Get EventType(s)
Route: ``/schedule/event-types`` | Method: ``GET``
Route: ``/schedule/event-types/<identifier>`` | Method: ``GET``
Args:
current_session: Session sent with Authorization Header
identifier: If querying a specific EventType
Returns:
JSON encoded (list of) EventType(s) or HTTP-error
"""
if identifier:
result = event_controller.get_event_type(identifier)
else:
result = event_controller.get_event_types()
return jsonify(result)
@schedule_bp.route("/event-types", methods=["POST"])
@login_required(permission=permissions.EVENT_TYPE)
def new_event_type(current_session):
"""Create a new EventType
Route: ``/schedule/event-types`` | Method: ``POST``
POST-data: ``{name: string}``
Args:
current_session: Session sent with Authorization Header
Returns:
HTTP-Created or HTTP-error
"""
data = request.get_json()
if "name" not in data:
raise BadRequest
event_type = event_controller.create_event_type(data["name"])
return jsonify(event_type)
@schedule_bp.route("/event-types/<int:identifier>", methods=["PUT", "DELETE"])
@login_required(permission=permissions.EVENT_TYPE)
def modify_event_type(identifier, current_session):
"""Rename or delete an event type
Route: ``/schedule/event-types/<id>`` | Method: ``PUT`` or ``DELETE``
POST-data: (if renaming) ``{name: string}``
Args:
identifier: Identifier of the EventType
current_session: Session sent with Authorization Header
Returns:
HTTP-NoContent or HTTP-error
"""
if request.method == "DELETE":
event_controller.delete_event_type(identifier)
else:
data = request.get_json()
if "name" not in data:
raise BadRequest("Parameter missing in data")
event_controller.rename_event_type(identifier, data["name"])
return "", NO_CONTENT
@schedule_bp.route("/job-types", methods=["GET"])
@login_required()
def get_job_types(current_session):
"""Get all JobTypes
Route: ``/schedule/job-types`` | Method: ``GET``
Args:
current_session: Session sent with Authorization Header
Returns:
JSON encoded list of JobType HTTP-error
"""
types = event_controller.get_job_types()
return jsonify(types)
@schedule_bp.route("/job-types", methods=["POST"])
@login_required(permission=permissions.JOB_TYPE)
def new_job_type(current_session):
"""Create a new JobType
Route: ``/schedule/job-types`` | Method: ``POST``
POST-data: ``{name: string}``
Args:
current_session: Session sent with Authorization Header
Returns:
JSON encoded JobType or HTTP-error
"""
data = request.get_json()
if "name" not in data:
raise BadRequest
jt = event_controller.create_job_type(data["name"])
return jsonify(jt)
@schedule_bp.route("/job-types/<int:type_id>", methods=["PUT", "DELETE"])
@login_required(permission=permissions.JOB_TYPE)
def modify_job_type(type_id, current_session):
"""Rename or delete a JobType
Route: ``/schedule/job-types/<name>`` | Method: ``PUT`` or ``DELETE``
POST-data: (if renaming) ``{name: string}``
Args:
type_id: Identifier of the JobType
current_session: Session sent with Authorization Header
Returns:
HTTP-NoContent or HTTP-error
"""
if request.method == "DELETE":
event_controller.delete_job_type(type_id)
else:
data = request.get_json()
if "name" not in data:
raise BadRequest("Parameter missing in data")
event_controller.rename_job_type(type_id, data["name"])
return "", NO_CONTENT
@schedule_bp.route("/events/<int:event_id>", methods=["GET"])
@login_required()
def get_event(event_id, current_session):
"""Get event by id
Route: ``/schedule/events/<event_id>`` | Method: ``GET``
Args:
event_id: ID identifying the event
current_session: Session sent with Authorization Header
Returns:
JSON encoded event object
"""
event = event_controller.get_event(event_id)
return jsonify(event)
@schedule_bp.route("/events", methods=["GET"])
@schedule_bp.route("/events/<int:year>/<int:month>", methods=["GET"])
@schedule_bp.route("/events/<int:year>/<int:month>/<int:day>", methods=["GET"])
@login_required()
def get_events(current_session, year=datetime.now().year, month=datetime.now().month, day=None):
"""Get Event objects for specified date (or month or year),
if nothing set then events for current month are returned
Route: ``/schedule/events[/<year>/<month>[/<int:day>]]`` | Method: ``GET``
Args:
year (int, optional): year to query, defaults to current year
month (int, optional): month to query (if set), defaults to current month
day (int, optional): day to query events for (if set)
current_session: Session sent with Authorization Header
Returns:
JSON encoded list containing events found or HTTP-error
"""
try:
begin = datetime(year=year, month=month, day=1, tzinfo=timezone.utc)
if day:
begin += timedelta(days=day - 1)
end = begin + timedelta(days=1)
else:
if month == 12:
end = datetime(year=year + 1, month=1, day=1, tzinfo=timezone.utc)
else:
end = datetime(year=year, month=month + 1, day=1, tzinfo=timezone.utc)
events = event_controller.get_events(begin, end)
return jsonify(events)
except ValueError:
raise BadRequest("Invalid date given")
def _add_job(event, data):
end = None
try:
start = from_iso_format(data["start"])
except (KeyError, ValueError):
raise BadRequest("Missing POST parameter")
if "end" in data:
end = from_iso_format(data["end"])
if "required_services" not in data:
raise BadRequest
job_type = data["type"]
if isinstance(job_type, dict):
job_type = data["type"]["id"]
job_type = event_controller.get_job_type(job_type)
event_controller.add_job(event, job_type, data["required_services"], start, end, comment=data.get("comment"))
@schedule_bp.route("/events", methods=["POST"])
@login_required(permission=permissions.CREATE)
def create_event(current_session):
"""Create an new event
Route: ``/schedule/events`` | Method: ``POST``
POST-data: See interfaces for Event, can already contain jobs
Args:
current_session: Session sent with Authorization Header
Returns:
JSON encoded Event object or HTTP-error
"""
data = request.get_json()
end = data.get("end", None)
try:
start = from_iso_format(data["start"])
if end is not None:
end = from_iso_format(end)
data_type = data["type"]
if isinstance(data_type, dict):
data_type = data["type"]["id"]
event_type = event_controller.get_event_type(data_type)
except KeyError:
raise BadRequest("Missing POST parameter")
except (NotFound, ValueError):
raise BadRequest("Invalid parameter")
event = event_controller.create_event(
start=start, end=end, event_type=event_type, description=data.get("description", None)
)
if "jobs" in data:
for job in data["jobs"]:
_add_job(event, job)
return jsonify(event)
@schedule_bp.route("/events/<int:event_id>", methods=["PUT"])
@login_required(permission=permissions.EDIT)
def modify_event(event_id, current_session):
"""Modify an event
Route: ``/schedule/events/<event_id>`` | Method: ``PUT``
POST-data: See interfaces for Event, can already contain slots
Args:
event_id: Identifier of the event
current_session: Session sent with Authorization Header
Returns:
JSON encoded Event object or HTTP-error
"""
event = event_controller.get_event(event_id)
data = request.get_json()
if "start" in data:
event.start = from_iso_format(data["start"])
if "description" in data:
event.description = data["description"]
if "type" in data:
event_type = event_controller.get_event_type(data["type"])
event.type = event_type
event_controller.update()
return jsonify(event)
@schedule_bp.route("/events/<int:event_id>", methods=["DELETE"])
@login_required(permission=permissions.DELETE)
def delete_event(event_id, current_session):
"""Delete an event
Route: ``/schedule/events/<event_id>`` | Method: ``DELETE``
Args:
event_id: Identifier of the event
current_session: Session sent with Authorization Header
Returns:
HTTP-NoContent or HTTP-error
"""
event_controller.delete_event(event_id)
return "", NO_CONTENT
@schedule_bp.route("/events/<int:event_id>/jobs", methods=["POST"])
@login_required(permission=permissions.EDIT)
def add_job(event_id, current_session):
"""Add an new Job to an Event / EventSlot
Route: ``/schedule/events/<event_id>/jobs`` | Method: ``POST``
POST-data: See Job
Args:
event_id: Identifier of the event
current_session: Session sent with Authorization Header
Returns:
JSON encoded Event object or HTTP-error
"""
event = event_controller.get_event(event_id)
_add_job(event, request.get_json())
return jsonify(event)
@schedule_bp.route("/events/<int:event_id>/jobs/<int:job_id>", methods=["DELETE"])
@login_required(permission=permissions.DELETE)
def delete_job(event_id, job_id, current_session):
"""Delete a Job
Route: ``/events/<event_id>/jobs/<job_id>`` | Method: ``DELETE``
Args:
event_id: Identifier of the event
job_id: Identifier of the Job
current_session: Session sent with Authorization Header
Returns:
HTTP-no-content or HTTP error
"""
job_slot = event_controller.get_job(job_id, event_id)
event_controller.delete_job(job_slot)
return no_content()
@schedule_bp.route("/events/<int:event_id>/jobs/<int:job_id>", methods=["PUT"])
@login_required()
def update_job(event_id, job_id, current_session: Session):
"""Edit Job or assign user to the Job
Route: ``/events/<event_id>/jobs/<job_id>`` | Method: ``PUT``
POST-data: See TS interface for Job or ``{user: {userid: string, value: number}}``
Args:
event_id: Identifier of the event
job_id: Identifier of the Job
current_session: Session sent with Authorization Header
Returns:
JSON encoded Event object or HTTP-error
"""
job = event_controller.get_job(job_id, event_id)
data = request.get_json()
if not data:
raise BadRequest
if ("user" not in data or len(data) > 1) and not current_session._user.has_permission(permissions.EDIT):
raise Forbidden
if "user" in data:
try:
user = userController.get_user(data["user"]["userid"])
value = data["user"]["value"]
if (user == current_session._user and not user.has_permission(permissions.ASSIGN)) or (
user != current_session._user and not current_session._user.has_permission(permissions.ASSIGN_OTHER)
):
raise Forbidden
event_controller.assign_to_job(job, user, value)
except (KeyError, ValueError):
raise BadRequest
if "required_services" in data:
job.required_services = data["required_services"]
if "type" in data:
job.type = event_controller.get_job_type(data["type"])
event_controller.update()
return jsonify(job.event_)
# TODO: JobTransfer

View File

@ -1,182 +0,0 @@
from werkzeug.exceptions import BadRequest, NotFound
from sqlalchemy.exc import IntegrityError
from flaschengeist import logger
from flaschengeist.database import db
from flaschengeist.plugins.schedule.models import EventType, Event, Job, JobType, Service
def update():
db.session.commit()
def get_event_types():
return EventType.query.all()
def get_event_type(identifier):
"""Get EventType by ID (int) or name (string)"""
if isinstance(identifier, int):
et = EventType.query.get(identifier)
elif isinstance(identifier, str):
et = EventType.query.filter(EventType.name == identifier).one_or_none()
else:
logger.debug("Invalid identifier type for EventType")
raise BadRequest
if not et:
raise NotFound
return et
def create_event_type(name):
try:
event = EventType(name=name)
db.session.add(event)
db.session.commit()
return event
except IntegrityError:
raise BadRequest("Name already exists")
def rename_event_type(identifier, new_name):
event_type = get_event_type(identifier)
event_type.name = new_name
try:
db.session.commit()
except IntegrityError:
raise BadRequest("Name already exists")
def delete_event_type(name):
event_type = get_event_type(name)
db.session.delete(event_type)
try:
db.session.commit()
except IntegrityError:
raise BadRequest("Type still in use")
def get_job_types():
return JobType.query.all()
def get_job_type(type_id):
job_type = JobType.query.get(type_id)
print(job_type)
if not job_type:
raise NotFound
return job_type
def create_job_type(name):
try:
job_type = JobType(name=name)
db.session.add(job_type)
db.session.commit()
return job_type
except IntegrityError:
raise BadRequest("Name already exists")
def rename_job_type(name, new_name):
job_type = get_job_type(name)
job_type.name = new_name
try:
db.session.commit()
except IntegrityError:
raise BadRequest("Name already exists")
def delete_job_type(name):
job_type = get_job_type(name)
db.session.delete(job_type)
try:
db.session.commit()
except IntegrityError:
raise BadRequest("Type still in use")
def get_event(event_id):
event = Event.query.get(event_id)
if event is None:
raise NotFound
return event
def get_events(start, end):
"""Query events which start from begin until end
Args:
start (datetime): Earliest start
end (datetime): Latest start
Returns: collection of Event objects
"""
return Event.query.filter((start <= Event.start), (Event.start < end)).all()
def delete_event(event_id):
"""Delete event with given ID
Args:
event_id: id of Event to delete
Raises:
NotFound if not found
"""
event = get_event(event_id)
db.session.delete(event)
db.session.commit()
def create_event(event_type, start, end=None, jobs=[], description=None):
try:
logger.debug(event_type)
event = Event(start=start, end=end, description=description, type=event_type, jobs=jobs)
db.session.add(event)
db.session.commit()
return event
except IntegrityError:
logger.debug("Database error when creating new event", exc_info=True)
raise BadRequest
def get_job(job_slot_id, event_id):
js = Job.query.filter(Job.id == job_slot_id).filter(Job._event_id == event_id).one_or_none()
if js is None:
raise NotFound
return js
def add_job(event, job_type, required_services, start, end=None, comment=None):
job = Job(required_services=required_services, type=job_type, start=start, end=end, comment=comment)
event.jobs.append(job)
update()
return job
def update():
try:
db.session.commit()
except IntegrityError:
logger.debug("Error, looks like a Job with that type already exists on an event", exc_info=True)
raise BadRequest()
def delete_job(job: Job):
db.session.delete(job)
db.session.commit()
def assign_to_job(job: Job, user, value):
service = Service.query.get((job.id, user._id))
if value < 0:
if not service:
raise BadRequest
db.session.delete(service)
else:
if service:
service.value = value
else:
service = Service(user_=user, value=value, job_=job)
db.session.add(service)
db.session.commit()

View File

@ -1,86 +0,0 @@
from datetime import datetime
from typing import Optional
from sqlalchemy import UniqueConstraint
from flaschengeist.models import ModelSerializeMixin, UtcDateTime
from flaschengeist.models.user import User
from flaschengeist.database import db
#########
# Types #
#########
class EventType(db.Model, ModelSerializeMixin):
__tablename__ = "schedule_event_type"
id: int = db.Column(db.Integer, primary_key=True)
name: str = db.Column(db.String(30), nullable=False, unique=True)
class JobType(db.Model, ModelSerializeMixin):
__tablename__ = "schedule_job_type"
id: int = db.Column(db.Integer, primary_key=True)
name: str = db.Column(db.String(30), nullable=False, unique=True)
########
# Jobs #
########
class Service(db.Model, ModelSerializeMixin):
__tablename__ = "schedule_service"
userid: str = ""
value: float = db.Column(db.Numeric(precision=3, scale=2, asdecimal=False), nullable=False)
_job_id = db.Column("job_id", db.Integer, db.ForeignKey("schedule_job.id"), nullable=False, primary_key=True)
_user_id = db.Column("user_id", db.Integer, db.ForeignKey("user.id"), nullable=False, primary_key=True)
user_: User = db.relationship("User")
job_ = db.relationship("Job")
@property
def userid(self):
return self.user_.userid
class Job(db.Model, ModelSerializeMixin):
__tablename__ = "schedule_job"
_type_id = db.Column("type_id", db.Integer, db.ForeignKey("schedule_job_type.id"), nullable=False)
_event_id = db.Column("event_id", db.Integer, db.ForeignKey("schedule_event.id"), nullable=False)
id: int = db.Column(db.Integer, primary_key=True)
start: datetime = db.Column(UtcDateTime, nullable=False)
end: Optional[datetime] = db.Column(UtcDateTime)
comment: str = db.Column(db.String(256))
type: JobType = db.relationship("JobType")
services: [Service] = db.relationship("Service", back_populates="job_")
required_services: float = db.Column(db.Numeric(precision=4, scale=2, asdecimal=False), nullable=False)
event_ = db.relationship("Event", back_populates="jobs")
__table_args__ = (UniqueConstraint("type_id", "start", name="_type_start_uc"),)
##########
# Events #
##########
class Event(db.Model, ModelSerializeMixin):
"""Model for an Event"""
__tablename__ = "schedule_event"
_type_id = db.Column(
"type_id", db.Integer, db.ForeignKey("schedule_event_type.id", ondelete="CASCADE"), nullable=False
)
id: int = db.Column(db.Integer, primary_key=True)
start: datetime = db.Column(UtcDateTime, nullable=False)
end: datetime = db.Column(UtcDateTime)
description: Optional[str] = db.Column(db.String(255))
type: EventType = db.relationship("EventType")
jobs: [Job] = db.relationship(
"Job", back_populates="event_", cascade="all,delete,delete-orphan", order_by="[Job.start, Job.end]"
)

View File

@ -1,22 +0,0 @@
CREATE = "schedule_create"
"""Can create events"""
EDIT = "schedule_edit"
"""Can edit events"""
DELETE = "schedule_delete"
"""Can delete events"""
EVENT_TYPE = "schedule_event_type"
"""Can create and edit EventTypes"""
JOB_TYPE = "schedule_job_type"
"""Can create and edit JobTypes"""
ASSIGN = "schedule_assign"
"""Can self assign to jobs"""
ASSIGN_OTHER = "schedule_assign_other"
"""Can assign other users to jobs"""
permissions = [value for key, value in globals().items() if not key.startswith("_")]

View File

@ -0,0 +1,86 @@
from flask import Blueprint
from datetime import datetime, timedelta
from flaschengeist import logger
from flaschengeist.config import config
from flaschengeist.plugins import Plugin
from flaschengeist.utils.HTTP import no_content
class __Task:
def __init__(self, function, **kwags):
self.function = function
self.interval = timedelta(**kwags)
_scheduled_tasks: dict[__Task] = dict()
def add_scheduled(id: str, function, replace=False, **kwargs):
if id not in _scheduled_tasks or replace:
_scheduled_tasks[id] = __Task(function, **kwargs)
logger.info(f"Registered task: {id}")
else:
logger.debug(f"Skipping already registered task: {id}")
def scheduled(id: str, replace=False, **kwargs):
"""
kwargs: days, hours, minutes
"""
def real_decorator(function):
add_scheduled(id, function, replace, **kwargs)
return function
if not isinstance(id, str):
raise TypeError
return real_decorator
class SchedulerPlugin(Plugin):
blueprint = Blueprint("scheduler", __name__)
def load(self):
def __view_func():
self.run_tasks()
return no_content()
def __passiv_func(v):
try:
self.run_tasks()
except:
logger.error("Error while executing scheduled tasks!", exc_info=True)
cron = config.get("scheduler", {}).get("cron", "passive_web").lower()
if cron == "passive_web":
self.blueprint.teardown_app_request(__passiv_func)
elif cron == "active_web":
self.blueprint.add_url_rule("/cron", view_func=__view_func)
def run_tasks(self):
from ..database import db
self = db.session.merge(self)
changed = False
now = datetime.now()
status = self.get_setting("status", default=dict())
for id, task in _scheduled_tasks.items():
last_run = status.setdefault(id, now)
if last_run + task.interval <= now:
logger.debug(
f"Run task {id}, was scheduled for {last_run + task.interval}, next iteration: {now + task.interval}"
)
task.function()
changed = True
else:
logger.debug(f"Skip task {id}, is scheduled for {last_run + task.interval}")
if changed:
# Remove not registered tasks
for id in status.keys():
if id not in _scheduled_tasks.keys():
del status[id]
self.set_setting("status", status)

View File

@ -2,29 +2,33 @@
Provides routes used to manage users Provides routes used to manage users
""" """
from http.client import NO_CONTENT, CREATED
from flask import Blueprint, request, jsonify, make_response, Response
from werkzeug.exceptions import BadRequest, Forbidden, MethodNotAllowed, NotFound
from . import permissions from datetime import datetime
from http.client import CREATED
from flask import Blueprint, Response, after_this_request, jsonify, make_response, request
from werkzeug.exceptions import BadRequest, Forbidden, MethodNotAllowed
from flaschengeist import logger from flaschengeist import logger
from flaschengeist.config import config from flaschengeist.config import config
from flaschengeist.plugins import Plugin
from flaschengeist.models.user import User, _Avatar
from flaschengeist.utils.decorators import login_required, extract_session, headers
from flaschengeist.controller import userController from flaschengeist.controller import userController
from flaschengeist.utils.HTTP import created from flaschengeist.models import User
from flaschengeist.plugins import Plugin
from flaschengeist.utils.datetime import from_iso_format from flaschengeist.utils.datetime import from_iso_format
from flaschengeist.utils.decorators import extract_session, headers, login_required
from flaschengeist.utils.HTTP import created, no_content
users_bp = Blueprint("users", __name__) from . import permissions
class UsersPlugin(Plugin): class UsersPlugin(Plugin):
def __init__(self, cfg): blueprint = Blueprint("users", __name__)
super().__init__(blueprint=users_bp, permissions=permissions.permissions)
def install(self):
self.install_permissions(permissions.permissions)
@users_bp.route("/users", methods=["POST"]) @UsersPlugin.blueprint.route("/users", methods=["POST"])
def register(): def register():
"""Register a new user """Register a new user
The password will be set to a random string of at lease 16byte entropy. The password will be set to a random string of at lease 16byte entropy.
@ -55,9 +59,9 @@ def register():
return make_response(jsonify(userController.register(data)), CREATED) return make_response(jsonify(userController.register(data)), CREATED)
@users_bp.route("/users", methods=["GET"]) @UsersPlugin.blueprint.route("/users", methods=["GET"])
@login_required() @login_required()
@headers({"Cache-Control": "private, must-revalidate, max-age=3600"}) # @headers({"Cache-Control": "private, must-revalidate, max-age=3600"})
def list_users(current_session): def list_users(current_session):
"""List all existing users """List all existing users
@ -74,9 +78,9 @@ def list_users(current_session):
return jsonify(users) return jsonify(users)
@users_bp.route("/users/<userid>", methods=["GET"]) @UsersPlugin.blueprint.route("/users/<userid>", methods=["GET"])
@login_required() @login_required()
@headers({"Cache-Control": "private, must-revalidate, max-age=3600"}) @headers({"Cache-Control": "private, must-revalidate, max-age=300"})
def get_user(userid, current_session): def get_user(userid, current_session):
"""Retrieve user by userid """Retrieve user by userid
@ -90,44 +94,69 @@ def get_user(userid, current_session):
JSON encoded `flaschengeist.models.user.User` or if userid is current user also containing permissions or HTTP error JSON encoded `flaschengeist.models.user.User` or if userid is current user also containing permissions or HTTP error
""" """
logger.debug("Get information of user {{ {} }}".format(userid)) logger.debug("Get information of user {{ {} }}".format(userid))
user: User = userController.get_user(userid) user: User = userController.get_user(
userid, True
) # This is the only API point that should return data for deleted users
serial = user.serialize() serial = user.serialize()
if userid == current_session._user.userid: if userid == current_session.user_.userid:
serial["permissions"] = user.get_permissions() serial["permissions"] = user.get_permissions()
return jsonify(serial) return jsonify(serial)
@users_bp.route("/users/<userid>/avatar", methods=["GET"]) @UsersPlugin.blueprint.route("/users/<userid>/frontend", methods=["POST", "GET"])
@headers({"Cache-Control": "public, max-age=604800"}) @login_required()
def frontend(userid, current_session):
if current_session.user_.userid != userid:
raise Forbidden
if request.method == "POST":
if request.content_length > 1024**2:
raise BadRequest
current_session.user_.set_attribute("frontend", request.get_json())
return no_content()
else:
content = current_session.user_.get_attribute("frontend", None)
if content is None:
return no_content()
return jsonify(content)
@UsersPlugin.blueprint.route("/users/<userid>/avatar", methods=["GET"])
@headers({"Cache-Control": "public, must-revalidate, max-age=10"})
def get_avatar(userid): def get_avatar(userid):
etag = None
if "If-None-Match" in request.headers:
etag = request.headers["If-None-Match"]
user = userController.get_user(userid) user = userController.get_user(userid)
avatar = userController.load_avatar(user) return userController.load_avatar(user, etag)
if len(avatar.binary) > 0:
response = Response(avatar.binary, mimetype=avatar.mimetype)
response.add_etag()
return response.make_conditional(request)
raise NotFound
@users_bp.route("/users/<userid>/avatar", methods=["POST"]) @UsersPlugin.blueprint.route("/users/<userid>/avatar", methods=["POST"])
@login_required() @login_required()
def set_avatar(userid, current_session): def set_avatar(userid, current_session):
user = userController.get_user(userid) user = userController.get_user(userid)
if userid != current_session._user.userid and not current_session._user.has_permission(permissions.EDIT): if userid != current_session.user_.userid and not current_session.user_.has_permission(permissions.EDIT):
raise Forbidden raise Forbidden
file = request.files.get("file") file = request.files.get("file")
if file: if file:
avatar = _Avatar() userController.save_avatar(user, file)
avatar.mimetype = file.content_type
avatar.binary = bytearray(file.stream.read())
userController.save_avatar(user, avatar)
return created() return created()
else: else:
raise BadRequest raise BadRequest
@users_bp.route("/users/<userid>", methods=["DELETE"]) @UsersPlugin.blueprint.route("/users/<userid>/avatar", methods=["DELETE"])
@login_required()
def delete_avatar(userid, current_session):
user = userController.get_user(userid)
if userid != current_session.user_.userid and not current_session.user_.has_permission(permissions.EDIT):
raise Forbidden
userController.delete_avatar(user)
return no_content()
@UsersPlugin.blueprint.route("/users/<userid>", methods=["DELETE"])
@login_required(permission=permissions.DELETE) @login_required(permission=permissions.DELETE)
def delete_user(userid, current_session): def delete_user(userid, current_session):
"""Delete user by userid """Delete user by userid
@ -143,11 +172,11 @@ def delete_user(userid, current_session):
""" """
logger.debug("Delete user {{ {} }}".format(userid)) logger.debug("Delete user {{ {} }}".format(userid))
user = userController.get_user(userid) user = userController.get_user(userid)
userController.delete(user) userController.delete_user(user)
return "", NO_CONTENT return no_content()
@users_bp.route("/users/<userid>", methods=["PUT"]) @UsersPlugin.blueprint.route("/users/<userid>", methods=["PUT"])
@login_required() @login_required()
def edit_user(userid, current_session): def edit_user(userid, current_session):
"""Modify user by userid """Modify user by userid
@ -172,8 +201,8 @@ def edit_user(userid, current_session):
new_password = data["new_password"] if "new_password" in data else None new_password = data["new_password"] if "new_password" in data else None
author = user author = user
if userid != current_session._user.userid: if userid != current_session.user_.userid:
author = current_session._user author = current_session.user_
if not author.has_permission(permissions.EDIT): if not author.has_permission(permissions.EDIT):
raise Forbidden raise Forbidden
else: else:
@ -196,5 +225,59 @@ def edit_user(userid, current_session):
userController.set_roles(user, roles) userController.set_roles(user, roles)
userController.modify_user(user, password, new_password) userController.modify_user(user, password, new_password)
userController.update_user(user) userController.update_user(
return "", NO_CONTENT user,
)
return no_content()
@UsersPlugin.blueprint.route("/notifications", methods=["GET"])
@login_required()
def notifications(current_session):
f = request.args.get("from", None)
if f is not None:
f = from_iso_format(f)
return jsonify(userController.get_notifications(current_session.user_, f))
@UsersPlugin.blueprint.route("/notifications/<nid>", methods=["DELETE"])
@login_required()
def remove_notification(nid, current_session):
userController.delete_notification(nid, current_session.user_)
return no_content()
@UsersPlugin.blueprint.route("/users/<userid>/shortcuts", methods=["GET", "PUT"])
@login_required()
def shortcuts(userid, current_session):
if userid != current_session.user_.userid:
raise Forbidden
user = userController.get_user(userid)
if request.method == "GET":
return jsonify(user.get_attribute("users_link_shortcuts", []))
else:
data = request.get_json()
if not isinstance(data, list) or not all(isinstance(n, dict) for n in data):
raise BadRequest
user.set_attribute("users_link_shortcuts", data)
userController.persist()
return no_content()
@UsersPlugin.blueprint.route("/users/<userid>/setting/<setting>", methods=["GET", "PUT"])
@login_required()
def settings(userid, setting, current_session):
if userid != current_session.user_.userid:
raise Forbidden
user = userController.get_user(userid)
if request.method == "GET":
retVal = user.get_attribute(setting, None)
logger.debug(f"Get setting >>{setting}<< for user >>{user.userid}<< with >>{retVal}<<")
return jsonify(retVal)
else:
data = request.get_json()
logger.debug(f"Set setting >>{setting}<< for user >>{user.userid}<< to >>{data}<<")
user.set_attribute(setting, data)
userController.persist()
return no_content()

View File

@ -0,0 +1,91 @@
import click
import sqlalchemy.exc
from flask.cli import with_appcontext
from werkzeug.exceptions import NotFound
from flaschengeist import logger
from flaschengeist.database import db
from flaschengeist.controller import roleController, userController
USER_KEY = f"{__name__}.user"
def user(ctx, param, value):
if not value or ctx.resilient_parsing:
return
click.echo("Adding new user")
ctx.meta[USER_KEY] = {}
try:
ctx.meta[USER_KEY]["userid"] = click.prompt("userid", type=str)
ctx.meta[USER_KEY]["firstname"] = click.prompt("firstname", type=str)
ctx.meta[USER_KEY]["lastname"] = click.prompt("lastname", type=str)
ctx.meta[USER_KEY]["display_name"] = click.prompt("displayed name", type=str, default="")
ctx.meta[USER_KEY]["mail"] = click.prompt("mail", type=str, default="")
ctx.meta[USER_KEY]["password"] = click.prompt("password", type=str, confirmation_prompt=True, hide_input=True)
ctx.meta[USER_KEY] = {k: v for k, v in ctx.meta[USER_KEY].items() if v != ""}
except click.Abort:
click.echo("\n!!! User was not added, aborted.")
del ctx.meta[USER_KEY]
@click.command()
@click.option("--create", help="Add new role", is_flag=True)
@click.option("--delete", help="Delete role", is_flag=True)
@click.option("--set-admin", is_flag=True, help="Make a role an admin role, adding all permissions", type=str)
@click.argument("role", nargs=-1, required=True, type=str)
def role(create, delete, set_admin, role):
"""Manage roles"""
ctx = click.get_current_context()
if (create and delete) or (set_admin and delete):
ctx.fail("Do not mix --delete with --create or --set-admin")
for role_name in role:
if create:
r = roleController.create_role(role_name)
else:
r = roleController.get(role_name)
if delete:
roleController.delete(r)
if set_admin:
r.permissions = roleController.get_permissions()
db.session.commit()
@click.command()
@click.option("--add-role", help="Add a role to an user", type=str)
@click.option("--create", help="Create new user interactivly", callback=user, is_flag=True, expose_value=False)
@click.option("--delete", help="Delete a user", is_flag=True)
@click.argument("user", nargs=-1, type=str)
@with_appcontext
def user(add_role, delete, user):
"""Manage users"""
from flaschengeist.database import db
ctx = click.get_current_context()
try:
if USER_KEY in ctx.meta:
userController.register(ctx.meta[USER_KEY], ctx.meta[USER_KEY]["password"])
else:
if not isinstance(user, list) or not isinstance(user, tuple):
user = [user]
for uid in user:
logger.debug(f"Userid: {uid}")
user = userController.get_user(uid)
logger.debug(f"User: {user}")
if delete:
logger.debug(f"Deleting user {user}")
userController.delete_user(user)
elif add_role:
logger.debug(f"Adding role {add_role} to user {user}")
role = roleController.get(add_role)
logger.debug(f"Role: {role}")
user.roles_.append(role)
userController.modify_user(user, None)
db.session.commit()
except NotFound:
ctx.fail(f"User not found {uid}")

View File

@ -2,6 +2,24 @@ from http.client import NO_CONTENT, CREATED
from flask import make_response, jsonify from flask import make_response, jsonify
from flaschengeist.utils.datetime import from_iso_format
def get_filter_args():
"""
Get filter parameter from request
returns: FROM, TO, LIMIT, OFFSET, DESCENDING
"""
from flask import request
return (
request.args.get("from", type=from_iso_format),
request.args.get("to", type=from_iso_format),
request.args.get("limit", type=int),
request.args.get("offset", type=int),
"descending" in request.args,
)
def no_content(): def no_content():
return make_response(jsonify(""), NO_CONTENT) return make_response(jsonify(""), NO_CONTENT)

View File

@ -0,0 +1 @@
"""Common utilities"""

View File

@ -1,7 +1,4 @@
import datetime import datetime
from backports.datetime_fromisoformat import MonkeyPatch
MonkeyPatch.patch_fromisoformat()
def from_iso_format(date_str): def from_iso_format(date_str):

View File

@ -14,7 +14,7 @@ def extract_session(permission=None):
logger.debug("Missing Authorization header or ill-formed") logger.debug("Missing Authorization header or ill-formed")
raise Unauthorized raise Unauthorized
session = sessionController.validate_token(token, request.user_agent, permission) session = sessionController.validate_token(token, request.headers, permission)
return session return session

View File

@ -1,43 +1,81 @@
_hook_dict = ({}, {}) from functools import wraps
class Hook(object): _hooks_before = {}
"""Decorator for Hooks _hooks_after = {}
Use to decorate system hooks where plugins should be able to hook in
def Hook(function=None, id=None):
"""Hook decorator
Use to decorate functions as hooks, so plugins can hook up their custom functions.
"""
# `id` passed as `arg` not `kwarg`
if isinstance(function, str):
return Hook(id=function)
def decorator(function):
@wraps(function)
def wrapped(*args, **kwargs):
_id = id if id is not None else function.__qualname__
# Hooks before
for f in _hooks_before.get(_id, []):
f(*args, **kwargs)
# Main function
result = function(*args, **kwargs)
# Hooks after
for f in _hooks_after.get(_id, []):
f(*args, hook_result=result, **kwargs)
return result
return wrapped
# Called @Hook or we are in the second step
if callable(function):
return decorator(function)
else:
return decorator
def HookBefore(id: str):
"""Decorator for functions to be called before a Hook-Function is called
The hooked up function must accept the same arguments as the function hooked onto,
as the functions are called with the same arguments.
Hint: This enables you to modify the arguments!
"""
if not id or not isinstance(id, str):
raise TypeError("HookBefore requires the ID of the function to hook up")
def wrapped(function):
_hooks_before.setdefault(id, []).append(function)
return function
return wrapped
def HookAfter(id: str):
"""Decorator for functions to be called after a Hook-Function is called
As with the HookBefore, the hooked up function must accept the same
arguments as the function hooked onto, but also receives a
`hook_result` kwarg containing the result of the function.
Example:
```py
@HookAfter("some.id")
def my_func(hook_result):
# This function is executed after the function registered with "some.id"
print(hook_result) # This is the result of the function
```
""" """
def __init__(self, function): if not id or not isinstance(id, str):
self.function = function raise TypeError("HookAfter requires the ID of the function to hook up")
def __call__(self, *args, **kwargs): def wrapped(function):
# Hooks before _hooks_after.setdefault(id, []).append(function)
for function in _hook_dict[0].get(self.function.__name__, []):
function(*args, **kwargs)
# Main function
ret = self.function(*args, **kwargs)
# Hooks after
for function in _hook_dict[1].get(self.function.__name__, []):
function(*args, **kwargs)
return ret
class HookBefore(object):
"""Decorator for functions to be called before a Hook-Function is called"""
def __init__(self, name):
self.name = name
def __call__(self, function):
_hook_dict[0].setdefault(self.name, []).append(function)
return function return function
return wrapped
class HookAfter(object):
"""Decorator for functions to be called after a Hook-Function is called"""
def __init__(self, name):
self.name = name
def __call__(self, function):
_hook_dict[1].setdefault(self.name, []).append(function)
return function

6
pyproject.toml Normal file
View File

@ -0,0 +1,6 @@
[build-system]
requires = ["setuptools", "wheel"]
build-backend = "setuptools.build_meta"
[tool.black]
line-length = 120

139
readme.md
View File

@ -1,139 +0,0 @@
# Flaschengeist
## Installation
### Requirements
- mysql or mariadb
- python 3.6+
### Install python files
pip3 install --user .
or with ldap support
pip3 install --user ".[ldap]"
or if you want to also run the tests:
pip3 install --user ".[ldap,test]"
You will also need a MySQL driver, recommended drivers are
- `mysqlclient`
- `PyMySQL`
#### Windows
Same as above, but for mysql you have to follow this guide:
https://www.radishlogic.com/coding/python-3/installing-mysqldb-for-python-3-in-windows/
### Configuration
Configuration is done within the a `flaschengeist.toml`file, you can copy the one located inside the module path
(where flaschegeist is installed) or create an empty one and place it inside either:
1. `~/.config/`
2. A custom path and set environment variable `FLASCHENGEIST_CONF`
Change at least the database parameters!
### Database installation
run_flaschengeist install
### Run
run_flaschengeist run
or with debug messages:
run_flaschengeist run --debug
## Tests
$ pip install '.[test]'
$ pytest
Run with coverage report:
$ coverage run -m pytest
$ coverage report
Or with html output (open `htmlcov/index.html` in a browser):
$ coverage html
## Development
### Code Style
We enforce you to use PEP 8 code style with a line length of 120 as used by Black.
See also [Black Code Style](https://github.com/psf/black/blob/master/docs/the_black_code_style.md).
#### Code formatting
We use [Black](https://github.com/psf/black) as the code formatter.
Installation:
pip install black
Usage:
black -l 120 DIRECTORY_OR_FILE
### Misc
#### Git blame
When using `git blame` use this to ignore the code formatting commits:
$ git blame FILE.py --ignore-revs-file .git-blame-ignore-revs
Or if you just want to use `git blame`, configure git like this:
$ git config blame.ignoreRevsFile .git-blame-ignore-revs
#### Ignore changes on config
git update-index --assume-unchanged flaschengeist/flaschengeist.toml
## Plugin Development
### File Structure
flaschengeist-example-plugin
|> __init__.py
|> model.py
|> setup.py
### Files
#### \_\_init\_\_.py
from flask import Blueprint
from flaschengeist.modules import Plugin
example_bp = Blueprint("example", __name__, url_prefix="/example")
permissions = ["example_hello"]
class PluginExample(Plugin):
def __init__(self, conf):
super().__init__(blueprint=example_bp, permissions=permissions)
def install(self):
from flaschengeist.system.database import db
import .model
db.create_all()
db.session.commit()
@example_bp.route("/hello", methods=['GET'])
@login_required(roles=['example_hello'])
def __hello(id, **kwargs):
return "Hello"
#### model.py
Optional, only needed if you need your own models (database)
from flaschengeist.system.database import db
class ExampleModel(db.Model):
"""Example Model"""
__tablename__ = 'example'
id = db.Column(db.Integer, primary_key=True)
description = db.Column(db.String(240))
#### setup.py
from setuptools import setup, find_packages
setup(
name="flaschengeist-example-plugin",
version="0.0.0-dev",
packages=find_packages(),
install_requires=[
"flaschengeist >= 2",
],
entry_points={
"flaschengeist.plugin": [
"example = flaschengeist-example-plugin:ExampleModel"
]
},
)

View File

@ -1,165 +0,0 @@
#!/usr/bin/python3
import inspect
import argparse
import sys
import pkg_resources
from flaschengeist.config import config
class PrefixMiddleware(object):
def __init__(self, app, prefix=''):
self.app = app
self.prefix = prefix
def __call__(self, environ, start_response):
if environ['PATH_INFO'].startswith(self.prefix):
environ['PATH_INFO'] = environ['PATH_INFO'][len(self.prefix):]
environ['SCRIPT_NAME'] = self.prefix
return self.app(environ, start_response)
else:
start_response('404', [('Content-Type', 'text/plain')])
return ["This url does not belong to the app.".encode()]
class InterfaceGenerator:
known = []
classes = {}
mapper = {"str": "string", "int": "number", "float": "number", "date": "Date", "datetime": "Date", "NoneType": "null"}
def __init__(self, namespace, filename):
self.basename = ""
self.namespace = namespace
self.filename = filename
self.this_type = None
def pytype(self, cls):
if isinstance(cls, list):
return "", "Array<{}>".format(self.pytype(cls[0])[1])
if sys.version_info >= (3, 8):
import typing
if isinstance(cls, typing.ForwardRef):
return "", "this" if cls.__forward_arg__ == self.this_type else cls.__forward_arg__
if typing.get_origin(cls) == typing.Union:
types = typing.get_args(cls)
if len(types) == 2 and types[-1] is type(None):
return "?", self.pytype(types[0])[1]
else:
return "", "|".join([self.pytype(pt)[1] for pt in types])
if hasattr(cls, "__name__"):
if cls.__name__ in self.mapper:
return "", self.mapper[cls.__name__]
else:
return "", cls.__name__
print(
"WARNING: This python version might not detect all types (try >= 3.8). Could not identify >{}<".format(cls)
)
return "?", "any"
def walker(self, module):
if (
inspect.ismodule(module[1])
and module[1].__name__.startswith(self.basename)
and module[1].__name__ not in self.known
):
self.known.append(module[1].__name__)
for cls in inspect.getmembers(module[1], lambda x: inspect.isclass(x) or inspect.ismodule(x)):
self.walker(cls)
elif (
inspect.isclass(module[1])
and module[1].__module__.startswith(self.basename)
and module[0] not in self.classes
and not module[0].startswith("_")
and hasattr(module[1], "__annotations__")
):
self.this_type = module[0]
d = {
param: self.pytype(ptype)
for param, ptype in module[1].__annotations__.items()
if not param.startswith("_") and not param.endswith("_")
}
if len(d) == 1:
key, value = d.popitem()
self.classes[module[0]] = value[1]
else:
self.classes[module[0]] = d
def run(self, models):
self.basename = models.__name__
self.walker(("models", models))
def write(self):
with open(self.filename, "w") as file:
file.write("declare namespace {} {{\n".format(self.namespace))
for cls, params in self.classes.items():
if isinstance(params, str):
file.write("\ttype {} = {};\n".format(cls, params))
else:
file.write("\tinterface {} {{\n".format(cls))
for name in params:
file.write("\t\t{}{}: {};\n".format(name, *params[name]))
file.write("\t}\n")
file.write("}\n")
def install(arguments):
from flaschengeist.app import create_app, install_all
app = create_app()
with app.app_context():
install_all()
def run(arguments):
from flaschengeist.app import create_app
app = create_app()
with app.app_context():
app.wsgi_app = PrefixMiddleware(app.wsgi_app, prefix=config["FLASCHENGEIST"].get("root", ""))
if arguments.debug:
app.run(arguments.host, arguments.port, debug=True)
else:
app.run(arguments.host, arguments.port, debug=False)
def export(arguments):
import flaschengeist.models as models
from flaschengeist.app import create_app
app = create_app()
with app.app_context():
gen = InterfaceGenerator(arguments.namespace, arguments.file)
gen.run(models)
if arguments.plugins:
for entry_point in pkg_resources.iter_entry_points("flaschengeist.plugin"):
plg = entry_point.load()
if hasattr(plg, "models"):
gen.run(plg.models)
gen.write()
if __name__ == "__main__":
# create the top-level parser
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(help="sub-command help", dest="sub_command")
subparsers.required = True
parser_run = subparsers.add_parser("run", help="run flaschengeist")
parser_run.set_defaults(func=run)
parser_run.add_argument("--host", help="set hostname to listen on", default="127.0.0.1")
parser_run.add_argument("--port", help="set port to listen on", type=int, default=5000)
parser_run.add_argument("--debug", help="run in debug mode", action="store_true")
parser_install = subparsers.add_parser(
"install", help="run database setup for flaschengeist and all installed plugins"
)
parser_install.set_defaults(func=install)
parser_export = subparsers.add_parser("export", help="export models to typescript interfaces")
parser_export.set_defaults(func=export)
parser_export.add_argument("--file", help="Filename where to save", default="flaschengeist.d.ts")
parser_export.add_argument("--namespace", help="Namespace of declarations", default="FG")
parser_export.add_argument("--plugins", help="Also export plugins", action="store_true")
args = parser.parse_args()
args.func(args)

View File

@ -1,3 +1,73 @@
[metadata]
license = MIT
version = 2.1.0
name = flaschengeist
author = Tim Gröger
author_email = flaschengeist@wu5.de
url = https://flaschengeist.dev
long_description = file: README.md
long_description_content_type = text/markdown
description = Modular student club administration system
project_urls =
Documentation = https://docs.flaschengeist.dev
Source = https://flaschengeist.dev/Flaschengeist/flaschengeist
Tracker = https://flaschengeist.dev/Flaschengeist/flaschengeist/issues
classifiers =
Programming Language :: Python :: 3
License :: OSI Approved :: MIT License
Operating System :: OS Independent
[options]
include_package_data = True
python_requires = >=3.10
packages = find:
install_requires =
#Flask>=2.2.2, <2.3
Flask>=2.2.2, <2.9
Pillow>=9.2
flask_cors
flask_migrate>=3.1.0
flask_sqlalchemy>=2.5.1
sqlalchemy_utils>=0.38.3
# Importlib requirement can be dropped when python requirement is >= 3.10
importlib_metadata>=4.3
#sqlalchemy>=1.4.40, <2.0
sqlalchemy >= 2.0
toml
werkzeug>=2.2.2
ua-parser>=0.16.1
[options.extras_require]
argon = argon2-cffi
ldap = flask_ldapconn @ git+https://github.com/rroemhild/flask-ldapconn.git; ldap3
tests = pytest; pytest-depends; coverage
mysql =
PyMySQL;platform_system=='Windows'
mysqlclient;platform_system!='Windows'
[options.package_data]
* = *.toml, script.py.mako, *.ini, */migrations/*, migrations/versions/*
[options.entry_points]
console_scripts =
flaschengeist = flaschengeist.cli:main
flask.commands =
ldap = flaschengeist.plugins.auth_ldap.cli:ldap
user = flaschengeist.plugins.users.cli:user
role = flaschengeist.plugins.users.cli:role
flaschengeist.plugins =
# Authentication providers
auth_plain = flaschengeist.plugins.auth_plain:AuthPlain
auth_ldap = flaschengeist.plugins.auth_ldap:AuthLDAP [ldap]
# Route providers (and misc)
auth = flaschengeist.plugins.auth:AuthRoutePlugin
users = flaschengeist.plugins.users:UsersPlugin
roles = flaschengeist.plugins.roles:RolesPlugin
balance = flaschengeist.plugins.balance:BalancePlugin
mail = flaschengeist.plugins.message_mail:MailMessagePlugin
pricelist = flaschengeist.plugins.pricelist:PriceListPlugin
scheduler = flaschengeist.plugins.scheduler:SchedulerPlugin
[bdist_wheel] [bdist_wheel]
universal = True universal = True
@ -6,4 +76,4 @@ testpaths = tests
[coverage:run] [coverage:run]
branch = True branch = True
source = flaschengeist source = flaschengeist

View File

@ -1,39 +0,0 @@
from setuptools import setup, find_packages
setup(
name="flaschengeist",
version="2.0.0-dev",
url="https://wu5.de/redmine/projects/geruecht",
author="WU5 + Friends",
author_email="tim@groeger-clan.de",
description="Does things",
packages=find_packages(),
package_data={"": ["*.toml"]},
scripts=["run_flaschengeist"],
python_requires=">=3.6",
install_requires=[
"Flask >= 1.1",
"toml",
"sqlalchemy>=1.3",
"flask_sqlalchemy",
"flask_cors",
"werkzeug",
# Needed for python < 3.7
"backports-datetime-fromisoformat",
],
extras_require={"ldap": ["flask_ldapconn", "ldap3"], "test": ["pytest", "coverage"]},
entry_points={
"flaschengeist.plugin": [
# Authentication providers
"auth_plain = flaschengeist.plugins.auth_plain:AuthPlain",
"auth_ldap = flaschengeist.plugins.auth_ldap:AuthLDAP [ldap]",
# Route providers (and misc)
"auth = flaschengeist.plugins.auth:AuthRoutePlugin",
"users = flaschengeist.plugins.users:UsersPlugin",
"roles = flaschengeist.plugins.roles:RolesPlugin",
"balance = flaschengeist.plugins.balance:BalancePlugin",
"schedule = flaschengeist.plugins.schedule:SchedulePlugin",
"mail = flaschengeist.plugins.message_mail:MailMessagePlugin",
],
},
)

View File

@ -3,8 +3,7 @@ import tempfile
import pytest import pytest
from flaschengeist import database from flaschengeist import database
from flaschengeist.app import create_app, install_all from flaschengeist.app import create_app
# read in SQL for populating test data # read in SQL for populating test data
with open(os.path.join(os.path.dirname(__file__), "data.sql"), "r") as f: with open(os.path.join(os.path.dirname(__file__), "data.sql"), "r") as f:
@ -22,9 +21,17 @@ with open(os.path.join(os.path.dirname(__file__), "data.sql"), "r") as f:
@pytest.fixture @pytest.fixture
def app(): def app():
db_fd, db_path = tempfile.mkstemp() db_fd, db_path = tempfile.mkstemp()
app = create_app({"TESTING": True, "DATABASE": {"file_path": f"/{db_path}"}, "LOGGING": {"level": "DEBUG"}}) app = create_app(
{
"TESTING": True,
"DATABASE": {"engine": "sqlite", "database": f"/{db_path}"},
"LOGGING": {"level": "DEBUG"},
}
)
with app.app_context(): with app.app_context():
install_all() database.db.create_all()
database.db.session.commit()
engine = database.db.engine engine = database.db.engine
with engine.connect() as connection: with engine.connect() as connection:
for statement in _data_sql: for statement in _data_sql:

View File

@ -1,4 +1,8 @@
INSERT INTO user ('userid', 'firstname', 'lastname', 'mail', 'id') VALUES ('user', 'Max', 'Mustermann', 'abc@def.gh', 1); INSERT INTO "user" ('userid', 'firstname', 'lastname', 'mail', 'deleted', 'id') VALUES ('user', 'Max', 'Mustermann', 'abc@def.gh', 0, 1);
INSERT INTO "user" ('userid', 'firstname', 'lastname', 'mail', 'deleted', 'id') VALUES ('deleted_user', 'John', 'Doe', 'doe@example.com', 1, 2);
-- Password = 1234 -- Password = 1234
INSERT INTO user_attribute VALUES(1,1,'password',X'800495c4000000000000008cc0373731346161336536623932613830366664353038656631323932623134393936393561386463353536623037363761323037623238346264623833313265323333373066376233663462643332666332653766303537333564366335393133366463366234356539633865613835643661643435343931376636626663343163653333643635646530386634396231323061316236386162613164373663663333306564306463303737303733336136353363393538396536343266393865942e'); INSERT INTO user_attribute VALUES(1,1,'password',X'800495c4000000000000008cc0373731346161336536623932613830366664353038656631323932623134393936393561386463353536623037363761323037623238346264623833313265323333373066376233663462643332666332653766303537333564366335393133366463366234356539633865613835643661643435343931376636626663343163653333643635646530386634396231323061316236386162613164373663663333306564306463303737303733336136353363393538396536343266393865942e');
INSERT INTO session ('expires', 'token', 'lifetime', 'id', 'user_id') VALUES ('2999-01-01 00:00:00', 'f4ecbe14be3527ca998143a49200e294', 600, 1, 1); INSERT INTO session ('expires', 'token', 'lifetime', 'id', 'user_id') VALUES ('2999-01-01 00:00:00', 'f4ecbe14be3527ca998143a49200e294', 600, 1, 1);
-- ROLES
INSERT INTO role ('name', 'id') VALUES ('role_1', 1);
INSERT INTO permission ('name', 'id') VALUES ('permission_1', 1);

View File

@ -15,9 +15,9 @@ def test_login(client):
# Login successful # Login successful
assert result.status_code == 201 assert result.status_code == 201
# User set correctly # User set correctly
assert json["user"]["userid"] == USERID assert json["userid"] == USERID
# Token works # Token works
assert client.get("/auth", headers={"Authorization": f"Bearer {json['session']['token']}"}).status_code == 200 assert client.get("/auth", headers={"Authorization": f"Bearer {json['token']}"}).status_code == 200
def test_login_decorator(client): def test_login_decorator(client):

52
tests/test_users.py Normal file
View File

@ -0,0 +1,52 @@
import pytest
from werkzeug.exceptions import BadRequest, NotFound
from flaschengeist.controller import roleController, userController
from flaschengeist.models.user import User
VALID_TOKEN = "f4ecbe14be3527ca998143a49200e294"
def test_get_user(app):
with app.app_context():
user = userController.get_user("user")
assert user is not None and isinstance(user, User)
assert user.userid == "user"
user = userController.get_user("deleted_user", deleted=True)
assert user is not None and isinstance(user, User)
assert user.userid == "deleted_user"
with pytest.raises(NotFound):
user = userController.get_user("__does_not_exist__")
with pytest.raises(NotFound):
user = userController.get_user("__does_not_exist__", deleted=True)
with pytest.raises(NotFound):
user = userController.get_user("deleted_user")
def test_set_roles(app):
with app.app_context():
user = userController.get_user("user")
userController.set_roles(user, [])
assert user.roles_ == []
userController.set_roles(user, ["role_1"])
assert len(user.roles_) == 1 and user.roles_[0].id == 1
# Test unknown role + no create flag -> raise no changes
with pytest.raises(BadRequest):
userController.set_roles(user, ["__custom__"])
assert len(user.roles_) == 1
userController.set_roles(user, ["__custom__"], create=True)
assert len(user.roles_) == 1 and user.roles_[0].name == "__custom__"
assert roleController.get("__custom__").id == user.roles_[0].id
userController.set_roles(user, ["__custom__"], create=True)
assert len(user.roles_) == 1
userController.set_roles(user, ["__custom__", "role_1"])
assert len(user.roles_) == 2
userController.set_roles(user, [])
assert len(user.roles_) == 0