diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..734d668 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,2 @@ +[run] +source = gengine diff --git a/.gitignore b/.gitignore index 772bee4..ec4ccb3 100644 --- a/.gitignore +++ b/.gitignore @@ -60,4 +60,10 @@ target/ *.pydevproject /*.dbm -venv/ \ No newline at end of file +venv/ +# Elastic Beanstalk Files +.elasticbeanstalk/* +!.elasticbeanstalk/*.cfg.yml +!.elasticbeanstalk/*.global.yml + +.idea diff --git a/.travis.yml b/.travis.yml index 71916be..b136e5e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,13 +1,28 @@ language: python +dist: trusty python: - - "2.7" - + - "3.4" + - "3.5" +sudo: required +env: + - TEST_POSTGRES=/usr/lib/postgresql/9.6/bin/postgres TEST_INITDB=/usr/lib/postgresql/9.6/bin/initdb +apt: + packages: + - postgresql-9.6 + - postgresql-contrib-9.6 + - postgis # command to install dependencies install: - - python setup.py -q install - + - pip install coveralls + - "pip install --upgrade -r requirements.txt" + - "pip install --upgrade -r optional-requirements.txt" + - pip install -e . + # command to run tests -script: nosetests +script: coverage run --source=gengine gengine/app/tests/runner.py + +after_success: + coveralls # deploy to pypi deploy: @@ -16,3 +31,10 @@ deploy: password: $PYPI_PASSWORD on: tags: true + +notifications: + email: + recipients: + - $ADMIN_EMAIL + on_success: always # default: change + on_failure: always # default: always diff --git a/CHANGES.txt b/CHANGES.txt index 945c9b4..6010065 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1 +1,9 @@ -. \ No newline at end of file +0.2.0 + * Implement new rule syntax + * Add time-aware / recurring achievements + * Add optional authentication & authorization + * Introduce goal triggers + * Introduce mobile pushes + * Introduce messages + * Lots of bugfixes + * Remove Python 2.x support diff --git a/MANIFEST.in b/MANIFEST.in index 9b217cb..ec33067 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1 @@ -include *.txt *.ini *.cfg *.rst -recursive-include gengine *.ico *.png *.css *.gif *.jpg *.pt *.txt *.mak *.mako *.js *.html *.xml README alembic alembic/versions/*.py alembic/env.py -recursive-include gengine_quickstart_template *.ini +include *.txt *.ini *.cfg *.rst *.ico *.png *.css *.gif *.jpg *.pt *.txt *.mak *.mako *.js *.html *.xml *.py README DUMMY LICENSE diff --git a/README.md b/README.md index ab8f4a6..572a634 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # gamification-engine [![Build Status](https://travis-ci.org/ActiDoo/gamification-engine.svg?branch=master)](https://travis-ci.org/ActiDoo/gamification-engine) +[![Coverage Status](https://coveralls.io/repos/github/ActiDoo/gamification-engine/badge.svg?branch=develop)](https://coveralls.io/github/ActiDoo/gamification-engine?branch=develop) [![Requirements Status](https://requires.io/github/ActiDoo/gamification-engine/requirements.svg?branch=master)](https://requires.io/github/ActiDoo/gamification-engine/requirements/?branch=master) [![Heroku](https://heroku-badge.herokuapp.com/?app=gamification-engine&root=admin)](https://gamification-engine.herokuapp.com) [![Documentation Status](https://img.shields.io/badge/docs-latest-brightgreen.svg?style=flat)](https://readthedocs.org/projects/gamification-engine/?badge=latest) @@ -15,8 +16,7 @@ It is framework for developing your own solution, implemented as a **service** t (commercial support available at https://www.gamification-software.com or together with app development at https://www.appnadoo.de) -Latest recommended version: https://github.com/ActiDoo/gamification-engine/releases/latest (not yet called "stable" though) -There are installation issues in the current master branch. The develop branch is working very well and is way more stable. Nevertheless the docs are out-of-date. We are currently integrating that state into a client's project and will release a new version once we find the time to update the docs and write some tests. +Latest recommended version: https://github.com/ActiDoo/gamification-engine/releases/latest ## Features @@ -32,6 +32,7 @@ There are installation issues in the current master branch. The develop branch i - custom definable achievement properties and rewards - custom definable languages and translations - dependencies between achievements (prerequisites & postconditions) +- goals can execute triggers (currently creation of messages and mobile pushes for iOS/Android) - high performance / scalable - administration ui diff --git a/alembic.ini b/alembic.ini deleted file mode 100644 index a4130cb..0000000 --- a/alembic.ini +++ /dev/null @@ -1,80 +0,0 @@ -# A generic, single database configuration. - -[alembic] -# path to migration scripts -script_location = gengine/alembic - -# template used to generate migration files -# file_template = %%(rev)s_%%(slug)s - -# max length of characters to apply to the -# "slug" field -#truncate_slug_length = 40 - -# set to 'true' to run the environment during -# the 'revision' command, regardless of autogenerate -# revision_environment = false - -# set to 'true' to allow .pyc and .pyo files without -# a source .py file to be detected as revisions in the -# versions/ directory -# sourceless = false - -# version location specification; this defaults -# to alembic/versions. When using multiple version -# directories, initial revisions must be specified with --version-path -# version_locations = %(here)s/bar %(here)s/bat alembic/versions - -# the output encoding used when revision files -# are written from script.py.mako -# output_encoding = utf-8 - -sqlalchemy.url = postgres://user:password@localhost/gengine - - -# Logging configuration -[loggers] -keys = root,sqlalchemy,alembic,sentry - -[handlers] -keys = console,sentry - -[formatters] -keys = generic - -[logger_root] -level = WARN -handlers = console,sentry -qualname = - -[logger_sqlalchemy] -level = WARN -handlers = -qualname = sqlalchemy.engine - -[logger_alembic] -level = INFO -handlers = -qualname = alembic - -[logger_sentry] -level = WARN -handlers = console -qualname = sentry.errors -propagate = 0 - -[handler_console] -class = StreamHandler -args = (sys.stderr,) -level = NOTSET -formatter = generic - -[handler_sentry] -class = raven.handlers.logging.SentryHandler -args = () -level = WARNING -formatter = generic - -[formatter_generic] -format = %(levelname)-5.5s [%(name)s] %(message)s -datefmt = %H:%M:%S diff --git a/development.ini b/development.ini index 1f2e45c..64ba63d 100644 --- a/development.ini +++ b/development.ini @@ -15,7 +15,7 @@ pyramid.includes = pyramid_debugtoolbar pyramid_tm -sqlalchemy.url = postgres://user:password@127.0.0.1/gengine +sqlalchemy.url = postgres://dev:dev@127.0.0.1/gengine #reverse proxy settings force_https = false @@ -59,6 +59,17 @@ urlcache_active = false # callback url, will be used for time-related leaderboard evaluations (daily,monthly,yearly) (TBD) notify_progress = +enable_user_authentication = false +fallback_language = en +gcm.api_key= +gcm.package= +apns.dev.key= +apns.dev.certificate= +apns.prod.key= +apns.prod.certificate= +push_title=Gamification Engine + + ### # wsgi server configuration ### diff --git a/docs/_static/2017-03-28-erm.svg b/docs/_static/2017-03-28-erm.svg new file mode 100644 index 0000000..0e26a4b --- /dev/null +++ b/docs/_static/2017-03-28-erm.svg @@ -0,0 +1,880 @@ + + + + + + +%3 + + +users + +users + +id + [BIGINT] + +lat + [DOUBLE PRECISION] + +lng + [DOUBLE PRECISION] + +language_id + [INTEGER] + +timezone + [VARCHAR] + +country + [VARCHAR] + +region + [VARCHAR] + +city + [VARCHAR] + +additional_public_data + [JSON] + +created_at + [TIMESTAMP WITHOUT TIME ZONE] + + +achievements_users + +achievements_users + +id + [INTEGER] + +user_id + [BIGINT] + +achievement_id + [INTEGER] + +achievement_date + [TIMESTAMP WITHOUT TIME ZONE] + +level + [INTEGER] + +updated_at + [TIMESTAMP WITHOUT TIME ZONE] + + +users--achievements_users + +{0,1} +0..N + + +goal_trigger_executions + +goal_trigger_executions + +id + [BIGINT] + +trigger_step_id + [INTEGER] + +user_id + [BIGINT] + +execution_level + [INTEGER] + +execution_date + [TIMESTAMP WITHOUT TIME ZONE] + +achievement_date + [TIMESTAMP WITHOUT TIME ZONE] + + +users--goal_trigger_executions + +{0,1} +0..N + + +goal_evaluation_cache + +goal_evaluation_cache + +id + [INTEGER] + +goal_id + [INTEGER] + +achievement_date + [TIMESTAMP WITHOUT TIME ZONE] + +user_id + [BIGINT] + +achieved + [BOOLEAN] + +value + [DOUBLE PRECISION] + + +users--goal_evaluation_cache + +{0,1} +0..N + + +user_devices + +user_devices + +device_id + [VARCHAR(255)] + +user_id + [BIGINT] + +device_os + [VARCHAR] + +push_id + [VARCHAR(255)] + +app_version + [VARCHAR(255)] + +registered_at + [TIMESTAMP WITHOUT TIME ZONE] + + +users--user_devices + +{0,1} +0..N + + +users_groups + +users_groups + +user_id + [BIGINT] + +group_id + [BIGINT] + + +users--users_groups + +{0,1} +0..N + + +users_users + +users_users + +from_id + [BIGINT] + +to_id + [BIGINT] + + +users--users_users + +{0,1} +0..N + + +users--users_users + +{0,1} +0..N + + +auth_users + +auth_users + +user_id + [BIGINT] + +email + [VARCHAR] + +password_hash + [VARCHAR] + +password_salt + [VARCHAR] + +active + [BOOLEAN] + +created_at + [TIMESTAMP WITHOUT TIME ZONE] + + +users--auth_users + +{0,1} +0..N + + +user_messages + +user_messages + +id + [BIGINT] + +user_id + [BIGINT] + +translation_id + [INTEGER] + +params + [JSON] + +is_read + [BOOLEAN] + +has_been_pushed + [BOOLEAN] + +created_at + [TIMESTAMP WITHOUT TIME ZONE] + + +users--user_messages + +{0,1} +0..N + + +values + +values + +user_id + [BIGINT] + +datetime + [TIMESTAMP WITH TIME ZONE] + +variable_id + [INTEGER] + +value + [INTEGER] + +key + [VARCHAR(100)] + + +users--values + +{0,1} +0..N + + +auth_tokens + +auth_tokens + +id + [BIGINT] + +user_id + [BIGINT] + +token + [VARCHAR] + +valid_until + [TIMESTAMP WITHOUT TIME ZONE] + + +auth_users--auth_tokens + +{0,1} +0..N + + +auth_users_roles + +auth_users_roles + +user_id + [BIGINT] + +role_id + [BIGINT] + + +auth_users--auth_users_roles + +{0,1} +0..N + + +goals_goalproperties + +goals_goalproperties + +goal_id + [INTEGER] + +property_id + [INTEGER] + +value + [VARCHAR(255)] + +value_translation_id + [INTEGER] + +from_level + [INTEGER] + + +groups + +groups + +id + [BIGINT] + + +groups--users_groups + +{0,1} +0..N + + +achievementproperties + +achievementproperties + +id + [INTEGER] + +name + [VARCHAR(255)] + +is_variable + [BOOLEAN] + + +achievements_achievementproperties + +achievements_achievementproperties + +achievement_id + [INTEGER] + +property_id + [INTEGER] + +value + [VARCHAR(255)] + +value_translation_id + [INTEGER] + +from_level + [INTEGER] + + +achievementproperties--achievements_achievementproperties + +{0,1} +0..N + + +achievementcategories + +achievementcategories + +id + [INTEGER] + +name + [VARCHAR(255)] + + +achievements + +achievements + +id + [INTEGER] + +achievementcategory_id + [INTEGER] + +name + [VARCHAR(255)] + +maxlevel + [INTEGER] + +hidden + [BOOLEAN] + +valid_start + [DATE] + +valid_end + [DATE] + +lat + [DOUBLE PRECISION] + +lng + [DOUBLE PRECISION] + +max_distance + [INTEGER] + +priority + [INTEGER] + +evaluation + [evaluation_types] + +evaluation_timezone + [VARCHAR] + +relevance + [relevance_types] + +view_permission + [achievement_view_permission] + +created_at + [TIMESTAMP WITHOUT TIME ZONE] + + +achievementcategories--achievements + +{0,1} +0..N + + +achievements--achievements_users + +{0,1} +0..N + + +achievements--achievements_achievementproperties + +{0,1} +0..N + + +requirements + +requirements + +from_id + [INTEGER] + +to_id + [INTEGER] + + +achievements--requirements + +{0,1} +0..N + + +achievements--requirements + +{0,1} +0..N + + +denials + +denials + +from_id + [INTEGER] + +to_id + [INTEGER] + + +achievements--denials + +{0,1} +0..N + + +achievements--denials + +{0,1} +0..N + + +goals + +goals + +id + [INTEGER] + +name + [VARCHAR(255)] + +name_translation_id + [INTEGER] + +condition + [VARCHAR(255)] + +timespan + [INTEGER] + +group_by_key + [BOOLEAN] + +group_by_dateformat + [VARCHAR(255)] + +goal + [VARCHAR(255)] + +operator + [goal_operators] + +maxmin + [goal_maxmin] + +achievement_id + [INTEGER] + +priority + [INTEGER] + + +achievements--goals + +{0,1} +0..N + + +achievements_rewards + +achievements_rewards + +id + [INTEGER] + +achievement_id + [INTEGER] + +reward_id + [INTEGER] + +value + [VARCHAR(255)] + +value_translation_id + [INTEGER] + +from_level + [INTEGER] + + +achievements--achievements_rewards + +{0,1} +0..N + + +goal_trigger_steps + +goal_trigger_steps + +id + [INTEGER] + +goal_trigger_id + [INTEGER] + +step + [INTEGER] + +condition_type + [goal_trigger_condition_types] + +condition_percentage + [DOUBLE PRECISION] + +action_type + [goal_trigger_action_types] + +action_translation_id + [INTEGER] + + +goal_trigger_steps--goal_trigger_executions + +{0,1} +0..N + + +auth_roles_permissions + +auth_roles_permissions + +id + [INTEGER] + +role_id + [INTEGER] + +name + [VARCHAR(255)] + + +goals--goal_evaluation_cache + +{0,1} +0..N + + +goals--goals_goalproperties + +{0,1} +0..N + + +goal_triggers + +goal_triggers + +id + [INTEGER] + +name + [VARCHAR(100)] + +goal_id + [INTEGER] + +execute_when_complete + [BOOLEAN] + + +goals--goal_triggers + +{0,1} +0..N + + +goal_triggers--goal_trigger_steps + +{0,1} +0..N + + +rewards + +rewards + +id + [INTEGER] + +name + [VARCHAR(255)] + + +rewards--achievements_rewards + +{0,1} +0..N + + +translations + +translations + +id + [INTEGER] + +translationvariable_id + [INTEGER] + +language_id + [INTEGER] + +text + [TEXT] + + +variables + +variables + +id + [INTEGER] + +name + [VARCHAR(255)] + +group + [variable_group_types] + +increase_permission + [variable_increase_permission] + + +variables--values + +{0,1} +0..N + + +languages + +languages + +id + [INTEGER] + +name + [VARCHAR(255)] + + +languages--users + +{0,1} +0..N + + +languages--translations + +{0,1} +0..N + + +alembic_version + +alembic_version + +version_num + [VARCHAR(32)] + + +auth_roles + +auth_roles + +id + [INTEGER] + +name + [VARCHAR(100)] + + +auth_roles--auth_roles_permissions + +{0,1} +0..N + + +auth_roles--auth_users_roles + +{0,1} +0..N + + +goalproperties + +goalproperties + +id + [INTEGER] + +name + [VARCHAR(255)] + +is_variable + [BOOLEAN] + + +goalproperties--goals_goalproperties + +{0,1} +0..N + + +translationvariables + +translationvariables + +id + [INTEGER] + +name + [VARCHAR(255)] + + +translationvariables--user_messages + +{0,1} +0..N + + +translationvariables--goals_goalproperties + +{0,1} +0..N + + +translationvariables--achievements_achievementproperties + +{0,1} +0..N + + +translationvariables--goal_trigger_steps + +{0,1} +0..N + + +translationvariables--goals + +{0,1} +0..N + + +translationvariables--achievements_rewards + +{0,1} +0..N + + +translationvariables--translations + +{0,1} +0..N + + + diff --git a/docs/concepts/index.rst b/docs/concepts/index.rst index ab4d40c..94e609d 100644 --- a/docs/concepts/index.rst +++ b/docs/concepts/index.rst @@ -4,7 +4,7 @@ Concepts -------- -Assumption: You installed the gamification-engine and can open the admin interface at /admin/ +Assumption: You installed the gamification-engine and you can open the admin interface at /admin/ Users ===== @@ -30,7 +30,7 @@ When such an event occurs, your application triggers the gamification engine to The storage of these values can be grouped by day, month or year to save storage. Note that if you want to specify time-based rules like "event X occurs Y times in the last 14 days", you may not group the values by month or year. -In addition to integers, the application can also set keys to model application-specific data. +In addition to integers, the application can also add additional keys to the variables to model application-specific data. Goals ===== @@ -39,28 +39,34 @@ Goals define conditions that need to be fulfilled in order to get an achievement - goal: the value that is used for comparison - operator: "geq" or "leq"; used for comparison - - condition: the rule as python code, see below + - condition: the rule in json format, see below - group_by_dateformat: passed as a parameter to to_char ( PostgreSQL-Docs_ ) e.g. you can select and group by the weekday by using "ID" for ISO 8601 day of the week (1-7) which can afterwards be used in the condition - group_by_key: group by the key of the values table - timespan: number of days which are considered (uses utc, i.e. days*24hours) - maxmin: "max" or "min" - select min or max value after grouping - - evaluation: "daily", "weekly", "monthly", "yearly" evaluation (users timezone) .. _PostgreSQL-Docs: http://www.postgresql.org/docs/9.3/static/functions-formatting.html The conditions contain a python expression that must evaluate to a valid parameter for SQLAlchemy's where function. -Examples: +### Examples: When the user has participated in the seminars 5, 7, and 9, he should get an achievement. We first need to create a variable "participate" and tell our application to increase the value of that variable with the seminar ID as key for the user by 1. The constraint that a user may not attend multiple times to one seminar is covered by the application and not discussed here. In the gamification-engine we create a Goal with the following formular: -.. code:: python +.. code:: json - and_(p.var=="participate", p.key.in_(["5","7","9"])) + { + "term": { + "type": "literal", + "variable": "participate", + "key": ["5","7","9"], + "key_operator": "IN" + } + } Whenever a value for "participate" is set, this Goal is evaluated. It sums up all rows with the given condition and compares it to the Goal's "goal" attribute using the given operator. @@ -73,13 +79,19 @@ We create a variable "invite_users" and set the condition as follows: .. code:: python p.var=="invite_users" + { + "term": { + "type": "literal", + "variable": "invite_users" + } + } Furthermore we set the Goal's goal to 30 and the operator to "geq". If you want to make use of Goals with multiple levels, you probably want to increase the goal attribute with every level. -Therefore, you can also use python formulars. +Therefore, you can mathematical formulas. Example: @@ -87,7 +99,11 @@ For the first level, the user needs to invite 5 other users, for the second leve .. code:: python - 5*p.level # p.level is set by the gamification engine + 5*level # level is set by the gamification engine + +For further information about the rule language, we currently need to refer to the sources_ . + +.. _sources: https://github.com/ActiDoo/gamification-engine/blob/develop/gengine/app/formular.py Achievements ============ @@ -98,20 +114,24 @@ To allow multiple levels, you can set the *maxlevel* attribute. You can specify time-based constraints by setting *valid_start* and *valid_end*, and location-based constraints by setting *lat*,*lng* and *max_distance*. -The *hidden* flag can be used to model secret achievements. The *priority* specifies a custom order in output lists. +The *hidden* flag can be used to model secret achievements. The *priority* specifies a custom order in output lists. Achievements can also be used to model leaderboards. Therefor you need to assign a single Goal whose *goal attribute* is set to None. The Achievement's *relevance* attribute specifies in which context the leaderboard should be computed. Valid values are "friends", "city" and "own". +For setting up recurring achievements, set the *evaluation* to e.g. *monthly*. The *evaluation_timezone* parameter specifies when exactly the periods begin and end. + +There is a *view_permission* setting that can be used when authorization is active. It specifies whether other users can see the goal progress. + Properties ========== -A property describes Achievements or Goals of our system, like the name, image, description or XP the user should get. -The Values of Properties can again be python formulars. -Inside the formular you can make use of the level by using *p.level*. +A property describes an Achievement or a Goal of our system, like the name, image, description or XP the user should get. +The Values of Properties can again be python formulas. +Inside the formula you can make use of the level by using *level*. -Additionally Properties can be used as Variables. +Additionally, Properties can be used as Variables. This is useful to model goals like "reach 1000xp". @@ -119,8 +139,19 @@ Rewards ======= From the model perspective Rewards are similar to Properties. The main difference occurs during the evaluation of Achievements, more specifically when a user reaches a new level. -While the formulars for the properties are simply evaluated for the specific level, -the evaluated formulars of the rewards are compared to lower levels. +While the formulas for the properties are simply evaluated for the specific level, +the evaluated formulas of the rewards are compared to lower levels. The engine thus knows for each achieved level, which reward is new and can tell the application about this. In your application this could for example trigger a badge notification. + + +Further new concepts +======= +Since the latest version, some complete new optional concepts and features are added to the gamification-engine: + + - Authentication + - Push Notifications + - Messages + +All of these features are optional and they are not required to successfully use the engine. For the moment we refer to the source code and the description of the Rest API, a detailed documentation will follow. diff --git a/docs/conf.py b/docs/conf.py index 0a0c878..bade3de 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -61,7 +61,7 @@ def __getattr__(cls, name): # General information about the project. project = u'gamification-engine' -copyright = u'2015, Marcel Sander, Jens Janiuk' +copyright = u'2015, ActiDoo GmbH' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -204,7 +204,7 @@ def __getattr__(cls, name): # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ ('index', 'gamification-engine.tex', u'gamification-engine Documentation', - u'Marcel Sander, Jens Janiuk', 'manual'), + u'ActiDoo GmbH', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of @@ -234,7 +234,7 @@ def __getattr__(cls, name): # (source start file, name, description, authors, manual section). man_pages = [ ('index', 'gamification-engine', u'gamification-engine Documentation', - [u'Marcel Sander, Jens Janiuk'], 1) + [u'ActiDoo GmbH'], 1) ] # If true, show URL addresses after external links. @@ -248,7 +248,7 @@ def __getattr__(cls, name): # dir menu entry, description, category) texinfo_documents = [ ('index', 'gamification-engine', u'gamification-engine Documentation', - u'Marcel Sander, Jens Janiuk', 'gamification-engine', 'One line description of project.', + u'ActiDoo GmbH', 'gamification-engine', 'One line description of project.', 'Miscellaneous'), ] diff --git a/docs/index.rst b/docs/index.rst index 7d8db61..7cdae75 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -20,6 +20,7 @@ Contents: :maxdepth: 2 installing/index + upgrading/index concepts/index rest/index internals/index diff --git a/docs/installing/index.rst b/docs/installing/index.rst index d3f7bca..952fc34 100644 --- a/docs/installing/index.rst +++ b/docs/installing/index.rst @@ -7,7 +7,7 @@ Installation Requirements ============ -The gamification-engine requires an installed python distribution in version 2.7. It uses different language structures which are not supported in Python 2.6 or Python 3.x. Furthermore, the only currently supported persistence layer is PostgreSQL. +The gamification-engine requires an installed python distribution in version 3.x. It uses several language structures which are not supported in Python 2.x. Furthermore, the only currently supported persistence layer is PostgreSQL. Also the corresponding development packages are required (for Ubuntu/Debian: libpq-dev and python3-dev). Installation from PyPI @@ -24,6 +24,14 @@ You can install it by invoking $ gengine_quickstart mygengine $ cd mygengine +In the latest version, there are some optional dependencies for auth pushes and testing. To use these features install it in the following way: + +.. highlight:: bash + +:: + + $ pip install gamification-engine[auth,pushes,testing] + Afterwards edit production.ini according to your needs. Database diff --git a/docs/internals/index.rst b/docs/internals/index.rst index be44cca..f95eac3 100644 --- a/docs/internals/index.rst +++ b/docs/internals/index.rst @@ -4,18 +4,5 @@ Modules ------- -.. image:: /_static/2015-09-07-erm.svg +.. image:: /_static/2017-03-28-erm.svg :width: 1000 - -gengine.models -============== - -.. automodule:: gengine.models - :members: ABase, User, Variable, Value, Achievement, Property, AchievementProperty, Reward, Goalproperty, GoalGoalproperty AchievementReward, AchievementUser, GoalEvaluationCache, Goal, Language, TranslationVariable, Translation - - -gengine.views -============= - -.. automodule:: gengine.views - :members: add_or_update_user, delete_user, get_progress, increas_value, get_achievement_level diff --git a/docs/rest/index.rst b/docs/rest/index.rst index 5349dac..9e8608b 100644 --- a/docs/rest/index.rst +++ b/docs/rest/index.rst @@ -18,6 +18,8 @@ Add or update user data - region (String city) - friends (comma separated list of user Ids) - groups (comma separated list of group Ids) + - language (name) + - additional_public_data (JSON) - add or updates a user with Id {userId} and Post parameters into the engines database - if friends Ids are not registered a empty record with only the user Id will be created @@ -32,12 +34,13 @@ Delete a user Increase Value ============== - - POST to "/increase_value/{variable_name}/{userId}" + - POST to "/increase_value/{variable_name}/{userId}/{key}" - URL parameters: - variable_name (the name of the variable to increase or decrease) - userId (the Id of the user) - - POST parameters: - - value (the increase/decrease value in Double) + - key (an optional key, describing the context of the event, can be used in rules) + - POST parameters: + - value (the increase/decrease value in Double) - if the userId is not registered an error will be thrown - directly evaluates all goals associated with this variable_name @@ -48,17 +51,17 @@ Increase multiple Values at once - POST to "/increase_multi_values" - JSON request body: - .. code:: json - { - "{userId}" : { - "{variable}" : [ - { - "key" : "{key}", - "value" : "{value}" - } - ] - } - } + .. code:: json + { + "{userId}" : { + "{variable}" : [ + { + "key" : "{key}", + "value" : "{value}" + } + ] + } + } - directly evaluates all goals associated with the given variables - directly returns new reached achievements @@ -75,6 +78,54 @@ Get Progress Get a single achievement Level ============================== - - GET to "/increase_value/{variable_name}/{userId}" + - GET to "/achievement/{achievement_id}/level/{level}" + + - retrieves information about the rewards/properties of an achievement level + +Authentication +============================== + - POST to "/auth/login" + - Parameters in JSON-Body: email, password + - Returns a json body with a token: + .. code:: json + { + "token" : "foobar...." + } + +Register Device (for Push-Messages) +============================== + - POST to "/register_device/{user_id}" + - Parameters in JSON-Body: device_id, push_id, device_os, app_version + - Returns a json body with an ok status, or an error: + .. code:: json + { + "status" : "ok" + } + +Get Messages +============================== + - GET to "/messages/{user_id}" + - Possible GET Parameters: offset + - Limit is always 100 + - Returns a json body with the messages: + .. code:: json + { + "messages" : [{ + "id" : "....", + "text" : "....", + "is_read" : false, + "created_at" : "...." + }] + } + +Set Messages Read +============================== + - POST to "/read_messages/{user_id}" + - Parameters in JSON-Body: message_id + - Sets all messages as read which are at least as old, as the given message + - Returns a json body with an ok status, or an error: + .. code:: json + { + "status" : "ok" + } - - can be used to check if a user is allowed to use a reward \ No newline at end of file diff --git a/docs/roadmap.rst b/docs/roadmap.rst index 2ef22d8..6f4ea5a 100644 --- a/docs/roadmap.rst +++ b/docs/roadmap.rst @@ -9,21 +9,17 @@ Features which might influence the overall performance or cause greater changes At ActiDoo.com we implement new functions as we need them and push them as soon as they are somewhat stable. -Important Todo +Todo ============== - - Tests (!) - -Less Important Todo -=================== + - Review and improve tests - Improve Caching Future Features =============== - possibility to store events (values table) in noSQL systems - - evaluate time-related leaderboards and reset them afterwards + - implement callback for time-aware achievements - nicer admin UI - statistics - maybe a possiblity to plugin authentication/authorization to allow users to directly push events to the engine - - this still needs to be discussed from an architectural point of view - - this would also introduce the need for security constraints to detect cheaters \ No newline at end of file + - this would also introduce the need for security constraints to detect cheaters \ No newline at end of file diff --git a/docs/upgrading/index.rst b/docs/upgrading/index.rst new file mode 100644 index 0000000..fbc639f --- /dev/null +++ b/docs/upgrading/index.rst @@ -0,0 +1,18 @@ +:title: gamification-engine installation +:description: installing the gamification-engine + +Upgrading +------------ + +From 0.1 to 0.2 +============ + +In version 0.2 we have introduced **breaking changes** that make it impossible to do an automatic upgrade. If you are happy with 0.1, there is no need to upgrade. Furthermore, we have switched to Python 3.x as our main target environment. +For performing a manual upgrade the following steps are required: + + - Install a new instance of 0.2 + - Recreate all settings / achievements manually using the new goal condition syntax + - Recreate users + - Copy values data + +*For future updates we will try to keep the goal condition syntax backwards compatible.* diff --git a/gengine/__init__.py b/gengine/__init__.py index 9a96d45..b179f92 100644 --- a/gengine/__init__.py +++ b/gengine/__init__.py @@ -1,23 +1,26 @@ # -*- coding: utf-8 -*- -__version__ = '0.1.36' +from pyramid.events import NewRequest -import datetime, os +from gengine.base.context import reset_context +from gengine.base.errors import APIError +from gengine.base.settings import set_settings +__version__ = '0.2.0' + +import datetime + +import os from pyramid.config import Configurator from pyramid.renderers import JSON - +from pyramid.settings import asbool from sqlalchemy import engine_from_config -from sqlalchemy.orm import sessionmaker -from pyramid.settings import asbool from gengine.wsgiutil import HTTPSProxied, init_reverse_proxy def main(global_config, **settings): """ This function returns a Pyramid WSGI application. """ - config = Configurator(settings=settings) - config.include('pyramid_dogpile_cache') durl = os.environ.get("DATABASE_URL") #heroku if durl: @@ -26,16 +29,30 @@ def main(global_config, **settings): murl = os.environ.get("MEMCACHED_URL") #heroku if murl: settings['urlcache_url']=murl + + set_settings(settings) + + engine = engine_from_config(settings, 'sqlalchemy.', connect_args={"options": "-c timezone=utc"}, ) + config = Configurator(settings=settings) - engine = engine_from_config(settings, 'sqlalchemy.',connect_args={"options": "-c timezone=utc"},) - - config.include("pyramid_tm") - + from gengine.app.cache import init_caches + init_caches() + from gengine.metadata import init_session, init_declarative_base, init_db + init_session() init_declarative_base() init_db(engine) - + + from gengine.base.monkeypatch_flaskadmin import do_monkeypatch + do_monkeypatch() + + def reset_context_on_new_request(event): + reset_context() + config.add_subscriber(reset_context_on_new_request,NewRequest) + config.include('pyramid_dogpile_cache') + + config.include("pyramid_tm") config.include('pyramid_chameleon') urlprefix = settings.get("urlprefix","") @@ -45,30 +62,55 @@ def main(global_config, **settings): urlcache_url = settings.get("urlcache_url","127.0.0.1:11211") urlcache_active = asbool(os.environ.get("URLCACHE_ACTIVE", settings.get("urlcache_active",True))) - + + #auth + def get_user(request): + if not asbool(settings.get("enable_user_authentication",False)): + return None + token = request.headers.get('X-Auth-Token') + if token is not None: + from gengine.app.model import DBSession, AuthUser, AuthToken + tokenObj = DBSession.query(AuthToken).filter(AuthToken.token==token).first() + user = None + if tokenObj and tokenObj.valid_until', - endpoint='static_gengine', - view_func=get_static_view('gengine:flask_static',flaskadminapp)) + adminapp.add_url_rule('/static_gengine/', + endpoint='static_gengine', + view_func=get_static_view('gengine:app/static', adminapp)) - @flaskadminapp.context_processor + @adminapp.context_processor def inject_version(): - return { "gamification_engine_version" : pkg_resources.get_distribution("gamification-engine").version } + return { "gamification_engine_version" : pkg_resources.get_distribution("gamification-engine").version, + "settings_enable_authentication" : asbool(get_settings().get("enable_user_authentication",False)), + "urlprefix" : get_settings().get("urlprefix","/")} if not override_admin: - admin = Admin(flaskadminapp, + admin = Admin(adminapp, name="Gamification Engine - Admin Control Panel", base_template='admin_layout.html', - url=urlprefix+"/admin" - ) + url=urlprefix+"" + ) else: admin = override_admin admin.add_view(ModelViewAchievement(DBSession, category="Rules")) admin.add_view(ModelViewGoal(DBSession, category="Rules")) + admin.add_view(ModelViewGoalTrigger(DBSession, category="Rules")) + admin.add_view(ModelView(AchievementAchievementProperty, DBSession, category="Rules", name="Achievement Property Values")) admin.add_view(ModelView(AchievementReward, DBSession, category="Rules", name="Achievement Reward Values")) admin.add_view(ModelView(GoalGoalProperty, DBSession, category="Rules", name="Goal Property Values")) @@ -88,11 +100,15 @@ def inject_version(): admin.add_view(ModelViewGoalProperty(DBSession, category="Settings", name="Goal Property Types")) admin.add_view(ModelView(Language, DBSession, category="Settings")) admin.add_view(MaintenanceView(name="Maintenance", category="Settings", url="maintenance")) + + admin.add_view(ModelViewAuthUser(DBSession, category="Authentication")) + admin.add_view(ModelViewAuthRole(DBSession, category="Authentication")) admin.add_view(ModelViewValue(DBSession, category="Debug")) admin.add_view(ModelViewGoalEvaluationCache(DBSession, category="Debug")) admin.add_view(ModelViewUser(DBSession, category="Debug")) admin.add_view(ModelView(AchievementUser, DBSession, category="Debug")) + admin.add_view(ModelViewUserMessage(DBSession, category="Debug")) class TranslationInlineModelForm(InlineFormAdmin): form_columns = ('id','language','text') @@ -115,7 +131,7 @@ def __init__(self, session, **kwargs): super(ModelViewAchievementCategory, self).__init__(AchievementCategory, session, **kwargs) class ModelViewAchievement(ModelView): - column_list = ('name','valid_start','valid_end','relevance') + column_list = ('name','evaluation','valid_start','valid_end','relevance') column_searchable_list = ('name',) form_excluded_columns =('rewards','users','goals','properties','updated_at') fast_mass_delete = True @@ -129,9 +145,31 @@ class ModelViewVariable(ModelView): def __init__(self, session, **kwargs): super(ModelViewVariable, self).__init__(Variable, session, **kwargs) +class GoalTriggerStepInlineModelForm(InlineFormAdmin): + form_columns = ( + 'id', + 'step', + 'condition_type', + 'condition_percentage', + 'action_type', + 'action_translation', + ) + +class ModelViewGoalTrigger(ModelView): + form_columns = ( + 'name', + 'goal', + 'steps', + 'execute_when_complete' + ) + inline_models = (GoalTriggerStepInlineModelForm(GoalTriggerStep),) + + def __init__(self, session, **kwargs): + super(ModelViewGoalTrigger, self).__init__(GoalTrigger, session, **kwargs) + class ModelViewGoal(ModelView): - column_list = ('condition','evaluation','operator','goal','timespan','priority','achievement','updated_at') - form_excluded_columns =('properties',) + column_list = ('condition','operator','goal','timespan','priority','achievement','updated_at') + form_excluded_columns =('properties','triggers') #column_searchable_list = ('name',) column_filters = (Achievement.id,) fast_mass_delete = True @@ -211,9 +249,40 @@ def index(self): self._template_args['clear_caches_form'] = self.clear_caches_form = ClearCacheForm(request.form) if request.method == 'POST': - from models import clear_all_caches + from gengine.app.cache import clear_all_caches if self.clear_caches_form.clear_check.data: clear_all_caches() self._template_args['msgs'].append("All caches cleared!") return self.render(template="admin_maintenance.html") - \ No newline at end of file + +class ModelViewAuthUser(ModelView): + column_list = ('user_id', 'email', 'active', 'created_at') + form_columns = ('user_id','email', 'password', 'active', 'roles') + column_labels = {'password': 'Password'} + + def __init__(self, session, **kwargs): + super(ModelViewAuthUser, self).__init__(AuthUser, session, **kwargs) + +class PermissionInlineModelForm(InlineFormAdmin): + form_columns = ('id','name') + form_choices = { + "name" : sorted(list(yield_all_perms()),key=lambda x:x[1]) + } + +class ModelViewAuthRole(ModelView): + column_list = ('id', 'name', 'permissions') + form_excluded_columns = ('users') + inline_models = (PermissionInlineModelForm(AuthRolePermission),) + + def __init__(self, session, **kwargs): + super(ModelViewAuthRole, self).__init__(AuthRole, session, **kwargs) + + +class ModelViewUserMessage(ModelView): + column_list = ('user','text','created_at','is_read') + column_details_list = ('user', 'text', 'created_at', 'is_read', 'params') + can_edit = False + can_view_details = True + + def __init__(self, session, **kwargs): + super(ModelViewUserMessage, self).__init__(UserMessage, session, **kwargs) diff --git a/gengine/alembic/README b/gengine/app/alembic/README similarity index 100% rename from gengine/alembic/README rename to gengine/app/alembic/README diff --git a/gengine/alembic/env.py b/gengine/app/alembic/env.py similarity index 55% rename from gengine/alembic/env.py rename to gengine/app/alembic/env.py index a49f0e5..42b6ed0 100644 --- a/gengine/alembic/env.py +++ b/gengine/app/alembic/env.py @@ -1,18 +1,19 @@ from __future__ import with_statement -from alembic import context -from sqlalchemy import engine_from_config, pool + from logging.config import fileConfig import os - +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 + +config = context.config # Interpret the config file for Python logging. # This line sets up loggers basically. -fileConfig(config.config_file_name) +if config.config_file_name: + fileConfig(config.config_file_name) overrides = {} @@ -29,48 +30,32 @@ from gengine.metadata import Base target_metadata = Base.metadata -from gengine.models import * - # target_metadata = None +from gengine.app.model import * + # other values from the config, defined by the needs of env.py, # can be acquired: # my_important_option = config.get_main_option("my_important_option") # ... etc. +def run_migrations_online(): + """Run migrations in 'online' mode. -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. + In this scenario we need to create an Engine + and associate a connection with the context. """ - url = config.get_main_option("sqlalchemy.url") - context.configure(url=url, target_metadata=target_metadata) - with context.begin_transaction(): - context.run_migrations() + engine = config.attributes["engine"] + schema = config.attributes["schema"] -def run_migrations_online(): - """Run migrations in 'online' mode. + #connectable = create_engine(url, poolclass=pool.NullPool) - In this scenario we need to create an Engine - and associate a connection with the context. + with engine.connect() as connection: - """ - connectable = engine_from_config( - config.get_section(config.config_ini_section), - prefix='sqlalchemy.', - poolclass=pool.NullPool) - - with connectable.connect() as connection: + connection.execute("SET search_path TO "+schema) context.configure( connection=connection, @@ -80,7 +65,4 @@ def run_migrations_online(): with context.begin_transaction(): context.run_migrations() -if context.is_offline_mode(): - run_migrations_offline() -else: - run_migrations_online() +run_migrations_online() diff --git a/gengine/alembic/script.py.mako b/gengine/app/alembic/script.py.mako similarity index 100% rename from gengine/alembic/script.py.mako rename to gengine/app/alembic/script.py.mako diff --git a/gengine/app/alembic/versions/2012674516fc_has_been_pushed.py b/gengine/app/alembic/versions/2012674516fc_has_been_pushed.py new file mode 100644 index 0000000..4543881 --- /dev/null +++ b/gengine/app/alembic/versions/2012674516fc_has_been_pushed.py @@ -0,0 +1,30 @@ +"""has_been_pushed + +Revision ID: 2012674516fc +Revises: 62026366cd60 +Create Date: 2017-03-08 17:44:02.214248 + +""" + +# revision identifiers, used by Alembic. +revision = '2012674516fc' +down_revision = '62026366cd60' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.add_column('user_messages', sa.Column('has_been_pushed', sa.Boolean(), nullable=False, server_default='1')) + op.create_index(op.f('ix_user_messages_has_been_pushed'), 'user_messages', ['has_been_pushed'], unique=False) + ### end Alembic commands ### + + +def downgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_user_messages_has_been_pushed'), table_name='user_messages') + op.drop_column('user_messages', 'has_been_pushed') + ### end Alembic commands ### diff --git a/gengine/app/alembic/versions/3512efb5496d_additional_public_data.py b/gengine/app/alembic/versions/3512efb5496d_additional_public_data.py new file mode 100644 index 0000000..0136440 --- /dev/null +++ b/gengine/app/alembic/versions/3512efb5496d_additional_public_data.py @@ -0,0 +1,28 @@ +"""additional_public_data + +Revision ID: 3512efb5496d +Revises: +Create Date: 2016-07-21 13:30:45.257569 + +""" + +# revision identifiers, used by Alembic. +revision = '3512efb5496d' +down_revision = None +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.add_column('users', sa.Column('additional_public_data', sa.JSON(), nullable=True)) + ### end Alembic commands ### + + +def downgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.drop_column('users', 'additional_public_data') + ### end Alembic commands ### diff --git a/gengine/app/alembic/versions/62026366cd60_evaluation_timezone.py b/gengine/app/alembic/versions/62026366cd60_evaluation_timezone.py new file mode 100644 index 0000000..00d50b4 --- /dev/null +++ b/gengine/app/alembic/versions/62026366cd60_evaluation_timezone.py @@ -0,0 +1,28 @@ +"""evaluation_timezone + +Revision ID: 62026366cd60 +Revises: 87dfedb58883 +Create Date: 2017-02-17 13:54:40.545893 + +""" + +# revision identifiers, used by Alembic. +revision = '62026366cd60' +down_revision = '87dfedb58883' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.add_column('achievements', sa.Column('evaluation_timezone', sa.String(), nullable=True)) + ### end Alembic commands ### + + +def downgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.drop_column('achievements', 'evaluation_timezone') + ### end Alembic commands ### diff --git a/gengine/app/alembic/versions/65c7a32b7322_achievement_date_unique.py b/gengine/app/alembic/versions/65c7a32b7322_achievement_date_unique.py new file mode 100644 index 0000000..c3e665d --- /dev/null +++ b/gengine/app/alembic/versions/65c7a32b7322_achievement_date_unique.py @@ -0,0 +1,43 @@ +"""achievement_date_unique + +Revision ID: 65c7a32b7322 +Revises: d4a70083f72e +Create Date: 2017-01-31 23:01:11.744725 + +""" + +# revision identifiers, used by Alembic. +revision = '65c7a32b7322' +down_revision = 'd4a70083f72e' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.execute("ALTER TABLE achievements_users DROP CONSTRAINT pk_achievements_users;") + op.execute("ALTER TABLE achievements_users ADD COLUMN id SERIAL;") + op.execute("ALTER TABLE achievements_users ADD CONSTRAINT pk_achievements_users PRIMARY KEY(id);") + + op.execute("ALTER TABLE goal_evaluation_cache DROP CONSTRAINT pk_goal_evaluation_cache;") + op.execute("ALTER TABLE goal_evaluation_cache ADD COLUMN id SERIAL;") + op.execute("ALTER TABLE goal_evaluation_cache ADD CONSTRAINT pk_goal_evaluation_cache PRIMARY KEY(id);") + + op.create_index('idx_achievements_users_date_not_null_unique', 'achievements_users', ['user_id', 'achievement_id', 'achievement_date', 'level'], unique=True, postgresql_where=sa.text('achievement_date IS NOT NULL')) + op.create_index('idx_achievements_users_date_null_unique', 'achievements_users', ['user_id', 'achievement_id', 'level'], unique=True, postgresql_where=sa.text('achievement_date IS NULL')) + op.create_index(op.f('ix_achievements_users_achievement_id'), 'achievements_users', ['achievement_id'], unique=False) + op.create_index(op.f('ix_achievements_users_level'), 'achievements_users', ['level'], unique=False) + + op.create_index('idx_goal_evaluation_cache_date_not_null_unique', 'goal_evaluation_cache', ['user_id', 'goal_id', 'achievement_date'], unique=True, postgresql_where=sa.text('achievement_date IS NOT NULL')) + op.create_index('idx_goal_evaluation_cache_date_null_unique', 'goal_evaluation_cache', ['user_id', 'goal_id'], unique=True, postgresql_where=sa.text('achievement_date IS NULL')) + op.create_index(op.f('ix_goal_evaluation_cache_goal_id'), 'goal_evaluation_cache', ['goal_id'], unique=False) + op.create_index(op.f('ix_goal_evaluation_cache_user_id'), 'goal_evaluation_cache', ['user_id'], unique=False) + ### end Alembic commands ### + + +def downgrade(): + pass + # not possible ! diff --git a/gengine/app/alembic/versions/87dfedb58883_goal_triggers_achievement_date.py b/gengine/app/alembic/versions/87dfedb58883_goal_triggers_achievement_date.py new file mode 100644 index 0000000..5c02996 --- /dev/null +++ b/gengine/app/alembic/versions/87dfedb58883_goal_triggers_achievement_date.py @@ -0,0 +1,30 @@ +"""goal_triggers_achievement_date + +Revision ID: 87dfedb58883 +Revises: 65c7a32b7322 +Create Date: 2017-02-08 15:59:53.780748 + +""" + +# revision identifiers, used by Alembic. +revision = '87dfedb58883' +down_revision = '65c7a32b7322' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.add_column('goal_trigger_executions', sa.Column('achievement_date', sa.DateTime(), nullable=True)) + op.create_index(op.f('ix_goal_trigger_executions_achievement_date'), 'goal_trigger_executions', ['achievement_date'], unique=False) + ### end Alembic commands ### + + +def downgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_goal_trigger_executions_achievement_date'), table_name='goal_trigger_executions') + op.drop_column('goal_trigger_executions', 'achievement_date') + ### end Alembic commands ### diff --git a/gengine/app/alembic/versions/DUMMY b/gengine/app/alembic/versions/DUMMY new file mode 100644 index 0000000..90a1d60 --- /dev/null +++ b/gengine/app/alembic/versions/DUMMY @@ -0,0 +1 @@ +... \ No newline at end of file diff --git a/gengine/app/alembic/versions/d4a70083f72e_add_user_language.py b/gengine/app/alembic/versions/d4a70083f72e_add_user_language.py new file mode 100644 index 0000000..5359c0a --- /dev/null +++ b/gengine/app/alembic/versions/d4a70083f72e_add_user_language.py @@ -0,0 +1,34 @@ +"""Add User Language + +Revision ID: d4a70083f72e +Revises: 3512efb5496d +Create Date: 2016-07-22 14:04:43.900826 + +""" + +# revision identifiers, used by Alembic. +revision = 'd4a70083f72e' +down_revision = '3512efb5496d' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.create_index(op.f('ix_languages_name'), 'languages', ['name'], unique=False) + op.create_index(op.f('ix_translationvariables_name'), 'translationvariables', ['name'], unique=False) + op.add_column('users', sa.Column('language_id', sa.Integer(), nullable=True)) + op.create_foreign_key(op.f('fk_users_language_id_languages'), 'users', 'languages', ['language_id'], ['id']) + ### end Alembic commands ### + + +def downgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(op.f('fk_users_language_id_languages'), 'users', type_='foreignkey') + op.drop_column('users', 'language_id') + op.drop_index(op.f('ix_translationvariables_name'), table_name='translationvariables') + op.drop_index(op.f('ix_languages_name'), table_name='languages') + ### end Alembic commands ### diff --git a/gengine/app/cache.py b/gengine/app/cache.py new file mode 100644 index 0000000..33c3734 --- /dev/null +++ b/gengine/app/cache.py @@ -0,0 +1,44 @@ +from gengine.base.cache import create_cache + +caches = {} + +cache_general = None +cache_goal_evaluation = None +cache_achievement_eval = None +cache_achievements_users_levels = None +cache_achievements_by_user_for_today = None +#cache_goal_statements = None +cache_translations = None + +def init_caches(): + global cache_general + cache_general = create_cache("general") + + global cache_achievement_eval + cache_achievement_eval = create_cache("achievement_eval") + + global cache_achievements_by_user_for_today + cache_achievements_by_user_for_today = create_cache("achievements_by_user_for_today") + + global cache_achievements_users_levels + cache_achievements_users_levels = create_cache("achievements_users_levels") + + global cache_translations + cache_translations = create_cache("translations") + + # The Goal evaluation Cache is implemented as a two-level cache (persistent in db, non-persistent as dogpile) + global cache_goal_evaluation + cache_goal_evaluation = create_cache("goal_evaluation") + + #global cache_goal_statements + #cache_goal_statements = create_memory_cache("goal_statements") + + +def clear_all_caches(): + cache_general.invalidate(hard=True) + cache_achievement_eval.invalidate(hard=True) + cache_achievements_by_user_for_today.invalidate(hard=True) + cache_achievements_users_levels.invalidate(hard=True) + cache_translations.invalidate(hard=True) + cache_goal_evaluation.invalidate(hard=True) + #cache_goal_statements.invalidate(hard=True) diff --git a/gengine/app/formular.py b/gengine/app/formular.py new file mode 100644 index 0000000..627c83d --- /dev/null +++ b/gengine/app/formular.py @@ -0,0 +1,270 @@ +import re + +import functools +import jsl +import json +import jsonschema +import pyparsing as pp + +import math +import operator + +from sqlalchemy.sql import and_, or_ + +class FormularEvaluationException(Exception): + def __init__(self, message): + self.message = message + +# The Expression Parser for mathematical formulars +class NumericStringParser(object): + ''' + Most of this code comes from the fourFn.py pyparsing example + + # from: http://stackoverflow.com/a/2371789 + + ''' + + def pushFirst(self, strg, loc, toks): + self.exprStack.append(toks[0]) + + def pushUMinus(self, strg, loc, toks): + if toks and toks[0] == '-': + self.exprStack.append('unary -') + + def __init__(self, extra_literals=[]): + """ + expop :: '^' + multop :: '*' | '/' + addop :: '+' | '-' + integer :: ['+' | '-'] '0'..'9'+ + atom :: PI | E | real | fn '(' expr ')' | '(' expr ')' + factor :: atom [ expop factor ]* + term :: factor [ multop factor ]* + expr :: term [ addop term ]* + """ + point = pp.Literal(".") + e = pp.CaselessLiteral("E") + fnumber = pp.Combine(pp.Word("+-" + pp.nums, pp.nums) + + pp.Optional(point + pp.Optional(pp.Word(pp.nums))) + + pp.Optional(e + pp.Word("+-" + pp.nums, pp.nums))) + ident = pp.Word(pp.alphas, pp.alphas + pp.nums + "_$") + plus = pp.Literal("+") + minus = pp.Literal("-") + mult = pp.Literal("*") + div = pp.Literal("/") + lpar = pp.Literal("(").suppress() + rpar = pp.Literal(")").suppress() + addop = plus | minus + multop = mult | div + expop = pp.Literal("^") + pi = pp.CaselessLiteral("PI") + + self.extra_literals = extra_literals + pp_extra_literals = functools.reduce(operator.or_, [pp.CaselessLiteral(e) for e in extra_literals], pp.NoMatch()) + + expr = pp.Forward() + atom = ((pp.Optional(pp.oneOf("- +")) + + (pi | e | pp_extra_literals | fnumber | ident + lpar + expr + rpar).setParseAction(self.pushFirst)) + | pp.Optional(pp.oneOf("- +")) + pp.Group(lpar + expr + rpar) + ).setParseAction(self.pushUMinus) + # by defining exponentiation as "atom [ ^ factor ]..." instead of + # "atom [ ^ atom ]...", we get right-to-left exponents, instead of left-to-right + # that is, 2^3^2 = 2^(3^2), not (2^3)^2. + factor = pp.Forward() + factor << atom + pp.ZeroOrMore((expop + factor).setParseAction(self.pushFirst)) + term = factor + pp.ZeroOrMore((multop + factor).setParseAction(self.pushFirst)) + expr << term + pp.ZeroOrMore((addop + term).setParseAction(self.pushFirst)) + # addop_term = ( addop + term ).setParseAction( self.pushFirst ) + # general_term = term + ZeroOrMore( addop_term ) | OneOrMore( addop_term) + # expr << general_term + self.bnf = expr + # map operator symbols to corresponding arithmetic operations + epsilon = 1e-12 + self.opn = {"+": operator.add, + "-": operator.sub, + "*": operator.mul, + "/": operator.truediv, + "^": operator.pow} + self.fn = {"sin": math.sin, + "cos": math.cos, + "tan": math.tan, + "abs": abs, + "trunc": lambda a: int(a), + "round": round, + "sgn": lambda a: abs(a) > epsilon and pp.cmp(a, 0) or 0} + + def evaluateStack(self, s, key_value_map={}): + op = s.pop() + if op == 'unary -': + return -self.evaluateStack(s, key_value_map) + if op in "+-*/^": + op2 = self.evaluateStack(s, key_value_map) + op1 = self.evaluateStack(s, key_value_map) + return self.opn[op](op1, op2) + elif op == "PI": + return math.pi # 3.1415926535 + elif op == "E": + return math.e # 2.718281828 + elif op in self.extra_literals: + return key_value_map[op] + elif op in self.fn: + return self.fn[op](self.evaluateStack(s, key_value_map)) + elif op[0].isalpha(): + return 0 + else: + return float(op) + + def eval(self, num_string, key_value_map={}, parseAll=True): + self.exprStack = [] + results = self.bnf.parseString(num_string, parseAll) + val = self.evaluateStack(self.exprStack[:], key_value_map = key_value_map) + return val + + +def evaluate_value_expression(expression, params={}): + if expression is None: + return None + try: + nsp = NumericStringParser(extra_literals=params.keys()) + return nsp.eval(expression,key_value_map=params) + except: + raise FormularEvaluationException(expression) + + +def render_string(tpl, params): + """Substitute text in <> with corresponding variable value.""" + regex = re.compile('\${(.+?)}') + def repl(m): + group = m.group(1) + value = evaluate_value_expression(group, params) + if int(value) == value: + value = int(value) + return str(value) + return regex.sub(repl, tpl) + + +def evaluate_string(inst, params=None): + try: + if inst is None: + return None + if params is not None: + formatted = render_string(inst, params) + else: + formatted = inst + + try: + if str(int(formatted)) == str(formatted): + return int(formatted) + except: + pass + + try: + if str(int(float)) == str(float): + return float(formatted) + except: + pass + + return formatted + except: + raise FormularEvaluationException(inst) + + +# The condition JSON-Schema +class Conjunction(jsl.Document): + terms = jsl.ArrayField(jsl.OneOfField([ + jsl.DocumentField("Conjunction", as_ref=True), + jsl.DocumentField("Disjunction", as_ref=True), + jsl.DocumentField("Literal", as_ref=True) + ], required=True), required=True) + type = jsl.StringField(pattern="^conjunction$") + + +class Disjunction(jsl.Document): + terms = jsl.ArrayField(jsl.OneOfField([ + jsl.DocumentField("Conjunction", as_ref=True), + jsl.DocumentField("Disjunction", as_ref=True), + jsl.DocumentField("Literal", as_ref=True) + ], required=True), required=True) + type = jsl.StringField(pattern="^disjunction$") + + +class Literal(jsl.Document): + variable = jsl.StringField(required=True) + key_operator = jsl.StringField(pattern = "^(IN|ILIKE)$", required=False) + key = jsl.ArrayField(jsl.StringField(), required=False) + type = jsl.StringField(pattern="^literal$") + + +class TermDocument(jsl.Document): + term = jsl.OneOfField([ + jsl.DocumentField(Conjunction, as_ref=True), + jsl.DocumentField(Disjunction, as_ref=True), + jsl.DocumentField(Literal, as_ref=True) + ], required=True) + + +def validate_term(condition_term): + return jsonschema.validate(condition_term, TermDocument.get_schema()) + +def _term_eval(term, column_variable, column_key): + + if term["type"].lower() == "conjunction": + return and_(*((_term_eval(t, column_variable, column_key) for t in term["terms"]))) + elif term["type"].lower() == "disjunction": + return or_(*((_term_eval(t, column_variable, column_key) for t in term["terms"]))) + elif term["type"].lower() == "literal": + if "key" in term and term["key"]: + key_operator = term.get("key_operator", "IN") + if key_operator is None or key_operator == "IN": + key_condition = column_key.in_(term["key"]) + elif key_operator=="ILIKE": + key_condition = or_(*(column_key.ilike(pattern) for pattern in term["key"])) + return and_(column_variable==term["variable"], key_condition) + else: + return column_variable==term["variable"] + + +def evaluate_condition(inst, column_variable=None, column_key=None): + try: + if isinstance(inst,str): + inst = json.loads(inst) + from gengine.app.model import t_values, t_variables + if column_variable is None: + column_variable = t_variables.c.name.label("variable_name") + if column_key is None: + column_key = t_variables.c.name.label("variable_name") + + jsonschema.validate(inst, TermDocument.get_schema()) + return _term_eval(inst["term"], column_variable, column_key) + except: + raise FormularEvaluationException(json.dumps(inst)) + + +demo_schema = { + 'term': { + 'variable': 'participate', + 'key_operator': 'IN', + 'key': ['2', ], + 'type': 'literal' + } +} + +demo2_schema = { + 'term': { + 'type': 'disjunction', + 'terms': [ + { + 'type': 'literal', + 'variable': 'participate', + 'key_operator': 'ILIKE', + 'key': ['%blah%', ] + }, + { + 'type': 'literal', + 'variable': 'participate', + 'key_operator': 'IN', + 'key': ['2', ] + } + ] + } +} diff --git a/gengine/models.py b/gengine/app/model.py similarity index 54% rename from gengine/models.py rename to gengine/app/model.py index 600c7ce..9025e5a 100644 --- a/gengine/models.py +++ b/gengine/app/model.py @@ -2,15 +2,23 @@ """models including business logic""" import datetime +import logging from datetime import timedelta import hashlib import pytz import sqlalchemy.types as ty -import warnings -from dogpile.cache import make_region -from pyramid_dogpile_cache import get_region -from pytz.exceptions import UnknownTimeZoneError +from sqlalchemy.dialects.postgresql import JSON +import sys + +from pyramid.settings import asbool +from sqlalchemy.ext.hybrid import hybrid_property +from sqlalchemy.sql.schema import UniqueConstraint, Index + +from gengine.app.permissions import perm_global_increase_value +from gengine.base.model import ABase, exists_by_expr, datetime_trunc, calc_distance, coords, update_connection +from gengine.app.cache import cache_general, cache_goal_evaluation, cache_achievement_eval, cache_achievements_users_levels, \ + cache_achievements_by_user_for_today, cache_translations from sqlalchemy import ( Table, ForeignKey, @@ -24,69 +32,67 @@ from sqlalchemy.dialects.postgresql import TIMESTAMP from sqlalchemy.orm import ( mapper, - relationship + relationship as sa_relationship, + backref as sa_backref ) -from zope.sqlalchemy.datamanager import mark_changed - -from gengine.metadata import Base, DBSession -from . import urlcache - -try: - import __builtin__ -except: - #py35 - import builtins as __builtin__ from sqlalchemy.sql import bindparam -def my_key_mangler(prefix): - def s(o): - if type(o)==dict: - return "_".join(["%s=%s" % (str(k),str(v)) for k,v in o.items()]) - if type(o)==tuple: - return "_".join([str(v) for v in o]) - if type(o)==list: - return "_".join([str(v) for v in o]) - else: - return str(o) - - def generate_key(key): - return prefix + s(key).replace(" ","") +from gengine.base.settings import get_settings +from gengine.metadata import Base, DBSession - return generate_key +from gengine.app.formular import evaluate_condition, evaluate_value_expression, evaluate_string -def create_cache(name): - ch = None - - try: - ch = get_region(name) - # The Goal evaluation Cache is implemented as a two-level cache (persistent in db, non-persistent as dogpile) - except: - ch = make_region().configure('dogpile.cache.memory') - warnings.warn("Warning: cache objects are in memory, are you creating docs?") - - ch.key_mangler = my_key_mangler(name) - globals()["cache_"+name] = ch - -create_cache("general") -create_cache("achievement_eval") -create_cache("achievements_by_user_for_today") -create_cache("achievements_users_levels") -create_cache("translations") -# The Goal evaluation Cache is implemented as a two-level cache (persistent in db, non-persistent as dogpile) -create_cache("goal_evaluation") -create_cache("goal_statements") +log = logging.getLogger(__name__) t_users = Table("users", Base.metadata, Column('id', ty.BigInteger, primary_key = True), Column("lat", ty.Float(Precision=64), nullable=True), Column("lng", ty.Float(Precision=64), nullable=True), + Column("language_id", ty.Integer, ForeignKey("languages.id"), nullable=True), Column("timezone", ty.String(), nullable=False, default="UTC"), Column("country", ty.String(), nullable=True, default=None), Column("region", ty.String(), nullable=True, default=None), Column("city", ty.String(), nullable=True, default=None), + Column("additional_public_data", JSON(), nullable=True, default=None), + Column('created_at', ty.DateTime, nullable = False, default=datetime.datetime.utcnow), +) + +t_auth_users = Table("auth_users", Base.metadata, + Column('user_id', ty.BigInteger, ForeignKey("users.id", ondelete="CASCADE"), primary_key = True, nullable=False), + Column("email", ty.String, unique=True), + Column("password_hash", ty.String, nullable=False), + Column("password_salt", ty.Unicode, nullable=False), + Column("active", ty.Boolean, nullable=False), Column('created_at', ty.DateTime, nullable = False, default=datetime.datetime.utcnow), ) +def get_default_token_valid_time(): + return datetime.datetime.utcnow() + datetime.timedelta(days=30) + +t_auth_tokens = Table("auth_tokens", Base.metadata, + Column("id", ty.BigInteger, primary_key=True), + Column("user_id", ty.BigInteger, ForeignKey("auth_users.user_id", ondelete="CASCADE"), nullable=False), + Column("token", ty.String, nullable=False), + Column('valid_until', ty.DateTime, nullable = False, default=get_default_token_valid_time), +) + +t_auth_roles = Table("auth_roles", Base.metadata, + Column("id", ty.Integer, primary_key=True), + Column("name", ty.String(100), unique=True), +) + +t_auth_users_roles = Table("auth_users_roles", Base.metadata, + Column("user_id", ty.BigInteger, ForeignKey("auth_users.user_id", ondelete="CASCADE"), primary_key=True, nullable=False), + Column("role_id", ty.BigInteger, ForeignKey("auth_roles.id", ondelete="CASCADE"), primary_key=True, nullable=False), +) + +t_auth_roles_permissions = Table("auth_roles_permissions", Base.metadata, + Column("id", ty.Integer, primary_key=True), + Column("role_id", ty.Integer, ForeignKey("auth_roles.id", use_alter=True, ondelete="CASCADE"), nullable=False, index=True), + Column("name", ty.String(255), nullable=False), + UniqueConstraint("role_id", "name") +) + t_users_users = Table("users_users", Base.metadata, Column('from_id', ty.BigInteger, ForeignKey("users.id", ondelete="CASCADE"), primary_key = True, nullable=False), Column('to_id', ty.BigInteger, ForeignKey("users.id", ondelete="CASCADE"), primary_key = True, nullable=False) @@ -111,14 +117,18 @@ def create_cache(name): Column("achievementcategory_id", ty.Integer, ForeignKey("achievementcategories.id", ondelete="SET NULL"), index=True, nullable=True), Column('name', ty.String(255), nullable = False), #internal use Column('maxlevel',ty.Integer, nullable=False, default=1), - Column('hidden',ty.Boolean, nullable=False, default=False), + Column('hidden',ty.Boolean, nullable=False, default=False), Column('valid_start',ty.Date, nullable=True), Column('valid_end',ty.Date, nullable=True), Column("lat", ty.Float(Precision=64), nullable=True), Column("lng", ty.Float(Precision=64), nullable=True), Column("max_distance", ty.Integer, nullable=True), Column('priority', ty.Integer, index=True, default=0), - Column('relevance',ty.Enum("friends","city","own", name="relevance_types"), default="own"), + Column('evaluation', ty.Enum("immediately","daily","weekly","monthly","yearly","end", name="evaluation_types"), default="immediately", nullable=False), + Column('evaluation_timezone', ty.String(), default=None, nullable=True), + Column('relevance',ty.Enum("global","friends","city","own", name="relevance_types"), default="own"), + Column('view_permission',ty.Enum("everyone", "own", name="achievement_view_permission"), default="everyone"), + Column('created_at', ty.DateTime, nullable = False, default=datetime.datetime.utcnow), ) t_goals = Table("goals", Base.metadata, @@ -127,7 +137,6 @@ def create_cache(name): Column('name_translation_id', ty.Integer, ForeignKey("translationvariables.id", ondelete="RESTRICT"), nullable = True), #TODO: deprecate name_translation Column('condition', ty.String(255), nullable=True), - Column('evaluation',ty.Enum("immediately","daily","weekly","monthly","yearly","end", name="evaluation_types")), Column('timespan',ty.Integer, nullable=True), Column('group_by_key', ty.Boolean(), default=False), Column('group_by_dateformat', ty.String(255), nullable=True), @@ -136,19 +145,38 @@ def create_cache(name): Column('maxmin', ty.Enum("max","min", name="goal_maxmin"), nullable=True, default="max"), Column('achievement_id', ty.Integer, ForeignKey("achievements.id", ondelete="CASCADE"), nullable=False), Column('priority', ty.Integer, index=True, default=0), -) +) t_goal_evaluation_cache = Table("goal_evaluation_cache", Base.metadata, - Column("goal_id", ty.Integer, ForeignKey("goals.id", ondelete="CASCADE"), primary_key=True, nullable=False), - Column("user_id", ty.BigInteger, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True, nullable=False), + Column('id', ty.Integer, primary_key=True), + Column("goal_id", ty.Integer, ForeignKey("goals.id", ondelete="CASCADE"), nullable=False, index=True), + Column('achievement_date', ty.DateTime, nullable=True), # To identify the goals for monthly, weekly, ... achievements; + Column("user_id", ty.BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True), Column("achieved", ty.Boolean), Column("value", ty.Float), ) +Index("idx_goal_evaluation_cache_date_not_null_unique", + t_goal_evaluation_cache.c.user_id, + t_goal_evaluation_cache.c.goal_id, + t_goal_evaluation_cache.c.achievement_date, + unique=True, + postgresql_where=t_goal_evaluation_cache.c.achievement_date!=None +) + +Index("idx_goal_evaluation_cache_date_null_unique", + t_goal_evaluation_cache.c.user_id, + t_goal_evaluation_cache.c.goal_id, + unique=True, + postgresql_where=t_goal_evaluation_cache.c.achievement_date==None +) + + t_variables = Table('variables', Base.metadata, Column('id', ty.Integer, primary_key = True), Column('name', ty.String(255), nullable = False, index=True), Column('group', ty.Enum("year","month","week","day","none", name="variable_group_types"), nullable = False, default="none"), + Column('increase_permission',ty.Enum("own", "admin", name="variable_increase_permission"), default="admin"), ) t_values = Table('values', Base.metadata, @@ -202,12 +230,32 @@ def create_cache(name): ) t_achievements_users = Table('achievements_users', Base.metadata, - Column('user_id', ty.BigInteger, ForeignKey("users.id"), primary_key = True, index=True, nullable=False), - Column('achievement_id', ty.Integer, ForeignKey("achievements.id", ondelete="CASCADE"), primary_key = True, nullable=False), - Column('level', ty.Integer, primary_key = True, default=1), - Column('updated_at', ty.DateTime, nullable = False, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow), + Column('id', ty.Integer, primary_key = True), + Column('user_id', ty.BigInteger, ForeignKey("users.id"), index=True, nullable=False), + Column('achievement_id', ty.Integer, ForeignKey("achievements.id", ondelete="CASCADE"), index=True, nullable=False), + Column('achievement_date', ty.DateTime, nullable=True, index=True), + Column('level', ty.Integer, default=1, nullable=False, index=True), + Column('updated_at', ty.DateTime, nullable = False, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow, index=True), ) +Index("idx_achievements_users_date_not_null_unique", + t_achievements_users.c.user_id, + t_achievements_users.c.achievement_id, + t_achievements_users.c.achievement_date, + t_achievements_users.c.level, + unique=True, + postgresql_where=t_achievements_users.c.achievement_date!=None +) + +Index("idx_achievements_users_date_null_unique", + t_achievements_users.c.user_id, + t_achievements_users.c.achievement_id, + t_achievements_users.c.level, + unique=True, + postgresql_where=t_achievements_users.c.achievement_date==None +) + + t_requirements = Table('requirements', Base.metadata, Column('from_id', ty.Integer, ForeignKey("achievements.id", ondelete="CASCADE"), primary_key = True, nullable=False), Column('to_id', ty.Integer, ForeignKey("achievements.id", ondelete="CASCADE"), primary_key = True, nullable=False), @@ -220,12 +268,12 @@ def create_cache(name): t_languages = Table('languages', Base.metadata, Column('id', ty.Integer, primary_key = True), - Column('name', ty.String(255), nullable = False), + Column('name', ty.String(255), nullable = False, index=True), ) t_translationvariables = Table('translationvariables', Base.metadata, Column('id', ty.Integer, primary_key = True), - Column('name', ty.String(255), nullable = False), + Column('name', ty.String(255), nullable = False, index=True), ) t_translations = Table('translations', Base.metadata, @@ -235,47 +283,193 @@ def create_cache(name): Column('text', ty.Text(), nullable = False), ) -class ABase(object): - """abstract base class which introduces a nice constructor for the model classes.""" - - def __init__(self,*args,**kw): - """ create a model object. - - pass attributes by using named parameters, e.g. name="foo", value=123 - """ - - for k,v in kw.items(): - setattr(self, k, v) - - def __str__(self): - if hasattr(self, "__unicode__"): - return self.__unicode__() +t_user_device = Table('user_devices', Base.metadata, + Column('device_id', ty.String(255), primary_key = True), + Column('user_id', ty.BigInteger, ForeignKey("users.id", ondelete="CASCADE"), primary_key = True, nullable=False), + Column('device_os', ty.String, nullable=False), + Column('push_id', ty.String(255), nullable=False), + Column('app_version', ty.String(255), nullable=False), + Column('registered_at', ty.DateTime(), nullable=False, default=datetime.datetime.utcnow), +) + +t_user_messages = Table('user_messages', Base.metadata, + Column('id', ty.BigInteger, primary_key = True), + Column('user_id', ty.BigInteger, ForeignKey("users.id", ondelete="CASCADE"), index = True, nullable=False), + Column('translation_id', ty.Integer, ForeignKey("translationvariables.id", ondelete="RESTRICT"), nullable = True), + Column('params', JSON(), nullable=True, default={}), + Column('is_read', ty.Boolean, index=True, default=False, nullable=False), + Column('has_been_pushed', ty.Boolean, index=True, default=True, server_default='0', nullable=False), + Column('created_at', ty.DateTime(), nullable=False, default=datetime.datetime.utcnow, index=True), +) + +t_goal_triggers = Table('goal_triggers', Base.metadata, + Column('id', ty.Integer, primary_key = True), + Column("name", ty.String(100), nullable=False), + Column('goal_id', ty.Integer, ForeignKey("goals.id", ondelete="CASCADE"), nullable=False, index=True), + Column('execute_when_complete', ty.Boolean, nullable=False, server_default='0', default=False), +) + +t_goal_trigger_steps = Table('goal_trigger_steps', Base.metadata, + Column('id', ty.Integer, primary_key = True), + Column('goal_trigger_id', ty.Integer, ForeignKey("goal_triggers.id", ondelete="CASCADE"), nullable=False, index=True), + Column('step', ty.Integer, nullable=False, default=0), + Column('condition_type', ty.Enum("percentage", name="goal_trigger_condition_types"), default="percentage"), + Column('condition_percentage', ty.Float, nullable=True), + Column('action_type', ty.Enum("user_message", name="goal_trigger_action_types"), default="user_message"), + Column('action_translation_id', ty.Integer, ForeignKey("translationvariables.id", ondelete="RESTRICT"), nullable = True), + + UniqueConstraint("goal_trigger_id", "step") +) + +t_goal_trigger_step_executions = Table('goal_trigger_executions', Base.metadata, + Column('id', ty.BigInteger, primary_key = True), + Column('trigger_step_id', ty.Integer, ForeignKey("goal_trigger_steps.id", ondelete="CASCADE"), nullable=False), + Column('user_id', ty.BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + Column('execution_level', ty.Integer, nullable = False, default=0), + Column('execution_date', ty.DateTime(), nullable=False, default=datetime.datetime.utcnow, index=True), + Column('achievement_date', ty.DateTime(), nullable=True, index=True), + Index("ix_goal_trigger_executions_combined", "trigger_step_id","user_id","execution_level") +) + +class AuthUser(ABase): + + @hybrid_property + def id(self): + return self.user_id + + @hybrid_property + def password(self): + return self.password_hash + + @password.setter + def password(self,new_pw): + if new_pw!=self.password_hash: + import argon2 + import crypt + import base64 + self.password_salt = crypt.mksalt()+crypt.mksalt()+crypt.mksalt()+crypt.mksalt()+crypt.mksalt() + hash = argon2.argon2_hash(new_pw, self.password_salt) + self.password_hash = base64.b64encode(hash).decode("UTF-8") + + def verify_password(self, pw): + import argon2 + import base64 + check = base64.b64encode(argon2.argon2_hash(pw, self.password_salt)).decode("UTF-8") + orig = self.password_hash + is_valid = check == orig + return is_valid + + def get_or_create_token(self): + tokenObj = DBSession.query(AuthToken).filter(and_( + AuthToken.valid_until>=datetime.datetime.utcnow(), + AuthToken.user_id == self.user_id + )).first() + + if not tokenObj: + token = AuthToken.generate_token() + tokenObj = AuthToken( + user_id=self.user_id, + token=token + ) + + DBSession.add(tokenObj) + + return tokenObj + +class AuthToken(ABase): + + @staticmethod + def generate_token(): + import crypt + return str(crypt.mksalt()+crypt.mksalt()) + + def extend(self): + self.valid_until = datetime.datetime.utcnow() + datetime.timedelta(days=30) + DBSession.add(self) + + def __unicode__(self, *args, **kwargs): + return "Token %s" % (self.id,) + +class AuthRole(ABase): + def __unicode__(self, *args, **kwargs): + return "Role %s" % (self.name,) + +class AuthRolePermission(ABase): + def __unicode__(self, *args, **kwargs): + return "%s" % (self.name,) + +class UserDevice(ABase): + def __unicode__(self, *args, **kwargs): + return "Device: %s" % (self.id,) + + @classmethod + def add_or_update_device(cls, user_id, device_id, push_id, device_os, app_version): + update_connection().execute(t_user_device.delete().where(and_( + t_user_device.c.push_id == push_id, + t_user_device.c.device_os == device_os + ))) + + device = DBSession.execute(t_user_device.select().where(and_( + t_user_device.c.device_id == device_id, + t_user_device.c.user_id == user_id + ))).fetchone() + if device and (device["push_id"] != push_id + or device["device_os"] != device_os + or device["app_version"] != app_version + ): + uSession = update_connection() + q = t_user_device.update().values({ + "push_id": push_id, + "device_os": device_os, + "app_version": app_version + }).where(and_( + t_user_device.c.device_id == device_id, + t_user_device.c.user_id == user_id + )) + uSession.execute(q) + elif not device: # insert + uSession = update_connection() + q = t_user_device.insert().values({ + "push_id": push_id, + "device_os": device_os, + "app_version": app_version, + "device_id": device_id, + "user_id": user_id + }) + uSession.execute(q) class User(ABase): """A user participates in the gamification, i.e. can get achievements, rewards, participate in leaderbaord etc.""" - + def __unicode__(self, *args, **kwargs): return "User %s" % (self.id,) - + def __init__(self, *args, **kw): """ create a user object - + Each user has a timezone and a location to support time- and geo-aware gamification. - There is also a friends-relation for leaderboards and a groups-relation. + There is also a friends-relation for leaderboards and a groups-relation. """ ABase.__init__(self, *args, **kw) - + #TODO:Cache @classmethod def get_user(cls,user_id): return DBSession.execute(t_users.select().where(t_users.c.id==user_id)).fetchone() - + + @classmethod + def get_users(cls, user_ids): + return { + x["id"] : x for x in + DBSession.execute(t_users.select().where(t_users.c.id.in_(user_ids))).fetchall() + } + @classmethod def get_cache_expiration_time_for_today(cls,user): """return the seconds until the day of the user ends (timezone of the user). - + This is needed as achievements may be limited to a specific time (e.g. only during holidays).""" - + tzobj = pytz.timezone(user["timezone"]) now = datetime.datetime.now(tzobj) today = now.replace(hour=0,minute=0,second=0,microsecond=0) @@ -283,30 +477,30 @@ def get_cache_expiration_time_for_today(cls,user): return int((tomorrow-today).total_seconds()) @classmethod - def set_infos(cls,user_id,lat,lng,timezone,country,region,city,friends, groups): + def set_infos(cls,user_id,lat,lng,timezone,country,region,city,language,friends, groups, additional_public_data): """set the user's metadata like friends,location and timezone""" - - + + new_friends_set = set(friends) existing_users_set = {x["id"] for x in DBSession.execute(select([t_users.c.id]).where(t_users.c.id.in_([user_id,]+friends))).fetchall()} existing_friends = {x["to_id"] for x in DBSession.execute(select([t_users_users.c.to_id]).where(t_users_users.c.from_id==user_id)).fetchall()} friends_to_create = (new_friends_set-existing_users_set-{user_id,}) friends_to_append = (new_friends_set-existing_friends) friends_to_delete = (existing_friends-new_friends_set) - + new_groups_set = set(groups) existing_groups_set = {x["id"] for x in DBSession.execute(select([t_groups.c.id]).where(t_groups.c.id.in_(groups))).fetchall()} existing_groups_of_user = {x["group_id"] for x in DBSession.execute(select([t_users_groups.c.group_id]).where(t_users_groups.c.user_id==user_id)).fetchall()} groups_to_create = (new_groups_set-existing_groups_set) groups_to_append = (new_groups_set-existing_groups_of_user) groups_to_delete = (existing_groups_of_user-new_groups_set) - + #add or select user if user_id in existing_users_set: user = DBSession.query(User).filter_by(id=user_id).first() else: user = User() - + user.id = user_id user.lat = lat user.lng = lng @@ -314,40 +508,47 @@ def set_infos(cls,user_id,lat,lng,timezone,country,region,city,friends, groups): user.country = country user.region = region user.city = city - + user.additional_public_data = additional_public_data + + language = DBSession.execute(t_languages.select().where(t_languages.c.name == language)).fetchone() + if language: + user.language_id = language["id"] + else: + user.language_id = None + DBSession.add(user) DBSession.flush() - + #FRIENDS - + #insert missing friends in user table if len(friends_to_create)>0: update_connection().execute(t_users.insert(), [{"id":f} for f in friends_to_create]) - + #delete old friends if len(friends_to_delete)>0: update_connection().execute(t_users_users.delete().where(and_(t_users_users.c.from_id==user_id, t_users_users.c.to_id.in_(friends_to_delete)))) - + #insert missing friends if len(friends_to_append)>0: update_connection().execute(t_users_users.insert(),[{"from_id":user_id,"to_id":f} for f in friends_to_append]) - + #GROUPS - + #insert missing groups in group table if len(groups_to_create)>0: update_connection().execute(t_groups.insert(), [{"id":f} for f in groups_to_create]) - + #delete old groups of user if len(groups_to_delete)>0: update_connection().execute(t_users_groups.delete().where(and_(t_users_groups.c.user_id==user_id, t_users_groups.c.group_id.in_(groups_to_delete)))) - + #insert missing groups of user if len(groups_to_append)>0: update_connection().execute(t_users_groups.insert(),[{"user_id":user_id,"group_id":f} for f in groups_to_append]) - + @classmethod def delete_user(cls,user_id): """delete a user including all dependencies.""" @@ -358,40 +559,97 @@ def delete_user(cls,user_id): update_connection().execute(t_users_groups.delete().where(t_users_groups.c.user_id==user_id)) update_connection().execute(t_values.delete().where(t_values.c.user_id==user_id)) update_connection().execute(t_users.delete().where(t_users.c.id==user_id)) - + + @classmethod + def basic_output(cls, user): + return { + "id" : user["id"], + "additional_public_data" : user["additional_public_data"] + } + + @classmethod + def full_output(cls, user_id): + + user = DBSession.execute(t_users.select().where(t_users.c.id == user_id)).fetchone() + + j = t_users.join(t_users_users,t_users_users.c.to_id == t_users.c.id) + friends = DBSession.execute(t_users.select(from_obj=j).where(t_users_users.c.from_id == user_id)).fetchall() + + j = t_groups.join(t_users_groups) + groups = DBSession.execute(t_groups.select(from_obj=j).where(t_users_groups.c.user_id== user_id)).fetchall() + + language = get_settings().get("fallback_language","en") + j = t_users.join(t_languages) + user_language = DBSession.execute(select([t_languages.c.name], from_obj=j).where(t_users.c.id == user_id)).fetchone() + if user_language: + language = user_language["name"] + + ret = { + "id" : user["id"], + "lat" : user["lat"], + "lng" : user["lng"], + "timezone" : user["timezone"], + "language": language, + "country": user["country"], + "region": user["region"], + "city": user["city"], + "created_at": user["created_at"], + "additional_public_data": user["additional_public_data"], + "friends" : [User.basic_output(f) for f in friends], + "groups": [Group.basic_output(g) for g in groups], + } + + if get_settings().get("enable_user_authentication"): + auth_user = DBSession.execute(t_auth_users.select().where(t_auth_users.c.user_id == user_id)).fetchone() + if auth_user: + ret.update({ + "email" : auth_user["email"] + }) + + return ret + class Group(ABase): def __unicode__(self, *args, **kwargs): return "(ID: %s)" % (self.id,) + @classmethod + def basic_output(cls, group): + return { + "id" : group["id"] + } + class Variable(ABase): """A Variable is anything that should be meassured in your application and be used in :class:`.Goal`. - + To save database rows, variables may be grouped by time: group needs to be set to "year","month","week","day","timeslot" or "none" (default) """ - + def __unicode__(self, *args, **kwargs): return self.name + " (ID: %s)" % (self.id,) - + @classmethod @cache_general.cache_on_arguments() def get_variable_by_name(cls,name): return DBSession.execute(t_variables.select(t_variables.c.name==name)).fetchone() - + @classmethod - def get_datetime_for_tz_and_group(cls,tz,group): - """get the datetime of the current row, needed for grouping - - when "timezone" is used as a group name, the values are grouped to the nearest time in (09:00, 12:00, 15:00, 18:00, 21:00) - (timezone to use is given as parameter) + def get_datetime_for_tz_and_group(cls,tz,group,at_datetime=None): + """ + get the datetime of the current row, needed for grouping + the optional parameter at_datetime can provide a timezone-aware datetime which overrides the default "now" """ tzobj = pytz.timezone(tz) - now = datetime.datetime.now(tzobj) - #now = now.replace(tzinfo=pytz.utc) - + + + if not at_datetime: + now = datetime.datetime.now(tzobj) + else: + now = at_datetime + t = None if group=="year": - t = now.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0 ) + t = now.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0) elif group=="month": t = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) elif group=="week": @@ -404,14 +662,15 @@ def get_datetime_for_tz_and_group(cls,tz,group): else: #return datetime.datetime.max.replace return datetime.datetime(year=2000,month=1,day=1,hour=0,minute=0,second=0,microsecond=0).replace(tzinfo=pytz.utc) - + + return t.astimezone(tzobj) - + @classmethod @cache_general.cache_on_arguments() def map_variables_to_rules(cls): """return a map from variable_ids to {"goal":goal_obj,"achievement":achievement_obj} dictionaries. - + Used to know which goals need to be reevaluated after a value for the variable changes. """ q = select([t_goals,t_variables.c.id.label("variable_id")])\ @@ -421,117 +680,138 @@ def map_variables_to_rules(cls): for row in DBSession.execute(q).fetchall(): if not row["variable_id"] in m: m[row["variable_id"]] = [] - + m[row["variable_id"]].append({"goal":row,"achievement":Achievement.get_achievement(row["achievement_id"])}) - return m - + @classmethod - def invalidate_caches_for_variable_and_user(cls,variable_id,user_id): + def invalidate_caches_for_variable_and_user(cls, variable_id, user_id, dt): """ invalidate the relevant caches for this user and all relevant users with concerned leaderboards""" - goalsandachievements = cls.map_variables_to_rules().get(variable_id,[]) + goalsandachievements = cls.map_variables_to_rules().get(variable_id, []) - Goal.clear_goal_caches(user_id, [entry["goal"]["id"] for entry in goalsandachievements]) + Goal.clear_goal_caches(user_id, [ + (entry["goal"]["id"], Achievement.get_datetime_for_evaluation_type(entry["achievement"]["evaluation_timezone"], entry["achievement"]["evaluation"], dt=dt)) + for entry in goalsandachievements + ] + ) for entry in goalsandachievements: - Achievement.invalidate_evaluate_cache(user_id,entry["achievement"]) - - + achievement_date = Achievement.get_datetime_for_evaluation_type(entry["achievement"]["evaluation_timezone"], entry["achievement"]["evaluation"],dt=dt) + Achievement.invalidate_evaluate_cache(user_id, entry["achievement"], achievement_date) + + @classmethod + def may_increase(cls, variable_row, request, user_id): + if not asbool(get_settings().get("enable_user_authentication", False)): + #Authentication deactivated + return True + if request.has_perm(perm_global_increase_value): + # I'm the global admin + return True + if variable_row["increase_permission"]=="own" and request.user and str(request.user.id)==str(user_id): + #The variable may be updated for myself + return True + return False + + class Value(ABase): """A Value describes the relation of the user to a variable. - + (e.g. it counts the occurences of the "events" which the variable represents) """ - + + @classmethod - def increase_value(cls, variable_name, user, value, key): + def increase_value(cls, variable_name, user, value, key, at_datetime=None): """increase the value of the variable for the user. - + In addition to the variable_name there may be an application-specific key which can be used in your :class:`.Goal` definitions + The parameter at_datetime is optional and can specify a timezone-aware datetime to define when the event happened """ - + user_id = user["id"] tz = user["timezone"] - + variable = Variable.get_variable_by_name(variable_name) - datetime = Variable.get_datetime_for_tz_and_group(tz,variable["group"]) - - condition = and_(t_values.c.datetime==datetime, - t_values.c.variable_id==variable["id"], - t_values.c.user_id==user_id, - t_values.c.key==str(key)) - + dt = Variable.get_datetime_for_tz_and_group(tz, variable["group"], at_datetime=at_datetime) + + key = '' if key is None else str(key) + + condition = and_(t_values.c.datetime == dt, + t_values.c.variable_id == variable["id"], + t_values.c.user_id == user_id, + t_values.c.key == key) + current_value = DBSession.execute(select([t_values.c.value,]).where(condition)).scalar() - if current_value is not None: update_connection().execute(t_values.update(condition, values={"value":current_value+value})) else: - update_connection().execute(t_values.insert({"datetime":datetime, - "variable_id":variable["id"], - "user_id" : user_id, - "key" : str(key), - "value":value})) + update_connection().execute(t_values.insert({"datetime": dt, + "variable_id": variable["id"], + "user_id": user_id, + "key": key, + "value": value})) + + Variable.invalidate_caches_for_variable_and_user(variable_id=variable["id"], user_id=user["id"], dt = dt) + new_value = DBSession.execute(select([t_values.c.value, ]).where(condition)).scalar() + return new_value - Variable.invalidate_caches_for_variable_and_user(variable["id"],user["id"]) - class AchievementCategory(ABase): """A category for grouping achievement types""" - + @classmethod @cache_general.cache_on_arguments() def get_achievementcategory(cls,achievementcategory_id): return DBSession.execute(t_achievementcategories.select().where(t_achievementcategories.c.id==achievementcategory_id)).fetchone() - + def __unicode__(self, *args, **kwargs): return self.name + " (ID: %s)" % (self.id,) - + class Achievement(ABase): """A collection of goals which has multiple :class:`AchievementProperty` and :class:`Reward`.""" - + def __unicode__(self, *args, **kwargs): return self.name + " (ID: %s)" % (self.id,) - + @classmethod @cache_general.cache_on_arguments() def get_achievement(cls,achievement_id): return DBSession.execute(t_achievements.select().where(t_achievements.c.id==achievement_id)).fetchone() - + @classmethod def get_achievements_by_user_for_today(cls,user): """Returns all achievements that are relevant for the user today. - + This is needed as achievements may be limited to a specific time (e.g. only during holidays) - """ - + """ + def generate_achievements_by_user_for_today(): today = datetime.date.today() by_loc = {x["id"] : x["distance"] for x in cls.get_achievements_by_location(coords(user))} by_date = cls.get_achievements_by_date(today) - def update(arr,distance): arr["distance"]=distance return arr - + return [update(arr,by_loc[arr["id"]]) for arr in by_date if arr["id"] in by_loc] - + key = str(user["id"]) expiration_time = User.get_cache_expiration_time_for_today(user) - + return cache_achievements_by_user_for_today.get_or_create(key,generate_achievements_by_user_for_today, expiration_time=expiration_time) - + #We need to fetch all achievement data in one of these methods -> by_date is just queried once a date - + @classmethod @cache_general.cache_on_arguments() def get_achievements_by_location(cls,latlng): - """return achievements which are valid in that location.""" + """return achievements which are valid in that location.""" #TODO: invalidate automatically when achievement in user's range is modified distance = calc_distance(latlng, (t_achievements.c.lat, t_achievements.c.lng)).label("distance") - q = select([t_achievements.c.id, distance])\ .where(or_(and_(t_achievements.c.lat==None,t_achievements.c.lng==None), distance < t_achievements.c.max_distance)) - return [dict(x.items()) for x in DBSession.execute(q).fetchall() if len(Goal.get_goals(x['id']))>0] - + + return [dict(x.items()) for x in DBSession.execute(q).fetchall() if len(Goal.get_goals(x['id'])) > 0] + @classmethod @cache_general.cache_on_arguments() def get_achievements_by_date(cls,date): @@ -541,72 +821,79 @@ def get_achievements_by_date(cls,date): or_(t_achievements.c.valid_end==None, t_achievements.c.valid_end>=date) )) + return [dict(x.items()) for x in DBSession.execute(q).fetchall() if len(Goal.get_goals(x['id']))>0] - + #TODO:CACHE @classmethod def get_relevant_users_by_achievement_and_user(cls,achievement,user_id): - """return all relevant other users for the leaderboard. - + """return all relevant other users for the leaderboard. + depends on the "relevance" attribute of the achivement, can be "friends" or "city" (city is still a todo) """ # this is needed to compute the leaderboards - users=[user_id,] + users=[user_id,] if achievement["relevance"]=="city": #TODO pass elif achievement["relevance"]=="friends": users += [x["to_id"] for x in DBSession.execute(select([t_users_users.c.to_id,], t_users_users.c.from_id==user_id)).fetchall()] + elif achievement["relevance"] == "global": + users += [x.id for x in DBSession.execute(select([t_users.c.id,])).fetchall()] return set(users) - + #TODO:CACHE @classmethod def get_relevant_users_by_achievement_and_user_reverse(cls,achievement,user_id): """return all users which have this user as friends and are relevant for this achievement. - + the reversed version is needed to know in whose contact list the user is. when the user's value is updated, all the leaderboards of these users need to be regenerated""" - users=[user_id,] + users=[user_id,] if achievement["relevance"]=="city": #TODO pass elif achievement["relevance"]=="friends": users += [x["from_id"] for x in DBSession.execute(select([t_users_users.c.from_id,], t_users_users.c.to_id==user_id)).fetchall()] + elif achievement["relevance"] == "global": + users += [x.id for x in DBSession.execute(select([t_users.c.id, ])).fetchall()] return set(users) - + @classmethod - def get_level(cls, user_id, achievement_id): + def get_level(cls, user_id, achievement_id, achievement_date): """get the current level of the user for this achievement.""" - - def generate(): + + def generate(): q = select([t_achievements_users.c.level, + t_achievements_users.c.achievement_date, t_achievements_users.c.updated_at], and_(t_achievements_users.c.user_id==user_id, + t_achievements_users.c.achievement_date== achievement_date, t_achievements_users.c.achievement_id==achievement_id)).order_by(t_achievements_users.c.level.desc()) return [x for x in DBSession.execute(q).fetchall()] - - return cache_achievements_users_levels.get_or_create("%s_%s" % (user_id,achievement_id),generate) - + + return cache_achievements_users_levels.get_or_create("%s_%s_%s" % (user_id,achievement_id,achievement_date),generate) + @classmethod - def get_level_int(cls,user_id,achievement_id): + def get_level_int(cls,user_id,achievement_id,achievement_date): """get the current level of the user for this achievement as int (0 if the user does not have this achievement)""" - lvls = Achievement.get_level(user_id, achievement_id) - + lvls = Achievement.get_level(user_id, achievement_id,achievement_date) if not lvls: return 0 else: return lvls[0]["level"] - + @classmethod def basic_output(cls,achievement,goals,include_levels=True, max_level_included=None): """construct the basic basic_output structure for the achievement.""" - + achievementcategory = None if achievement["achievementcategory_id"]!=None: achievementcategory = AchievementCategory.get_achievementcategory(achievement["achievementcategory_id"]) - + out = { "id" : achievement["id"], + "view_permission" : achievement["view_permission"], "internal_name" : achievement["name"], "maxlevel" : achievement["maxlevel"], "priority" : achievement["priority"], @@ -614,12 +901,12 @@ def basic_output(cls,achievement,goals,include_levels=True, "achievementcategory" : achievementcategory["name"] if achievementcategory!=None else "" #"updated_at" : combine_updated_at([achievement["updated_at"],]), } - + if include_levels: levellimit = achievement["maxlevel"] if max_level_included: - max_level_included = min(max_level_included,levellimit) - + max_level_included = min(max_level_included,levellimit) + out["levels"] = { str(i) : { "level" : i, @@ -628,66 +915,68 @@ def basic_output(cls,achievement,goals,include_levels=True, "id" : r["id"], "reward_id" : r["reward_id"], "name" : r["name"], - "value" : eval_formular(r["value"], {"level":i}), + "value" : evaluate_string(r["value"], {"level":i}), "value_translated" : Translation.trs(r["value_translation_id"], {"level":i}), } for r in Achievement.get_rewards(achievement["id"],i)}, "properties" : {str(r["property_id"]) : { "property_id" : r["property_id"], "name" : r["name"], - "value" : eval_formular(r["value"], {"level":i}), + "value" : evaluate_string(r["value"], {"level":i}), "value_translated" : Translation.trs(r["value_translation_id"], {"level":i}), } for r in Achievement.get_achievement_properties(achievement["id"],i)} - } for i in range(0,max_level_included+1)} + } for i in range(1,max_level_included+1)} return out - + @classmethod - def evaluate(cls, user, achievement_id): + def evaluate(cls, user, achievement_id, achievement_date, execute_triggers=True): """evaluate the achievement including all its subgoals for the user. - + return the basic_output for the achievement plus information about the new achieved levels """ - def generate(): - user_id = user["id"] achievement = Achievement.get_achievement(achievement_id) + + user_id = user["id"] user_ids = Achievement.get_relevant_users_by_achievement_and_user(achievement, user_id) - - user_has_level = Achievement.get_level_int(user_id, achievement["id"]) + + user_has_level = Achievement.get_level_int(user_id, achievement["id"], achievement_date) user_wants_level = min((user_has_level or 0)+1, achievement["maxlevel"]) - + goal_evals={} all_goals_achieved = True goals = Goal.get_goals(achievement["id"]) - for goal in goals: - - goal_eval = Goal.get_goal_eval_cache(goal["id"], user_id) + goal_eval = Goal.get_goal_eval_cache(goal["id"], achievement_date, user_id) if not goal_eval: - Goal.evaluate(goal, user_id, user_wants_level,None) - goal_eval = Goal.get_goal_eval_cache(goal["id"], user_id) - - if achievement["relevance"]=="friends" or achievement["relevance"]=="city": - goal_eval["leaderboard"] = Goal.get_leaderboard(goal, user_ids) - goal_eval["leaderboard_position"] = filter(lambda x : x["user_id"]==user_id, goal_eval["leaderboard"])[0]["position"] - + Goal.evaluate(goal, achievement, achievement_date, user, user_wants_level,None, execute_triggers=execute_triggers) + goal_eval = Goal.get_goal_eval_cache(goal["id"], achievement_date, user_id) + + if achievement["relevance"]=="friends" or achievement["relevance"]=="city" or achievement["relevance"]=="global": + goal_eval["leaderboard"] = Goal.get_leaderboard(goal, achievement_date, user_ids) + own_filter = list(filter(lambda x: x["user"]["id"] == user_id, goal_eval["leaderboard"])) + if len(own_filter)>0: + goal_eval["leaderboard_position"] = own_filter[0]["position"] + else: + goal_eval["leaderboard_position"] = None + goal_evals[goal["id"]]=goal_eval if not goal_eval["achieved"]: all_goals_achieved = False - + output = "" new_level_output = None full_output = True # will be false, if the full basic_output is generated in a recursion step - + if all_goals_achieved and user_has_level < achievement["maxlevel"]: #NEW LEVEL YEAH! - + new_level_output = { "rewards" : { str(r["id"]) : { "id" : r["id"], "reward_id" : r["reward_id"], "name" : r["name"], - "value" : eval_formular(r["value"], {"level":user_wants_level}), + "value" : evaluate_string(r["value"], {"level":user_wants_level}), "value_translated" : Translation.trs(r["value_translation_id"], {"level":user_wants_level}), } for r in Achievement.get_rewards(achievement["id"],user_wants_level) }, @@ -696,70 +985,72 @@ def generate(): "property_id" : r["property_id"], "name" : r["name"], "is_variable" : r["is_variable"], - "value" : eval_formular(r["value"], {"level":user_wants_level}), + "value" : evaluate_string(r["value"], {"level":user_wants_level}), "value_translated" : Translation.trs(r["value_translation_id"], {"level":user_wants_level}) } for r in Achievement.get_achievement_properties(achievement["id"],user_wants_level) }, "level" : user_wants_level } - + for prop in new_level_output["properties"].values(): if prop["is_variable"]: Value.increase_value(prop["name"], user, prop["value"], achievement_id) - + update_connection().execute(t_achievements_users.insert().values({ "user_id" : user_id, "achievement_id" : achievement["id"], + "achievement_date" : achievement_date, "level" : user_wants_level })) - + #invalidate getter - cache_achievements_users_levels.delete("%s_%s" % (user_id,achievement_id)) - + cache_achievements_users_levels.delete("%s_%s_%s" % (user_id,achievement_id,achievement_date)) + user_has_level = user_wants_level user_wants_level = user_wants_level+1 - - Goal.clear_goal_caches(user_id, [g["goal_id"] for g in goal_evals.values()]) + + Goal.clear_goal_caches(user_id, [(g["goal_id"],achievement_date) for g in goal_evals.values()]) #the level has been updated, we need to do recursion now... #but only if there are more levels... if user_has_level < achievement["maxlevel"]: output = generate() full_output = False - + if full_output: #is executed, if this is the last recursion step output = Achievement.basic_output(achievement,goals,True,max_level_included=user_has_level+1) output.update({ "level" : user_has_level, "levels_achieved" : { - str(x["level"]) : x["updated_at"] for x in Achievement.get_level(user_id, achievement["id"]) + str(x["level"]) : x["updated_at"] for x in Achievement.get_level(user_id, achievement["id"], achievement_date) }, "maxlevel" : achievement["maxlevel"], "new_levels" : {}, "goals":goal_evals, + "achievement_date": achievement_date, #"updated_at":combine_updated_at([achievement["updated_at"],] + [g["updated_at"] for g in goal_evals]) }) - + if new_level_output is not None: #if we reached a new level in this recursion step, add the previous levels rewards and properties output["new_levels"][str(user_has_level)]=new_level_output - + return output - - return cache_achievement_eval.get_or_create("%s_%s" % (user["id"],achievement_id),generate) - + + #TODO ACHIEVEMENT + return cache_achievement_eval.get_or_create("%s_%s_%s" % (user["id"],achievement_id,achievement_date),generate) + @classmethod - def invalidate_evaluate_cache(cls,user_id,achievement): + def invalidate_evaluate_cache(cls,user_id,achievement, achievement_date): """invalidate the evaluation cache for all goals of this achievement for the user.""" - + #We neeed to invalidate for all relevant users because of the leaderboards for uid in Achievement.get_relevant_users_by_achievement_and_user_reverse(achievement, user_id): - cache_achievement_eval.delete("%s_%s" % (uid,achievement["id"])) - urlcache.invalidate("/progress/"+str(uid)) - + cache_achievement_eval.delete("%s_%s_%s" % (uid, achievement["id"], achievement_date)) + @classmethod @cache_general.cache_on_arguments() def get_rewards(cls,achievement_id,level): """return the new rewards which are given for the achievement level.""" - + this_level = DBSession.execute(select([t_rewards.c.id.label("reward_id"), t_achievements_rewards.c.id, t_rewards.c.name, @@ -772,7 +1063,7 @@ def get_rewards(cls,achievement_id,level): t_achievements_rewards.c.achievement_id==achievement_id))\ .order_by(t_achievements_rewards.c.from_level))\ .fetchall() - + prev_level = DBSession.execute(select([t_rewards.c.id.label("reward_id"), t_achievements_rewards.c.id, t_achievements_rewards.c.value, @@ -783,20 +1074,32 @@ def get_rewards(cls,achievement_id,level): t_achievements_rewards.c.achievement_id==achievement_id))\ .order_by(t_achievements_rewards.c.from_level))\ .fetchall() + #now compute the diff :-/ - build_hash = lambda x,l : hashlib.md5(str(x["id"])+str(eval_formular(x["value"], {"level":l}))+str(Translation.trs(x["value_translation_id"], {"level":l}))).hexdigest() - + build_hash = lambda x,l : hashlib.md5((str(x["id"])+str(evaluate_string(x["value"], {"level":l}))+str(Translation.trs(x["value_translation_id"], {"level":l}))).encode("UTF-8")).hexdigest() prev_hashes = {build_hash(x,level-1) for x in prev_level} - this_hashes = {build_hash(x,level) for x in this_level} - + #this_hashes = {build_hash(x,level) for x in this_level} + retlist = [x for x in this_level if not build_hash(x,level) in prev_hashes] return retlist - + @classmethod @cache_general.cache_on_arguments() def get_achievement_properties(cls,achievement_id,level): """return all properties which are associated to the achievement level.""" - + result = DBSession.execute(select([t_achievementproperties.c.id.label("property_id"), + t_achievementproperties.c.name, + t_achievementproperties.c.is_variable, + t_achievements_achievementproperties.c.from_level, + t_achievements_achievementproperties.c.value, + t_achievements_achievementproperties.c.value_translation_id], + from_obj=t_achievementproperties.join(t_achievements_achievementproperties))\ + .where(and_(or_(t_achievements_achievementproperties.c.from_level<=level, + t_achievements_achievementproperties.c.from_level==None), + t_achievements_achievementproperties.c.achievement_id==achievement_id))\ + .order_by(t_achievements_achievementproperties.c.from_level))\ + .fetchall() + return DBSession.execute(select([t_achievementproperties.c.id.label("property_id"), t_achievementproperties.c.name, t_achievementproperties.c.is_variable, @@ -809,16 +1112,51 @@ def get_achievement_properties(cls,achievement_id,level): t_achievements_achievementproperties.c.achievement_id==achievement_id))\ .order_by(t_achievements_achievementproperties.c.from_level))\ .fetchall() - - + + @classmethod + def get_datetime_for_evaluation_type(cls, evaluation_timezone, evaluation_type, dt=None): + """ + This computes the datetime to identify the time of the achievement. + Only relevant for repeating achievements (monthly, yearly, weekly, daily) + Returns None for all other achievement types + """ + + if evaluation_type and not evaluation_timezone: + evaluation_timezone = "UTC" + + tzobj = pytz.timezone(evaluation_timezone) + if not dt: + dt = datetime.datetime.now(tzobj) + + else: + dt = dt.astimezone(tzobj) + + t = None + if evaluation_type == "yearly": + t = dt.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0) + elif evaluation_type == "monthly": + t = dt.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + elif evaluation_type == "weekly": + t = dt - datetime.timedelta(days=dt.weekday()) + t = t.replace(hour=0, minute=0, second=0, microsecond=0) + elif evaluation_type == "daily": + t = dt.replace(hour=0, minute=0, second=0, microsecond=0) + elif evaluation_type == "immediately": + return None + elif evaluation_type == "end": + return None + + return t.astimezone(tzobj) + + class AchievementProperty(ABase): """A AchievementProperty describes the :class:`Achievement`s of our system. - + Examples: name, image, description, xp - + Additionally Properties can be used as variables. - This is useful to model goals like "reach 1000xp" - + This is useful to model goals like "reach 1000xp" + """ def __unicode__(self, *args, **kwargs): return self.name + " (ID: %s)" % (self.id,) @@ -826,15 +1164,15 @@ def __unicode__(self, *args, **kwargs): class AchievementAchievementProperty(ABase): """A poperty value for an :class:`Achievement`""" pass - + class GoalProperty(ABase): """A goalproperty describes the :class:`Goal`s of our system. - + Examples: name, image, description, xp - + Additionally Properties can be used as variables. - This is useful to model goals like "reach 1000xp" - + This is useful to model goals like "reach 1000xp" + """ def __unicode__(self, *args, **kwargs): return self.name + " (ID: %s)" % (self.id,) @@ -842,10 +1180,10 @@ def __unicode__(self, *args, **kwargs): class GoalGoalProperty(ABase): """A goalpoperty value for a :class:`Goal`""" pass - + class Reward(ABase): """Rewards are given when reaching :class:`Achievement`s. - + Examples: badge, item """ def __unicode__(self, *args, **kwargs): @@ -853,7 +1191,7 @@ def __unicode__(self, *args, **kwargs): class AchievementReward(ABase): """A Reward value for an :class:`Achievement` """ - + @classmethod def get_achievement_reward(cls, achievement_reward_id): return DBSession.execute(t_achievements_rewards.select(t_achievements_rewards.c.id==achievement_reward_id)).fetchone() @@ -861,195 +1199,306 @@ def get_achievement_reward(cls, achievement_reward_id): class AchievementUser(ABase): """Relation between users and achievements, contains level and updated_at date""" pass - + class GoalEvaluationCache(ABase): """Cache for the evaluation of goals for users""" pass class Goal(ABase): """A Goal defines a rule on variables that needs to be reached to get achievements""" - + def __unicode__(self, *args, **kwargs): - if self.name_translation!=None: - name = Translation.trs(self.name_translation.id, {"level":1, "goal":'0'})[_fallback_language] + if self.name_translation_id!=None: + name = Translation.trs(self.name_translation.id, {"level":1, "goal":'0'})[get_settings().get("fallback_language","en")] return str(name) + " (ID: %s)" % (self.id,) else: return self.name + " (ID: %s)" % (self.id,) - + @classmethod @cache_general.cache_on_arguments() def get_goals(cls,achievement_id): return DBSession.execute(t_goals.select(t_goals.c.achievement_id==achievement_id)).fetchall() - + @classmethod - def compute_progress(cls,goal,user_id): + def compute_progress(cls, goal, achievement, user, evaluation_date): """computes the progress of the goal for the given user_id - + goal attributes: - goal: the value that is used for comparison - operator: "geq" or "leq"; used for comparison - condition: the rule as python code - group_by_dateformat: passed as a parameter to to_char ( http://www.postgresql.org/docs/9.3/static/functions-formatting.html ) e.g. you can select and group by the weekday by using "ID" for ISO 8601 day of the week (1-7) which can afterwards be used in the condition - + - group_by_key: group by the key of the values table - timespan: number of days which are considered (uses utc, i.e. days*24hours) - maxmin: "max" or "min" - select min or max value after grouping - evaluation: "daily", "weekly", "monthly", "yearly" evaluation (users timezone) - + """ - + + user_id = user["id"] + timezone = achievement["evaluation_timezone"] + def generate_statement_cache(): - condition = eval_formular(goal["condition"],{"var" : t_variables.c.name.label("variable_name"), - "key" : t_values.c.key}) + condition = evaluate_condition(goal["condition"], column_variable = t_variables.c.name.label("variable_name"), + column_key = t_values.c.key) group_by_dateformat = goal["group_by_dateformat"] group_by_key = goal["group_by_key"] timespan = goal["timespan"] maxmin = goal["maxmin"] - evaluation_type = goal["evaluation"] - + evaluation_type = achievement["evaluation"] + #prepare select_cols=[func.sum(t_values.c.value).label("value"), t_values.c.user_id] - + j = t_values.join(t_variables) - - if evaluation_type in ("daily","weekly","monthly","yearly"): - # We need to access the user's timezone later - j = j.join(t_users) - + + # # We need to access the user's timezone later + j = j.join(t_users) + datetime_col=None if group_by_dateformat: # here we need to convert to users' time zone, as we might need to group by e.g. USER's weekday - datetime_col = func.to_char(text("values.datetime AT TIME ZONE users.timezone"),group_by_dateformat).label("datetime") + if timezone: + datetime_col = func.to_char(text("values.datetime AT TIME ZONE '%s'" % (timezone,)), group_by_dateformat).label("datetime") + else: + datetime_col = func.to_char(text("values.datetime AT TIME ZONE users.timezone"), + group_by_dateformat).label("datetime") select_cols.append(datetime_col) - + if group_by_key: select_cols.append(t_values.c.key) - + #build query q = select(select_cols, from_obj=j)\ .where(t_values.c.user_id==bindparam("user_id"))\ .group_by(t_values.c.user_id) - + if condition is not None: q = q.where(condition) - + if timespan: #here we can use the utc time q = q.where(t_values.c.datetime>=datetime.datetime.utcnow()-datetime.timedelta(days=timespan)) - + if evaluation_type!="immediately": - + + achievement_date = Achievement.get_datetime_for_evaluation_type(timezone, evaluation_type, evaluation_date) if evaluation_type=="daily": - q = q.where(text("values.datetime AT TIME ZONE users.timezone>"+datetime_trunc("day","users.timezone"))) + q = q.where(and_( + t_values.c.datetime >= achievement_date, + t_values.c.datetime < achievement_date + datetime.timedelta(days=1)) + ) elif evaluation_type=="weekly": - q = q.where(text("values.datetime AT TIME ZONE users.timezone>"+datetime_trunc("week","users.timezone"))) + q = q.where(and_( + t_values.c.datetime >= achievement_date, + t_values.c.datetime < achievement_date + datetime.timedelta(days=7)) + ) elif evaluation_type=="monthly": - q = q.where(text("values.datetime AT TIME ZONE users.timezone>"+datetime_trunc("month","users.timezone"))) + next_month = Achievement.get_datetime_for_evaluation_type(timezone, "monthly", achievement_date + datetime.timedelta(days=32)) + q = q.where(and_( + t_values.c.datetime >= achievement_date, + t_values.c.datetime < next_month) + ) elif evaluation_type=="yearly": - q = q.where(text("values.datetime AT TIME ZONE users.timezone>"+datetime_trunc("year","users.timezone"))) - - if datetime_col or group_by_key: - if datetime_col: + next_year = Achievement.get_datetime_for_evaluation_type(timezone, "yearly", achievement_date + datetime.timedelta(days=366)) + q = q.where(and_( + t_values.c.datetime >= achievement_date, + t_values.c.datetime < next_year) + ) + elif evaluation_type == "end": + pass + #Todo implement for end + + if datetime_col is not None or group_by_key is not False: + if datetime_col is not None: q = q.group_by(datetime_col) - - if group_by_key: + + if group_by_key is not False: q = q.group_by(t_values.c.key) - query_with_groups = q.alias() - + select_cols2 = [query_with_groups.c.user_id] - + if maxmin=="min": select_cols2.append(func.min(query_with_groups.c.value).label("value")) else: select_cols2.append(func.max(query_with_groups.c.value).label("value")) - + combined_user_query = select(select_cols2,from_obj=query_with_groups)\ .group_by(query_with_groups.c.user_id) - + return combined_user_query else: return q - - q = cache_goal_statements.get_or_create(str(goal["id"]),generate_statement_cache) - return DBSession.bind.execute(q, user_id=user_id) + + #q = cache_goal_statements.get_or_create(str(goal["id"]),generate_statement_cache) + # TODO: Cache the statement / Make it serializable for caching in redis + q = generate_statement_cache() + + return DBSession.execute(q, {'user_id' : user_id}) @classmethod - def evaluate(cls, goal, user_id, level, goal_eval_cache_before=False): + def evaluate(cls, goal, achievement, achievement_date, user, level, goal_eval_cache_before=False, execute_triggers=True): """evaluate the goal for the user_ids and the level""" - + operator = goal["operator"] - - users_progress = Goal.compute_progress(goal,user_id) - + + #TODO: Move this call to outer loops + user_id = user["id"] + + users_progress = Goal.compute_progress(goal, achievement, user, achievement_date) goal_evaluation = {e["user_id"] : e["value"] for e in users_progress} - goal_achieved = False if goal_eval_cache_before is False: - goal_eval_cache_before = cls.get_goal_eval_cache(goal["id"], user_id) - + goal_eval_cache_before = cls.get_goal_eval_cache(goal["id"], achievement_date, user_id) + new = goal_evaluation.get(user_id,0.0) - + if goal_eval_cache_before is None or goal_eval_cache_before.get("value",0.0)!=goal_evaluation.get(user_id,0.0): - + #Level is the next level, or the current level if I'm alread at max params = { "level" : level } - - goal_goal = eval_formular(goal["goal"], params) - + goal_goal = evaluate_value_expression(goal["goal"], params) if goal_goal is not None and operator=="geq" and new>=goal_goal: goal_achieved = True new = min(new,goal_goal) - + elif goal_goal is not None and operator=="leq" and new<=goal_goal: goal_achieved = True new = max(new,goal_goal) - + + previous_goal = Goal.basic_goal_output(goal, level-1).get("goal_goal",0) + # Evaluate triggers + if execute_triggers: + Goal.select_and_execute_triggers( + goal = goal, + achievement_date = achievement_date, + user_id = user_id, + level = level, + current_goal = goal_goal, + previous_goal = previous_goal, + value = new + ) + return Goal.set_goal_eval_cache(goal=goal, user_id=user_id, + achievement_date=achievement_date, value=new, achieved = goal_achieved) else: - return Goal.get_goal_eval_cache(goal["id"], user_id) - + return Goal.get_goal_eval_cache(goal["id"], achievement_date, user_id) + @classmethod - def get_goal_eval_cache(cls,goal_id,user_id): + def select_and_execute_triggers(cls, goal, achievement_date, user_id, level, current_goal, value, previous_goal): + + if previous_goal == current_goal: + previous_goal = 0.0 + + j = t_goal_trigger_step_executions.join(t_goal_trigger_steps) + executions = {r["goal_trigger_id"] : r["step"] for r in + DBSession.execute( + select([t_goal_trigger_steps.c.id.label("step_id"), + t_goal_trigger_steps.c.goal_trigger_id, + t_goal_trigger_steps.c.step], from_obj=j).\ + where(and_(t_goal_triggers.c.goal_id == goal["id"], + t_goal_trigger_step_executions.c.achievement_date == achievement_date, + t_goal_trigger_step_executions.c.user_id == user_id, + t_goal_trigger_step_executions.c.execution_level == level))).fetchall() + } + + j = t_goal_trigger_steps.join(t_goal_triggers) + + trigger_steps = DBSession.execute(select([ + t_goal_trigger_steps.c.id, + t_goal_trigger_steps.c.goal_trigger_id, + t_goal_trigger_steps.c.step, + t_goal_trigger_steps.c.condition_type, + t_goal_trigger_steps.c.condition_percentage, + t_goal_trigger_steps.c.action_type, + t_goal_trigger_steps.c.action_translation_id, + t_goal_triggers.c.execute_when_complete, + ],from_obj=j).\ + where(t_goal_triggers.c.goal_id == goal["id"],)).fetchall() + + trigger_steps = [s for s in trigger_steps if s["step"]>executions.get(s["goal_trigger_id"],-sys.maxsize)] + + exec_queue = {} + + #When editing things here, check the insert_trigger_step_executions_after_step_upsert event listener too!!!!!!! + if len(trigger_steps)>0: + operator = goal["operator"] + + goal_properties = Goal.get_properties(goal,level) + + for step in trigger_steps: + if step["condition_type"] == "percentage" and step["condition_percentage"]: + current_percentage = float(value - previous_goal) / float(current_goal - previous_goal) + required_percentage = step["condition_percentage"] + if current_percentage>=1.0 and required_percentage!=1.0 and not step["execute_when_complete"]: + # When the user reaches the full goal, and there is a trigger at e.g. 90%, we don't want it to be executed anymore. + continue + if (operator == "geq" and current_percentage >= required_percentage) \ + or (operator == "leq" and current_percentage <= required_percentage): + if exec_queue.get(step["goal_trigger_id"],{"step" : -sys.maxsize})["step"] < step["step"]: + exec_queue[step["goal_trigger_id"]] = step + + for step in exec_queue.values(): + current_percentage = float(value - previous_goal) / float(current_goal - previous_goal) + GoalTriggerStep.execute( + trigger_step = step, + user_id = user_id, + current_percentage = current_percentage, + value = value, + goal_goal = current_goal, + goal_level = level, + goal_properties = goal_properties, + achievement_date = achievement_date + ) + + + @classmethod + def get_goal_eval_cache(cls,goal_id,achievement_date,user_id): """lookup and return cache entry, else return None""" - v = cache_goal_evaluation.get("%s_%s" % (goal_id,user_id)) + v = cache_goal_evaluation.get("%s_%s_%s" % (goal_id,achievement_date,user_id)) if v: return v else: return None - + @classmethod - def set_goal_eval_cache(cls,goal,user_id,value,achieved): + def set_goal_eval_cache(cls,goal, achievement_date, user_id,value,achieved): """set cache entry after evaluation""" - - cache = t_goal_evaluation_cache.select().where(and_(t_goal_evaluation_cache.c.goal_id==goal["id"], - t_goal_evaluation_cache.c.user_id==user_id)).execute().fetchone() - + cache_query = t_goal_evaluation_cache.select().where(and_(t_goal_evaluation_cache.c.goal_id==goal["id"], + t_goal_evaluation_cache.c.user_id==user_id, + t_goal_evaluation_cache.c.achievement_date==achievement_date)) + cache = DBSession.execute(cache_query).fetchone() + if not cache: q = t_goal_evaluation_cache.insert()\ .values({"user_id":user_id, "goal_id":goal["id"], "value" : value, - "achieved" : achieved}) + "achieved" : achieved, + "achievement_date" : achievement_date}) update_connection().execute(q) elif cache["value"]!=value or cache["achieved"]!=achieved: #update q = t_goal_evaluation_cache.update()\ .where(and_(t_goal_evaluation_cache.c.goal_id==goal["id"], - t_goal_evaluation_cache.c.user_id==user_id))\ + t_goal_evaluation_cache.c.user_id==user_id, + t_goal_evaluation_cache.c.achievement_date == achievement_date))\ .values({"value" : value, - "achieved" : achieved}) + "achieved" : achieved, + "achievement_date": achievement_date}) update_connection().execute(q) - + data = { "id" : goal["id"], "value" : value, @@ -1059,70 +1508,81 @@ def set_goal_eval_cache(cls,goal,user_id,value,achieved): "achievement_id" : goal["achievement_id"], "priority" : goal["priority"] } - + achievement_id = goal["achievement_id"] achievement = Achievement.get_achievement(achievement_id) - - level = min((Achievement.get_level_int(user_id, achievement["id"]) or 0)+1,achievement["maxlevel"]) - + + level = min((Achievement.get_level_int(user_id, achievement["id"], achievement_date) or 0)+1,achievement["maxlevel"]) + goal_output = Goal.basic_goal_output(data,level) - + goal_output.update({ "achieved" : achieved, "value" : value, }) - - cache_goal_evaluation.set("%s_%s" % (goal["id"],user_id),goal_output) - + + cache_goal_evaluation.set("%s_%s_%s" % (goal["id"],achievement_date,user_id),goal_output) + return goal_output - + @classmethod - def clear_goal_caches(cls, user_id, goal_ids): + def clear_goal_caches(cls, user_id, goal_ids_with_achievement_date): """clear the evaluation cache for the user and gaols""" - for goal_id in goal_ids: - cache_goal_evaluation.delete("%s_%s" % (goal_id,user_id)) - update_connection().execute(t_goal_evaluation_cache.delete().where(and_(t_goal_evaluation_cache.c.user_id==user_id, - t_goal_evaluation_cache.c.goal_id.in_(goal_ids)))) + for goal_id, achievement_date in goal_ids_with_achievement_date: + cache_goal_evaluation.delete("%s_%s_%s" % (goal_id, achievement_date, user_id)) + s = update_connection() + s.execute(t_goal_evaluation_cache.delete().where( + and_(t_goal_evaluation_cache.c.user_id == user_id, + t_goal_evaluation_cache.c.goal_id == goal_id, + t_goal_evaluation_cache.c.achievement_date == achievement_date )) + ) + @classmethod - def get_leaderboard(cls, goal, user_ids): + def get_leaderboard(cls, goal, achievement_date, user_ids): """get the leaderboard for the goal and userids""" + q = select([t_goal_evaluation_cache.c.user_id, t_goal_evaluation_cache.c.value])\ .where(and_(t_goal_evaluation_cache.c.user_id.in_(user_ids), - t_goal_evaluation_cache.c.goal_id==goal["id"]))\ + t_goal_evaluation_cache.c.goal_id==goal["id"], + t_goal_evaluation_cache.c.achievement_date==achievement_date, + ))\ .order_by(t_goal_evaluation_cache.c.value.desc(), t_goal_evaluation_cache.c.user_id.desc()) items = DBSession.execute(q).fetchall() - - missing_users = set(user_ids)-set([x["user_id"] for x in items]) + + users = User.get_users(user_ids) + + requested_user_ids = set(int(s) for s in user_ids) + values_found_for_user_ids = set([int(x["user_id"]) for x in items]) + missing_user_ids = requested_user_ids - values_found_for_user_ids + missing_users = User.get_users(missing_user_ids).values() if len(missing_users)>0: #the goal has not been evaluated for some users... achievement = Achievement.get_achievement(goal["achievement_id"]) - - for user_id in missing_users: - user = User.get_user(user_id) - - user_has_level = Achievement.get_level_int(user_id, achievement["id"]) + + for user in missing_users: + user_has_level = Achievement.get_level_int(user["id"], achievement["id"], achievement_date) user_wants_level = min((user_has_level or 0)+1, achievement["maxlevel"]) - - goal_eval = Goal.evaluate(goal, user_id, user_wants_level) - + + Goal.evaluate(goal, achievement, achievement_date, user, user_wants_level) + #rerun the query items = DBSession.execute(q).fetchall() - - positions = [{ "user_id" : items[i]["user_id"], + + positions = [{ "user": User.basic_output(users[items[i]["user_id"]]), "value" : items[i]["value"], "position" : i} for i in range(0,len(items))] - + return positions - + @classmethod @cache_general.cache_on_arguments() def get_goal_properties(cls,goal_id,level): """return all properties which are associated to the achievement level.""" - + #NOT CACHED, as full-basic_output is cached (see Goal.basic_output) - + return DBSession.execute(select([t_goalproperties.c.id.label("property_id"), t_goalproperties.c.name, t_goalproperties.c.is_variable, @@ -1135,16 +1595,16 @@ def get_goal_properties(cls,goal_id,level): t_goals_goalproperties.c.goal_id==goal_id))\ .order_by(t_goals_goalproperties.c.from_level))\ .fetchall() - + @classmethod @cache_general.cache_on_arguments() def basic_goal_output(cls,goal,level): - goal_goal = eval_formular(goal["goal"], {"level":level}) + goal_goal = evaluate_value_expression(goal["goal"], {"level":level}) properties = { str(r["property_id"]) : { "property_id" : r["property_id"], "name" : r["name"], - "value" : eval_formular(r["value"], {"level":level}), + "value" : evaluate_string(r["value"], {"level":level}), "value_translated" : Translation.trs(r["value_translation_id"], {"level":level}), } for r in Goal.get_goal_properties(goal["id"],level) } @@ -1156,7 +1616,14 @@ def basic_goal_output(cls,goal,level): "properties" : properties, #"updated_at" : goal["updated_at"] } - + + @classmethod + @cache_general.cache_on_arguments() + def get_properties(cls, goal, level): + return { + p["name"]: (p["value_translated"] if p["value_translated"] else p["value"]) + for p in Goal.basic_goal_output(goal, level).get("properties").values() + } class Language(ABase): def __unicode__(self, *args, **kwargs): @@ -1166,29 +1633,31 @@ class TranslationVariable(ABase): def __unicode__(self, *args, **kwargs): return "%s" % (self.name,) -_fallback_language="en" class Translation(ABase): def __unicode__(self, *args, **kwargs): return "%s" % (self.text,) - + @classmethod @cache_translations.cache_on_arguments() def trs(cls,translation_id,params={}): """returns a map of translations for the translation_id for ALL languages""" - + if translation_id is None: - return None + return None try: - ret = {str(x["name"]) : eval_formular(x["text"],params) for x in cls.get_translation_variable(translation_id)} - except: + # TODO support params which are results of this function itself (dicts of lang -> value) + # maybe even better: add possibility to refer to other translationvariables directly (so they can be modified later on) + ret = {str(x["name"]) : evaluate_string(x["text"],params) for x in cls.get_translation_variable(translation_id)} + except Exception as e: ret = {str(x["name"]) : x["text"] for x in cls.get_translation_variable(translation_id)} + log.exception("Evaluation of string-forumlar failed: %s" % (ret.get(get_settings().get("fallback_language","en"),translation_id),)) - if not _fallback_language in ret: - ret[_fallback_language] = "[not_translated]_"+str(translation_id) + if not get_settings().get("fallback_language","en") in ret: + ret[get_settings().get("fallback_language","en")] = "[not_translated]_"+str(translation_id) for lang in cls.get_languages(): if not str(lang["name"]) in ret: - ret[str(lang["name"])] = ret[_fallback_language] + ret[str(lang["name"])] = ret[get_settings().get("fallback_language","en")] return ret @@ -1204,15 +1673,155 @@ def get_translation_variable(cls,translation_id): @cache_translations.cache_on_arguments() def get_languages(cls): return DBSession.execute(t_languages.select()).fetchall() - + +class UserMessage(ABase): + def __unicode__(self, *args, **kwargs): + return "Message: %s" % (Translation.trs(self.translation_id,self.params).get(get_settings().get("fallback_language","en")),) + + @classmethod + def get_text(cls, row): + return Translation.trs(row["translation_id"],row["params"]) + + @property + def text(self): + return Translation.trs(self.translation_id, self.params) + + @classmethod + def deliver(cls, message): + from gengine.app.push import send_push_message + text = UserMessage.get_text(message) + language = get_settings().get("fallback_language", "en") + j = t_users.join(t_languages) + user_language = DBSession.execute(select([t_languages.c.name],from_obj=j).where(t_users.c.id==message["user_id"])).fetchone() + if user_language: + language = user_language["name"] + translated_text = text[language] + + if not message["has_been_pushed"]: + try: + send_push_message( + user_id = message["user_id"], + text = translated_text, + custom_payload = {}, + title = get_settings().get("push_title","Gamification-Engine") + ) + except Exception as e: + log.error(e, exc_info=True) + else: + DBSession.execute(t_user_messages.update().values({ "has_been_pushed" : True }).where(t_user_messages.c.id == message["id"])) + +class GoalTrigger(ABase): + def __unicode__(self, *args, **kwargs): + return "GoalTrigger: %s" % (self.id,) + +class GoalTriggerStep(ABase): + def __unicode__(self, *args, **kwargs): + return "GoalTriggerStep: %s" % (self.id,) + + @classmethod + def execute(cls, trigger_step, user_id, current_percentage, value, goal_goal, goal_level, goal_properties, achievement_date, suppress_actions=False): + uS = update_connection() + uS.execute(t_goal_trigger_step_executions.insert().values({ + 'user_id': user_id, + 'trigger_step_id': trigger_step["id"], + 'execution_level': goal_level, + 'achievement_date': achievement_date + })) + if not suppress_actions: + if trigger_step["action_type"] == "user_message": + m = UserMessage( + user_id = user_id, + translation_id = trigger_step["action_translation_id"], + params = dict({ + 'value' : value, + 'goal' : goal_goal, + 'percentage' : current_percentage + },**goal_properties), + is_read = False, + has_been_pushed = False + ) + uS.add(m) + +@event.listens_for(GoalTriggerStep, "after_insert") +@event.listens_for(GoalTriggerStep, 'after_update') +def insert_trigger_step_executions_after_step_upsert(mapper,connection,target): + """When we create a new Trigger-Step, we must ensure, that is will not be executed for the users who already met the conditions before.""" + + user_ids = [x["id"] for x in DBSession.execute(select([t_users.c.id,],from_obj=t_users)).fetchall()] + users = User.get_users(user_ids).values() + goal = target.trigger.goal + achievement = goal.achievement + + for user in users: + achievement_date = Achievement.get_datetime_for_evaluation_type(evaluation_timezone=achievement["evaluation_timezone"], evaluation_type=achievement["evaluation"]) + user_has_level = Achievement.get_level_int(user["id"], achievement["id"], achievement_date) + user_wants_level = min((user_has_level or 0) + 1, achievement["maxlevel"]) + goal_eval = Goal.evaluate(goal, achievement, achievement_date, user, user_wants_level, None, execute_triggers=False) + + previous_goal = Goal.basic_goal_output(goal, user_wants_level - 1).get("goal_goal", 0) + if previous_goal == goal_eval["goal_goal"]: + previous_goal = 0.0 + + current_percentage = float(goal_eval["value"]-previous_goal) / float(goal_eval["goal_goal"]-previous_goal) + operator = goal["operator"] + required_percentage = target["condition_percentage"] + + if (operator == "geq" and current_percentage >= required_percentage) \ + or (operator == "leq" and current_percentage <= required_percentage): + GoalTriggerStep.execute( + trigger_step=target, + user_id=user["id"], + current_percentage=current_percentage, + value=goal_eval["value"], + goal_goal=goal_eval["goal_goal"], + goal_level=user_wants_level, + goal_properties=Goal.get_properties(goal,user_wants_level), + achievement_date=achievement_date, + suppress_actions = True + ) + +def backref(*args,**kw): + if not "passive_deletes" in kw: + kw["passive_deletes"] = True + return sa_backref(*args,**kw) + +def relationship(*args,**kw): + if not "passive_deletes" in kw: + kw["passive_deletes"] = True + if "backref" in kw: + if type(kw["backref"]=="str"): + kw["backref"] = backref(kw["backref"]) + return sa_relationship(*args,**kw) + +mapper(AuthUser, t_auth_users, properties={ + 'roles' : relationship(AuthRole, secondary=t_auth_users_roles, backref="users") +}) + +mapper(AuthToken, t_auth_tokens, properties={ + 'user' : relationship(AuthUser, backref="tokens") +}) + +mapper(AuthRole, t_auth_roles, properties={ + +}) + +mapper(AuthRolePermission, t_auth_roles_permissions, properties={ + 'role' : relationship(AuthRole, backref="permissions"), +}) + mapper(User, t_users, properties={ 'friends': relationship(User, secondary=t_users_users, primaryjoin=t_users.c.id==t_users_users.c.from_id, - secondaryjoin=t_users.c.id==t_users_users.c.to_id) + secondaryjoin=t_users.c.id==t_users_users.c.to_id), + 'language' : relationship(Language,backref="users"), +}) + +mapper(UserDevice, t_user_device, properties={ + 'user' : relationship(User, backref="devices"), }) mapper(Group, t_groups, properties={ - 'users' : relationship(User, secondary=t_users_groups, backref="groups"), + 'users' : relationship(User, secondary=t_users_groups, backref="groups"), }) mapper(Variable, t_variables, properties={ @@ -1252,18 +1861,26 @@ def get_languages(cls): mapper(Goal, t_goals, properties={ 'name_translation' : relationship(TranslationVariable), - 'properties' : relationship(GoalGoalProperty, backref='goal'), }) mapper(GoalProperty, t_goalproperties) mapper(GoalGoalProperty, t_goals_goalproperties, properties={ 'property' : relationship(GoalProperty, backref='goals'), - 'value_translation' : relationship(TranslationVariable) + 'value_translation' : relationship(TranslationVariable), + 'goal' : relationship(Goal, backref='properties',), }) mapper(GoalEvaluationCache, t_goal_evaluation_cache,properties={ 'user' : relationship(User), 'goal' : relationship(Goal) }) +mapper(GoalTrigger,t_goal_triggers, properties={ + 'goal' : relationship(Goal,backref="triggers"), +}) +mapper(GoalTriggerStep,t_goal_trigger_steps, properties={ + 'trigger' : relationship(GoalTrigger,backref="steps"), + 'action_translation' : relationship(TranslationVariable) +}) + mapper(Language, t_languages) mapper(TranslationVariable,t_translationvariables) mapper(Translation, t_translations, properties={ @@ -1271,6 +1888,11 @@ def get_languages(cls): 'translationvariable' : relationship(TranslationVariable, backref="translations"), }) +mapper(UserMessage, t_user_messages, properties = { + 'user' : relationship(User, backref="user_messages"), + 'translationvariable' : relationship(TranslationVariable), +}) + @event.listens_for(AchievementProperty, "after_insert") @event.listens_for(AchievementProperty, 'after_update') def insert_variable_for_property(mapper,connection,target): @@ -1280,127 +1902,3 @@ def insert_variable_for_property(mapper,connection,target): variable.name = target.name variable.group = "day" DBSession.add(variable) - -#some query helpers - -def calc_distance(latlong1, latlong2): - """generates a sqlalchemy expression for distance query in km - - :param latlong1: the location from which we look for rows, as tuple (lat,lng) - - :param latlong2: the columns containing the latitude and longitude, as tuple (lat,lng) - """ - - #explain: http://geokoder.com/distances - - #return func.sqrt(func.pow(69.1 * (latlong1[0] - latlong2[0]),2) - # + func.pow(53.0 * (latlong1[1] - latlong2[1]),2)) - - return func.sqrt(func.pow(111.2 * (latlong1[0]-latlong2[0]),2) - + func.pow(111.2 * (latlong1[1]-latlong2[1]) * func.cos(latlong2[0]),2)) - -def coords(row): - return (row["lat"],row["lng"]) - -safe_list = ['math','acos', 'asin', 'atan', 'atan2', 'ceil', - 'cos', 'cosh', 'degrees', 'e', 'exp', 'fabs', 'floor', - 'fmod', 'frexp', 'hypot', 'ldexp', 'log', 'log10', 'modf', - 'pi', 'pow', 'radians', 'sin', 'sinh', 'sqrt', 'tan', 'tanh', 'sum', 'range', 'str', 'int', 'float'] - -#use the list to filter the local namespace -from math import * -safe_dict = dict([ (k, locals().get(k, None)) for k in safe_list]) -for k in safe_dict.keys(): - if safe_dict[k] is None: - if hasattr(__builtin__, k): - safe_dict[k] = getattr(__builtin__, k) -safe_dict['and_'] = and_ -safe_dict['or_'] = or_ -safe_dict['abs'] = abs - -class FormularEvaluationException(Exception): - pass - -#TODO: Cache -def eval_formular(s,params={}): - """evaluates the formular. - - parameters are available as p.name, - - available math functions: - 'math','acos', 'asin', 'atan', 'atan2', 'ceil', - 'cos', 'cosh', 'degrees', 'e', 'exp', 'fabs', 'floor', - 'fmod', 'frexp', 'hypot', 'ldexp', 'log', 'log10', 'modf', - 'pi', 'pow', 'radians', 'sin', 'sinh', 'sqrt', 'tan', 'tanh', 'sum', 'range' - """ - try: - if s is None: - return None - else: - p = DictObjectProxy(params) - - #add any needed builtins back in. - safe_dict['p'] = p - - result = eval(s,{"__builtins__":None},safe_dict) - if type(result)==str or type(result)==unicode: - return result % params - else: - return result - except: - raise FormularEvaluationException(s) - -class DictObjectProxy(): - obj = None - - def __init__(self, obj): - self.obj = obj - def __getattr__(self, name): - if not name in self.obj: - return "" - return self.obj[name] - -def combine_updated_at(list_of_dates): - return max(list_of_dates) - -def get_insert_id_by_result(r): - return r.last_inserted_ids()[0] - -def get_insert_ids_by_result(r): - return r.last_inserted_ids() - -def exists_by_expr(t, expr): - #TODO: use exists instead of count - q = select([func.count("*").label("c")], from_obj=t).where(expr) - r = DBSession.execute(q).fetchone() - if r.c > 0: - return True - else: - return False - -@cache_general.cache_on_arguments() -def datetime_trunc(field,timezone): - return "date_trunc('%(field)s', CAST(to_char(NOW() AT TIME ZONE %(timezone)s, 'YYYY-MM-DD HH24:MI:SS') AS TIMESTAMP)) AT TIME ZONE %(timezone)s" % { - "field" : field, - "timezone" : timezone - } - -@cache_general.cache_on_arguments() -def valid_timezone(timezone): - try: - pytz.timezone(timezone) - except UnknownTimeZoneError: - return False - return True - -def update_connection(): - session = DBSession() - mark_changed(session) - return session - -def clear_all_caches(): - cache_achievement_eval.invalidate(hard=True) - cache_achievements_by_user_for_today.invalidate(hard=True) - cache_translations.invalidate(hard=True) - cache_general.invalidate(hard=True) - urlcache.invalidate_all() diff --git a/gengine/app/permissions.py b/gengine/app/permissions.py new file mode 100644 index 0000000..e7b1771 --- /dev/null +++ b/gengine/app/permissions.py @@ -0,0 +1,35 @@ + +perm_global_access_admin_ui = "global_access_admin_ui" +desc_global_access_admin_ui = "(Admin) Can access Admin-UI" + +perm_global_update_user_infos = "global_update_user_infos" +desc_global_update_user_infos = "(Admin) Update every user's information" + +perm_own_update_user_infos = "own_update_user_infos" +desc_own_update_user_infos = "Update my own infos" + +perm_global_delete_user = "global_delete_user" +desc_global_delete_user = "(Admin) Delete all users" + +perm_own_delete_user = "own_delete_user" +desc_own_delete_user = "Delete myself" + +perm_global_increase_value = "global_increase_value" +desc_global_increase_value = "(Admin) Increase every user's values" + +perm_global_register_device = "global_register_device" +desc_global_register_device = "(Admin) Register devices for any user" + +perm_own_register_device = "own_register_device" +desc_own_register_device = "Register devices for myself" + +perm_global_read_messages= "global_read_messages" +desc_global_read_messages = "(Admin) Read messages of all users" + +perm_own_read_messages = "perm_own_read_messages" +desc_own_read_messages = "Read own messages" + +def yield_all_perms(): + for k,v in globals().items(): + if k.startswith("perm_"): + yield (v, globals().get("desc_"+k.lstrip("perm_"),k)) diff --git a/gengine/app/push.py b/gengine/app/push.py new file mode 100644 index 0000000..64ae5d4 --- /dev/null +++ b/gengine/app/push.py @@ -0,0 +1,214 @@ +import random +import threading + +import os +from sqlalchemy.sql.expression import and_, select +from sqlalchemy.sql.functions import func + +from gengine.app.model import t_user_device, t_user_messages +from gengine.base.model import update_connection +from gengine.base.settings import get_settings +from gengine.metadata import DBSession + +threadlocal = threading.local() + +import logging +log = logging.getLogger(__name__) + +try: + from apns import APNs, Payload +except ImportError as e: + log.info("tapns3 not installed") + +try: + from gcm import GCM +except ImportError as e: + log.info("python-gcm not installed") + + +def get_prod_apns(): + """ + http://stackoverflow.com/questions/1762555/creating-pem-file-for-apns + + Step 1: Create Certificate .pem from Certificate .p12 + Command: openssl pkcs12 -clcerts -nokeys -out apns-dev-cert.pem -in apns-dev-cert.p12 + + Step 2: Create Key .pem from Key .p12 + Command : openssl pkcs12 -nocerts -out apns-dev-key.pem -in apns-dev-key.p12 + + Step 3: If you want to remove pass phrase asked in second step + Command : openssl rsa -in apns-dev-key.pem -out apns-dev-key-noenc.pem + + """ + if not hasattr(threadlocal, "prod_apns"): + settings = get_settings() + cert_file = os.environ.get("APNS_CERT", settings.get("apns.prod.certificate")) + key_file = os.environ.get("APNS_KEY", settings.get("apns.prod.key")) + sandbox = False # other_helpers.boolify(os.environ.get("APNS_SANDBOX",settings.get("apns.sandbox"))) + threadlocal.prod_apns = APNs(use_sandbox=sandbox, cert_file=cert_file, key_file=key_file, enhanced=True) + + def response_listener(error_response): + log.debug("client get error-response: " + str(error_response)) + + threadlocal.prod_apns.gateway_server.register_response_listener(response_listener) + return threadlocal.prod_apns + +def get_dev_apns(): + """ + http://stackoverflow.com/questions/1762555/creating-pem-file-for-apns + + Step 1: Create Certificate .pem from Certificate .p12 + Command: openssl pkcs12 -clcerts -nokeys -out apns-dev-cert.pem -in apns-dev-cert.p12 + + Step 2: Create Key .pem from Key .p12 + Command : openssl pkcs12 -nocerts -out apns-dev-key.pem -in apns-dev-key.p12 + + Step 3: If you want to remove pass phrase asked in second step + Command : openssl rsa -in apns-dev-key.pem -out apns-dev-key-noenc.pem + + """ + if not hasattr(threadlocal, "dev_apns"): + settings = get_settings() + cert_file = os.environ.get("APNS_CERT", settings.get("apns.dev.certificate")) + key_file = os.environ.get("APNS_KEY", settings.get("apns.dev.key")) + sandbox = True # other_helpers.boolify(os.environ.get("APNS_SANDBOX",settings.get("apns.sandbox"))) + threadlocal.dev_apns = APNs(use_sandbox=sandbox, cert_file=cert_file, key_file=key_file, enhanced=True) + + def response_listener(error_response): + log.debug("client get error-response: " + str(error_response)) + + threadlocal.dev_apns.gateway_server.register_response_listener(response_listener) + return threadlocal.dev_apns + +def get_gcm(): + if not hasattr(threadlocal, "gcm"): + settings = get_settings() + # JSON request + API_KEY = os.environ.get("GCM_API_KEY", settings.get("gcm.api_key")) + threadlocal.gcm = GCM(API_KEY) + return threadlocal.gcm + +def prod_apns_feedback(): + apns_feedback(get_prod_apns(), "prod_") + +def dev_apns_feedback(): + apns_feedback(get_dev_apns(), "dev_") + +def apns_feedback(apns, prefix): + # Get feedback messages. + uS = update_connection() + + for (token_hex, fail_time) in apns.feedback_server.items(): + try: + if not isinstance(token_hex, str): + token_hex = token_hex.decode("utf8") + + token_hex = prefix + token_hex + + log.debug("APNS Feedback Entry: %s", token_hex + "_" + str(fail_time)) + + # do stuff with token_hex and fail_time + q = t_user_device.select().where(t_user_device.c.push_id==token_hex) + rows = uS.execute(q).fetchall() + + for device in rows: + log.debug("APNSPushID found in Database: %s", token_hex) + if fail_time > device["registered"]: + log.debug("Fail-Time is before Registered-At") + uS.execute(t_user_device.delete().where( + t_user_device.c.device_id == device["device_id"], + t_user_device.c.user_id == device["user_id"], + )) + except: + log.exception("Processing APNS Feedback failed for an entry.") + + +def gcm_feedback(response): + # Successfully handled registration_ids + if 'success' in response: + for reg_id, success_id in response['success'].items(): + log.debug('Successfully sent notification for reg_id {0}'.format(reg_id)) + + + # Handling errors + if 'errors' in response: + + for error, reg_ids in response['errors'].items(): + # Check for errors and act accordingly + if error in ['NotRegistered', 'InvalidRegistration']: + # Remove reg_ids from database + for reg_id in reg_ids: + q = t_user_device.delete().where(t_user_device.c.push_id == reg_id) + DBSession.execute(q) + + # Repace reg_id with canonical_id in your database + if 'canonical' in response: + for reg_id, canonical_id in response['canonical'].items(): + if not isinstance(reg_id, str): + reg_id = reg_id.decode("utf8") + + log.debug("Replacing reg_id: {0} with canonical_id: {1} in db".format(reg_id, canonical_id)) + + q = t_user_device.update().values({ + "push_id" : canonical_id + }).where(t_user_device.c.push_id == reg_id) + + DBSession.execute(q) + + DBSession.flush() + +def send_push_message( + user_id, + text="", + custom_payload={}, + title="Gamification-Engine", + android_text=None, + ios_text=None): + + message_count = DBSession.execute(select([func.count("*").label("c")],from_obj=t_user_messages).where(and_( + t_user_messages.c.user_id == user_id, + t_user_messages.c.is_read == False + ))).scalar() + + data = dict({"title": title, + "badge": message_count}, **custom_payload) + + settings = get_settings() + + if not ios_text: + ios_text = text + + if not android_text: + android_text = text + + rows = DBSession.execute(select([t_user_device.c.push_id, t_user_device.c.device_os], from_obj=t_user_device).distinct().where(t_user_device.c.user_id==user_id)).fetchall() + + for device in rows: + + if "ios" in device.device_os.lower(): + identifier = random.getrandbits(32) + + if custom_payload: + payload = Payload(alert=ios_text, custom=data, badge=message_count, sound="default") + else: + payload = Payload(alert=ios_text, custom=data, badge=message_count, sound="default") + + log.debug("Sending Push message to User (ID: %s)", user_id) + + if device.push_id.startswith("prod_"): + get_prod_apns().gateway_server.send_notification(device.push_id[5:], payload, identifier=identifier) + elif device.push_id.startswith("dev_"): + get_dev_apns().gateway_server.send_notification(device.push_id[4:], payload, identifier=identifier) + + if "android" in device.device_os.lower(): + + log.debug("Sending Push message to User (ID: %s)", user_id) + push_id = device.push_id.lstrip("dev_").lstrip("prod_") + + response = get_gcm().json_request(registration_ids=[push_id, ], + data={"message": android_text, "data": data, "title": title}, + restricted_package_name=os.environ.get("GCM_PACKAGE", settings.get("gcm.package","")), + priority='high', + delay_while_idle=False) + if response: + gcm_feedback(response) \ No newline at end of file diff --git a/gengine/app/route.py b/gengine/app/route.py new file mode 100644 index 0000000..05ffa55 --- /dev/null +++ b/gengine/app/route.py @@ -0,0 +1,15 @@ + +def config_routes(config): + config.add_route('get_progress', '/progress/{user_id}') + config.add_route('increase_value', '/increase_value/{variable_name}/{user_id}') + config.add_route('increase_value_with_key', '/increase_value/{variable_name}/{user_id}/{key}') + config.add_route('increase_multi_values', '/increase_multi_values') + config.add_route('add_or_update_user', '/add_or_update_user/{user_id}') + config.add_route('delete_user', '/delete_user/{user_id}') + config.add_route('get_achievement_level', '/achievement/{achievement_id}/level/{level}') + + config.add_route('auth_login', '/auth/login') + + config.add_route('register_device', '/register_device/{user_id}') + config.add_route('get_messages', '/messages/{user_id}') + config.add_route('read_messages', '/read_messages/{user_id}') \ No newline at end of file diff --git a/gengine/flask_static/admin.js b/gengine/app/static/admin.js similarity index 51% rename from gengine/flask_static/admin.js rename to gengine/app/static/admin.js index 5dac964..7f840c0 100644 --- a/gengine/flask_static/admin.js +++ b/gengine/app/static/admin.js @@ -2,7 +2,9 @@ jQuery().ready(function($) { var defaultcall = "progress"; var fields=["userid","variable","value","key","achievementid","level", - "lat","lon","friends","groups","timezone","country","region","city"]; + "lat","lon","friends","groups","timezone","country","region","city", + "email","password","device_id","push_id","device_os","app_version", + "offset","message_id","additional_public_data","language"]; var api_funcs = { "progress" : { @@ -17,10 +19,10 @@ jQuery().ready(function($) { "postparams":["value"] }, "add_or_update_user" : { - "fields":["userid","lat","lon","friends","groups","timezone","country","region","city"], + "fields":["userid","lat","lon","friends","groups","timezone","country","region","city","additional_public_data","language"], "url":"/add_or_update_user/{userid}", "method":"POST", - "postparams":["lat","lon","friends","groups","timezone","country","region","city"] + "postparams":["lat","lon","friends","groups","timezone","country","region","city","additional_public_data","language"] }, "delete_user" : { "fields":["userid"], @@ -31,6 +33,30 @@ jQuery().ready(function($) { "fields":["achievementid","level"], "url":"/achievement/{achievementid}/level/{level}", "method":"GET" + }, + "auth_login" : { + "fields":["email","password"], + "url":"/auth/login", + "method":"POST", + "jsonparams":["email","password"] + }, + "register_device" : { + "fields":["userid","device_id","push_id","device_os","app_version"], + "url":"/register_device/{userid}", + "method":"POST", + "jsonparams":["device_id","push_id","device_os","app_version"] + }, + "get_messages" : { + "fields":["userid","offset"], + "url":"/messages/{userid}", + "method":"GET", + "getparams":["offset"] + }, + "set_messages_read" : { + "fields":["userid","message_id"], + "url":"/read_messages/{userid}", + "method":"POST", + "jsonparams":["message_id"] } }; diff --git a/gengine/flask_static/admin_layout.css b/gengine/app/static/admin_layout.css similarity index 65% rename from gengine/flask_static/admin_layout.css rename to gengine/app/static/admin_layout.css index ac2c36f..325b7cc 100644 --- a/gengine/flask_static/admin_layout.css +++ b/gengine/app/static/admin_layout.css @@ -8,4 +8,14 @@ pre {outline: 1px solid #ccc; padding: 5px; margin: 5px; } .number { color: darkorange; } .boolean { color: blue; } .null { color: magenta; } -.key { color: red; } \ No newline at end of file +.key { color: red; } + +.auth_active { + color:green; + font-weight:bold; +} + +.auth_inactive { + color:red; + font-weight:bold; +} \ No newline at end of file diff --git a/gengine/flask_static/api.js b/gengine/app/static/api.js similarity index 75% rename from gengine/flask_static/api.js rename to gengine/app/static/api.js index 5d2f56e..0040422 100644 --- a/gengine/flask_static/api.js +++ b/gengine/app/static/api.js @@ -33,23 +33,29 @@ var setupAPIForm = function($, defaultcall, fields, api_funcs) { "url" : "/", "data" : { - } + }, }; + $.ajaxSetup({headers: {"X-Auth-Token": $.cookie("X-Auth-Token")}}); + var api_settings_url; var api_settings_method; - var api_settings_postparams; + var api_settings_postparams; + var api_settings_jsonparams; + var api_settings_getparams; - var setURL = function(url,method,postparams) { - api_settings_url = url; + var setURL = function(url, method, postparams, jsonparams, getparams) { + api_settings_url = API_BASE_URL ? API_BASE_URL+url : url; api_settings_method=method; api_settings_postparams = postparams; + api_settings_jsonparams = jsonparams; + api_settings_getparams = getparams; }; var activationfuncs = {}; $.each(api_funcs,function(k,f) { activationfuncs[k] = function() { setActiveFields(f["fields"]); - setURL(f["url"],f["method"],f["postparams"]); + setURL(f["url"],f["method"],f["postparams"],f["jsonparams"],f["getparams"]); }; }); @@ -81,9 +87,14 @@ var setupAPIForm = function($, defaultcall, fields, api_funcs) { var url = api_settings_url; var method = api_settings_method; var postparams = api_settings_postparams; + var jsonparams = api_settings_jsonparams; + var getparams = api_settings_getparams; var ajax_options={}; ajax_options["data"] = {}; + jsondata = {}; + encoded_get_params = []; + for(var i=0; i0) { + ajax_options["data"] = JSON.stringify(jsondata) + } var request = $.ajax(ajax_options); diff --git a/gengine/templates/admin/index.html b/gengine/app/templates/admin/index.html similarity index 65% rename from gengine/templates/admin/index.html rename to gengine/app/templates/admin/index.html index 78ebc0b..181ef96 100644 --- a/gengine/templates/admin/index.html +++ b/gengine/app/templates/admin/index.html @@ -42,6 +42,12 @@

Welcome to the Gamification Engine Admin-Area

+ {%if settings_enable_authentication %} + + {% endif %} + + +
@@ -98,6 +104,11 @@

Welcome to the Gamification Engine Admin-Area

+ +
+ + +
@@ -113,7 +124,52 @@

Welcome to the Gamification Engine Admin-Area

- + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ @@ -148,6 +204,28 @@

Welcome to the Gamification Engine Admin-Area

GET to "/achievement/{achievement_id}/level/{level}"

+ + {%if settings_enable_authentication %} + Login +

+ POST to "/auth/login" +

+ {% endif %} + + Register Device +

+ POST to "/register_device/{user_id}" +

+ + Get Messages +

+ GET to "/messages/{user_id}" +

+ + Set Messages Read +

+ POST to "/read_messages/{user_id}" +

@@ -159,4 +237,5 @@

Welcome to the Gamification Engine Admin-Area

{% block tail %} {{ super() }} -{% endblock %} + +{% endblock %} \ No newline at end of file diff --git a/gengine/templates/admin/layout.html b/gengine/app/templates/admin/layout.html similarity index 92% rename from gengine/templates/admin/layout.html rename to gengine/app/templates/admin/layout.html index 12e3f9a..be1fd6c 100644 --- a/gengine/templates/admin/layout.html +++ b/gengine/app/templates/admin/layout.html @@ -61,6 +61,10 @@ {% endif %} {% endfor %} +
  • + {% endmacro %} {% macro messages() %} diff --git a/gengine/templates/admin_layout.html b/gengine/app/templates/admin_layout.html similarity index 81% rename from gengine/templates/admin_layout.html rename to gengine/app/templates/admin_layout.html index f9bdff9..f8aadaa 100644 --- a/gengine/templates/admin_layout.html +++ b/gengine/app/templates/admin_layout.html @@ -3,12 +3,15 @@ {% block head_tail %} {{ super() }} - + + {% endblock %} {% block tail %} {{ super() }} - + {% endblock %} diff --git a/gengine/templates/admin_maintenance.html b/gengine/app/templates/admin_maintenance.html similarity index 100% rename from gengine/templates/admin_maintenance.html rename to gengine/app/templates/admin_maintenance.html diff --git a/gengine/templates/error.html b/gengine/app/templates/error.html similarity index 100% rename from gengine/templates/error.html rename to gengine/app/templates/error.html diff --git a/gengine/app/tests/__init__.py b/gengine/app/tests/__init__.py new file mode 100644 index 0000000..fc80254 --- /dev/null +++ b/gengine/app/tests/__init__.py @@ -0,0 +1 @@ +pass \ No newline at end of file diff --git a/gengine/app/tests/base.py b/gengine/app/tests/base.py new file mode 100644 index 0000000..32f0e2c --- /dev/null +++ b/gengine/app/tests/base.py @@ -0,0 +1,59 @@ +import unittest +import os +from sqlalchemy.engine import create_engine +from sqlalchemy.sql.schema import Table +from sqlalchemy.orm.scoping import scoped_session +from gengine.metadata import init_session, get_sessionmaker +from gengine.app.tests import db + +class BaseDBTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + if cls is BaseDBTest: + raise unittest.SkipTest("Skip BaseTest tests, it's a base class") + super(BaseDBTest, cls).setUpClass() + + def setUp(self): + from gengine.app.cache import clear_all_caches + clear_all_caches() + self.db = db.db() + dsn = self.db.dsn() + self.engine = create_engine( + "postgresql://%(user)s@%(host)s:%(port)s/%(database)s" % { + "user" : dsn["user"], + "host": dsn["host"], + "port": dsn["port"], + "database": dsn["database"], + } + ) + init_session(override_session=scoped_session(get_sessionmaker(bind=self.engine)), replace=True) + from gengine.metadata import Base + Base.metadata.bind = self.engine + + Base.metadata.drop_all(self.engine) + self.engine.execute("DROP SCHEMA IF EXISTS public CASCADE") + self.engine.execute("CREATE SCHEMA IF NOT EXISTS public") + + from alembic.config import Config + from alembic import command + + alembic_cfg = Config(attributes={ + 'engine': self.engine, + 'schema': 'public' + }) + script_location = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), + 'app/alembic' + ) + alembic_cfg.set_main_option("script_location", script_location) + + from gengine.app import model + + tables = [t for name, t in model.__dict__.items() if isinstance(t, Table)] + Base.metadata.create_all(self.engine, tables=tables) + + command.stamp(alembic_cfg, "head") + + def tearDown(self): + self.db.stop() diff --git a/gengine/app/tests/db.py b/gengine/app/tests/db.py new file mode 100644 index 0000000..21eb8a1 --- /dev/null +++ b/gengine/app/tests/db.py @@ -0,0 +1,25 @@ +import os + +import logging +log = logging.getLogger(__name__) + +try: + import testing.postgresql +except ImportError as e: + log.info("testing.postgresql not installed") + +db = None + +def setupDB(): + # Generate Postgresql class which shares the generated database + global db + db = testing.postgresql.PostgresqlFactory( + postgres=os.environ.get("TEST_POSTGRES",None), + initdb=os.environ.get("TEST_INITDB",None), + cache_initialized_db=True + ) + +def unsetupDB(): + # clear cached database at end of tests + global db + db.clear_cache() diff --git a/gengine/app/tests/helpers.py b/gengine/app/tests/helpers.py new file mode 100644 index 0000000..6ab5a51 --- /dev/null +++ b/gengine/app/tests/helpers.py @@ -0,0 +1,495 @@ +import random +import datetime + +from gengine.app.model import User, Language, Achievement,Goal, Variable, Value, t_goals, GoalProperty, GoalGoalProperty, TranslationVariable, \ + t_goals_goalproperties, t_users, GoalEvaluationCache, Reward, AchievementReward, AchievementUser +from gengine.metadata import DBSession + +from gengine.app.model import UserDevice, t_user_device +from sqlalchemy import and_, select + +import logging +log = logging.getLogger(__name__) + +try: + import names +except ImportError as e: + log.info("names not installed") + +default_gen_data = { + "timezone" : "Europe/Berlin", + "area" : { + "min_lat" : 51.65, + "max_lat" : 51.75, + "min_lng" : 8.70, + "max_lng" : 8.79 + }, + "country" : "DE", + "region" : "NRW", + "city" : "Paderborn", + "language" : "de", + "additional_public_data" : { + "first_name" : "Matthew", + "last_name" : "Hayden" + } +} + +alt_gen_data = { + "timezone" : "US/Eastern", + "area" : { + "min_lat" : 40.680, + "max_lat" : 40.780, + "min_lng" : -73.89, + "max_lng" : -73.97 + } +} + +default_device_data = { + "device_os" : "iOS 5", + "app_version" : "1.1", + "push_id" : "5678", + "device_id" : "1234" +} + + +class Undefined(): + pass + +undefined = Undefined() + + +def randrange_float(f1,f2): + return random.random() * abs(f1 - f2) + min(f1,f2) + + +def create_user( + user_id = undefined, + lat = undefined, + lng = undefined, + country = undefined, + region = undefined, + city = undefined, + timezone = undefined, + language = undefined, + friends = [], + groups = [], + additional_public_data = undefined, + gen_data = default_gen_data + ): + if additional_public_data is undefined: + additional_public_data = { + 'first_name' : 'Stefan', + 'last_name' : 'Rogers' + } + + if user_id is undefined: + user_id = (DBSession.execute("SELECT max(id) as c FROM users").scalar() or 0) + 1 + if lat is undefined: + lat = randrange_float(gen_data["area"]["min_lat"],gen_data["area"]["max_lat"]) + + if lng is undefined: + lng = randrange_float(gen_data["area"]["min_lng"], gen_data["area"]["max_lng"]) + + if country is undefined: + country = gen_data["country"] + + if timezone is undefined: + timezone = gen_data["timezone"] + + if region is undefined: + region = gen_data["region"] + + if city is undefined: + city = gen_data["city"] + + if language is undefined: + language = gen_data["language"] + + User.set_infos( + user_id = user_id, + lat = lat, + lng = lng, + timezone = timezone, + country = country, + region = region, + city = city, + language = language, + groups = groups, + friends = friends, + additional_public_data = additional_public_data + ) + + return User.get_user(user_id) + + +def update_user( + user_id = undefined, + lat = undefined, + lng = undefined, + country = undefined, + region = undefined, + city = undefined, + timezone = undefined, + language = undefined, + friends = [], + groups = [], + additional_public_data = undefined, + ): + + User.set_infos( + user_id = user_id, + lat = lat, + lng = lng, + timezone = timezone, + country = country, + region = region, + city = city, + language = language, + groups = groups, + friends = friends, + additional_public_data = additional_public_data + ) + + return User.get_user(user_id) + + +def delete_user( + user_id = undefined, + ): + + User.delete_user(user_id) + users = DBSession.execute(select([t_users.c.id,])).fetchall() + return users + + +def get_or_create_language(name): + lang = DBSession.query(Language).filter_by(name=name).first() + if not lang: + lang = Language() + lang.name = name + DBSession.add(lang) + DBSession.flush() + return lang + + +def create_device( + user_id=undefined, + device_id=undefined, + device_os=undefined, + push_id=undefined, + app_version=undefined, + gen_data=default_device_data + ): + + if push_id is undefined: + push_id = gen_data["push_id"] + + if device_os is undefined: + device_os = gen_data["device_os"] + + if app_version is undefined: + app_version = gen_data["app_version"] + + if device_id is undefined: + device_id = gen_data["device_id"] + + UserDevice.add_or_update_device( + device_id = device_id, + user_id = user_id, + device_os = device_os, + push_id = push_id, + app_version = app_version + ) + + device = DBSession.execute(t_user_device.select().where(and_( + t_user_device.c.device_id == device_id, + t_user_device.c.user_id == user_id + ))).fetchone() + + return device + + +def update_device( + user_id=undefined, + device_id=undefined, + device_os=undefined, + push_id=undefined, + app_version=undefined, + ): + UserDevice.add_or_update_device( + device_id=device_id, + user_id=user_id, + device_os=device_os, + push_id=push_id, + app_version=app_version + ) + + device = DBSession.execute(t_user_device.select().where(and_( + t_user_device.c.device_id == device_id, + t_user_device.c.user_id == user_id + ))).fetchone() + + return device + + +def create_achievement( + achievement_name = undefined, + achievement_valid_start = undefined, + achievement_valid_end = undefined, + achievement_lat = undefined, + achievement_lng = undefined, + achievement_max_distance = undefined, + achievement_evaluation = undefined, + achievement_relevance = undefined, + achievement_maxlevel = undefined, + achievement_view_permission = undefined + ): + achievement = Achievement() + + if achievement_name is undefined: + achievement.name = "invite_users_achievement" + else: + achievement.name = achievement_name + + if achievement_valid_start is undefined: + achievement.valid_start = "2016-12-16" + else: + achievement.valid_start = achievement_valid_start + + if achievement_valid_end is undefined: + achievement.valid_end = datetime.date.today() + else: + achievement.valid_end = achievement_valid_end + + if achievement_lat is undefined: + achievement.lat = 40.983 + else: + achievement.lat = achievement_lat + + if achievement_lng is undefined: + achievement.lng = 41.562 + else: + achievement.lng = achievement_lng + + if achievement_max_distance is undefined: + achievement.max_distance = 20000 + else: + achievement.max_distance = achievement_max_distance + + if achievement_evaluation is undefined: + achievement.evaluation = "immediately" + else: + achievement.evaluation = achievement_evaluation + + if achievement_relevance is undefined: + achievement.relevance = "friends" + else: + achievement.relevance = achievement_relevance + + if achievement_maxlevel is undefined: + achievement.maxlevel = 3 + else: + achievement.maxlevel = achievement_maxlevel + + if achievement_view_permission is undefined: + achievement.view_permission = "everyone" + else: + achievement.view_permission = achievement_view_permission + + achievement.evaluation_timezone = "UTC" + + DBSession.add(achievement) + DBSession.flush() + + return achievement + + +def create_goals( + achievement = undefined, + goal_condition = undefined, + goal_goal = undefined, + goal_operator = undefined, + goal_group_by_key = undefined, + goal_name = undefined + ): + goal = Goal() + if achievement["name"] is "invite_users_achievement": + + if goal_condition is undefined: + goal.condition = """{"term": {"type": "literal", "variable": "invite_users"}}""" + else: + goal.condition = goal_condition + + if goal_goal is undefined: + goal.goal = "5*level" + else: + goal.goal = goal_goal + + if goal_operator is undefined: + goal.operator = "geq" + else: + goal.operator = goal_operator + + if goal_group_by_key is undefined: + goal.group_by_key = False + else: + goal.group_by_key = goal_group_by_key + + if goal_name is undefined: + goal.name = "goal_invite_users" + else: + goal.name = goal_name + + goal.achievement_id = achievement.id + DBSession.add(goal) + DBSession.flush() + + if achievement["name"] is "participate_achievement": + + if goal_condition is undefined: + goal.condition = """{"term": {"key": ["5","7"], "type": "literal", "key_operator": "IN", "variable": "participate"}}""" + else: + goal.condition = goal_condition + + if goal_goal is undefined: + goal.goal = "3*level" + else: + goal.goal = goal_goal + + if goal_operator is undefined: + goal.operator = "geq" + else: + goal.operator = goal_operator + + if goal_group_by_key is undefined: + goal.group_by_key = True + else: + goal.group_by_key = goal_group_by_key + + if goal_name is undefined: + goal.name = "goal_participate" + else: + goal.name = goal_name + + goal.achievement_id = achievement.id + DBSession.add(goal) + DBSession.flush() + + return goal + + +def create_goal_properties(goal_id): + + goal_property = GoalProperty() + goal_property.name = "participate" + goal_property.is_variable = True + DBSession.add(goal_property) + DBSession.flush() + + translation_variable = TranslationVariable() + translation_variable.name = "invite_users_goal_name" + DBSession.add(translation_variable) + DBSession.flush() + + goals_goal_property = GoalGoalProperty() + goals_goal_property.goal_id = goal_id + goals_goal_property.property_id = goal_property.id + goals_goal_property.value = "7" + goals_goal_property.value_translation_id = translation_variable.id + goals_goal_property.from_level = 2 + DBSession.add(goals_goal_property) + DBSession.flush() + + goals_goal_property_result = DBSession.execute(t_goals_goalproperties.select().where(t_goals_goalproperties.c.goal_id == goal_id)).fetchone() + + return goals_goal_property_result + + +def create_achievement_rewards(achievement): + reward = Reward() + reward.name = "badge" + DBSession.add(reward) + DBSession.flush() + + achievement_reward = AchievementReward() + achievement_reward.achievement_id = achievement.id + achievement_reward.reward_id = reward.id + achievement_reward.value = "https://www.gamification-software.com/img/trophy_{level1}.png" + achievement_reward.from_level = achievement.maxlevel + DBSession.add(achievement_reward) + DBSession.flush() + + return achievement_reward + + +def create_achievement_user(user, achievement, achievement_date, level): + achievement_user = AchievementUser() + achievement_user.user_id = user.id + achievement_user.achievement_id = achievement.id + achievement_user.achievement_date = achievement_date + achievement_user.level = level + DBSession.add(achievement_user) + DBSession.flush() + + return achievement_user + + +def create_goal_evaluation_cache( + goal_id , + gec_achievement_date, + gec_user_id, + gec_achieved = undefined, + gec_value = undefined, + ): + goal_evaluation_cache = GoalEvaluationCache() + + if gec_achieved is undefined: + goal_evaluation_cache.gec_achieved = True + else: + goal_evaluation_cache.gec_achieved = gec_achieved + + if gec_value is undefined: + goal_evaluation_cache.gec_value = 20.0 + else: + goal_evaluation_cache.gec_value = gec_value + + goal_evaluation_cache.goal_id = goal_id + goal_evaluation_cache.achievement_date = gec_achievement_date + goal_evaluation_cache.user_id = gec_user_id + goal_evaluation_cache.achieved = gec_achieved + goal_evaluation_cache.value = gec_value + DBSession.add(goal_evaluation_cache) + DBSession.flush() + + return goal_evaluation_cache + + +def create_variable( + variable_name = undefined, + variable_group = undefined, + ): + variable = Variable() + variable.name = variable_name + variable.group = variable_group + DBSession.add(variable) + DBSession.flush() + + return variable + + +def create_value( + user_id=undefined, + variable_id=undefined, + var_value=undefined, + key="", + ): + + value = Value() + value.user_id = user_id + value.variable_id = variable_id + value.value = var_value + value.key = key + DBSession.add(value) + DBSession.flush() + + return value diff --git a/gengine/app/tests/runner.py b/gengine/app/tests/runner.py new file mode 100644 index 0000000..a25393c --- /dev/null +++ b/gengine/app/tests/runner.py @@ -0,0 +1,55 @@ +from gengine.app.tests import db as db +from gengine.metadata import init_declarative_base, init_session +import unittest +import os +import pkgutil +import logging +import sys + +log = logging.getLogger(__name__) + +try: + import testing.redis +except ImportError as e: + log.info("testing.redis not installed") + +init_session() +init_declarative_base() + +__path__ = [x[0] for x in os.walk(os.path.dirname(__file__))] + +def create_test_suite(): + suite = unittest.TestSuite() + for imp, modname, _ in pkgutil.walk_packages(__path__): + mod = imp.find_module(modname).load_module(modname) + for test in unittest.defaultTestLoader.loadTestsFromModule(mod): + suite.addTests(test) + return suite + +if __name__=="__main__": + exit = 1 + try: + redis = testing.redis.RedisServer() + + from gengine.base.cache import setup_redis_cache + dsn = redis.dsn() + setup_redis_cache(dsn["host"], dsn["port"], dsn["db"]) + + from gengine.app.cache import init_caches + init_caches() + + db.setupDB() + testSuite = create_test_suite() + text_runner = unittest.TextTestRunner(failfast=True).run(testSuite) + if text_runner.wasSuccessful(): + exit = 0 + finally: + try: + db.unsetupDB() + except: + log.exception() + try: + redis.stop() + except: + log.exception() + sys.exit(exit) diff --git a/gengine/app/tests/test_achievement1.py b/gengine/app/tests/test_achievement1.py new file mode 100644 index 0000000..4703a95 --- /dev/null +++ b/gengine/app/tests/test_achievement1.py @@ -0,0 +1,403 @@ +# -*- coding: utf-8 -*- +import datetime +import pytz + +from gengine.app.cache import clear_all_caches +from gengine.app.tests.base import BaseDBTest +from gengine.app.tests.helpers import create_user, create_achievement, create_variable, create_goals, create_achievement_rewards, create_achievement_user +from gengine.metadata import DBSession +from gengine.app.model import Achievement, User, AchievementUser, Value, AchievementReward, Reward, AchievementProperty, AchievementAchievementProperty, t_values +from gengine.base.model import update_connection + +class TestAchievement(BaseDBTest): + + # Includes get_achievement_by_location and get_achievement_by_date + def test_get_achievements_by_location_and_date(self): + + user = create_user() + achievement1 = create_achievement(achievement_name="invite_users_achievement") + achievement2 = create_achievement(achievement_name="participate_achievement") + create_goals(achievement1) + create_goals(achievement2) + achievement_today = Achievement.get_achievements_by_user_for_today(user) + print("achievement_today") + print(achievement_today) + + self.assertEqual(achievement_today[0]["name"], "invite_users_achievement") + self.assertEqual(len(achievement_today), 2) + + def test_get_relevant_users_by_achievement_friends_and_user(self): + + #Create First user + user1 = create_user() + + # Create Second user + user2 = create_user( + lat = 85.59, + lng = 65.75, + country = "DE", + region = "Niedersachsen", + city = "Osnabrück", + timezone = "Europe/Berlin", + language = "de", + additional_public_data = { + "first_name" : "Michael", + "last_name" : "Clarke" + } + ) + + # Create Third user + user3 = create_user( + lat = 12.1, + lng = 12.2, + country = "RO", + region = "Transylvania", + city = "Cluj-Napoca", + timezone = "Europe/Bukarest", + language = "en", + additional_public_data = { + "first_name" : "Rudolf", + "last_name" : "Red Nose" + }, + friends=[1, 2] + ) + + # Create Fourth user + user4 = create_user( + lat = 25.56, + lng = 15.89, + country = "AU", + region = "Sydney", + city = "New South Wales", + timezone = "Australia", + language = "en", + additional_public_data = { + "first_name" : "Steve", + "last_name" : "Waugh" + }, + friends=[3] + ) + + achievement = create_achievement() + friendsOfuser1 = achievement.get_relevant_users_by_achievement_and_user(achievement, user1.id) + friendsOfuser3 = achievement.get_relevant_users_by_achievement_and_user(achievement, user3.id) + friendsOfuser4 = achievement.get_relevant_users_by_achievement_and_user(achievement, user4.id) + + self.assertIn(1, friendsOfuser1) + self.assertIn(1, friendsOfuser3) + self.assertIn(2, friendsOfuser3) + self.assertIn(3, friendsOfuser4) + + # For the relevance global + achievement1 = create_achievement(achievement_relevance = "global") + + friendsOfuser1 = achievement.get_relevant_users_by_achievement_and_user(achievement1, user3.id) + + self.assertIn(1, friendsOfuser1) + self.assertIn(2, friendsOfuser1) + self.assertIn(3, friendsOfuser1) + self.assertIn(4, friendsOfuser1) + + def test_get_relevant_users_by_achievement_friends_and_user_reverse(self): + + # Create First user + user1 = create_user() + + # Create Second user + user2 = create_user( + lat=85.59, + lng=65.75, + country="DE", + region="Niedersachsen", + city="Osnabrück", + timezone="Europe/Berlin", + language="de", + additional_public_data={ + "first_name": "Michael", + "last_name": "Clarke" + }, + friends = [user1.id] + ) + + # Create Third user + user3 = create_user( + lat=12.1, + lng=12.2, + country="RO", + region="Transylvania", + city="Cluj-Napoca", + timezone="Europe/Bukarest", + language="en", + additional_public_data={ + "first_name": "Rudolf", + "last_name": "Red Nose" + }, + friends=[user1.id, user2.id] + ) + + # Create Fourth user + user4 = create_user( + lat=25.56, + lng=15.89, + country="AU", + region="Sydney", + city="New South Wales", + timezone="Australia", + language="en", + additional_public_data={ + "first_name": "Steve", + "last_name": "Waugh" + }, + friends=[user2.id, user3.id] + ) + + achievement = create_achievement() + usersForFriend1 = achievement.get_relevant_users_by_achievement_and_user_reverse(achievement, user1.id) + usersForFriend2 = achievement.get_relevant_users_by_achievement_and_user_reverse(achievement, user2.id) + usersForFriend3 = achievement.get_relevant_users_by_achievement_and_user_reverse(achievement, user3.id) + usersForFriend4 = achievement.get_relevant_users_by_achievement_and_user_reverse(achievement, user4.id) + + self.assertIn(user2.id, usersForFriend1) + self.assertIn(user3.id, usersForFriend1) + self.assertIn(user3.id, usersForFriend2) + self.assertIn(user4.id, usersForFriend2) + self.assertIn(user4.id, usersForFriend3) + self.assertIn(user4.id, usersForFriend4) + + def test_get_level(self): + + user = create_user(timezone="Australia/Sydney", country="Australia", region="xyz", city="Sydney") + achievement = create_achievement(achievement_name="invite_users_achievement", achievement_evaluation="weekly") + + achievement_date = Achievement.get_datetime_for_evaluation_type(evaluation_timezone=achievement.evaluation_timezone, evaluation_type="weekly") + + create_achievement_user(user, achievement, achievement_date, level=2) + + achievement.get_level(user.id, achievement["id"], achievement_date) + level = achievement.get_level_int(user.id, achievement.id, achievement_date) + + achievement_date1 = Achievement.get_datetime_for_evaluation_type(evaluation_timezone=achievement.evaluation_timezone, evaluation_type="weekly", dt=achievement_date + datetime.timedelta(7)) + + achievement.get_level(user.id, achievement["id"], achievement_date1) + level1 = achievement.get_level_int(user.id, achievement.id, achievement_date1) + + # Test for get_level as integer + print("level1:", level1) + self.assertEqual(level, 2) + self.assertEqual(level1, 0) + + def test_get_rewards(self): + + achievement = create_achievement(achievement_maxlevel=3) + create_achievement_rewards(achievement) + clear_all_caches() + rewardlist1 = Achievement.get_rewards(achievement.id, 1) + print("rewardlist1",rewardlist1) + + rewardlist2 = Achievement.get_rewards(achievement.id, 5) + print("rewardlist2", rewardlist2) + + rewardlist3 = Achievement.get_rewards(achievement.id, 3) + print("rewardlist3", rewardlist3) + + self.assertEqual(rewardlist1, []) + self.assertEqual(rewardlist2, []) + self.assertNotEqual(rewardlist3, []) + + def test_get_achievement_properties(self): + + achievement = create_achievement(achievement_maxlevel=3) + + achievementproperty = AchievementProperty() + achievementproperty.name = "xp" + DBSession.add(achievementproperty) + DBSession.flush() + + achievements_achievementproperty = AchievementAchievementProperty() + achievements_achievementproperty.achievement_id = achievement.id + achievements_achievementproperty.property_id = achievementproperty.id + achievements_achievementproperty.value = "5" + achievements_achievementproperty.from_level = 2 + DBSession.add(achievements_achievementproperty) + DBSession.flush() + + clear_all_caches() + + result1 = Achievement.get_achievement_properties(achievement.id, 4) + print(result1) + + result2 = Achievement.get_achievement_properties(achievement.id, 1) + print(result2) + + self.assertNotEqual(result1, []) + self.assertEqual(result2, []) + + def test_evaluate_achievement_for_participate(self): + + achievement = create_achievement(achievement_name="participate_achievement", achievement_relevance="own", achievement_maxlevel=4) + + user = create_user() + + achievement_date = Achievement.get_datetime_for_evaluation_type(achievement.evaluation_timezone, achievement.evaluation) + + current_level = 1 + achievement_user = AchievementUser() + achievement_user.user_id = user.id + achievement_user.achievement_id = achievement.id + achievement_user.achievement_date = achievement_date + achievement_user.level = current_level + DBSession.add(achievement_user) + DBSession.flush() + + variable = create_variable("participate", variable_group="day") + Value.increase_value(variable_name=variable.name, user=user, value=1, key="5") + + create_goals(achievement, + goal_condition="""{"term": {"key": ["5","7"], "type": "literal", "key_operator": "IN", "variable": "participate"}}""", + goal_group_by_key=True, + goal_operator="geq", + goal_goal="1*level") + + clear_all_caches() + + level = Achievement.evaluate(user, achievement.id, achievement_date).get("level") + + Value.increase_value(variable_name="participate", user=user, value=1, key="7") + level2 = Achievement.evaluate(user, achievement.id, achievement_date).get("level") + + Value.increase_value(variable_name="participate", user=user, value=5, key="5") + level1 = Achievement.evaluate(user, achievement.id, achievement_date).get("level") + + self.assertEqual(level, 1) + self.assertEqual(level2, 1) + self.assertEqual(level1, 4) + + def test_evaluate_achievement_for_invite_users(self): + + achievement = create_achievement(achievement_name="invite_users_achievement", achievement_relevance="friends", achievement_maxlevel=10) + + user = create_user() + + achievement_date = Achievement.get_datetime_for_evaluation_type(achievement.evaluation_timezone, achievement.evaluation) + + create_achievement_user(user=user, achievement=achievement, achievement_date=achievement_date, level=1) + + update_connection().execute(t_values.delete()) + create_variable("invite_users", variable_group="day") + Value.increase_value(variable_name="invite_users", user=user, value=1, key=None) + + create_goals(achievement, + goal_goal="1*level", + goal_operator="geq", + goal_group_by_key=False + ) + clear_all_caches() + + level = Achievement.evaluate(user, achievement.id, achievement_date).get("level") + print("level: ", level) + + Value.increase_value(variable_name="invite_users", user=user, value=8, key=None) + level1 = Achievement.evaluate(user, achievement.id, achievement_date).get("level") + print("level1 ", level1) + + Value.increase_value(variable_name="invite_users", user=user, value=5, key=None) + level2 = Achievement.evaluate(user, achievement.id, achievement_date).get("level") + print("level2: ", level2) + + self.assertEqual(level, 1) + self.assertEqual(level1, 9) + self.assertEqual(level2, 10) + + def test_get_reward_and_properties_for_achievement(self): + + user = create_user() + + achievement = create_achievement(achievement_name="invite_users_achievement", achievement_relevance="friends", achievement_maxlevel=3) + + achievementproperty = AchievementProperty() + achievementproperty.name = "xp" + DBSession.add(achievementproperty) + DBSession.flush() + + achievements_achievementproperty = AchievementAchievementProperty() + achievements_achievementproperty.achievement_id = achievement.id + achievements_achievementproperty.property_id = achievementproperty.id + achievements_achievementproperty.value = "5" + achievements_achievementproperty.from_level = None + DBSession.add(achievements_achievementproperty) + DBSession.flush() + + create_achievement_rewards(achievement=achievement) + + achievement_date = Achievement.get_datetime_for_evaluation_type(achievement.evaluation_timezone, achievement.evaluation) + + create_achievement_user(user=user, achievement=achievement, achievement_date=achievement_date, level=1) + + create_variable("invite_users", "none") + Value.increase_value(variable_name="invite_users", user=user, value=4, key="5") + + create_goals(achievement = achievement, + goal_condition="""{"term": {"type": "literal", "variable": "invite_users"}}""", + goal_group_by_key=True, + goal_operator="geq", + goal_goal="1*level") + + clear_all_caches() + result = Achievement.evaluate(user, achievement.id, achievement_date) + print("reward_achievement_result:",result) + + self.assertEqual(len(result["new_levels"]["2"]["rewards"]), 0) + self.assertEqual(len(result["new_levels"]["3"]["rewards"]), 1) + self.assertEqual(len(result["new_levels"]["2"]["properties"]), 1) + self.assertEqual(len(result["new_levels"]["3"]["properties"]), 1) + + def test_multiple_goals_of_same_achievement(self): + + user = create_user() + + achievement = create_achievement(achievement_name="participate_achievement", achievement_maxlevel=3) + + create_achievement_rewards(achievement=achievement) + + achievement_date = Achievement.get_datetime_for_evaluation_type(achievement.evaluation_timezone, achievement.evaluation) + + create_goals(achievement=achievement, + goal_condition="""{"term": {"key": ["5","7"], "type": "literal", "key_operator": "IN", "variable": "participate_seminar"}}""", + goal_group_by_key=False, + goal_operator="geq", + goal_goal="2*level", + goal_name = "goal_participate_seminar") + + create_goals(achievement=achievement, + goal_condition="""{"term": {"type": "literal", "variable": "participate_talk"}}""", + goal_group_by_key=False, + goal_operator="geq", + goal_goal="1*level", + goal_name="goal_participate_talk") + + clear_all_caches() + create_achievement_user(user=user, achievement=achievement, achievement_date=achievement_date, level=1) + + variable1 = create_variable("participate_seminar", variable_group=None) + variable2 = create_variable("participate_talk", variable_group=None) + Value.increase_value(variable1.name, user, "2", "5") + Value.increase_value(variable1.name, user, "3", "7") + Value.increase_value(variable2.name, user, "3", key=None) + + result = Achievement.evaluate(user, achievement.id, achievement_date) + print("multiple_goals_of_same_achievement:",result) + Value.increase_value(variable1.name, user, "2", "7") + result1 = Achievement.evaluate(user, achievement.id, achievement_date) + print(result1) + Value.increase_value(variable2.name, user, "2", key=None) + result2 = Achievement.evaluate(user, achievement.id, achievement_date) + print(result2) + + self.assertEqual(len(result["levels"]["3"]["rewards"]), 1) + self.assertEqual(result["levels"]["1"]["goals"]["1"]["goal_goal"], 2) + self.assertEqual(result["levels"]["3"]["goals"]["2"]["goal_goal"], 3) + self.assertEqual(result1["levels"]["2"]["goals"]["1"]["goal_goal"], 4) + self.assertEqual(result1["levels"]["3"]["goals"]["2"]["goal_goal"], 3) + self.assertEqual(result2["levels"]["2"]["goals"]["1"]["goal_goal"], 4) + self.assertEqual(result2["levels"]["3"]["goals"]["2"]["goal_goal"], 3) + diff --git a/gengine/app/tests/test_achievement_integration_tests.py b/gengine/app/tests/test_achievement_integration_tests.py new file mode 100644 index 0000000..d03d0f4 --- /dev/null +++ b/gengine/app/tests/test_achievement_integration_tests.py @@ -0,0 +1,421 @@ +import datetime +from gengine.app.cache import clear_all_caches +from gengine.app.tests.base import BaseDBTest +from gengine.app.tests.helpers import create_user, create_achievement, create_variable, create_goals, create_achievement_user +from gengine.app.model import Achievement, Value + + +class TestAchievementEvaluationType(BaseDBTest): + + # Case1: Achieved in first and next week + def test_evaluate_achievement_for_weekly_evaluation_case1(self): + + achievement = create_achievement(achievement_name="invite_users_achievement", + achievement_relevance="friends", + achievement_maxlevel=3, + achievement_evaluation="weekly") + + user = create_user() + + achievement_date = Achievement.get_datetime_for_evaluation_type(achievement.evaluation_timezone, achievement["evaluation"]) + print(achievement_date) + next_weekdate = achievement_date + datetime.timedelta(10) + print(next_weekdate) + + create_achievement_user(user, achievement, achievement_date, level=1) + + create_variable("invite_users", variable_group="day") + + create_goals(achievement, + goal_goal="3*level", + goal_operator="geq", + goal_group_by_key=False + ) + clear_all_caches() + + # User has achieved in first week and 2nd week + print("Weekly evaluation Case 1") + Value.increase_value(variable_name="invite_users", user=user, value=10, key=None) + achievement_result = Achievement.evaluate(user, achievement.id, achievement_date) + print(achievement_result) + + next_date = Achievement.get_datetime_for_evaluation_type(achievement.evaluation_timezone, evaluation_type="weekly", dt=next_weekdate) + + Value.increase_value(variable_name="invite_users", user=user, value=16, key=None, at_datetime=next_date) + achievement_result1 = Achievement.evaluate(user, achievement.id, next_date) + print(achievement_result1) + + self.assertEqual(achievement_result["achievement_date"], achievement_date) + self.assertEqual(achievement_result1["achievement_date"], next_date) + self.assertNotEqual(next_weekdate, next_date) + self.assertIn('1', achievement_result["levels_achieved"]) + self.assertIn('2', achievement_result["levels_achieved"]) + self.assertIn('3', achievement_result["levels_achieved"]) + self.assertIn('1', achievement_result1["new_levels"]) + self.assertIn('2', achievement_result1["new_levels"]) + self.assertIn('3', achievement_result1["new_levels"]) + + # Case2: NOT Achieved in first week but in next week + def test_evaluate_achievement_for_weekly_evaluation_case2(self): + + achievement = create_achievement(achievement_name="invite_users_achievement", + achievement_relevance="friends", + achievement_maxlevel=3, + achievement_evaluation="weekly") + + user = create_user() + + achievement_date = Achievement.get_datetime_for_evaluation_type(achievement.evaluation_timezone, achievement["evaluation"]) + next_weekdate = achievement_date + datetime.timedelta(11) + + create_achievement_user(user, achievement, achievement_date, level=1) + + create_variable("invite_users", variable_group="day") + + create_goals(achievement, + goal_goal="3*level", + goal_operator="geq", + goal_group_by_key=False + ) + clear_all_caches() + + # User has not achieved in first week but in 2nd week + print("Weekly evaluation Case 2") + Value.increase_value(variable_name="invite_users", user=user, value=5, key=None) + achievement_result = Achievement.evaluate(user, achievement.id, achievement_date) + print(achievement_result) + + next_date = Achievement.get_datetime_for_evaluation_type(achievement.evaluation_timezone, evaluation_type="weekly", dt=next_weekdate) + Value.increase_value(variable_name="invite_users", user=user, value=10, key=None, at_datetime=next_date) + achievement_result1 = Achievement.evaluate(user, achievement.id, next_date) + print("achievement result1: ", achievement_result1) + + self.assertEqual(achievement_result["achievement_date"], achievement_date) + self.assertEqual(achievement_result1["achievement_date"], next_date) + self.assertNotEqual(next_weekdate, next_date) + self.assertIn('1', achievement_result["levels_achieved"]) + self.assertIn('1', achievement_result1["new_levels"]) + self.assertIn('2', achievement_result1["new_levels"]) + self.assertIn('3', achievement_result1["new_levels"]) + + # Case3: NOT Achieved in first week but after some days in same week + def test_evaluate_achievement_for_weekly_evaluation_case3(self): + + achievement = create_achievement(achievement_name="invite_users_achievement", + achievement_relevance="friends", + achievement_maxlevel=3, + achievement_evaluation="weekly") + + user = create_user() + + achievement_date = Achievement.get_datetime_for_evaluation_type(achievement.evaluation_timezone, achievement["evaluation"]) + + create_achievement_user(user, achievement, achievement_date, level=1) + + create_variable("invite_users", variable_group="day") + + create_goals(achievement, + goal_goal="3*level", + goal_operator="geq", + goal_group_by_key=False + ) + clear_all_caches() + + # User has not achieved in first week and achieved after few days in a same week + print("Weekly evaluation Case 3") + Value.increase_value(variable_name="invite_users", user=user, value=5, key=None) + achievement_result = Achievement.evaluate(user, achievement.id, achievement_date) + print(achievement_result) + + next_date = Achievement.get_datetime_for_evaluation_type(achievement.evaluation_timezone, evaluation_type="weekly", dt=achievement_date+datetime.timedelta(3)) + Value.increase_value(variable_name="invite_users", user=user, value=10, key=None, at_datetime=next_date) + achievement_result1 = Achievement.evaluate(user, achievement.id, next_date) + print("achievement result1: ", achievement_result1) + + self.assertEqual(achievement_result["achievement_date"], achievement_date) + self.assertEqual(achievement_result1["achievement_date"], next_date) + self.assertEqual(achievement_date, next_date) + self.assertIn('1', achievement_result["levels_achieved"]) + self.assertIn('2', achievement_result1["new_levels"]) + self.assertIn('3', achievement_result1["new_levels"]) + + # Case1: Achieved in first and next month + def test_evaluate_achievement_for_monthly_evaluation_case1(self): + + achievement = create_achievement(achievement_name="invite_users_achievement", + achievement_relevance="friends", + achievement_maxlevel=3, + achievement_evaluation="monthly") + + user = create_user() + + achievement_date = Achievement.get_datetime_for_evaluation_type(achievement.evaluation_timezone, achievement["evaluation"]) + print(achievement_date) + next_month = achievement_date + datetime.timedelta(35) + print(next_month) + + create_achievement_user(user, achievement, achievement_date, level=1) + + create_variable("invite_users", variable_group="day") + + create_goals(achievement, + goal_goal="3*level", + goal_operator="geq", + goal_group_by_key=False + ) + clear_all_caches() + + # User has achieved in this month and next month + print("Monthly evaluation Case 1") + + Value.increase_value(variable_name="invite_users", user=user, value=10, key=None) + achievement_result = Achievement.evaluate(user, achievement.id, achievement_date) + print("achievement result: ", achievement_result) + + next_date = Achievement.get_datetime_for_evaluation_type(achievement.evaluation_timezone, evaluation_type="monthly", dt=next_month) + + Value.increase_value(variable_name="invite_users", user=user, value=10, key=None, at_datetime=next_date) + achievement_result1 = Achievement.evaluate(user, achievement.id, next_date) + print("achievement result1: ", achievement_result1) + + self.assertEqual(achievement_result["achievement_date"], achievement_date) + self.assertEqual(achievement_result1["achievement_date"], next_date) + self.assertNotEqual(next_month, next_date) + self.assertIn('1', achievement_result["levels_achieved"]) + self.assertIn('2', achievement_result["levels_achieved"]) + self.assertIn('3', achievement_result["levels_achieved"]) + self.assertIn('1', achievement_result1["new_levels"]) + self.assertIn('2', achievement_result1["new_levels"]) + self.assertIn('3', achievement_result1["new_levels"]) + + # Case2: Not achieved in first but in next month + def test_evaluate_achievement_for_monthly_evaluation_case2(self): + + achievement = create_achievement(achievement_name="invite_users_achievement", + achievement_relevance="friends", + achievement_maxlevel=3, + achievement_evaluation="monthly") + + user = create_user() + + achievement_date = Achievement.get_datetime_for_evaluation_type(achievement.evaluation_timezone, achievement["evaluation"]) + print(achievement_date) + next_month = achievement_date + datetime.timedelta(31) + print(next_month) + + create_achievement_user(user, achievement, achievement_date, level=1) + + create_variable("invite_users", variable_group="day") + + create_goals(achievement, + goal_goal="3*level", + goal_operator="geq", + goal_group_by_key=False + ) + clear_all_caches() + + # User has NOT achieved in this month but in the next month + print("Monthly evaluation Case 2") + + Value.increase_value(variable_name="invite_users", user=user, value=5, key=None) + achievement_result = Achievement.evaluate(user, achievement.id, achievement_date) + print("achievement result: ", achievement_result) + + next_date = Achievement.get_datetime_for_evaluation_type(achievement.evaluation_timezone, evaluation_type="monthly", dt=next_month+datetime.timedelta(days=10)) + + Value.increase_value(variable_name="invite_users", user=user, value=10, key=None, at_datetime=next_date) + achievement_result1 = Achievement.evaluate(user, achievement.id, next_date) + print("achievement result1: ", achievement_result1) + + self.assertEqual(achievement_result["achievement_date"], achievement_date) + self.assertEqual(achievement_result1["achievement_date"], next_date) + self.assertGreaterEqual(next_month, next_date) # next_month can be the 1st, 2nd, 3rd of 4th (February) + self.assertIn('1', achievement_result["levels_achieved"]) + self.assertIn('1', achievement_result1["new_levels"]) + self.assertIn('2', achievement_result1["new_levels"]) + self.assertIn('3', achievement_result1["new_levels"]) + + # Case3: Achieved in first month and after some days in a same month + def test_evaluate_achievement_for_monthly_evaluation_case3(self): + + achievement = create_achievement(achievement_name="invite_users_achievement", + achievement_relevance="friends", + achievement_maxlevel=3, + achievement_evaluation="monthly") + + user = create_user() + + achievement_date = Achievement.get_datetime_for_evaluation_type(achievement.evaluation_timezone, achievement["evaluation"]) + print(achievement_date) + + create_achievement_user(user, achievement, achievement_date, level=1) + + create_variable("invite_users", variable_group="day") + + create_goals(achievement, + goal_goal="3*level", + goal_operator="geq", + goal_group_by_key=False + ) + clear_all_caches() + + # Not achieved in first month after some days in the same month + print("Monthly evaluation Case 3") + + Value.increase_value(variable_name="invite_users", user=user, value=5, key=None) + achievement_result = Achievement.evaluate(user, achievement.id, achievement_date) + print("achievement result: ", achievement_result) + + next_date = Achievement.get_datetime_for_evaluation_type(achievement.evaluation_timezone, evaluation_type="monthly", dt=achievement_date+datetime.timedelta(10)) + Value.increase_value(variable_name="invite_users", user=user, value=10, key=None, at_datetime=next_date) + achievement_result1 = Achievement.evaluate(user, achievement.id, next_date) + print("achievement result1: ", achievement_result1) + + self.assertEqual(achievement_result["achievement_date"], achievement_date) + self.assertEqual(achievement_result1["achievement_date"], next_date) + self.assertEqual(achievement_date, next_date) + self.assertIn('1', achievement_result["levels_achieved"]) + self.assertIn('2', achievement_result1["new_levels"]) + self.assertIn('3', achievement_result1["new_levels"]) + + # Case1: Achieved in first year and next year + def test_evaluate_achievement_for_yearly_evaluation_case1(self): + + achievement = create_achievement(achievement_name="invite_users_achievement", + achievement_relevance="friends", + achievement_maxlevel=3, + achievement_evaluation="yearly") + + user = create_user() + + achievement_date = Achievement.get_datetime_for_evaluation_type(achievement.evaluation_timezone, achievement["evaluation"]) + print(achievement_date) + next_year = achievement_date + datetime.timedelta(425) + print(next_year) + + create_achievement_user(user, achievement, achievement_date, level=1) + + create_variable("invite_users", variable_group="day") + + create_goals(achievement, + goal_goal="3*level", + goal_operator="geq", + goal_group_by_key=False + ) + clear_all_caches() + + # Goal achieved in both this month and next year + print("Yearly evaluation Case 1") + + Value.increase_value(variable_name="invite_users", user=user, value=10, key=None) + achievement_result = Achievement.evaluate(user, achievement.id, achievement_date) + print("achievement result: ", achievement_result) + + next_date = Achievement.get_datetime_for_evaluation_type(achievement.evaluation_timezone, evaluation_type="yearly", dt=next_year) + + Value.increase_value(variable_name="invite_users", user=user, value=15, key=None, at_datetime=next_date) + achievement_result1 = Achievement.evaluate(user, achievement.id, next_date) + print(achievement_result1) + + self.assertEqual(achievement_result["achievement_date"], achievement_date) + self.assertEqual(achievement_result1["achievement_date"], next_date) + self.assertNotEqual(next_year, next_date) + self.assertIn('1', achievement_result["levels_achieved"]) + self.assertIn('2', achievement_result["levels_achieved"]) + self.assertIn('3', achievement_result["levels_achieved"]) + self.assertIn('1', achievement_result1["new_levels"]) + self.assertIn('2', achievement_result1["new_levels"]) + self.assertIn('3', achievement_result1["new_levels"]) + + # Case2: Not Achieved in first year but in next year + def test_evaluate_achievement_for_yearly_evaluation_case2(self): + + achievement = create_achievement(achievement_name="invite_users_achievement", + achievement_relevance="friends", + achievement_maxlevel=3, + achievement_evaluation="yearly") + + user = create_user() + + achievement_date = Achievement.get_datetime_for_evaluation_type(achievement.evaluation_timezone, achievement["evaluation"]) + print(achievement_date) + next_year = achievement_date + datetime.timedelta(534) + print(next_year) + + create_achievement_user(user, achievement, achievement_date, level=1) + + create_variable("invite_users", variable_group="day") + + create_goals(achievement, + goal_goal="3*level", + goal_operator="geq", + goal_group_by_key=False + ) + clear_all_caches() + + # Not achieved in first year but in the second year + print("Yearly evaluation Case 2") + + Value.increase_value(variable_name="invite_users", user=user, value=5, key=None) + achievement_result = Achievement.evaluate(user, achievement.id, achievement_date) + print("achievement result: ", achievement_result) + + next_date = Achievement.get_datetime_for_evaluation_type(achievement.evaluation_timezone, evaluation_type="yearly", dt=next_year + datetime.timedelta(10)) + + Value.increase_value(variable_name="invite_users", user=user, value=15, key=None, at_datetime=next_date) + achievement_result1 = Achievement.evaluate(user, achievement.id, next_date) + print("achievement result1: ", achievement_result1) + + self.assertEqual(achievement_result["achievement_date"], achievement_date) + self.assertEqual(achievement_result1["achievement_date"], next_date) + self.assertNotEqual(next_year, next_date) + self.assertIn('1', achievement_result["levels_achieved"]) + self.assertIn('1', achievement_result1["new_levels"]) + self.assertIn('2', achievement_result1["new_levels"]) + self.assertIn('3', achievement_result1["new_levels"]) + + # Case3: Achieved in this year and after some days in same year + def test_evaluate_achievement_for_yearly_evaluation_case3(self): + + achievement = create_achievement(achievement_name="invite_users_achievement", + achievement_relevance="friends", + achievement_maxlevel=3, + achievement_evaluation="yearly") + + user = create_user() + + achievement_date = Achievement.get_datetime_for_evaluation_type(achievement.evaluation_timezone, achievement["evaluation"]) + print(achievement_date) + next_year = achievement_date + datetime.timedelta(501) + print(next_year) + + create_achievement_user(user, achievement, achievement_date, level=1) + + create_variable("invite_users", variable_group="day") + + create_goals(achievement, + goal_goal="3*level", + goal_operator="geq", + goal_group_by_key=False + ) + clear_all_caches() + + # Not achieved in first month after some days in the same year + print("Yearly evaluation Case 3") + + Value.increase_value(variable_name="invite_users", user=user, value=5, key=None) + achievement_result = Achievement.evaluate(user, achievement.id, achievement_date) + print("achievement result: ", achievement_result) + + next_date = Achievement.get_datetime_for_evaluation_type(achievement.evaluation_timezone, evaluation_type="yearly", dt=achievement_date + datetime.timedelta(110)) + + Value.increase_value(variable_name="invite_users", user=user, value=10, key=None, at_datetime=next_date) + achievement_result1 = Achievement.evaluate(user, achievement.id, next_date) + print("achievement result1: ", achievement_result1) + + self.assertEqual(achievement_result["achievement_date"], achievement_date) + self.assertEqual(achievement_result1["achievement_date"], next_date) + self.assertEqual(achievement_date, next_date) + self.assertIn('1', achievement_result["levels_achieved"]) + self.assertIn('2', achievement_result1["new_levels"]) + self.assertIn('3', achievement_result1["new_levels"]) \ No newline at end of file diff --git a/gengine/app/tests/test_auth.py b/gengine/app/tests/test_auth.py new file mode 100644 index 0000000..9d7436d --- /dev/null +++ b/gengine/app/tests/test_auth.py @@ -0,0 +1,121 @@ +# -*- coding: utf-8 -*- +from gengine.app.tests.base import BaseDBTest +from gengine.app.tests.helpers import create_user, update_user, delete_user, get_or_create_language +from gengine.metadata import DBSession +from gengine.app.model import AuthUser + + +class TestUserCreation(BaseDBTest): + + def test_user_creation(self): + + lang = get_or_create_language("en") + user = create_user( + lat = 12.1, + lng = 12.2, + country = "RO", + region = "Transylvania", + city = "Cluj-Napoca", + timezone = "Europe/Bukarest", + language = "en", + additional_public_data = { + "first_name" : "Rudolf", + "last_name" : "Red Nose" + } + ) + + self.assertTrue(user.lat == 12.1) + self.assertTrue(user.lng == 12.2) + self.assertTrue(user.country == "RO") + self.assertTrue(user.region == "Transylvania") + self.assertTrue(user.city == "Cluj-Napoca") + self.assertTrue(user.timezone == "Europe/Bukarest") + self.assertTrue(user.language_id == lang.id) + self.assertTrue(user.additional_public_data["first_name"] == "Rudolf") + self.assertTrue(user.additional_public_data["last_name"] == "Red Nose") + + def test_user_updation(self): + + lang = get_or_create_language("en") + user = create_user() + user = update_user( + user_id = user.id, + lat = 14.2, + lng = 16.3, + country = "EN", + region = "Transylvania", + city = "Cluj-Napoca", + timezone = "Europe/Bukarest", + language = "en", + additional_public_data = { + "first_name" : "Rudolf", + "last_name" : "Red Nose" + } + ) + + # Correct cases + self.assertTrue(user.lat == 14.2) + self.assertTrue(user.lng == 16.3) + self.assertTrue(user.country == "EN") + self.assertTrue(user.region == "Transylvania") + self.assertTrue(user.city == "Cluj-Napoca") + self.assertTrue(user.timezone == "Europe/Bukarest") + self.assertTrue(user.language_id == lang.id) + + def test_user_deletion(self): + + user1 = create_user() + + # Create Second user + user2 = create_user( + lat=85.59, + lng=65.75, + country="DE", + region="Niedersachsen", + city="Osnabrück", + timezone="Europe/Berlin", + language="de", + additional_public_data={ + "first_name": "Michael", + "last_name": "Clarke" + }, + friends=[1] + ) + + remaining_users = delete_user( + user_id = user1.id + ) + + # Correct cases + self.assertNotIn(user1.id, remaining_users) + self.assertEqual(user2.id, remaining_users[0].id) + + def test_verify_password(self): + auth_user = AuthUser() + auth_user.password = "test12345" + auth_user.active = True + auth_user.email = "test@actidoo.com" + DBSession.add(auth_user) + + iscorrect = auth_user.verify_password("test12345") + + self.assertEqual(iscorrect, True) + + def test_create_token(self): + user = create_user() + auth_user = AuthUser() + auth_user.user_id = user.id + auth_user.password = "test12345" + auth_user.active = True + auth_user.email = "test@actidoo.com" + DBSession.add(auth_user) + + if auth_user.verify_password("test12345"): + token = auth_user.get_or_create_token() + + self.assertNotEqual(token, None) + + + + + diff --git a/gengine/app/tests/test_device.py b/gengine/app/tests/test_device.py new file mode 100644 index 0000000..81b3100 --- /dev/null +++ b/gengine/app/tests/test_device.py @@ -0,0 +1,67 @@ +from gengine.app.tests.base import BaseDBTest +from gengine.app.tests.helpers import create_user, create_device, update_device + + +class TestUserDevice(BaseDBTest): + + def test_create_user_device(self): + + user = create_user() + + device = create_device( + device_id='3424', + user_id=user.id, + device_os='Android', + push_id='1234', + app_version='1.1' + ) + + self.assertTrue(device.device_id == '3424') + self.assertTrue(device.user_id == user.id) + self.assertTrue(device.device_os == 'Android') + self.assertTrue(device.push_id == '1234') + self.assertTrue(device.app_version == '1.1') + + def test_update_user_device(self): + + user = create_user() + create_device(user_id=user.id) + + device = update_device( + user_id=user.id, + device_id='1256', + push_id='5126', + device_os='iOS', + app_version='1.2' + ) + + # Correct cases + self.assertTrue(device.device_id == '1256') + self.assertTrue(device.user_id == user.id) + self.assertTrue(device.push_id == '5126') + self.assertTrue(device.app_version == '1.2') + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gengine/app/tests/test_eval_types_and_rewards.py b/gengine/app/tests/test_eval_types_and_rewards.py new file mode 100644 index 0000000..2d338bc --- /dev/null +++ b/gengine/app/tests/test_eval_types_and_rewards.py @@ -0,0 +1,101 @@ +import datetime + +from gengine.app.cache import clear_all_caches +from gengine.app.tests.base import BaseDBTest +from gengine.app.tests.helpers import create_user, create_achievement, create_variable, create_goals, create_achievement_user +from gengine.app.model import Achievement, Value + + +class TestEvaluationForMultipleUsersAndTimzone(BaseDBTest): + + def test_friends_leaderboard(self): + + user1 = create_user() + + # Create Second user + user2 = create_user( + lat=85.59, + lng=65.75, + country="DE", + region="Roland", + city="New York", + timezone="US/Eastern", + language="en", + additional_public_data={ + "first_name": "Michael", + "last_name": "Clarke" + } + ) + + # Create Third user + user3 = create_user( + lat=12.1, + lng=12.2, + country="RO", + region="Transylvania", + city="Cluj-Napoca", + timezone="Europe/Bucharest", + language="en", + additional_public_data={ + "first_name": "Rudolf", + "last_name": "Red Nose" + }, + friends=[1, 2] + ) + + # Create Fourth user + user4 = create_user( + lat=25.56, + lng=15.89, + country="AU", + region="Sydney", + city="New South Wales", + timezone="Australia/Sydney", + language="en", + additional_public_data={ + "first_name": "Steve", + "last_name": "Waugh" + }, + friends=[3] + ) + + achievement = create_achievement(achievement_name="invite_users_achievement", + achievement_relevance="friends", + achievement_maxlevel=3, + achievement_evaluation="weekly") + + print(achievement.evaluation_timezone) + achievement_date1 = Achievement.get_datetime_for_evaluation_type(achievement.evaluation_timezone, achievement.evaluation) + print("Achievement date for first user:") + print(achievement_date1) + + create_variable("invite_users", variable_group="day") + + create_goals(achievement, + goal_goal=None, + goal_operator="geq", + goal_group_by_key=False + ) + + Value.increase_value(variable_name="invite_users", user=user1, value=12, key=None) + Value.increase_value(variable_name="invite_users", user=user2, value=2, key=None) + Value.increase_value(variable_name="invite_users", user=user3, value=11, key=None) + Value.increase_value(variable_name="invite_users", user=user4, value=6, key=None) + + clear_all_caches() + + print("test for multiple users") + + # Evaluate achievement for friends of user 3 + achievement1 = Achievement.evaluate(user3, achievement.id, achievement_date1) + print(achievement1["goals"][1]["leaderboard"]) + + # user 3 has to friends: user 1 and user 2 + self.assertEqual(user1["id"], achievement1["goals"][1]["leaderboard"][0]["user"]["id"]) + self.assertEqual(user3["id"], achievement1["goals"][1]["leaderboard"][1]["user"]["id"]) + self.assertEqual(user2["id"], achievement1["goals"][1]["leaderboard"][2]["user"]["id"]) + + self.assertEqual(12.0, achievement1["goals"][1]["leaderboard"][0]["value"]) + self.assertEqual(11.0, achievement1["goals"][1]["leaderboard"][1]["value"]) + self.assertEqual(2.0, achievement1["goals"][1]["leaderboard"][2]["value"]) + diff --git a/gengine/app/tests/test_goal.py b/gengine/app/tests/test_goal.py new file mode 100644 index 0000000..155213b --- /dev/null +++ b/gengine/app/tests/test_goal.py @@ -0,0 +1,190 @@ +from gengine.app.tests.base import BaseDBTest +from gengine.app.tests.helpers import create_user, create_achievement, create_variable, create_goals, create_goal_properties, create_goal_evaluation_cache +from gengine.app.model import Achievement, User, Goal, Value + + +class TestEvaluateGoal(BaseDBTest): + def test_compute_progress(self): + + user = create_user() + create_variable(variable_name="invite_users", variable_group="day") + Value.increase_value(variable_name="invite_users", user=user, value=6, key=None) + Value.increase_value(variable_name="invite_users", user=user, value=7, key=None) + + create_variable(variable_name="participate", variable_group="day") + Value.increase_value(variable_name="participate", user=user, value=2, key="5") + Value.increase_value(variable_name="participate", user=user, value=3, key="7") + Value.increase_value(variable_name="participate", user=user, value=5, key="7") + + achievement = create_achievement(achievement_name="invite_users_achievement") + goal = create_goals(achievement) + + # goal is for invite_users, its group_by_key is false, progress is sum of all the values + achievement_date = Achievement.get_datetime_for_evaluation_type(User.get_user(user.id)["timezone"], achievement["evaluation"]) + users_progress_goal = Goal.compute_progress(goal=goal, achievement=achievement, user=user, evaluation_date=achievement_date) + goal_evaluation = {e["user_id"]: e["value"] for e in users_progress_goal} + print(goal_evaluation) + + self.assertLessEqual(goal_evaluation.get(user.id), 13) + + # For goal1, since its group_by_key is True, it'll add the values of the same key + achievement1 = create_achievement(achievement_name="participate_achievement") + goal1 = create_goals(achievement1) + achievement_date1= Achievement.get_datetime_for_evaluation_type(User.get_user(user.id)["timezone"], achievement1["evaluation"]) + users_progress_goal1 = Goal.compute_progress(goal=goal1, achievement=achievement1, user=user, evaluation_date=achievement_date1) + goal_evaluation1 = {e["user_id"]: e["value"] for e in users_progress_goal1} + print(goal_evaluation1) + + self.assertLess(goal_evaluation1.get(user.id), 10) + + # Check with group_by_key for goals participate = False + goal2 = create_goals(achievement1, goal_group_by_key=False) + users_progress_goal1 = Goal.compute_progress(goal=goal2, achievement=achievement1, user=user, evaluation_date=achievement_date1) + goal_evaluation2 = {e["user_id"]: e["value"] for e in users_progress_goal1} + print(goal_evaluation2) + self.assertLessEqual(goal_evaluation2.get(user.id), 10) + + def test_evaluate_goal(self): + + user = create_user() + create_variable(variable_name="invite_users", variable_group="day") + Value.increase_value(variable_name="invite_users", user=user, value=6, key=None) + Value.increase_value(variable_name="invite_users", user=user, value=7, key=None) + + create_variable(variable_name="participate", variable_group="day") + Value.increase_value(variable_name="participate", user=user, value=6, key="5") + Value.increase_value(variable_name="participate", user=user, value=3, key="7") + Value.increase_value(variable_name="participate", user=user, value=5, key="7") + + # Goal Participate with group_by = False + achievement = create_achievement(achievement_name="participate_achievement") + goal = create_goals(achievement, goal_group_by_key=False, goal_goal="3*level") + achievement_date = Achievement.get_datetime_for_evaluation_type(User.get_user(user.id)["timezone"], achievement["evaluation"]) + + evaluation_result = Goal.evaluate(goal, achievement, achievement_date, user, level=4, goal_eval_cache_before=False, execute_triggers=True) + print(evaluation_result) + # True cases + self.assertGreaterEqual(evaluation_result["value"], 12) + self.assertEqual(evaluation_result["achieved"], True) + + # Goal Participate with group_by = True + goal2 = create_goals(achievement, goal_group_by_key=True, goal_goal="3*level") + evaluation_result2 = Goal.evaluate(goal2, achievement, achievement_date, user, level=4, goal_eval_cache_before=False, execute_triggers=True) + print(evaluation_result2) + + self.assertLessEqual(evaluation_result2["value"], 12) + self.assertEqual(evaluation_result2["achieved"], False) + + # Goal invite_users + achievement1 = create_achievement(achievement_name="invite_users_achievement") + goal1 = create_goals(achievement1, goal_goal="4*level") + achievement_date1 = Achievement.get_datetime_for_evaluation_type(User.get_user(user.id)["timezone"], achievement1["evaluation"]) + + evaluation_result1 = Goal.evaluate(goal1, achievement1, achievement_date1, user, level=2, goal_eval_cache_before=False, execute_triggers=True) + print(evaluation_result1) + + self.assertGreaterEqual(evaluation_result1["value"], 8) + self.assertEqual(evaluation_result1["achieved"], True) + + def test_get_goal_properties(self): + + achievement = create_achievement() + goals = create_goals(achievement) + + create_goal_properties(goals.id) + level = 4 + result = Goal.get_goal_properties(goals.id, level) + print(result) + + level1 = 1 + result1 = Goal.get_goal_properties(goals.id, level1) + print(result1) + + self.assertIsNot(result, []) + self.assertEquals(result1, []) + + def test_get_leaderboard(self): + + achievement = create_achievement(achievement_name="invite_users_achievement") + goals = create_goals(achievement) + + # Create multiple users for a goal + user1 = create_user() + user2 = create_user( + lat=85.59, + lng=65.75, + country="USA", + region="Lethal crosside", + city="New York", + timezone="US/Eastern", + language="en", + additional_public_data={ + "first_name": "Michael", + "last_name": "Clarke" + } + ) + + # Create Third user + user3 = create_user( + lat=12.1, + lng=12.2, + country="RO", + region="Transylvania", + city="Cluj-Napoca", + timezone="Europe/Bucharest", + language="en", + additional_public_data={ + "first_name": "Rudolf", + "last_name": "Red Nose" + }, + friends=[1, 2] + ) + + # Create Fourth user + user4 = create_user( + lat=25.56, + lng=15.89, + country="AU", + region="Sydney", + city="New South Wales", + timezone="Australia/Sydney", + language="en", + additional_public_data={ + "first_name": "Steve", + "last_name": "Waugh" + }, + friends=[3] + ) + + achievement_date_for_user1 = Achievement.get_datetime_for_evaluation_type(User.get_user(user1.id)["timezone"], achievement["evaluation"]) + achievement_date_for_user2 = Achievement.get_datetime_for_evaluation_type(User.get_user(user2.id)["timezone"], achievement["evaluation"]) + achievement_date_for_user3 = Achievement.get_datetime_for_evaluation_type(User.get_user(user3.id)["timezone"], achievement["evaluation"]) + achievement_date_for_user4 = Achievement.get_datetime_for_evaluation_type(User.get_user(user4.id)["timezone"], achievement["evaluation"]) + print(achievement_date_for_user4) + + create_goal_evaluation_cache(goal_id=goals.id, gec_achievement_date=achievement_date_for_user1, gec_user_id=user1.id, gec_value=22.00, gec_achieved=True) + create_goal_evaluation_cache(goal_id=goals.id, gec_achievement_date=achievement_date_for_user2, gec_user_id=user2.id, gec_value=8.00, gec_achieved=True) + create_goal_evaluation_cache(goal_id=goals.id, gec_achievement_date=achievement_date_for_user3, gec_user_id=user3.id, gec_value=15.00, gec_achieved=True) + + # Test for finding leaderboard in case where goal has been evaluated for all given users + + # First get list of friends (user_ids) of given user + user_ids = Achievement.get_relevant_users_by_achievement_and_user(achievement, user3.id) + + # Get leaderboard + positions = Goal.get_leaderboard(goals, achievement_date_for_user3, user_ids) + print(positions) + self.assertEqual(positions[0]["value"], 22.00) + self.assertEqual(positions[1]["value"], 15.00) + self.assertEqual(positions[2]["value"], 8.00) + + # Test for Goal is not evaluated for few user_ids + create_variable(variable_name="invite_users", variable_group="day") + Value.increase_value(variable_name="invite_users", user=user4, value=6, key=None) + Value.increase_value(variable_name="invite_users", user=user4, value=9, key=None) + + user_ids = Achievement.get_relevant_users_by_achievement_and_user(achievement, user4.id) + positions = Goal.get_leaderboard(goals, achievement_date_for_user4, user_ids) + + print(positions) + self.assertEqual(positions[0]["value"], 15.00) diff --git a/gengine/app/tests/test_value.py b/gengine/app/tests/test_value.py new file mode 100644 index 0000000..c9932a4 --- /dev/null +++ b/gengine/app/tests/test_value.py @@ -0,0 +1,26 @@ +from gengine.app.tests.base import BaseDBTest +from gengine.app.tests.helpers import create_user, create_variable,create_value +from gengine.app.model import Value + + +class TestValue(BaseDBTest): + def test_increase_value(self): + user = create_user() + variable = create_variable(variable_name="participate", variable_group="day") + + value1 = Value.increase_value(variable.name, user, value=3, key="5") + value2 = Value.increase_value(variable.name, user, value=3, key="5") + value3 = Value.increase_value(variable.name, user, value=6, key="7") + + # Correct cases + self.assertGreater(value2, value1) + self.assertEqual(value3, value2) + + # Doesn't work when give variable_group = none i.e. current_datetime check which differes for two successive calls + # Increase value is being called only in evaluate_achievement function and not in evaluate_goal + + def test_increase_value_null_key(self): + user = create_user() + variable = create_variable(variable_name="login", variable_group="day") + value1 = Value.increase_value(variable.name, user, value=1, key=None) + self.assertIs(value1, 1) diff --git a/gengine/app/tests/test_variable.py b/gengine/app/tests/test_variable.py new file mode 100644 index 0000000..e69de29 diff --git a/gengine/app/views.py b/gengine/app/views.py new file mode 100644 index 0000000..8338baf --- /dev/null +++ b/gengine/app/views.py @@ -0,0 +1,586 @@ +# -*- coding: utf-8 -*- +import traceback + +import binascii +from http.cookies import SimpleCookie + +import base64 +import copy +import datetime + +import json + +import pytz +from pyramid.request import Request +from pyramid.response import Response +from pyramid.settings import asbool +from sqlalchemy.sql.expression import select, and_ + +from gengine.app.permissions import perm_own_update_user_infos, perm_global_update_user_infos, perm_global_delete_user, perm_own_delete_user, \ + perm_global_access_admin_ui, perm_global_register_device, perm_own_register_device, perm_global_read_messages, \ + perm_own_read_messages +from gengine.base.model import valid_timezone, exists_by_expr, update_connection +from gengine.base.errors import APIError +from pyramid.exceptions import NotFound +from pyramid.renderers import render +from pyramid.view import view_config +from pyramid.wsgi import wsgiapp2 +from werkzeug import DebuggedApplication + +from gengine.app.admin import adminapp +from gengine.app.formular import FormularEvaluationException +from gengine.app.model import ( + User, + Achievement, + Value, + Variable, + AuthUser, AuthToken, t_users, t_auth_users, t_auth_users_roles, t_auth_roles, t_auth_roles_permissions, UserDevice, + t_user_device, t_user_messages, UserMessage) +from gengine.base.settings import get_settings +from gengine.metadata import DBSession +from gengine.wsgiutil import HTTPSProxied + +@view_config(route_name='add_or_update_user', renderer='string', request_method="POST") +def add_or_update_user(request): + """add a user and set its metadata""" + + user_id = int(request.matchdict["user_id"]) + + if asbool(get_settings().get("enable_user_authentication", False)): + #ensure that the user exists and we have the permission to update it + may_update = request.has_perm(perm_global_update_user_infos) or request.has_perm(perm_own_update_user_infos) and request.user.id == user_id + if not may_update: + raise APIError(403, "forbidden", "You may not edit this user.") + + #if not exists_by_expr(t_users,t_users.c.id==user_id): + # raise APIError(403, "forbidden", "The user does not exist. As the user authentication is enabled, you need to create the AuthUser first.") + + + lat=None + if len(request.POST.get("lat",""))>0: + lat = float(request.POST["lat"]) + + lon=None + if len(request.POST.get("lon",""))>0: + lon = float(request.POST["lon"]) + + friends=[] + if len(request.POST.get("friends",""))>0: + friends = [int(x) for x in request.POST["friends"].split(",")] + + groups=[] + if len(request.POST.get("groups",""))>0: + groups = [int(x) for x in request.POST["groups"].split(",")] + + timezone="UTC" + if len(request.POST.get("timezone",""))>0: + timezone = request.POST["timezone"] + + if not valid_timezone(timezone): + timezone = 'UTC' + + country=None + if len(request.POST.get("country",""))>0: + country = request.POST["country"] + + region=None + if len(request.POST.get("region",""))>0: + region = request.POST["region"] + + city=None + if len(request.POST.get("city",""))>0: + city = request.POST["city"] + + language = None + if len(request.POST.get("language", "")) > 0: + language= request.POST["language"] + + additional_public_data = {} + if len(request.POST.get("additional_public_data", "")) > 0: + try: + additional_public_data = json.loads(request.POST["additional_public_data"]) + except: + additional_public_data = {} + + + User.set_infos(user_id=user_id, + lat=lat, + lng=lon, + timezone=timezone, + country=country, + region=region, + city=city, + language=language, + friends=friends, + groups=groups, + additional_public_data = additional_public_data) + return {"status": "OK", "user" : User.full_output(user_id)} + +@view_config(route_name='delete_user', renderer='string', request_method="DELETE") +def delete_user(request): + """delete a user completely""" + user_id = int(request.matchdict["user_id"]) + + if asbool(get_settings().get("enable_user_authentication", False)): + # ensure that the user exists and we have the permission to update it + may_delete = request.has_perm(perm_global_delete_user) or request.has_perm(perm_own_delete_user) and request.user.id == user_id + if not may_delete: + raise APIError(403, "forbidden", "You may not delete this user.") + + User.delete_user(user_id) + return {"status": "OK"} + +def _get_progress(achievements_for_user, requesting_user): + + achievements = Achievement.get_achievements_by_user_for_today(achievements_for_user) + + def ea(achievement, achievement_date, execute_triggers): + try: + return Achievement.evaluate(achievements_for_user, achievement["id"], achievement_date, execute_triggers=execute_triggers) + except FormularEvaluationException as e: + return { "error": "Cannot evaluate formular: " + e.message, "id" : achievement["id"] } + except Exception as e: + tb = traceback.format_exc() + return { "error": tb, "id" : achievement["id"] } + + check = lambda x : x!=None and not "error" in x and (x["hidden"]==False or x["level"]>0) + + def may_view(achievement, requesting_user): + if not asbool(get_settings().get("enable_user_authentication", False)): + return True + + if achievement["view_permission"] == "everyone": + return True + if achievement["view_permission"] == "own" and achievements_for_user["id"] == requesting_user["id"]: + return True + return False + + evaluatelist = [] + now = datetime.datetime.now(pytz.timezone(achievements_for_user["timezone"])) + for achievement in achievements: + if may_view(achievement, requesting_user): + achievement_dates = set() + d = max(achievement["created_at"], achievements_for_user["created_at"]).replace(tzinfo=pytz.utc) + dr = Achievement.get_datetime_for_evaluation_type( + achievement["evaluation_timezone"], + achievement["evaluation"], + dt=d + ) + + achievement_dates.add(dr) + if dr != None: + while d <= now: + if achievement["evaluation"] == "yearly": + d += datetime.timedelta(days=364) + elif achievement["evaluation"] == "monthly": + d += datetime.timedelta(days=28) + elif achievement["evaluation"] == "weekly": + d += datetime.timedelta(days=6) + elif achievement["evaluation"] == "daily": + d += datetime.timedelta(hours=23) + else: + break # should not happen + + dr = Achievement.get_datetime_for_evaluation_type( + achievement["evaluation_timezone"], + achievement["evaluation"], + dt=d + ) + + if dr <= now: + achievement_dates.add(dr) + + i=0 + for achievement_date in reversed(sorted(achievement_dates)): + # We execute the goal triggers only for the newest and previous period, not for any periods longer ago + # (To not send messages for very old things....) + evaluatelist.append(ea(achievement, achievement_date, execute_triggers=(i == 0 or i == 1 or achievement_date == None))) + i += 1 + + + ret = { + "achievements" : [ + x for x in evaluatelist if check(x) + ], + "achievement_errors" : [ + x for x in evaluatelist if x!=None and "error" in x + ] + } + + return ret + + +@view_config(route_name='get_progress', renderer='json', request_method="GET") +def get_progress(request): + """get all relevant data concerning the user's progress""" + try: + user_id = int(request.matchdict["user_id"]) + except: + raise APIError(400, "illegal_user_id", "no valid user_id given") + + user = User.get_user(user_id) + if not user: + raise APIError(404, "user_not_found", "user not found") + + output = _get_progress(achievements_for_user=user, requesting_user=request.user) + output = copy.deepcopy(output) + + for i in range(len(output["achievements"])): + if "new_levels" in output["achievements"][i]: + del output["achievements"][i]["new_levels"] + + return output + +@view_config(route_name='increase_value', renderer='json', request_method="POST") +@view_config(route_name='increase_value_with_key', renderer='json', request_method="POST") +def increase_value(request): + """increase a value for the user""" + + user_id = int(request.matchdict["user_id"]) + try: + value = float(request.POST["value"]) + except: + try: + doc = request.json_body + value = doc["value"] + except: + raise APIError(400,"invalid_value","Invalid value provided") + + key = request.matchdict["key"] if ("key" in request.matchdict and request.matchdict["key"] is not None) else "" + variable_name = request.matchdict["variable_name"] + + user = User.get_user(user_id) + if not user: + raise APIError(404, "user_not_found", "user not found") + + variable = Variable.get_variable_by_name(variable_name) + if not variable: + raise APIError(404, "variable_not_found", "variable not found") + + if asbool(get_settings().get("enable_user_authentication", False)): + if not Variable.may_increase(variable, request, user_id): + raise APIError(403, "forbidden", "You may not increase the variable for this user.") + + Value.increase_value(variable_name, user, value, key) + + output = _get_progress(achievements_for_user=user, requesting_user=request.user) + output = copy.deepcopy(output) + to_delete = list() + for i in range(len(output["achievements"])): + if len(output["achievements"][i]["new_levels"])>0: + if "levels" in output["achievements"][i]: + del output["achievements"][i]["levels"] + if "priority" in output["achievements"][i]: + del output["achievements"][i]["priority"] + if "goals" in output["achievements"][i]: + del output["achievements"][i]["goals"] + else: + to_delete.append(i) + + for i in sorted(to_delete,reverse=True): + del output["achievements"][i] + + return output + +@view_config(route_name="increase_multi_values", renderer="json", request_method="POST") +def increase_multi_values(request): + try: + doc = request.json_body + except: + raise APIError(400, "invalid_json", "no valid json body") + ret = {} + for user_id, values in doc.items(): + user = User.get_user(user_id) + if not user: + raise APIError(404, "user_not_found", "user %s not found" % (user_id,)) + + for variable_name, values_and_keys in values.items(): + for value_and_key in values_and_keys: + variable = Variable.get_variable_by_name(variable_name) + + if asbool(get_settings().get("enable_user_authentication", False)): + if not Variable.may_increase(variable, request, user_id): + raise APIError(403, "forbidden", "You may not increase the variable %s for user %s." % (variable_name, user_id)) + + if not variable: + raise APIError(404, "variable_not_found", "variable %s not found" % (variable_name,)) + + if not 'value' in value_and_key: + raise APIError(400, "variable_not_found", "illegal value for %s" % (variable_name,)) + + value = value_and_key['value'] + key = value_and_key.get('key','') + + Value.increase_value(variable_name, user, value, key) + + output = _get_progress(achievements_for_user=user, requesting_user=request.user) + output = copy.deepcopy(output) + to_delete = list() + for i in range(len(output["achievements"])): + if len(output["achievements"][i]["new_levels"])>0: + if "levels" in output["achievements"][i]: + del output["achievements"][i]["levels"] + if "priority" in output["achievements"][i]: + del output["achievements"][i]["priority"] + if "goals" in output["achievements"][i]: + del output["achievements"][i]["goals"] + else: + to_delete.append(i) + + for i in sorted(to_delete, reverse=True): + del output["achievements"][i] + + if len(output["achievements"])>0 : + ret[user_id]=output + + return ret + +@view_config(route_name='get_achievement_level', renderer='json', request_method="GET") +def get_achievement_level(request): + """get all information about an achievement for a specific level""" + try: + achievement_id = int(request.matchdict.get("achievement_id",None)) + level = int(request.matchdict.get("level",None)) + except: + raise APIError(400, "invalid_input", "invalid input") + + achievement = Achievement.get_achievement(achievement_id) + + if not achievement: + raise APIError(404, "achievement_not_found", "achievement not found") + + level_output = Achievement.basic_output(achievement, [], True, level).get("levels").get(str(level), {"properties": {}, "rewards": {}}) + if "goals" in level_output: + del level_output["goals"] + if "level" in level_output: + del level_output["level"] + + return level_output + + +@view_config(route_name='auth_login', renderer='json', request_method="POST") +def auth_login(request): + try: + doc = request.json_body + except: + raise APIError(400, "invalid_json", "no valid json body") + + user = request.user + email = doc.get("email") + password = doc.get("password") + + if user: + #already logged in + token = user.get_or_create_token().token + else: + if not email or not password: + raise APIError(400, "login.email_and_password_required", "You need to send your email and password.") + + user = DBSession.query(AuthUser).filter_by(email=email).first() + + if not user or not user.verify_password(password): + raise APIError(401, "login.email_or_password_invalid", "Either the email address or the password is wrong.") + + if not user.active: + raise APIError(400, "user_is_not_activated", "Your user is not activated.") + + token = AuthToken.generate_token() + tokenObj = AuthToken( + user_id = user.id, + token = token + ) + + DBSession.add(tokenObj) + + return { + "token" : token, + "user" : User.full_output(user.user_id), + } + +@view_config(route_name='register_device', renderer='json', request_method="POST") +def register_device(request): + try: + doc = request.json_body + except: + raise APIError(400, "invalid_json", "no valid json body") + + user_id = int(request.matchdict["user_id"]) + + device_id = doc.get("device_id") + push_id = doc.get("push_id") + device_os = doc.get("device_os") + app_version = doc.get("app_version") + + if not device_id \ + or not push_id \ + or not user_id \ + or not device_os \ + or not app_version: + raise APIError(400, "register_device.required_fields", + "Required fields: device_id, push_id, device_os, app_version") + + if asbool(get_settings().get("enable_user_authentication", False)): + may_register = request.has_perm(perm_global_register_device) or request.has_perm( + perm_own_register_device) and str(request.user.id) == str(user_id) + if not may_register: + raise APIError(403, "forbidden", "You may not register devices for this user.") + + if not exists_by_expr(t_users, t_users.c.id==user_id): + raise APIError(404, "register_device.user_not_found", + "There is no user with this id.") + + UserDevice.add_or_update_device(user_id = user_id, device_id = device_id, push_id = push_id, device_os = device_os, app_version = app_version) + + return { + "status" : "ok" + } + +@view_config(route_name='get_messages', renderer='json', request_method="GET") +def get_messages(request): + try: + user_id = int(request.matchdict["user_id"]) + except: + user_id = None + + try: + offset = int(request.GET.get("offset",0)) + except: + offset = 0 + + limit = 100 + + if asbool(get_settings().get("enable_user_authentication", False)): + may_read_messages = request.has_perm(perm_global_read_messages) or request.has_perm( + perm_own_read_messages) and str(request.user.id) == str(user_id) + if not may_read_messages: + raise APIError(403, "forbidden", "You may not read the messages of this user.") + + if not exists_by_expr(t_users, t_users.c.id == user_id): + raise APIError(404, "get_messages.user_not_found", + "There is no user with this id.") + + q = t_user_messages.select().where(t_user_messages.c.user_id==user_id).order_by(t_user_messages.c.created_at.desc()).limit(limit).offset(offset) + rows = DBSession.execute(q).fetchall() + + return { + "messages" : [{ + "id" : message["id"], + "text" : UserMessage.get_text(message), + "is_read" : message["is_read"], + "created_at" : message["created_at"] + } for message in rows] + } + + +@view_config(route_name='read_messages', renderer='json', request_method="POST") +def set_messages_read(request): + try: + doc = request.json_body + except: + raise APIError(400, "invalid_json", "no valid json body") + + user_id = int(request.matchdict["user_id"]) + + if asbool(get_settings().get("enable_user_authentication", False)): + may_read_messages = request.has_perm(perm_global_read_messages) or request.has_perm( + perm_own_read_messages) and str(request.user.id) == str(user_id) + if not may_read_messages: + raise APIError(403, "forbidden", "You may not read the messages of this user.") + + if not exists_by_expr(t_users, t_users.c.id == user_id): + raise APIError(404, "set_messages_read.user_not_found", "There is no user with this id.") + + message_id = doc.get("message_id") + q = select([t_user_messages.c.id, + t_user_messages.c.created_at], from_obj=t_user_messages).where(and_(t_user_messages.c.id==message_id, + t_user_messages.c.user_id==user_id)) + msg = DBSession.execute(q).fetchone() + if not msg: + raise APIError(404, "set_messages_read.message_not_found", "There is no message with this id.") + + uS = update_connection() + uS.execute(t_user_messages.update().values({ + "is_read" : True + }).where(and_( + t_user_messages.c.user_id == user_id, + t_user_messages.c.created_at <= msg["created_at"] + ))) + + return { + "status" : "ok" + } + +@view_config(route_name='admin_app') +@wsgiapp2 +def admin_tenant(environ, start_response): + + def admin_app(environ, start_response): + #return HTTPSProxied(DebuggedApplication(adminapp.wsgi_app, True))(environ, start_response) + return HTTPSProxied(adminapp.wsgi_app)(environ, start_response) + + def request_auth(environ, start_response): + resp = Response() + resp.status_code = 401 + resp.www_authenticate = 'Basic realm="%s"' % ("Gamification Engine Admin",) + return resp(environ, start_response) + + if not asbool(get_settings().get("enable_user_authentication", False)): + return admin_app(environ, start_response) + + req = Request(environ) + + def _get_basicauth_credentials(request): + authorization = request.headers.get("authorization","") + try: + authmeth, auth = authorization.split(' ', 1) + except ValueError: # not enough values to unpack + return None + if authmeth.lower() == 'basic': + try: + auth = base64.b64decode(auth.strip()).decode("UTF-8") + except binascii.Error: # can't decode + return None + try: + login, password = auth.split(':', 1) + except ValueError: # not enough values to unpack + return None + return {'login': login, 'password': password} + return None + + user = None + cred = _get_basicauth_credentials(req) + token = req.cookies.get("token",None) + if token: + tokenObj = DBSession.query(AuthToken).filter(AuthToken.token == token).first() + user = None + if tokenObj and tokenObj.valid_until < datetime.datetime.utcnow(): + tokenObj.extend() + if tokenObj: + user = tokenObj.user + + if not user: + if cred: + user = DBSession.query(AuthUser).filter_by(email=cred["login"]).first() + if not user or not user.verify_password(cred["password"]): + return request_auth(environ, start_response) + + if user: + j = t_auth_users.join(t_auth_users_roles).join(t_auth_roles).join(t_auth_roles_permissions) + q = select([t_auth_roles_permissions.c.name], from_obj=j).where(t_auth_users.c.user_id==user.user_id) + permissions = [r["name"] for r in DBSession.execute(q).fetchall()] + if not perm_global_access_admin_ui in permissions: + return request_auth(environ, start_response) + else: + token_s = user.get_or_create_token().token + + def start_response_with_headers(status, headers, exc_info=None): + + cookie = SimpleCookie() + cookie['X-Auth-Token'] = token_s + cookie['X-Auth-Token']['path'] = get_settings().get("urlprefix", "").rstrip("/") + "/" + + headers.append(('Set-Cookie', cookie['X-Auth-Token'].OutputString()),) + + return start_response(status, headers, exc_info) + + return admin_app(environ, start_response_with_headers) \ No newline at end of file diff --git a/gengine/base/__init__.py b/gengine/base/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gengine/base/cache.py b/gengine/base/cache.py new file mode 100644 index 0000000..a9e215a --- /dev/null +++ b/gengine/base/cache.py @@ -0,0 +1,55 @@ +import warnings +from dogpile.cache import make_region +from pyramid_dogpile_cache import get_region + +force_redis = None + +def setup_redis_cache(host,port,db): + """ This is used to override all caching settings in the ini file. Needed for Testing. """ + global force_redis + force_redis = { + 'host': host, + 'port': port, + 'db': db, + 'redis_expiration_time': 60 * 60 * 2, # 2 hours + 'distributed_lock': True + } + + +def my_key_mangler(prefix): + def s(o): + if type(o) == dict: + return "_".join(["%s=%s" % (str(k), str(v)) for k, v in o.items()]) + if type(o) == tuple: + return "_".join([str(v) for v in o]) + if type(o) == list: + return "_".join([str(v) for v in o]) + else: + return str(o) + + def generate_key(key): + ret = "" + ret += prefix + s(key).replace(" ", "") + return ret + + return generate_key + + +def create_cache(name): + ch = None + + if force_redis: + ch = make_region().configure( + 'dogpile.cache.redis', + arguments=force_redis + ) + else: + try: + ch = get_region(name) + except: + ch = make_region().configure('dogpile.cache.memory') + warnings.warn("Warning: cache objects are in memory, are you creating docs?") + + ch.key_mangler = my_key_mangler(name) + + return ch diff --git a/gengine/base/context.py b/gengine/base/context.py new file mode 100644 index 0000000..4ed1047 --- /dev/null +++ b/gengine/base/context.py @@ -0,0 +1,13 @@ +import threading + +from gengine.base.util import DictObjectProxy + +_local = threading.local() + +def get_context(): + if not hasattr(_local, "context"): + _local.context = DictObjectProxy() + return _local.context + +def reset_context(): + _local.context = DictObjectProxy() \ No newline at end of file diff --git a/gengine/errors.py b/gengine/base/errors.py similarity index 100% rename from gengine/errors.py rename to gengine/base/errors.py diff --git a/gengine/base/model.py b/gengine/base/model.py new file mode 100644 index 0000000..cfed947 --- /dev/null +++ b/gengine/base/model.py @@ -0,0 +1,117 @@ +import pytz +from pytz.exceptions import UnknownTimeZoneError +from sqlalchemy.inspection import inspect +from sqlalchemy.orm.exc import DetachedInstanceError +from sqlalchemy.sql.expression import select +from sqlalchemy.sql.functions import func +from sqlalchemy.util.compat import with_metaclass +from zope.sqlalchemy.datamanager import mark_changed + +import gengine.metadata as meta + +class ABaseMeta(type): + def __init__(cls, name, bases, nmspc): + super(ABaseMeta, cls).__init__(name, bases, nmspc) + + # monkey patch __unicode__ + # this is required to give show the SQL error to the user in flask admin if constraints are violated + if hasattr(cls,"__unicode__"): + old_unicode = cls.__unicode__ + def patched(self): + try: + return old_unicode(self) + except DetachedInstanceError: + return "(DetachedInstance)" + cls.__unicode__ = patched + + def __getattr__(cls, item): + if item == "__table__": + return inspect(cls).local_table + raise AttributeError(item) + + +class ABase(with_metaclass(ABaseMeta, object)): + """abstract base class which introduces a nice constructor for the model classes.""" + + def __init__(self, *args, **kw): + """ create a model object. + + pass attributes by using named parameters, e.g. name="foo", value=123 + """ + + for k, v in kw.items(): + setattr(self, k, v) + + def __str__(self): + if hasattr(self, "__unicode__"): + return self.__unicode__() + + def __getitem__(self, key): + return getattr(self,key) + + def __setitem__(self, key, item): + return setattr(self,key,item) + + +def calc_distance(latlong1, latlong2): + """generates a sqlalchemy expression for distance query in km + + :param latlong1: the location from which we look for rows, as tuple (lat,lng) + + :param latlong2: the columns containing the latitude and longitude, as tuple (lat,lng) + """ + + # explain: http://geokoder.com/distances + + # return func.sqrt(func.pow(69.1 * (latlong1[0] - latlong2[0]),2) + # + func.pow(53.0 * (latlong1[1] - latlong2[1]),2)) + + return func.sqrt(func.pow(111.2 * (latlong1[0] - latlong2[0]), 2) + + func.pow(111.2 * (latlong1[1] - latlong2[1]) * func.cos(latlong2[0]), 2)) + + +def coords(row): + return (row["lat"], row["lng"]) + + +def combine_updated_at(list_of_dates): + return max(list_of_dates) + + +def get_insert_id_by_result(r): + return r.last_inserted_ids()[0] + + +def get_insert_ids_by_result(r): + return r.last_inserted_ids() + + +def exists_by_expr(t, expr): + # TODO: use exists instead of count + q = select([func.count("*").label("c")], from_obj=t).where(expr) + r = meta.DBSession.execute(q).fetchone() + if r.c > 0: + return True + else: + return False + + +def datetime_trunc(field, timezone): + return "date_trunc('%(field)s', CAST(to_char(NOW() AT TIME ZONE %(timezone)s, 'YYYY-MM-DD HH24:MI:SS') AS TIMESTAMP)) AT TIME ZONE %(timezone)s" % { + "field": field, + "timezone": timezone + } + + +def valid_timezone(timezone): + try: + pytz.timezone(timezone) + except UnknownTimeZoneError: + return False + return True + + +def update_connection(): + session = meta.DBSession() if callable(meta.DBSession) else meta.DBSession + mark_changed(session) + return session diff --git a/gengine/base/monkeypatch_flaskadmin.py b/gengine/base/monkeypatch_flaskadmin.py new file mode 100644 index 0000000..f50a147 --- /dev/null +++ b/gengine/base/monkeypatch_flaskadmin.py @@ -0,0 +1,6 @@ +def do_monkeypatch(): + def get_url(self): + return self._view.get_url('%s.%s' % (self._view.endpoint, self._view._default_view)) + + import flask_admin.menu + flask_admin.menu.MenuView.get_url = get_url \ No newline at end of file diff --git a/gengine/base/settings.py b/gengine/base/settings.py new file mode 100644 index 0000000..0e468a1 --- /dev/null +++ b/gengine/base/settings.py @@ -0,0 +1,9 @@ +_settings = None + +def set_settings(settings): + global _settings + _settings = settings + +def get_settings(): + global _settings + return _settings \ No newline at end of file diff --git a/gengine/base/util.py b/gengine/base/util.py new file mode 100644 index 0000000..ad092fb --- /dev/null +++ b/gengine/base/util.py @@ -0,0 +1,30 @@ +class DictObjectProxy: + + def __init__(self, obj={}): + super().__setattr__("obj",obj) + + def __getattr__(self, name): + if not name in super().__getattribute__("obj"): + raise AttributeError + return super().__getattribute__("obj")[name] + + def __setattr__(self, key, value): + super().__getattribute__("obj")[key] = value + + +class Proxy(object): + def __init__(self): + self.target = None + + def __getattr__(self, name): + return getattr(self.target, name) + + def __setattr__(self, name, value): + if name == "target": + return object.__setattr__(self, name, value) + else: + setattr(self.target, name, value) + + def __call__(self, *args, **kwargs): + return self.target(*args, **kwargs) + \ No newline at end of file diff --git a/gengine/maintenance/__init__.py b/gengine/maintenance/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gengine/scripts/__init__.py b/gengine/maintenance/scripts/__init__.py similarity index 100% rename from gengine/scripts/__init__.py rename to gengine/maintenance/scripts/__init__.py diff --git a/gengine/scripts/generate_erd.py b/gengine/maintenance/scripts/generate_erd.py similarity index 100% rename from gengine/scripts/generate_erd.py rename to gengine/maintenance/scripts/generate_erd.py diff --git a/gengine/maintenance/scripts/generate_revision.py b/gengine/maintenance/scripts/generate_revision.py new file mode 100644 index 0000000..ba5dca1 --- /dev/null +++ b/gengine/maintenance/scripts/generate_revision.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +import sys + +import os +import pyramid_dogpile_cache + +from pyramid.config import Configurator +from pyramid.paster import ( + get_appsettings, + setup_logging, +) +from pyramid.scripts.common import parse_vars +from sqlalchemy import engine_from_config + +def usage(argv): + cmd = os.path.basename(argv[0]) + print('usage: %s [var=value]\n' + '(example: "%s production.ini new_table_xy_created")' % (cmd, cmd)) + sys.exit(1) + + +def main(argv=sys.argv): + if len(argv) < 3: + usage(argv) + config_uri = argv[1] + message = argv[2] + options = parse_vars(argv[3:]) + setup_logging(config_uri) + settings = get_appsettings(config_uri, options=options) + + durl = os.environ.get("DATABASE_URL") # heroku + if durl: + settings['sqlalchemy.url'] = durl + + murl = os.environ.get("MEMCACHED_URL") + if murl: + settings['urlcache_url'] = murl + + revision(settings, message, options) + + +def revision(settings, message, options): + engine = engine_from_config(settings, 'sqlalchemy.') + + config = Configurator(settings=settings) + pyramid_dogpile_cache.includeme(config) + + from gengine.metadata import ( + init_session, + init_declarative_base, + init_db + ) + init_session() + init_declarative_base() + init_db(engine) + + from gengine.app.cache import init_caches + init_caches() + + from gengine.metadata import ( + Base, + ) + + if options.get("reset_db", False): + Base.metadata.drop_all(engine) + engine.execute("DROP SCHEMA IF EXISTS public CASCADE") + + engine.execute("CREATE SCHEMA IF NOT EXISTS public") + + from alembic.config import Config + from alembic import command + + alembic_cfg = Config(attributes={ + 'engine': engine, + 'schema': 'public' + }) + script_location = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), + 'app/alembic' + ) + alembic_cfg.set_main_option("script_location", script_location) + + command.revision(alembic_cfg,message,True) + + engine.dispose() + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/gengine/maintenance/scripts/initializedb.py b/gengine/maintenance/scripts/initializedb.py new file mode 100644 index 0000000..b79f944 --- /dev/null +++ b/gengine/maintenance/scripts/initializedb.py @@ -0,0 +1,312 @@ +# -*- coding: utf-8 -*- +import sys + +import os +import pyramid_dogpile_cache +import transaction +from pyramid.config import Configurator +from pyramid.paster import ( + get_appsettings, + setup_logging, + ) +from pyramid.scripts.common import parse_vars +from sqlalchemy import engine_from_config +from sqlalchemy.sql.schema import Table + +from gengine.app.cache import init_caches +from gengine.app.permissions import perm_global_delete_user, perm_global_increase_value, perm_global_update_user_infos, \ + perm_global_access_admin_ui, perm_global_read_messages, perm_global_register_device +from gengine.base.model import exists_by_expr + + +def usage(argv): + cmd = os.path.basename(argv[0]) + print('usage: %s [var=value]\n' + '(example: "%s production.ini")' % (cmd, cmd)) + sys.exit(1) + + +def main(argv=sys.argv): + if len(argv) < 2: + usage(argv) + config_uri = argv[1] + options = parse_vars(argv[2:]) + setup_logging(config_uri) + settings = get_appsettings(config_uri, options=options) + + durl = os.environ.get("DATABASE_URL") #heroku + if durl: + settings['sqlalchemy.url']=durl + + murl = os.environ.get("MEMCACHED_URL") + if murl: + settings['urlcache_url']=murl + + initialize(settings,options) + +def initialize(settings,options): + engine = engine_from_config(settings, 'sqlalchemy.') + + config = Configurator(settings=settings) + pyramid_dogpile_cache.includeme(config) + + from gengine.metadata import ( + init_session, + init_declarative_base, + init_db + ) + init_caches() + init_session() + init_declarative_base() + init_db(engine) + + from gengine.metadata import ( + Base, + DBSession + ) + + if options.get("reset_db",False): + Base.metadata.drop_all(engine) + engine.execute("DROP SCHEMA IF EXISTS public CASCADE") + + engine.execute("CREATE SCHEMA IF NOT EXISTS public") + + from alembic.config import Config + from alembic import command + + alembic_cfg = Config(attributes={ + 'engine' : engine, + 'schema' : 'public' + }) + script_location = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), + 'app/alembic' + ) + alembic_cfg.set_main_option("script_location", script_location) + + do_upgrade = options.get("upgrade",False) + if not do_upgrade: + #init + from gengine.app import model + + tables = [t for name, t in model.__dict__.items() if isinstance(t, Table)] + Base.metadata.create_all(engine, tables=tables) + + command.stamp(alembic_cfg, "head") + + if options.get("populate_demo", False): + populate_demo(DBSession) + else: + #upgrade + command.upgrade(alembic_cfg,'head') + + admin_user = options.get("admin_user", False) + admin_password = options.get("admin_password", False) + + if admin_user and admin_password: + create_user(DBSession = DBSession, user=admin_user,password=admin_password) + + engine.dispose() + +def create_user(DBSession, user, password): + from gengine.app.model import ( + AuthUser, + User, + AuthRole, + AuthRolePermission + ) + with transaction.manager: + existing = DBSession.query(AuthUser).filter_by(email=user).first() + if not existing: + try: + user1 = User(id=1, lat=10, lng=50, timezone="Europe/Berlin") + DBSession.add(user1) + DBSession.flush() + + auth_user = AuthUser(user_id=user1.id, email=user, password=password, active=True) + DBSession.add(auth_user) + + auth_role = AuthRole(name="Global Admin") + DBSession.add(auth_role) + + DBSession.add(AuthRolePermission(role=auth_role, name=perm_global_access_admin_ui)) + DBSession.add(AuthRolePermission(role=auth_role, name=perm_global_delete_user)) + DBSession.add(AuthRolePermission(role=auth_role, name=perm_global_increase_value)) + DBSession.add(AuthRolePermission(role=auth_role, name=perm_global_update_user_infos)) + DBSession.add(AuthRolePermission(role=auth_role, name=perm_global_read_messages)) + DBSession.add(AuthRolePermission(role=auth_role, name=perm_global_register_device)) + + auth_user.roles.append(auth_role) + DBSession.add(auth_user) + except: + pass + +def populate_demo(DBSession): + + from gengine.app.model import ( + Achievement, + AchievementCategory, + Goal, + Variable, + User, + Language, + TranslationVariable, + Translation, + GoalProperty, + GoalGoalProperty, + Reward, + AchievementProperty, + AchievementAchievementProperty, + AchievementReward, + AuthUser, + AuthRole, + AuthRolePermission + ) + + def add_translation_variable(name): + t = TranslationVariable(name=name) + DBSession.add(t) + return t + + def add_translation(variable, lang, text): + tr = Translation(translationvariable=variable, text=text, language=lang) + DBSession.add(tr) + return tr + + with transaction.manager: + lang_de = Language(name="de") + lang_en = Language(name="en") + DBSession.add(lang_de) + DBSession.add(lang_en) + + var_invited_users = Variable(name="invite_users") + DBSession.add(var_invited_users) + + var_invited_users = Variable(name="participate", + group="none") + DBSession.add(var_invited_users) + + goal_property_name = GoalProperty(name='name') + DBSession.add(goal_property_name) + + achievementcategory_community = AchievementCategory(name="community") + DBSession.add(achievementcategory_community) + + achievement_invite = Achievement(name='invite_users', + evaluation="immediately", + maxtimes=20, + achievementcategory=achievementcategory_community) + DBSession.add(achievement_invite) + + transvar_invite = add_translation_variable(name="invite_users_goal_name") + add_translation(transvar_invite, lang_en, 'Invite ${5*level} Users') + add_translation(transvar_invite, lang_de, 'Lade ${5*level} Freunde ein') + + achievement_invite_goal1 = Goal(name_translation=transvar_invite, + condition='{"term": {"type": "literal", "variable": "invite_users"}}', + goal="5*level", + operator="geq", + achievement=achievement_invite) + DBSession.add(achievement_invite_goal1) + + DBSession.add(GoalGoalProperty(goal=achievement_invite_goal1, property=goal_property_name, value_translation=transvar_invite)) + + achievementcategory_sports = AchievementCategory(name="sports") + DBSession.add(achievementcategory_sports) + + achievement_fittest = Achievement(name='fittest', + relevance="friends", + maxlevel=100, + achievementcategory=achievementcategory_sports) + DBSession.add(achievement_fittest) + + transvar_fittest = add_translation_variable(name="fittest_goal_name") + add_translation(transvar_fittest, lang_en, 'Do the most sport activities among your friends') + add_translation(transvar_fittest, lang_de, 'Mache unter deinen Freunden am meisten Sportaktivitäten') + + achievement_fittest_goal1 = Goal(name_translation=transvar_fittest, + condition='{"term": {"key": ["5","7","9"], "type": "literal", "key_operator": "IN", "variable": "participate"}}', + evaluation="weekly", + goal="5*level", + achievement=achievement_fittest + ) + + DBSession.add(achievement_fittest_goal1) + DBSession.add(GoalGoalProperty(goal=achievement_fittest_goal1, property=goal_property_name, value_translation=transvar_fittest)) + + property_name = AchievementProperty(name='name') + DBSession.add(property_name) + + property_xp = AchievementProperty(name='xp') + DBSession.add(property_xp) + + property_icon = AchievementProperty(name='icon') + DBSession.add(property_icon) + + reward_badge = Reward(name='badge') + DBSession.add(reward_badge) + + reward_image = Reward(name='backgroud_image') + DBSession.add(reward_image) + + transvar_invite_name = add_translation_variable(name="invite_achievement_name") + add_translation(transvar_invite_name, lang_en, 'The Community!') + add_translation(transvar_invite_name, lang_de, 'Die Community!') + + DBSession.add(AchievementAchievementProperty(achievement=achievement_invite, property=property_name, value_translation=transvar_invite_name)) + DBSession.add(AchievementAchievementProperty(achievement=achievement_invite, property=property_xp, value='${100 * level}')) + DBSession.add(AchievementAchievementProperty(achievement=achievement_invite, property=property_icon, value="https://www.gamification-software.com/img/running.png")) + + DBSession.add(AchievementReward(achievement=achievement_invite, reward=reward_badge, value="https://www.gamification-software.com/img/trophy.png", from_level=5)) + DBSession.add(AchievementReward(achievement=achievement_invite, reward=reward_image, value="https://www.gamification-software.com/img/video-controller-336657_1920.jpg", from_level=5)) + + transvar_fittest_name = add_translation_variable(name="fittest_achievement_name") + add_translation(transvar_fittest_name, lang_en, 'The Fittest!') + add_translation(transvar_fittest_name, lang_de, 'Der Fitteste!') + + DBSession.add(AchievementAchievementProperty(achievement=achievement_fittest, property=property_name, value_translation=transvar_fittest_name)) + DBSession.add(AchievementAchievementProperty(achievement=achievement_fittest, property=property_xp, value='${50 + (200 * level)}')) + DBSession.add(AchievementAchievementProperty(achievement=achievement_fittest, property=property_icon, value="https://www.gamification-software.com/img/colorwheel.png")) + + DBSession.add(AchievementReward(achievement=achievement_fittest, reward=reward_badge, value="https://www.gamification-software.com/img/easel.png", from_level=1)) + DBSession.add(AchievementReward(achievement=achievement_fittest, reward=reward_image, value="https://www.gamification-software.com/img/game-characters-622654.jpg", from_level=1)) + + + user1 = User(id=1,lat=10,lng=50,timezone="Europe/Berlin") + user2 = User(id=2,lat=10,lng=50,timezone="US/Eastern") + user3 = User(id=3,lat=10,lng=50) + + user1.friends.append(user2) + user1.friends.append(user3) + + user2.friends.append(user1) + user2.friends.append(user3) + + user3.friends.append(user1) + user3.friends.append(user2) + + DBSession.add(user1) + DBSession.add(user2) + DBSession.add(user3) + DBSession.flush() + + try: + auth_user = AuthUser(user_id=user1.id,email="admin@gamification-software.com",password="test123",active=True) + DBSession.add(auth_user) + + auth_role = AuthRole(name="Global Admin") + DBSession.add(auth_role) + + DBSession.add(AuthRolePermission(role=auth_role, name=perm_global_access_admin_ui)) + DBSession.add(AuthRolePermission(role=auth_role, name=perm_global_delete_user)) + DBSession.add(AuthRolePermission(role=auth_role, name=perm_global_increase_value)) + DBSession.add(AuthRolePermission(role=auth_role, name=perm_global_update_user_infos)) + + auth_user.roles.append(auth_role) + DBSession.add(auth_user) + except ImportError as e: + print("[auth] feature not installed - not importing auth demo data") + + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/gengine/maintenance/scripts/push_messages.py b/gengine/maintenance/scripts/push_messages.py new file mode 100644 index 0000000..620e858 --- /dev/null +++ b/gengine/maintenance/scripts/push_messages.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +import sys +import logging + +from zope.sqlalchemy.datamanager import mark_changed + +from gengine.metadata import MySession + +log = logging.getLogger(__name__) +log.addHandler(logging.StreamHandler()) + +import os +import pyramid_dogpile_cache +import transaction +from gengine.app.cache import init_caches +from pyramid.config import Configurator +from pyramid.paster import ( + get_appsettings, + setup_logging, +) +from pyramid.scripts.common import parse_vars +from sqlalchemy import engine_from_config + +def usage(argv): + cmd = os.path.basename(argv[0]) + print('usage: %s [var=value]\n' + '(example: "%s production.ini")' % (cmd, cmd)) + sys.exit(1) + + +def main(argv=sys.argv): + if len(argv) < 2: + usage(argv) + config_uri = argv[1] + options = parse_vars(argv[2:]) + setup_logging(config_uri) + settings = get_appsettings(config_uri, options=options) + + from gengine.base.settings import set_settings + set_settings(settings) + + durl = os.environ.get("DATABASE_URL") # heroku + if durl: + settings['sqlalchemy.url'] = durl + + murl = os.environ.get("MEMCACHED_URL") + if murl: + settings['urlcache_url'] = murl + + engine = engine_from_config(settings, 'sqlalchemy.') + + config = Configurator(settings=settings) + pyramid_dogpile_cache.includeme(config) + + from gengine.metadata import ( + init_session, + init_declarative_base, + init_db + ) + init_session() + init_declarative_base() + init_db(engine) + init_caches() + + from gengine.metadata import ( + DBSession + ) + sess = DBSession() + init_session(override_session=sess, replace=True) + + import gengine.app.model as m + with transaction.manager: + mark_changed(sess, transaction.manager, True) + + messages = sess.execute(m.t_user_messages.select().where(m.t_user_messages.c.has_been_pushed == False)) + for msg in messages: + m.UserMessage.deliver(msg) + sess.flush() + sess.commit() diff --git a/gengine/scripts/quickstart.py b/gengine/maintenance/scripts/quickstart.py similarity index 100% rename from gengine/scripts/quickstart.py rename to gengine/maintenance/scripts/quickstart.py diff --git a/gengine/metadata.py b/gengine/metadata.py index 249de08..d0f0c87 100644 --- a/gengine/metadata.py +++ b/gengine/metadata.py @@ -1,37 +1,58 @@ from sqlalchemy.orm.session import Session, sessionmaker import transaction from sqlalchemy.orm.scoping import scoped_session +from sqlalchemy.sql.schema import MetaData from zope.sqlalchemy.datamanager import ZopeTransactionExtension from sqlalchemy.ext.declarative.api import declarative_base +from gengine.base.util import Proxy + + class MySession(Session): """This allow us to use the flask-admin sqla extension, which uses DBSession.commit() rather than transaction.commit()""" def commit(self,*args,**kw): transaction.commit(*args,**kw) - + def rollback(self,*args,**kw): transaction.abort(*args,**kw) -DBSession=None +DBSession=Proxy() + +def get_sessionmaker(bind=None): + return sessionmaker( + extension=ZopeTransactionExtension(), + class_=MySession, + bind=bind + ) -def init_session(override_session=None): +def init_session(override_session=None, replace=False): global DBSession + if DBSession.target and not replace: + return if override_session: - DBSession = override_session + DBSession.target = override_session else: - DBSession = scoped_session(sessionmaker(extension=ZopeTransactionExtension(), class_=MySession)) + DBSession.target = scoped_session(get_sessionmaker()) Base=None def init_declarative_base(override_base=None): global Base + if Base: + return if override_base: - Base=override_base + Base = override_base else: - Base = declarative_base() + convention = { + "ix": 'ix_%(column_0_label)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", + "pk": "pk_%(table_name)s" + } + metadata = MetaData(naming_convention=convention) + Base = declarative_base(metadata = metadata) def init_db(engine): DBSession.configure(bind=engine) Base.metadata.bind = engine - - \ No newline at end of file diff --git a/gengine/scripts/initializedb.py b/gengine/scripts/initializedb.py deleted file mode 100644 index 5a44ef2..0000000 --- a/gengine/scripts/initializedb.py +++ /dev/null @@ -1,218 +0,0 @@ -# -*- coding: utf-8 -*- -import os -import sys -import transaction - -from sqlalchemy import engine_from_config - -from pyramid.paster import ( - get_appsettings, - setup_logging, - ) - -from pyramid.scripts.common import parse_vars -import pyramid_dogpile_cache -from pyramid.config import Configurator - -def usage(argv): - cmd = os.path.basename(argv[0]) - print('usage: %s [var=value]\n' - '(example: "%s production.ini alembic.ini")' % (cmd, cmd)) - sys.exit(1) - - -def main(argv=sys.argv): - if len(argv) < 3: - usage(argv) - config_uri = argv[1] - alembic_uri = argv[2] - options = parse_vars(argv[3:]) - setup_logging(config_uri) - settings = get_appsettings(config_uri, options=options) - - durl = os.environ.get("DATABASE_URL") #heroku - if durl: - settings['sqlalchemy.url']=durl - - murl = os.environ.get("MEMCACHED_URL") - if murl: - settings['urlcache_url']=murl - - engine = engine_from_config(settings, 'sqlalchemy.') - - config = Configurator(settings=settings) - pyramid_dogpile_cache.includeme(config) - - from ..metadata import ( - init_session, - init_declarative_base - ) - init_session() - init_declarative_base() - - from ..metadata import ( - Base, - DBSession - ) - - from ..models import ( - Achievement, - AchievementCategory, - Goal, - Variable, - User, - Language, - TranslationVariable, - Translation, - GoalProperty, - GoalGoalProperty, - Reward, - AchievementProperty, - AchievementAchievementProperty, - AchievementReward - ) - - DBSession.configure(bind=engine) - - if options.get("reset_db",False): - Base.metadata.drop_all(engine) - - Base.metadata.create_all(engine) - - - # then, load the Alembic configuration and generate the - # version table, "stamping" it with the most recent rev: - from alembic.config import Config - from alembic import command - alembic_cfg = Config(alembic_uri) - command.stamp(alembic_cfg, "head") - - - - def add_translation_variable(name): - t = TranslationVariable(name=name) - DBSession.add(t) - return t - - def add_translation(variable,lang,text): - tr = Translation(translationvariable=variable,text=text,language=lang) - DBSession.add(tr) - return tr - - if options.get("populate_demo",False): - with transaction.manager: - - lang_de = Language(name="de") - lang_en = Language(name="en") - DBSession.add(lang_de) - DBSession.add(lang_en) - - var_invited_users = Variable(name="invite_users") - DBSession.add(var_invited_users) - - var_invited_users = Variable(name="participate", - group="none") - DBSession.add(var_invited_users) - - goal_property_name = GoalProperty(name='name') - DBSession.add(goal_property_name) - - achievementcategory_community = AchievementCategory(name="community") - DBSession.add(achievementcategory_community) - - achievement_invite = Achievement(name='invite_users', - evaluation="immediately", - maxtimes=20, - achievementcategory=achievementcategory_community) - DBSession.add(achievement_invite) - - transvar_invite = add_translation_variable(name="invite_users_goal_name") - add_translation(transvar_invite, lang_en, '"Invite "+`(5*p.level)`+" Users"') - add_translation(transvar_invite, lang_de, '"Lade "+`(5*p.level)`+" Freunde ein"') - - achievement_invite_goal1 = Goal(name_translation=transvar_invite, - condition='p.var=="invite_users"', - goal="5*p.level", - operator="geq", - achievement=achievement_invite) - DBSession.add(achievement_invite_goal1) - - DBSession.add(GoalGoalProperty(goal=achievement_invite_goal1, property=goal_property_name, value_translation=transvar_invite)) - - achievementcategory_sports = AchievementCategory(name="sports") - DBSession.add(achievementcategory_sports) - - achievement_fittest = Achievement(name='fittest', - relevance="friends", - maxlevel=100, - achievementcategory=achievementcategory_sports) - DBSession.add(achievement_fittest) - - transvar_fittest = add_translation_variable(name="fittest_goal_name") - add_translation(transvar_fittest, lang_en, '"Do the most sport activities among your friends"') - add_translation(transvar_fittest, lang_de, '"Mache unter deinen Freunden am meisten Sportaktivitäten"') - - achievement_fittest_goal1 = Goal(name_translation=transvar_fittest, - condition='and_(p.var=="participate", p.key.in_(["5","7","9"]))', - evaluation="weekly", - goal="5*p.level", - achievement=achievement_fittest - ) - - DBSession.add(achievement_fittest_goal1) - DBSession.add(GoalGoalProperty(goal=achievement_fittest_goal1, property=goal_property_name, value_translation=transvar_fittest)) - - property_name = AchievementProperty(name='name') - DBSession.add(property_name) - - property_xp = AchievementProperty(name='xp') - DBSession.add(property_xp) - - property_icon = AchievementProperty(name='icon') - DBSession.add(property_icon) - - reward_badge = Reward(name='badge') - DBSession.add(reward_badge) - - reward_image = Reward(name='backgroud_image') - DBSession.add(reward_image) - - transvar_invite_name = add_translation_variable(name="invite_achievement_name") - add_translation(transvar_invite_name, lang_en, '"The Community!"') - add_translation(transvar_invite_name, lang_de, '"Die Community!"') - - DBSession.add(AchievementAchievementProperty(achievement=achievement_invite, property=property_name, value_translation=transvar_invite_name)) - DBSession.add(AchievementAchievementProperty(achievement=achievement_invite, property=property_xp, value='100 * p.level')) - DBSession.add(AchievementAchievementProperty(achievement=achievement_invite, property=property_icon, value="'https://www.gamification-software.com/img/running.png'")) - - DBSession.add(AchievementReward(achievement=achievement_invite, reward=reward_badge, value="'https://www.gamification-software.com/img/trophy.png'", from_level=5)) - DBSession.add(AchievementReward(achievement=achievement_invite, reward=reward_image, value="'https://www.gamification-software.com/img/video-controller-336657_1920.jpg'", from_level=5)) - - transvar_fittest_name = add_translation_variable(name="fittest_achievement_name") - add_translation(transvar_fittest_name, lang_en, '"The Fittest!"') - add_translation(transvar_fittest_name, lang_de, '"Der Fitteste!"') - - DBSession.add(AchievementAchievementProperty(achievement=achievement_fittest, property=property_name, value_translation=transvar_fittest_name)) - DBSession.add(AchievementAchievementProperty(achievement=achievement_fittest, property=property_xp, value='50 + (200 * p.level)')) - DBSession.add(AchievementAchievementProperty(achievement=achievement_fittest, property=property_icon, value="'https://www.gamification-software.com/img/colorwheel.png'")) - - DBSession.add(AchievementReward(achievement=achievement_fittest, reward=reward_badge, value="'https://www.gamification-software.com/img/easel.png'", from_level=1)) - DBSession.add(AchievementReward(achievement=achievement_fittest, reward=reward_image, value="'https://www.gamification-software.com/img/game-characters-622654.jpg'", from_level=1)) - - - user1 = User(id=1,lat=10,lng=50,timezone="Europe/Berlin") - user2 = User(id=2,lat=10,lng=50,timezone="US/Eastern") - user3 = User(id=3,lat=10,lng=50) - - user1.friends.append(user2) - user1.friends.append(user3) - - user2.friends.append(user1) - user2.friends.append(user3) - - user3.friends.append(user1) - user3.friends.append(user2) - - DBSession.add(user1) - DBSession.add(user2) - DBSession.add(user3) \ No newline at end of file diff --git a/gengine/urlcache.py b/gengine/urlcache.py deleted file mode 100644 index a36371f..0000000 --- a/gengine/urlcache.py +++ /dev/null @@ -1,52 +0,0 @@ -# -*- coding: utf-8 -*- -from pymemcache.client import Client - -host = "localhost" -port = 11211 -urlprefix = "" -is_active = True -urlcacheid = "gengine" - -def setup_urlcache(prefix, url, active, id): - global urlprefix, host, port, is_active, urlcacheid - urlprefix = prefix - host, port = url.split(":") - port = int(port) - is_active = active - urlcacheid = id - -def __build_key(key): - return "::URL_CACHE::"+str(urlcacheid)+"::"+urlprefix+str(key) - -def get_or_set(key,generator): - if is_active: - client = Client((host,port)) - key = __build_key(key) - result = client.get(key) - if not result: - result = generator() - client.set(key, result) - client.quit() - return result - else: - return generator() - -def set_value(key,value): - if is_active: - client = Client((host,port)) - key = __build_key(key) - client.set(key, value) - client.quit() - -def invalidate(key): - if is_active: - key = __build_key(key) - client = Client((host,port)) - client.delete(key) - client.quit() - -def invalidate_all(): - if is_active: - client = Client((host,port)) - client.flush_all() - client.quit() \ No newline at end of file diff --git a/gengine/views.py b/gengine/views.py deleted file mode 100644 index cdff0eb..0000000 --- a/gengine/views.py +++ /dev/null @@ -1,243 +0,0 @@ -# -*- coding: utf-8 -*- -from pyramid.view import view_config -from .models import ( - User, - Achievement, - Value - ) - -from .urlcache import get_or_set -from pyramid.renderers import render, JSON - -from .flaskadmin import flaskadminapp -from pyramid.wsgi import wsgiapp2, wsgiapp -from _collections import defaultdict -from gengine.models import Variable, valid_timezone, Goal, AchievementReward, FormularEvaluationException -from gengine.urlcache import set_value -from pyramid.exceptions import NotFound, HTTPBadRequest -from werkzeug import DebuggedApplication -from gengine.wsgiutil import HTTPSProxied -from .errors import APIError - -import traceback - -@view_config(route_name='add_or_update_user', renderer='string', request_method="POST") -def add_or_update_user(request): - """add a user and set its metadata""" - - user_id = int(request.matchdict["user_id"]) - - lat=None - if len(request.POST.get("lat",""))>0: - lat = float(request.POST["lat"]) - - lon=None - if len(request.POST.get("lon",""))>0: - lon = float(request.POST["lon"]) - - friends=[] - if len(request.POST.get("friends",""))>0: - friends = [int(x) for x in request.POST["friends"].split(",")] - - groups=[] - if len(request.POST.get("groups",""))>0: - groups = [int(x) for x in request.POST["groups"].split(",")] - - timezone="UTC" - if len(request.POST.get("timezone",""))>0: - timezone = request.POST["timezone"] - - if not valid_timezone(timezone): - timezone = 'UTC' - - country=None - if len(request.POST.get("country",""))>0: - country = request.POST["country"] - - region=None - if len(request.POST.get("region",""))>0: - region = request.POST["region"] - - city=None - if len(request.POST.get("city",""))>0: - city = request.POST["city"] - - User.set_infos(user_id=user_id, - lat=lat, - lng=lon, - timezone=timezone, - country=country, - region=region, - city=city, - friends=friends, - groups=groups) - - return {"status" : "OK"} - -@view_config(route_name='delete_user', renderer='string', request_method="DELETE") -def delete_user(request): - """delete a user completely""" - - user_id = int(request.matchdict["user_id"]) - User.delete_user(user_id) - return {"status" : "OK"} - -def _get_progress(user,force_generation=False): - def generate(): - achievements = Achievement.get_achievements_by_user_for_today(user) - - def ea(achievement): - try: - #print "evaluating "+`achievement["id"]` - return Achievement.evaluate(user, achievement["id"]) - except FormularEvaluationException as e: - return { "error": "Cannot evaluate formular: " + e.message, "id" : achievement["id"] } - except Exception as e: - tb = traceback.format_exc() - return { "error": tb, "id" : achievement["id"] } - - check = lambda x : x!=None and not "error" in x and (x["hidden"]==False or x["level"]>0) - - evaluatelist = [ea(achievement) for achievement in achievements] - - ret = { - "achievements" : { - x["id"] : x for x in evaluatelist if check(x) - }, - "achievement_errors" : { - x["id"] : x for x in evaluatelist if x!=None and "error" in x - } - } - - return render("json",ret),ret - - key = "/progress/"+str(user.id) - - if not force_generation: - #in this case, we do not return the decoded json object - the caller has to take of this if needed - return get_or_set(key,lambda:generate()[0]), None - else: - ret_str, ret = generate() - set_value(key,ret_str) - return ret_str, ret - -@view_config(route_name='get_progress', renderer='json') -def get_progress(request): - """get all relevant data concerning the user's progress""" - user_id = int(request.matchdict["user_id"]) - - user = User.get_user(user_id) - if not user: - raise NotFound("user not found") - - return _get_progress(user, force_generation=False)[0] - -@view_config(route_name='increase_value', renderer='json', request_method="POST") -@view_config(route_name='increase_value_with_key', renderer='json', request_method="POST") -def increase_value(request): - """increase a value for the user""" - - user_id = int(request.matchdict["user_id"]) - try: - value = float(request.POST["value"]) - except: - raise APIError(400,"invalid_value","Invalid value provided") - - key = request.matchdict["key"] if "key" in request.matchdict else "" - variable_name = request.matchdict["variable_name"] - - user = User.get_user(user_id) - if not user: - raise APIError(404, "user_not_found", "user not found") - - variable = Variable.get_variable_by_name(variable_name) - if not variable: - raise APIError(404, "variable_not_found", "variable not found") - - Value.increase_value(variable_name, user, value, key) - - output = _get_progress(user,force_generation=True)[1] - - for aid in output["achievements"].keys(): - if len(output["achievements"][aid]["new_levels"])>0: - del output["achievements"][aid]["levels"] - del output["achievements"][aid]["priority"] - del output["achievements"][aid]["goals"] - else: - del output["achievements"][aid] - return output - -@view_config(route_name="increase_multi_values", renderer="json", request_method="POST") -def increase_multi_values(request): - try: - doc = request.json_body - except: - raise APIError(400, "invalid_json", "no valid json body") - ret = {} - for user_id, values in doc.items(): - user = User.get_user(user_id) - if not user: - raise APIError(404, "user_not_found", "user %s not found" % (user_id,)) - - for variable_name, values_and_keys in values.items(): - for value_and_key in values_and_keys: - variable = Variable.get_variable_by_name(variable_name) - - if not variable: - raise APIError(404, "variable_not_found", "variable %s not found" % (variable_name,)) - - if not 'value' in value_and_key: - raise APIError(400, "variable_not_found", "illegal value for %s" % (variable_name,)) - - value = value_and_key['value'] - key = value_and_key.get('key','') - - Value.increase_value(variable_name, user, value, key) - - output = _get_progress(user,force_generation=True)[1] - - for aid in output["achievements"].keys(): - if len(output["achievements"][aid]["new_levels"])>0: - del output["achievements"][aid]["levels"] - del output["achievements"][aid]["priority"] - del output["achievements"][aid]["goals"] - else: - del output["achievements"][aid] - - if len(output["achievements"])>0 : - ret[user_id]=output - - return ret - -@view_config(route_name='get_achievement_level', renderer='string', request_method="GET") -def get_achievement_level(request): - """get all information about an achievement for a specific level""" - try: - achievement_id = int(request.matchdict.get("achievement_id",None)) - level = int(request.matchdict.get("level",None)) - except: - raise APIError(400, "invalid_input", "invalid input") - - def generate(): - achievement = Achievement.get_achievement(achievement_id) - - if not achievement: - raise APIError(404, "achievement_not_found", "achievement not found") - - level_output = Achievement.basic_output(achievement, [], True, level).get("levels").get(str(level), {"properties":{},"rewards":{}}) - if "goals" in level_output: - del level_output["goals"] - if "level" in level_output: - del level_output["level"] - return render("json",level_output) - - key = "/achievement/"+str(achievement_id)+"/level/"+str(level) - request.response.content_type = 'application/json' - - return get_or_set(key,generate) - -@view_config(route_name='admin') -@wsgiapp2 -def admin(environ, start_response): - return HTTPSProxied(DebuggedApplication(flaskadminapp.wsgi_app, True))(environ,start_response) - #return \ No newline at end of file diff --git a/gengine_quickstart_template/production.ini b/gengine_quickstart_template/production.ini index aa13c3d..49fcc8b 100644 --- a/gengine_quickstart_template/production.ini +++ b/gengine_quickstart_template/production.ini @@ -39,6 +39,13 @@ dogpile_cache.achievements_by_user_for_today.arguments.filename = achievements_b dogpile_cache.translations.backend = dogpile.cache.dbm dogpile_cache.translations.arguments.filename = translations.dbm +dogpile_cache.achievements_users_levels.backend = dogpile.cache.dbm +dogpile_cache.achievements_users_levels.arguments.filename = achievements_users_levels.dbm + +dogpile_cache.goal_evaluation.backend = dogpile.cache.dbm +dogpile_cache.goal_evaluation.arguments.filename = goal_evaluation.dbm + +dogpile_cache.goal_statements.backend = dogpile.cache.memory # memcache urlcache_url = 127.0.0.1:11211 @@ -47,6 +54,16 @@ urlcache_active = true # callback url, will be used for time-related leaderboard evaluations (daily,monthly,yearly) (TBD) notify_progress = +enable_user_authentication = false +fallback_language = en +gcm.api_key= +gcm.package= +apns.dev.key= +apns.dev.certificate= +apns.prod.key= +apns.prod.certificate= +push_title=Gamification Engine + ### # wsgi server configuration ### diff --git a/optional-requirements.txt b/optional-requirements.txt new file mode 100644 index 0000000..39258ea --- /dev/null +++ b/optional-requirements.txt @@ -0,0 +1,11 @@ +argon2==0.1.10 +names==0.3.0 +pbr==2.0.0 +pg8000==1.10.6 +python-gcm==0.4 +redis==2.10.5 +requests==2.13.0 +tapns3==3.0.0 +testing.common.database==2.0.0 +testing.postgresql==1.3.0 +testing.redis==1.1.1 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6e6912e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,44 @@ +alembic==0.9.1 +appdirs==1.4.3 +Chameleon==3.1 +click==6.7 +dogpile.cache==0.6.2 +Flask==0.12 +Flask-Admin==1.5.0 +hupper==0.4.4 +itsdangerous==0.24 +Jinja2==2.9.5 +jsl==0.2.4 +jsonschema==2.6.0 +Mako==1.0.6 +MarkupSafe==1.0 +mock==2.0.0 +packaging==16.8 +PasteDeploy==1.5.2 +pbr==2.0.0 +psycopg2==2.7.1 +Pygments==2.2.0 +pymemcache==1.4.2 +pyparsing==2.2.0 +pyramid==1.8.3 +pyramid-chameleon==0.3 +pyramid-debugtoolbar==3.0.5 +pyramid-dogpile-cache==0.0.4 +pyramid-mako==1.0.2 +pyramid-tm==1.1.1 +python-editor==1.0.3 +pytz==2016.10 +raven==6.0.0 +repoze.lru==0.6 +six==1.10.0 +SQLAlchemy==1.1.7 +transaction==2.1.2 +translationstring==1.3 +venusian==1.0 +waitress==1.0.2 +WebOb==1.7.2 +Werkzeug==0.12.1 +WTForms==2.1 +zope.deprecation==4.2.0 +zope.interface==4.3.3 +zope.sqlalchemy==0.7.7 diff --git a/setup.py b/setup.py index b007ddc..96bdbdc 100644 --- a/setup.py +++ b/setup.py @@ -27,8 +27,11 @@ 'pymemcache', 'mock', 'alembic', - 'raven' - ] + 'raven', + 'jsl', + 'jsonschema', + 'pyparsing', +] version = '' with open('gengine/__init__.py', 'r') as fd: @@ -48,10 +51,11 @@ "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: WSGI :: Application", "Topic :: Software Development :: Libraries", - "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3.4", + "Programming Language :: Python :: 3.5", "License :: OSI Approved :: MIT License" ], - author='Marcel Sander, Jens Janiuk', + author='Marcel Sander, Jens Janiuk, Matthias Feldotto', author_email='marcel@gamification-software.com', license='MIT', url='https://www.gamification-software.com', @@ -61,12 +65,30 @@ zip_safe=False, test_suite='gengine', install_requires=requires, + extras_require={ + "auth": [ + 'argon2' + ], + "pushes": [ + 'tapns3', + 'python-gcm', + ], + "testing": [ + 'testing.postgresql', + 'testing.redis', + 'names' + ] + }, entry_points="""\ [paste.app_factory] main = gengine:main [console_scripts] - initialize_gengine_db = gengine.scripts.initializedb:main - gengine_quickstart = gengine.scripts.quickstart:main - generate_gengine_erd = gengine.scripts.generate_erd:main + initialize_gengine_db = gengine.maintenance.scripts.initializedb:main + gengine_quickstart = gengine.maintenance.scripts.quickstart:main + generate_gengine_erd = gengine.maintenance.scripts.generate_erd:main + generate_gengine_revision = gengine.maintenance.scripts.generate_revision:main + gengine_push_messages = gengine.maintenance.scripts.push_messages:main + [redgalaxy.plugins] + gengine = gengine:redgalaxy """, )