├── .circleci └── config.yml ├── .dockerignore ├── .gitignore ├── .gitmodules ├── Dockerfile ├── LICENSE.txt ├── MANIFEST.in ├── README.md ├── adaero ├── __init__.py ├── alembic.ini ├── config.py ├── constants.py ├── data_migrations │ └── __init__.py ├── database.py ├── date.py ├── example.ini ├── forms.py ├── gunicorn │ ├── config.py │ └── wsgi.py ├── history.py ├── host_example.ini ├── mail.py ├── migration.py ├── migrations │ ├── README │ ├── env.py │ ├── script.py.mako │ └── versions │ │ └── aa9125020ba8_initial_commit.py ├── models │ ├── __init__.py │ ├── all.py │ ├── period.py │ └── user.py ├── population.py ├── scripts │ ├── __init__.py │ ├── configure_db.py │ └── feedback_app.py ├── security │ ├── __init__.py │ └── ldapauth.py ├── static │ └── assets │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ └── logo.png ├── stats.py ├── templates │ ├── __init__.py │ └── email │ │ ├── __init__.py │ │ ├── approve.html.j2 │ │ ├── base.html.j2 │ │ ├── enrol.html.j2 │ │ ├── entry.html.j2 │ │ ├── invite.html.j2 │ │ ├── review.html.j2 │ │ └── tm_confirmation.html.j2 ├── text.py └── views │ ├── __init__.py │ ├── auth.py │ ├── error.py │ ├── external.py │ ├── feedback.py │ ├── manager.py │ ├── metadata.py │ ├── nomination.py │ └── talent_manager.py ├── architecture.dot ├── architecture.png ├── docker ├── dev │ └── docker-compose.yml ├── prod │ ├── docker-compose.yml │ └── nginx │ │ ├── adaero.conf │ │ └── nginx.conf └── shared │ └── ldif │ └── 01-data.ldif ├── docs ├── login.png ├── user-guide.pdf └── welcome.png ├── frontend ├── .editorconfig ├── .gitignore ├── Dockerfile ├── README.md ├── angular.json ├── browserslist ├── e2e │ ├── app.e2e-spec.ts │ ├── app.po.ts │ └── tsconfig.e2e.json ├── karma.conf.js ├── package-lock.json ├── package.json ├── protractor.conf.js ├── proxy.conf.json ├── proxy.local.conf.json ├── src │ ├── app │ │ ├── app-routing.module.ts │ │ ├── app.component.html │ │ ├── app.component.scss │ │ ├── app.component.spec.ts │ │ ├── app.component.ts │ │ ├── app.module.ts │ │ ├── components │ │ │ ├── views │ │ │ │ ├── authenticated │ │ │ │ │ ├── authenticated.component.html │ │ │ │ │ ├── authenticated.component.scss │ │ │ │ │ ├── authenticated.component.spec.ts │ │ │ │ │ └── authenticated.component.ts │ │ │ │ ├── external-invite │ │ │ │ │ ├── external-invite.component.html │ │ │ │ │ ├── external-invite.component.scss │ │ │ │ │ ├── external-invite.component.spec.ts │ │ │ │ │ └── external-invite.component.ts │ │ │ │ ├── feedback-about-me │ │ │ │ │ ├── feedback-about-me.component.html │ │ │ │ │ ├── feedback-about-me.component.scss │ │ │ │ │ ├── feedback-about-me.component.spec.ts │ │ │ │ │ └── feedback-about-me.component.ts │ │ │ │ ├── feedback-history-view │ │ │ │ │ ├── feedback-history-view.component.html │ │ │ │ │ ├── feedback-history-view.component.scss │ │ │ │ │ ├── feedback-history-view.component.spec.ts │ │ │ │ │ └── feedback-history-view.component.ts │ │ │ │ ├── give-feedback │ │ │ │ │ ├── give-feedback.component.html │ │ │ │ │ ├── give-feedback.component.scss │ │ │ │ │ └── give-feedback.component.ts │ │ │ │ ├── index.ts │ │ │ │ ├── login │ │ │ │ │ ├── login.component.html │ │ │ │ │ ├── login.component.scss │ │ │ │ │ ├── login.component.spec.ts │ │ │ │ │ └── login.component.ts │ │ │ │ ├── nominees-list │ │ │ │ │ ├── nominees-list.component.html │ │ │ │ │ ├── nominees-list.component.scss │ │ │ │ │ └── nominees-list.component.ts │ │ │ │ ├── self-nominate │ │ │ │ │ ├── self-nominate.component.html │ │ │ │ │ ├── self-nominate.component.spec.ts │ │ │ │ │ └── self-nominate.component.ts │ │ │ │ ├── stats │ │ │ │ │ ├── stats.component.html │ │ │ │ │ ├── stats.component.scss │ │ │ │ │ ├── stats.component.spec.ts │ │ │ │ │ └── stats.component.ts │ │ │ │ ├── summarise-feedback │ │ │ │ │ ├── summarise-feedback.component.html │ │ │ │ │ ├── summarise-feedback.component.scss │ │ │ │ │ ├── summarise-feedback.component.spec.ts │ │ │ │ │ └── summarise-feedback.component.ts │ │ │ │ └── talent-manager-panel │ │ │ │ │ ├── talent-manager-panel.component.html │ │ │ │ │ ├── talent-manager-panel.component.scss │ │ │ │ │ ├── talent-manager-panel.component.spec.ts │ │ │ │ │ └── talent-manager-panel.component.ts │ │ │ └── widgets │ │ │ │ ├── feedback-form │ │ │ │ ├── feedback-form.component.html │ │ │ │ ├── feedback-form.component.scss │ │ │ │ ├── feedback-form.component.spec.ts │ │ │ │ └── feedback-form.component.ts │ │ │ │ ├── feedback-history │ │ │ │ ├── feedback-history.component.html │ │ │ │ ├── feedback-history.component.scss │ │ │ │ ├── feedback-history.component.spec.ts │ │ │ │ └── feedback-history.component.ts │ │ │ │ ├── index.ts │ │ │ │ └── modal │ │ │ │ ├── modal.component.html │ │ │ │ ├── modal.component.scss │ │ │ │ ├── modal.component.spec.ts │ │ │ │ └── modal.component.ts │ │ ├── guards │ │ │ ├── anon-guard.service.spec.ts │ │ │ ├── anon-guard.service.ts │ │ │ ├── auth-guard.service.spec.ts │ │ │ ├── auth-guard.service.ts │ │ │ ├── index.ts │ │ │ ├── pending-changes-guard.service.spec.ts │ │ │ └── pending-changes-guard.service.ts │ │ ├── pipes │ │ │ ├── nominee-filter.pipe.spec.ts │ │ │ └── nominee-filter.pipe.ts │ │ └── services │ │ │ ├── api.service.ts │ │ │ ├── cookie.service.spec.ts │ │ │ └── cookie.service.ts │ ├── assets │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ └── logo.png │ ├── environments │ │ ├── environment.prod.ts │ │ └── environment.ts │ ├── index.html │ ├── main.ts │ ├── polyfills.ts │ ├── styles.scss │ ├── test.ts │ ├── tsconfig.app.json │ ├── tsconfig.spec.json │ └── typings.d.ts ├── tsconfig.json └── tslint.json ├── gunicorn_starter.sh ├── install-packages.sh ├── population.dot ├── population.png ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── integration ├── README.md ├── __init__.py ├── conftest.py ├── constants.py ├── models │ ├── __init__.py │ ├── test_feedback.py │ └── test_period.py └── views │ ├── __init__.py │ ├── company_stats.csv │ ├── conftest.py │ ├── test_auth.py │ ├── test_external.py │ ├── test_feedback.py │ ├── test_mail.py │ ├── test_manager.py │ ├── test_metadata.py │ ├── test_nomination.py │ └── test_talent_manager.py ├── scripts ├── __init__.py └── configure_db.py ├── settings.py └── unit ├── README.md ├── __init__.py ├── population.csv ├── test_config.py ├── test_date.py ├── test_period.py ├── test_population.py ├── test_security.py └── test_text.py /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 # use CircleCI 2.0 2 | jobs: # A basic unit of work in a run 3 | build: # runs not using Workflows must have a `build` job as entry point 4 | # directory where steps are run 5 | docker: # run the steps with Docker 6 | # CircleCI Python images available at: https://hub.docker.com/r/circleci/python/ 7 | - image: circleci/python:3.6.4 8 | steps: 9 | - checkout 10 | - run: sudo apt-get update 11 | - run: sudo apt-get install libldap2-dev 12 | - run: sudo apt-get install libsasl2-dev 13 | - run: sudo python setup.py develop 14 | - run: sudo pip install .[test] 15 | - run: py.test tests/ -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | 3 | /*.egg 4 | /*.log 5 | /*egg-info 6 | /.cache 7 | /.coverage 8 | /.coverage.* 9 | /.coveragerc 10 | /.project 11 | /.pydevproject 12 | /.settings 13 | */__pycache__ 14 | /build 15 | /coverage.xml 16 | /dist 17 | docs/autodoc 18 | /junit.xml 19 | /pylint.xml 20 | /temp 21 | /tmp 22 | /.eggs 23 | tests/integration/.coverage* 24 | tests/integration/__pycache__ 25 | tests/unit/__pycache__ 26 | *.pyc 27 | 28 | /*.idea 29 | *.DS_Store 30 | *.sqlite3 31 | # generated static assets should never be stored in repo 32 | adaero/static/3rdpartylicenses.txt 33 | adaero/static/*.js 34 | adaero/static/*.map 35 | adaero/static/*.bundle.css 36 | adaero/static/index.html 37 | 38 | docker 39 | tests 40 | frontend 41 | venv -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /*.egg 2 | /*.log 3 | /*egg-info 4 | /.cache 5 | /.coverage 6 | /.coverage.* 7 | /.coveragerc 8 | /.project 9 | /.pydevproject 10 | /.settings 11 | */__pycache__ 12 | /build 13 | /coverage.xml 14 | /dist 15 | docs/autodoc 16 | /junit.xml 17 | /pylint.xml 18 | /temp 19 | /tmp 20 | /.eggs 21 | tests/integration/.coverage* 22 | tests/integration/__pycache__ 23 | tests/unit/__pycache__ 24 | *.pyc 25 | 26 | /*.idea 27 | *.DS_Store 28 | *.sqlite3 29 | # generated static assets should never be stored in repo 30 | adaero/static/3rdpartylicenses.txt 31 | adaero/static/*.js 32 | adaero/static/*.map 33 | adaero/static/*.css 34 | adaero/static/index.html 35 | adaero/local.ini 36 | docker/bin/docker_common.sh 37 | adaero/test.ini 38 | frontend/package-lock.json 39 | 40 | .tox 41 | venv 42 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/man-group/adaero/991515895fd0063662755e0051577e1b7053a9fd/.gitmodules -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:18.04 2 | 3 | WORKDIR /usr/src/app 4 | COPY install-packages.sh . 5 | RUN ./install-packages.sh 6 | RUN curl https://bootstrap.pypa.io/get-pip.py | python3.6 7 | COPY setup.py . 8 | RUN pip3.6 install -e . 9 | COPY adaero adaero 10 | COPY gunicorn_starter.sh . 11 | 12 | EXPOSE 8080 13 | ENTRYPOINT ["./gunicorn_starter.sh"] 14 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.txt *.ini *.cfg *.rst 2 | recursive-include feedback *.png *.css *.gif *.jpg *.txt *.js *.html *.xml 3 | -------------------------------------------------------------------------------- /adaero/__init__.py: -------------------------------------------------------------------------------- 1 | # $HeadURL$ 2 | """ This is the main python package for adaero. 3 | """ 4 | 5 | from pyramid.config import Configurator 6 | 7 | 8 | def main(global_config, **settings): # pylint: disable=unused-argument 9 | """ This function returns a Pyramid WSGI application. 10 | """ 11 | config = Configurator(settings=settings) 12 | config.include(".security") 13 | config.include(".models") 14 | config.include(".mail") 15 | config.include("pyramid_beaker") 16 | config.include("rest_toolkit") 17 | # migrations do not form part of web app runtime 18 | config.scan(ignore="adaero.migrations") 19 | # views include must happen after scan or else scan will not work because 20 | # include overrides 21 | config.include(".views") 22 | return config.make_wsgi_app() 23 | -------------------------------------------------------------------------------- /adaero/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = migrations 6 | 7 | # template used to generate migration files 8 | # file_template = %%(rev)s_%%(slug)s 9 | 10 | # max length of characters to apply to the 11 | # "slug" field 12 | #truncate_slug_length = 40 13 | 14 | # set to 'true' to run the environment during 15 | # the 'revision' command, regardless of autogenerate 16 | # revision_environment = false 17 | 18 | # set to 'true' to allow .pyc and .pyo files without 19 | # a source .py file to be detected as revisions in the 20 | # versions/ directory 21 | # sourceless = false 22 | 23 | # version location specification; this defaults 24 | # to adaero/migrations/versions. When using multiple version 25 | # directories, initial revisions must be specified with --version-path 26 | # version_locations = %(here)s/bar %(here)s/bat adaero/migrations/versions 27 | 28 | # the output encoding used when revision files 29 | # are written from script.py.mako 30 | # output_encoding = utf-8 31 | 32 | # should passed in programmatically 33 | # sqlalchemy.url = driver://user:pass@localhost/dbname 34 | sqlalchemy.url = postgres://postgres:mysecretpassword@localhost:5432 35 | 36 | 37 | # Logging configuration 38 | [loggers] 39 | keys = root,sqlalchemy,alembic 40 | 41 | [handlers] 42 | keys = console 43 | 44 | [formatters] 45 | keys = generic 46 | 47 | [logger_root] 48 | level = WARN 49 | handlers = console 50 | qualname = 51 | 52 | [logger_sqlalchemy] 53 | level = WARN 54 | handlers = 55 | qualname = sqlalchemy.engine 56 | 57 | [logger_alembic] 58 | level = INFO 59 | handlers = 60 | qualname = alembic 61 | 62 | [handler_console] 63 | class = StreamHandler 64 | args = (sys.stderr,) 65 | level = NOTSET 66 | formatter = generic 67 | 68 | [formatter_generic] 69 | format = %(levelname)-5.5s [%(name)s] %(message)s 70 | datefmt = %H:%M:%S 71 | -------------------------------------------------------------------------------- /adaero/config.py: -------------------------------------------------------------------------------- 1 | import getpass 2 | import os 3 | import socket 4 | 5 | from logging import getLogger as get_logger 6 | from adaero import constants 7 | 8 | log = get_logger(__name__) 9 | 10 | 11 | def get_envvar_name(key): 12 | return key.split(".")[-1].upper() 13 | 14 | 15 | def get_config_value(settings, key, default=None, raise_if_not_set=False): 16 | """ 17 | For a given `key`, provide a value to resolves first to an environment 18 | variable, else the Pyramid config, else lastly, a default if provided. 19 | 20 | Parameters 21 | ---------- 22 | settings 23 | key 24 | default: 25 | raise_if_not_set: 26 | If `True`, then default is ignored if unset and throw an exception. 27 | 28 | Returns 29 | ------- 30 | Configuration value to use 31 | """ 32 | envvar_key = get_envvar_name(key) 33 | val = os.getenv(envvar_key, settings.get(key)) 34 | if isinstance(val, str): 35 | if val.lower() == "false": 36 | val = False 37 | elif val.lower() == "true": 38 | val = True 39 | if val is not None: 40 | return val 41 | else: 42 | if raise_if_not_set: 43 | raise ValueError( 44 | "`{}` is not set! Please set and try " "again.".format(key) 45 | ) 46 | return default 47 | 48 | 49 | def check_if_production(settings): 50 | hostname = socket.gethostname() 51 | unix_user = getpass.getuser() 52 | configured_hostname = get_config_value(settings, constants.PRODUCTION_HOSTNAME_KEY) 53 | configured_user = get_config_value(settings, constants.PRODUCTION_USER_KEY) 54 | is_production = hostname == configured_hostname and unix_user == configured_user 55 | if is_production: 56 | log.warning( 57 | "Configured production hostname and user match current " 58 | "environment so running in production mode." 59 | ) 60 | else: 61 | log.warning( 62 | "Configured production hostname and user (%s, %s) don't " 63 | "match the current environment (%s, %s) so not running in " 64 | "production mode." 65 | % (configured_hostname, configured_user, hostname, unix_user) 66 | ) 67 | return is_production 68 | -------------------------------------------------------------------------------- /adaero/constants.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | CONNECTION_STRING = "oracle+cx_oracle://%s:%s@%s" 5 | 6 | # all *_KEY below have a corresponding environment variable. please look at 7 | # `adaero/config.py` for more details. 8 | DB_URL_KEY = "adaero.db_url" 9 | DB_HOST_KEY = "adaero.db_host" 10 | DB_PORT_KEY = "adaero.db_port" 11 | DB_NAME_KEY = "adaero.db_name" 12 | DB_SERVICE_NAME_KEY = "adaero.db_service_name" 13 | BUSINESS_UNIT_KEY = "adaero.business_unit" 14 | HOMEBASE_LOCATION_KEY = "adaero.homebase_location" 15 | TALENT_MANAGER_USERNAMES_KEY = "adaero.talent_manager_usernames" 16 | RELOAD_USERS_ON_APP_START_KEY = "adaero.reload_users_on_app_start" 17 | LOAD_TALENT_MANAGERS_ON_APP_START_KEY = ( 18 | "adaero.load_talent_managers_" "on_app_start" 19 | ) 20 | CORS_ALLOW_ORIGIN_KEY = "adaero.cors.allow_origin" 21 | ALLOW_PASSWORDLESS_ACCESS_KEY = "adaero.allow_passwordless_access" 22 | LOAD_USER_EMAIL_LIST_KEY = "adaero.load_user_email_list" 23 | CHECK_AND_SEND_EMAIL_INT_KEY = "adaero.check_and_send_email_interval_s" 24 | RUN_EMAIL_INTERVAL_JOB_KEY = "adaero.run_email_interval_job" 25 | TALENT_MANAGER_ON_EMAIL_KEY = "adaero.talent_manager_on_email" 26 | SERVED_ON_HTTPS_KEY = "adaero.served_on_https" 27 | FRONTEND_SERVER_PORT_KEY = "adaero.frontend_server_port" 28 | PRODUCTION_HOSTNAME_KEY = "adaero.production_hostname" 29 | PRODUCTION_USER_KEY = "adaero.production_user" 30 | EMAIL_START_DELAY_S_KEY = "adaero.email_start_delay_s" 31 | EMAIL_DELAY_BETWEEN_S_KEY = "adaero.email_delay_between_s" 32 | ENABLE_SEND_EMAIL_KEY = "adaero.enable_send_email" 33 | DISPLAYED_HOSTNAME_KEY = "adaero.displayed_hostname" 34 | DATABASE_REVISION_KEY = "adaero.database_revision" 35 | LDAP_URI_KEY = "adaero.ldap_uri" 36 | LDAP_USER_BIND_TEMPLATE_KEY = "adaero.ldap_user_bind_template" 37 | LDAP_USERNAME_KEY = "adaero.ldap_username_key" 38 | LDAP_MANAGER_KEY = "adaero.ldap_manager_key" 39 | LDAP_LOCATION_KEY = "adaero.ldap_location_key" 40 | LDAP_UID_KEY = "adaero.ldap_uid_key" 41 | LDAP_DEPARTMENT_KEY = "adaero.ldap_department_key" 42 | LDAP_BUSINESS_UNIT_KEY = "adaero.ldap_business_unit_key" 43 | LDAP_SEARCH_BIND_DN_KEY = "adaero.ldap_search_bind_dn" 44 | LDAP_SEARCH_PASSWORD_KEY = "adaero.ldap_search_password" 45 | LDAP_BASE_DN_KEY = "adaero.ldap_base_dn" 46 | LDAP_DN_USERNAME_ATTRIBUTE_KEY = "adaero.ldap_dn_username_attribute" 47 | LDAP_DN_USERNAME_REGEX_KEY = "adaero.ldap_dn_username_regex" 48 | 49 | COMPANY_NAME_KEY = "adaero.company_name" 50 | SUPPORT_EMAIL_KEY = "adaero.support_email" 51 | REPLY_EMAIL_KEY = "adaero.reply_email" 52 | LOGIN_USERNAME_MSG_KEY = "adaero.login_username_msg" 53 | LOGIN_PASSWORD_MSG_KEY = "adaero.login_password_msg" 54 | TM_UPLOAD_NEW_POPULATION_MSG_KEY = "adaero.tm_upload_new_population_msg" 55 | TM_GENERATE_POPULATION_MSG_KEY = "adaero.tm_generate_population_msg" 56 | LOGO_FILENAME_KEY = "adaero.logo_filename" 57 | 58 | DEFAULT_DISPLAY_DATETIME_FORMAT = "%H:%M%p, %d %B %Y" 59 | ENROL_START = "enrol_start" 60 | ENTRY_START = "entry_start" 61 | APPROVE_START = "approve_start" 62 | REVIEW_START = "review_start" 63 | ENROL_REMINDER = "enrol_reminder" 64 | ENTRY_REMINDER = "entry_reminder" 65 | APPROVE_REMINDER = "approve_reminder" 66 | EMAIL_TEMPLATE_MAP = { 67 | ENROL_START: { 68 | "summary": "Opportunity to Enrol", 69 | "template": "enrol.html.j2", 70 | "audience": "employee", 71 | "code": "ust01", 72 | }, 73 | ENROL_REMINDER: { 74 | "summary": "Opportunity to Enrol Reminder", 75 | "template": "enrol.html.j2", 76 | "audience": "non-nominated", 77 | "code": "ust02", 78 | }, 79 | ENTRY_START: { 80 | "summary": "Provide Feedback", 81 | "template": "entry.html.j2", 82 | "audience": "employee", 83 | "code": "ust03", 84 | }, 85 | ENTRY_REMINDER: { 86 | "summary": "Provide Feedback Reminder", 87 | "template": "entry.html.j2", 88 | "audience": "employee", 89 | "code": "ust04", 90 | }, 91 | APPROVE_START: { 92 | "summary": "Manager Review and Schedule Feedback Discussions", 93 | "template": "approve.html.j2", 94 | "audience": "manager", 95 | "code": "ust05", 96 | }, 97 | REVIEW_START: { 98 | "summary": "Your feedback is available", 99 | "template": "review.html.j2", 100 | "audience": "summarised", 101 | "code": "ust06", 102 | }, 103 | APPROVE_REMINDER: { 104 | "summary": "Manager Review and Schedule Feedback Discussions Reminder", 105 | "template": "approve.html.j2", 106 | "audience": "manager", 107 | "code": "ust07", 108 | }, 109 | } 110 | AUDIENCE_VALUES = {t["audience"] for t in EMAIL_TEMPLATE_MAP.values()} 111 | EMAIL_CODES = {"ust01", "ust02", "ust03", "ust04", "ust05", "ust06", "ust07"} 112 | ANSWER_CHAR_LIMIT = 60000 113 | 114 | 115 | # If the below number is changed, please update: 116 | # * `frontend/components/views/feedback-about-me.component` 117 | # * `frontend/components/views/feedback-history-view.component` 118 | MANAGER_VIEW_HISTORY_LIMIT = 3 119 | MISCONFIGURATION_MESSAGE = "{error}. Please refer to the readme on how to set." 120 | -------------------------------------------------------------------------------- /adaero/data_migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/man-group/adaero/991515895fd0063662755e0051577e1b7053a9fd/adaero/data_migrations/__init__.py -------------------------------------------------------------------------------- /adaero/database.py: -------------------------------------------------------------------------------- 1 | from logging import getLogger as get_logger 2 | 3 | from sqlalchemy import create_engine 4 | from sqlalchemy.pool import QueuePool 5 | 6 | from adaero import config, constants 7 | 8 | 9 | log = get_logger(__name__) 10 | 11 | 12 | def prepare_db(settings): 13 | db_url = config.get_config_value( 14 | settings, constants.DB_URL_KEY, raise_if_not_set=True 15 | ) 16 | log.info("Connecting to DB %s", db_url) 17 | return create_engine( 18 | db_url, 19 | pool_size=5, 20 | max_overflow=40, 21 | echo_pool=True, 22 | pool_recycle=300, 23 | poolclass=QueuePool, 24 | echo=False, 25 | ) 26 | -------------------------------------------------------------------------------- /adaero/date.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from datetime import timedelta 5 | 6 | from pytz import timezone 7 | 8 | # https://en.wikipedia.org/wiki/List_of_tz_database_time_zones 9 | 10 | LONDON = "London" 11 | NEW_YORK = "New York" 12 | OXFORD = "Oxford" 13 | SHANGHAI = "Shanghai" 14 | HONG_KONG = "Hong Kong" 15 | GBR = "GBR" 16 | BOSTON = "Boston" 17 | PFAFFIKON = "Pfäffikon" 18 | USA = "USA" 19 | CUSTOM_LOC_TO_PYTZ_LOC = { 20 | LONDON: "Europe/London", 21 | NEW_YORK: "America/New_York", 22 | OXFORD: "Europe/London", 23 | SHANGHAI: "Asia/Shanghai", 24 | HONG_KONG: "Asia/Hong_Kong", 25 | GBR: "Europe/London", 26 | BOSTON: "US/Eastern", 27 | PFAFFIKON: "Europe/Zurich", 28 | USA: "US/Eastern", 29 | } 30 | 31 | 32 | def datetimeformat( 33 | utc_naive_dt, user, format_="%-d %B 18:00 (%Z)", fallback_location=LONDON 34 | ): 35 | """ 36 | By default, 1 April 18:00 (HKT) 37 | * To stay on the safe side, displayed datetime on email is 38 | 1 day prior, but no change to actual phase timing. 39 | * This means HK will have move time to complete 40 | * US has less time 41 | * Will not work for anytime zones UTC-6 42 | """ 43 | if format_.count("18:00"): 44 | utc_naive_dt -= timedelta(days=1) 45 | elif 0 < utc_naive_dt.minute < 30: 46 | utc_naive_dt -= timedelta(minutes=utc_naive_dt.minute) 47 | elif 30 < utc_naive_dt.minute <= 59: 48 | utc_naive_dt -= timedelta(minutes=utc_naive_dt.minute - 30) 49 | if user.location and user.location in list(CUSTOM_LOC_TO_PYTZ_LOC.keys()): 50 | valid_loc = user.location 51 | else: 52 | valid_loc = fallback_location 53 | tz = timezone(CUSTOM_LOC_TO_PYTZ_LOC[valid_loc]) 54 | return tz.localize(utc_naive_dt).strftime(format_) 55 | 56 | 57 | def adjust_dt_for_location(dt, location, fallback_location=LONDON): 58 | if location and location in list(CUSTOM_LOC_TO_PYTZ_LOC.keys()): 59 | valid_loc = location 60 | else: 61 | valid_loc = fallback_location 62 | return ( 63 | timezone(CUSTOM_LOC_TO_PYTZ_LOC[valid_loc]) 64 | .localize(dt) 65 | .astimezone(timezone("UTC")) 66 | .replace(tzinfo=None) 67 | ) 68 | -------------------------------------------------------------------------------- /adaero/example.ini: -------------------------------------------------------------------------------- 1 | ### 2 | # app configuration 3 | # https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/environment.html 4 | ### 5 | 6 | [app:adaero] 7 | use = egg:adaero 8 | pyramid.debug_authorization = false 9 | pyramid.debug_notfound = false 10 | pyramid.debug_routematch = false 11 | pyramid.default_locale_name = en 12 | 13 | adaero.debug_all = true 14 | adaero.db_url = postgres://postgres:mysecretpassword@db:5432 15 | 16 | # sessions 17 | beaker.session.type = memory 18 | beaker.session.secret = ibetterbeareallylongrandomkeyinproduction 19 | beaker.session.httponly = true 20 | 21 | # Please refer to the README.md for more information 22 | adaero.business_unit = 23 | adaero.homebase_location = London 24 | adaero.enable_send_email = false 25 | adaero.served_on_https = false 26 | adaero.talent_manager_usernames = ["eforshaw"] 27 | adaero.reload_users_on_app_start = false 28 | adaero.allow_passwordless_access = true 29 | adaero.run_email_interval_job = false 30 | adaero.check_and_send_email_interval_s = 600 31 | adaero.frontend_server_port = 4200 32 | adaero.email_start_delay_s = 5 33 | adaero.email_delay_between_s = 0.5 34 | 35 | adaero.ldap_uri = ldap://ldap:389 36 | adaero.ldap_username_key = uid 37 | adaero.ldap_manager_key = manager 38 | adaero.ldap_user_bind_template = uid={username},ou=People,dc=example,dc=org 39 | adaero.ldap_location_key = l 40 | adaero.ldap_uid_key = uidNumber 41 | adaero.ldap_department_key = departmentNumber 42 | adaero.ldap_business_unit_key = o 43 | adaero.ldap_search_bind_dn = cn=admin,dc=example,dc=org 44 | adaero.ldap_search_password = admin 45 | adaero.ldap_base_dn = ou=People,dc=example,dc=org 46 | adaero.ldap_dn_username_attribute = uid 47 | adaero.ldap_dn_username_regex = uid=(\w*) 48 | 49 | adaero.company_name = Example Org. 50 | adaero.support_email = support@example.com 51 | adaero.reply_email = no-reply@example.com 52 | adaero.login_username_msg = Enter your LDAP username 53 | adaero.login_password_msg = Enter your LDAP password 54 | adaero.tm_upload_new_population_msg = Please upload a CSV that has the same columns as the downloaded or generated CSV files. Use the PHPLDAPAdmin tool to find out more information about users. 55 | adaero.tm_generate_population_msg = Enter business unit i.e. Engineering 56 | 57 | 58 | 59 | rest_toolkit.debug = True 60 | 61 | ### 62 | # wsgi server configuration 63 | ### 64 | 65 | [server:main] 66 | use = egg:gunicorn#main 67 | # not using localhost to ensure we don't serve on ::0 68 | host = 0.0.0.0 69 | port = 8080 70 | 71 | [filter:translogger] 72 | use = egg:Paste#translogger 73 | setup_console_handler = False 74 | 75 | 76 | ### 77 | # logging configuration 78 | # https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/logging.html 79 | # to hide wsgi logs, remove `translogger` from the pipeline 80 | ### 81 | 82 | [pipeline:main] 83 | pipeline = translogger 84 | adaero 85 | 86 | [loggers] 87 | keys = root, adaero, sqlalchemy, txn 88 | 89 | [handlers] 90 | keys = console 91 | 92 | [formatters] 93 | keys = generic 94 | 95 | [logger_root] 96 | level = DEBUG 97 | handlers = console 98 | 99 | [logger_adaero] 100 | level = DEBUG 101 | handlers = console 102 | qualname = adaero 103 | propagate = 0 104 | 105 | [logger_sqlalchemy] 106 | level = WARN 107 | handlers = console 108 | qualname = sqlalchemy.engine 109 | propagate = 0 110 | # "level = INFO" logs SQL queries. 111 | # "level = DEBUG" logs SQL queries and results. 112 | # "level = WARN" logs neither. (Recommended for production systems.) 113 | 114 | [logger_txn] 115 | level = WARN 116 | handlers = console 117 | qualname = txn 118 | 119 | [handler_console] 120 | class = StreamHandler 121 | args = (sys.stderr,) 122 | level = NOTSET 123 | formatter = generic 124 | 125 | [formatter_generic] 126 | format = %(asctime)s %(levelname)-5.5s [%(name)s:%(lineno)s] %(message)s 127 | -------------------------------------------------------------------------------- /adaero/gunicorn/config.py: -------------------------------------------------------------------------------- 1 | logconfig = "/opt/app-root/src/adaero/example.ini" 2 | -------------------------------------------------------------------------------- /adaero/gunicorn/wsgi.py: -------------------------------------------------------------------------------- 1 | from paste.deploy import loadapp 2 | 3 | application = loadapp("config:/opt/app-root/src/adaero/example.ini") 4 | -------------------------------------------------------------------------------- /adaero/history.py: -------------------------------------------------------------------------------- 1 | import transaction 2 | from sqlalchemy import and_, desc 3 | 4 | from adaero.config import get_config_value 5 | from adaero.models import Period, FeedbackForm, Nominee, User 6 | from adaero import constants 7 | 8 | 9 | def fetch_feedback_history(dbsession, username, settings, fetch_full=False): 10 | """ 11 | Parameters 12 | ---------- 13 | dbsession: `sqlalchemy.session.Session` 14 | username: `str` 15 | Username of user to fetch the feedback for 16 | settings: `dict` 17 | Global settings that Pyramid generates from the ini file 18 | fetch_full: `bool` 19 | If `False`, only fetch the latest 20 | `constants.MANAGER_VIEW_HISTORY_LIMIT` feedbacks 21 | 22 | Returns 23 | ------- 24 | JSON-serialisable payload that contains the feedback history of 25 | provided user. 26 | """ 27 | location = get_config_value(settings, constants.HOMEBASE_LOCATION_KEY) 28 | q = dbsession.query(Period, FeedbackForm, Nominee) 29 | with transaction.manager: 30 | user = dbsession.query(User).get(username) 31 | history = ( 32 | q.outerjoin( 33 | FeedbackForm, 34 | and_( 35 | FeedbackForm.period_id == Period.id, 36 | FeedbackForm.to_username == username, 37 | FeedbackForm.is_summary == True, 38 | ), 39 | ) # noqa 40 | .outerjoin( 41 | Nominee, 42 | and_(Period.id == Nominee.period_id, Nominee.username == username), 43 | ) 44 | .order_by(desc(Period.enrollment_start_utc)) 45 | ) 46 | 47 | if fetch_full: 48 | history = history.all() 49 | else: 50 | history = history.limit(constants.MANAGER_VIEW_HISTORY_LIMIT) 51 | 52 | feedbacks = [] 53 | for period, summary_form, nominee in history: 54 | if period.subperiod(location) != Period.REVIEW_SUBPERIOD: 55 | feedbacks.append( 56 | { 57 | "periodDescription": "%s pending" % period.name, 58 | "enable": False, 59 | "items": [], 60 | } 61 | ) 62 | elif not summary_form and not nominee: 63 | feedbacks.append( 64 | { 65 | "periodDescription": "Did not request feedback for period " 66 | "%s" % period.name, 67 | "enable": False, 68 | "items": [], 69 | } 70 | ) 71 | elif not summary_form: 72 | feedbacks.append( 73 | { 74 | "periodDescription": "No feedback available for period %s" 75 | % period.name, 76 | "enable": False, 77 | "items": [], 78 | } 79 | ) 80 | else: 81 | feedback = { 82 | "periodDescription": period.name, 83 | "enable": True, 84 | "items": [], 85 | } 86 | ordered_questions = sorted( 87 | [qu for qu in period.template.rows], key=lambda x: x.position 88 | ) 89 | answers_by_q_id = {f.question_id: f for f in summary_form.answers} 90 | ordered_rows = [ 91 | answers_by_q_id[r.question.id] for r in ordered_questions 92 | ] 93 | for answer in ordered_rows: 94 | item = { 95 | "question": answer.question.question_template.format( 96 | display_name=user.display_name, period_name=period.name 97 | ), 98 | "answer": answer.content, 99 | } 100 | feedback["items"].append(item) 101 | feedbacks.append(feedback) 102 | 103 | return {"feedback": {"displayName": user.display_name, "items": feedbacks}} 104 | -------------------------------------------------------------------------------- /adaero/host_example.ini: -------------------------------------------------------------------------------- 1 | ### 2 | # app configuration 3 | # https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/environment.html 4 | ### 5 | 6 | [app:adaero] 7 | use = egg:adaero 8 | pyramid.debug_authorization = false 9 | pyramid.debug_notfound = false 10 | pyramid.debug_routematch = false 11 | pyramid.default_locale_name = en 12 | 13 | adaero.debug_all = true 14 | adaero.db_url = postgres://postgres:mysecretpassword@localhost:5432 15 | 16 | # sessions 17 | beaker.session.type = memory 18 | beaker.session.secret = ibetterbeareallylongrandomkeyinproduction 19 | beaker.session.httponly = true 20 | 21 | # Please refer to the README.md for more information 22 | adaero.homebase_location = London 23 | adaero.enable_send_email = false 24 | adaero.served_on_https = false 25 | adaero.talent_manager_usernames = ["eforshaw"] 26 | adaero.reload_users_on_app_start = false 27 | adaero.allow_passwordless_access = true 28 | adaero.run_email_interval_job = false 29 | adaero.check_and_send_email_interval_s = 600 30 | adaero.frontend_server_port = 4200 31 | adaero.email_start_delay_s = 5 32 | adaero.email_delay_between_s = 0.5 33 | 34 | adaero.ldap_uri = ldap://localhost:389 35 | adaero.ldap_username_key = uid 36 | adaero.ldap_manager_key = manager 37 | adaero.ldap_user_bind_template = uid={username},ou=People,dc=example,dc=org 38 | adaero.ldap_location_key = l 39 | adaero.ldap_uid_key = uidNumber 40 | adaero.ldap_department_key = departmentNumber 41 | adaero.ldap_business_unit_key = o 42 | adaero.ldap_search_bind_dn = cn=admin,dc=example,dc=org 43 | adaero.ldap_search_password = admin 44 | adaero.ldap_base_dn = ou=People,dc=example,dc=org 45 | adaero.ldap_dn_username_attribute = uid 46 | adaero.ldap_dn_username_regex = uid=(\w*) 47 | 48 | adaero.company_name = Example Org. 49 | adaero.support_email = support@example.com 50 | adaero.reply_email = no-reply@example.com 51 | adaero.login_username_msg = Enter your LDAP username 52 | adaero.login_password_msg = Enter your LDAP password 53 | adaero.tm_upload_new_population_msg = Please upload a CSV that has the same columns as the downloaded or generated CSV files. Use the PHPLDAPAdmin tool to find out more information about users. 54 | adaero.tm_generate_population_msg = Enter business unit i.e. Engineering 55 | 56 | 57 | 58 | rest_toolkit.debug = True 59 | 60 | ### 61 | # wsgi server configuration 62 | ### 63 | 64 | [server:main] 65 | use = egg:gunicorn#main 66 | # not using localhost to ensure we don't serve on ::0 67 | host = 0.0.0.0 68 | port = 8080 69 | 70 | [filter:translogger] 71 | use = egg:Paste#translogger 72 | setup_console_handler = False 73 | 74 | 75 | ### 76 | # logging configuration 77 | # https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/logging.html 78 | # to hide wsgi logs, remove `translogger` from the pipeline 79 | ### 80 | 81 | [pipeline:main] 82 | pipeline = translogger 83 | adaero 84 | 85 | [loggers] 86 | keys = root, adaero, sqlalchemy, txn 87 | 88 | [handlers] 89 | keys = console 90 | 91 | [formatters] 92 | keys = generic 93 | 94 | [logger_root] 95 | level = DEBUG 96 | handlers = console 97 | 98 | [logger_adaero] 99 | level = DEBUG 100 | handlers = console 101 | qualname = adaero 102 | propagate = 0 103 | 104 | [logger_sqlalchemy] 105 | level = WARN 106 | handlers = console 107 | qualname = sqlalchemy.engine 108 | propagate = 0 109 | # "level = INFO" logs SQL queries. 110 | # "level = DEBUG" logs SQL queries and results. 111 | # "level = WARN" logs neither. (Recommended for production systems.) 112 | 113 | [logger_txn] 114 | level = WARN 115 | handlers = console 116 | qualname = txn 117 | 118 | [handler_console] 119 | class = StreamHandler 120 | args = (sys.stderr,) 121 | level = NOTSET 122 | formatter = generic 123 | 124 | [formatter_generic] 125 | format = %(asctime)s %(levelname)-5.5s [%(name)s:%(lineno)s] %(message)s 126 | -------------------------------------------------------------------------------- /adaero/migration.py: -------------------------------------------------------------------------------- 1 | import alembic.config 2 | import os 3 | import pkg_resources 4 | 5 | from adaero import config, constants 6 | 7 | 8 | def generate_alembic_config(settings): 9 | pth = pkg_resources.resource_filename("adaero", "alembic.ini") 10 | os.chdir(os.path.dirname(pth)) 11 | alembic_cfg = alembic.config.Config(pth) 12 | return alembic_cfg 13 | 14 | 15 | def upgrade_db_to_configured_revision(settings): 16 | alembic_cfg = generate_alembic_config(settings) 17 | revision = config.get_config_value( 18 | settings, constants.DATABASE_REVISION_KEY, default="head" 19 | ) 20 | if not revision: 21 | raise ValueError( 22 | constants.MISCONFIGURATION_MESSAGE.format( 23 | error="Database revision is not set" 24 | ) 25 | ) 26 | alembic.command.upgrade(alembic_cfg, revision=revision) 27 | -------------------------------------------------------------------------------- /adaero/migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. Please refer to the adaero 2 | README.md for more information. -------------------------------------------------------------------------------- /adaero/migrations/env.py: -------------------------------------------------------------------------------- 1 | from __future__ import with_statement 2 | from alembic import context 3 | from sqlalchemy import engine_from_config, pool 4 | from logging.config import fileConfig 5 | 6 | from adaero.models.all import Base 7 | 8 | # this is the Alembic Config object, which provides 9 | # access to the values within the .ini file in use. 10 | config = context.config 11 | 12 | # Interpret the config file for Python logging. 13 | # This line sets up loggers basically. 14 | fileConfig(config.config_file_name) 15 | 16 | # Configuration for autogeneration of schema migration scripts 17 | target_metadata = Base.metadata 18 | 19 | # other values from the config, defined by the needs of env.py, 20 | # can be acquired: 21 | # my_important_option = config.get_main_option("my_important_option") 22 | # ... etc. 23 | 24 | 25 | def run_migrations_offline(): 26 | """Run migrations in 'offline' mode. 27 | 28 | This configures the context with just a URL 29 | and not an Engine, though an Engine is acceptable 30 | here as well. By skipping the Engine creation 31 | we don't even need a DBAPI to be available. 32 | 33 | Calls to context.execute() here emit the given string to the 34 | script output. 35 | 36 | """ 37 | url = config.get_main_option("sqlalchemy.url") 38 | context.configure(url=url, target_metadata=target_metadata) 39 | 40 | with context.begin_transaction(): 41 | context.run_migrations() 42 | 43 | 44 | def run_migrations_online(): 45 | """Run migrations in 'online' mode. 46 | 47 | In this scenario we need to create an Engine 48 | and associate a connection with the context. 49 | 50 | """ 51 | engine = engine_from_config( 52 | config.get_section(config.config_ini_section), 53 | prefix="sqlalchemy.", 54 | poolclass=pool.NullPool, 55 | ) 56 | 57 | connection = engine.connect() 58 | context.configure(connection=connection, target_metadata=target_metadata) 59 | 60 | try: 61 | with context.begin_transaction(): 62 | context.run_migrations() 63 | finally: 64 | connection.close() 65 | 66 | 67 | if context.is_offline_mode(): 68 | run_migrations_offline() 69 | else: 70 | run_migrations_online() 71 | -------------------------------------------------------------------------------- /adaero/migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = ${repr(up_revision)} 11 | down_revision = ${repr(down_revision)} 12 | branch_labels = ${repr(branch_labels)} 13 | depends_on = ${repr(depends_on)} 14 | 15 | from alembic import op 16 | import sqlalchemy as sa 17 | ${imports if imports else ""} 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /adaero/scripts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/man-group/adaero/991515895fd0063662755e0051577e1b7053a9fd/adaero/scripts/__init__.py -------------------------------------------------------------------------------- /adaero/scripts/feedback_app.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from pyramid.scripts import pserve 4 | import pkg_resources 5 | 6 | 7 | def main(): 8 | config_ini_file = sys.argv[1] 9 | config_path = pkg_resources.resource_filename("adaero", config_ini_file) 10 | return pserve.main(argv=["pserve", config_path]) 11 | -------------------------------------------------------------------------------- /adaero/security/__init__.py: -------------------------------------------------------------------------------- 1 | from logging import getLogger as get_logger 2 | from pyramid.authentication import SessionAuthenticationPolicy 3 | from pyramid.authorization import ACLAuthorizationPolicy 4 | from pyramid.events import NewRequest 5 | from pyramid.security import Authenticated, Everyone 6 | 7 | from adaero import constants 8 | from adaero.config import get_config_value, get_envvar_name, check_if_production 9 | from adaero.models.user import request_user_callback 10 | from adaero.security.ldapauth import request_ldapauth_callback 11 | 12 | log = get_logger(__name__) 13 | 14 | USER_SESSION_KEY = "user" 15 | # cookie named aligned with frontend 16 | # https://angular.io/api/common/http/HttpClientXsrfModule 17 | ANGULAR_2_XSRF_TOKEN_COOKIE_NAME = "XSRF-TOKEN" 18 | ANGULAR_2_XSRF_TOKEN_HEADER_NAME = "X-XSRF-TOKEN" 19 | PYRAMID_XSRF_TOKEN_COOKIE_NAME = "X-CSRF-Token" 20 | 21 | EXTERNAL_BUSINESS_UNIT_ROLE = "role:external_business_unit" 22 | EMPLOYEE_ROLE = "role:employee" 23 | MANAGER_ROLE = "role:manager" 24 | TALENT_MANAGER_ROLE = "role:talent_manager" 25 | DIRECT_REPORT_PREFIX = "direct_report:" 26 | TRADING_DEPARTMENT_TEMPLATE = "Trading - {}" 27 | 28 | 29 | def add_cors_callback_builder(allow_origin_domain): 30 | def add_cors(event): 31 | 32 | headers = "Origin, Content-Type, Accept, Authorization" 33 | 34 | def cors_headers(request, response): 35 | origin = allow_origin_domain 36 | response.headers.update( 37 | { 38 | # In production you would be careful with this 39 | "Access-Control-Allow-Origin": origin, 40 | "Access-Control-Allow-Headers": headers, 41 | "Access-Control-Allow-Credentials": "true", 42 | } 43 | ) 44 | 45 | event.request.add_response_callback(cors_headers) 46 | 47 | return add_cors 48 | 49 | 50 | class SimpleAuthenticationPolicy(SessionAuthenticationPolicy): 51 | def authenticated_userid(self, request): 52 | user = request.user 53 | if user is not None: 54 | return user.username 55 | return None 56 | 57 | def effective_principals(self, request): 58 | principals = [Everyone] 59 | user = request.user 60 | if not user: 61 | return principals 62 | 63 | principals.append(Authenticated) 64 | principals.append(user.username) 65 | 66 | if user.is_staff: 67 | principals.append(EMPLOYEE_ROLE) 68 | else: 69 | principals.append(EXTERNAL_BUSINESS_UNIT_ROLE) 70 | 71 | if user.has_direct_reports: 72 | principals.append(MANAGER_ROLE) 73 | for direct_report_user in user.direct_reports: 74 | principal_string = DIRECT_REPORT_PREFIX + direct_report_user.username 75 | principals.append(principal_string) 76 | 77 | if ( 78 | user.username 79 | in request.registry.settings[constants.TALENT_MANAGER_USERNAMES_KEY] 80 | ): 81 | principals.append(TALENT_MANAGER_ROLE) 82 | 83 | return principals 84 | 85 | 86 | def setup_cors(config): 87 | settings = config.get_settings() 88 | allow_origin_string = get_config_value(settings, constants.CORS_ALLOW_ORIGIN_KEY) 89 | if allow_origin_string: 90 | log.warning( 91 | "CORS enabled. Access-Control-Allow-Origin will be " 92 | "restricted to %s" % allow_origin_string 93 | ) 94 | config.add_subscriber( 95 | add_cors_callback_builder(allow_origin_string), NewRequest 96 | ) 97 | 98 | 99 | def includeme(config): 100 | 101 | settings = config.get_settings() 102 | 103 | if get_config_value(settings, constants.ALLOW_PASSWORDLESS_ACCESS_KEY): 104 | log.warning( 105 | "PASSWORDLESS ACCESS IS ENABLED (has been set in " 106 | "config %s or envvar %s)" 107 | % ( 108 | constants.ALLOW_PASSWORDLESS_ACCESS_KEY, 109 | get_envvar_name(constants.ALLOW_PASSWORDLESS_ACCESS_KEY), 110 | ) 111 | ) 112 | 113 | authn_policy = SimpleAuthenticationPolicy(callback=None) 114 | config.set_authentication_policy(authn_policy) 115 | config.set_authorization_policy(ACLAuthorizationPolicy()) 116 | config.set_default_csrf_options( 117 | require_csrf=True, header=ANGULAR_2_XSRF_TOKEN_HEADER_NAME 118 | ) 119 | config.add_request_method(request_user_callback, "user", reify=True) 120 | config.add_request_method(request_ldapauth_callback, "ldapsource", reify=True) 121 | 122 | config.add_request_method( 123 | lambda: check_if_production(settings), "is_production", reify=True 124 | ) 125 | setup_cors(config) 126 | -------------------------------------------------------------------------------- /adaero/static/assets/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/man-group/adaero/991515895fd0063662755e0051577e1b7053a9fd/adaero/static/assets/favicon-16x16.png -------------------------------------------------------------------------------- /adaero/static/assets/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/man-group/adaero/991515895fd0063662755e0051577e1b7053a9fd/adaero/static/assets/favicon-32x32.png -------------------------------------------------------------------------------- /adaero/static/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/man-group/adaero/991515895fd0063662755e0051577e1b7053a9fd/adaero/static/assets/logo.png -------------------------------------------------------------------------------- /adaero/templates/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/man-group/adaero/991515895fd0063662755e0051577e1b7053a9fd/adaero/templates/__init__.py -------------------------------------------------------------------------------- /adaero/templates/email/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/man-group/adaero/991515895fd0063662755e0051577e1b7053a9fd/adaero/templates/email/__init__.py -------------------------------------------------------------------------------- /adaero/templates/email/approve.html.j2: -------------------------------------------------------------------------------- 1 | {% extends 'email/base.html.j2' %} 2 | {% block content %} 3 |

4 | Hi {{ user.first_name }},

5 |

6 | We have now reached the end of this feedback period and you have direct report(s) who have requested feedback 7 | on how they are performing. You should review and summarise feedback at {{ app_host }}/team-feedback 8 | (please open with Google Chrome) by {{ period.approval_end_utc | datetimeformat(user) }}.

9 |

10 | Remember that feedback should motivate, improve performance and support continuous learning, so consider how the 11 | feedback you are sharing does this.

12 | {% endblock %} 13 | -------------------------------------------------------------------------------- /adaero/templates/email/base.html.j2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | {% block content %} 11 | {% endblock %} 12 | 13 | 14 | -------------------------------------------------------------------------------- /adaero/templates/email/enrol.html.j2: -------------------------------------------------------------------------------- 1 | {% extends 'email/base.html.j2' %} 2 | {% block content %} 3 |

4 | Hi {{ user.first_name }},

5 |

6 | How well are you performing? How well do you understand your strengths and areas of improvement? Are there things you 7 | could do to improve your performance?

8 |

9 | If now would be a useful time to receive feedback from your colleagues, then enrol in the next 10 | feedback period here: {{ app_host }}/self-nominate 11 | (please open with Google Chrome) by {{ period.entry_start_utc | datetimeformat(user) }}.

12 | {% endblock %} 13 | -------------------------------------------------------------------------------- /adaero/templates/email/entry.html.j2: -------------------------------------------------------------------------------- 1 | {% extends 'email/base.html.j2' %} 2 | {% block content %} 3 |

4 | Hi {{ user.first_name }},

5 |

6 | Some of your colleagues would like to receive feedback on how they are performing. You are able to give feedback about anyone that has enrolled. You should prioritise people that you regularly work with.

7 |

1. You can give feedback to your colleagues here: {{ app_host }}/feedback (please open with Google Chrome).

8 |

2. If you have requested feedback for this period and would like to receive feedback from outside {{ business_unit }} (but within {{ company_name}}), you can invite them here: {{ app_host }}/invite-outside-reviewers.

9 |

10 | Guidelines:

11 |

1. There are only a few simple questions. You can leave as much or as little feedback as you choose to.

12 |

2. Everyone should contribute to improving the performance of their colleagues, so we recommend you invest time to give feedback to colleagues you have worked with in recent months. 13 |

3. Remember that feedback should motivate, improve performance and support continuous learning. Please consider this when providing feedback.

14 |

15 | The "Give feedback" period will close by {{ period.approval_start_utc | datetimeformat(user) }}.

16 | {% endblock %} 17 | -------------------------------------------------------------------------------- /adaero/templates/email/invite.html.j2: -------------------------------------------------------------------------------- 1 | {% extends 'email/base.html.j2' %} 2 | {% block content %} 3 |

4 | Hi {{ invitee.first_name }},

5 |

6 | You have been invited by {{ inviter.display_name }} from {{ inviter.business_unit }} to give feedback on their performance 7 | over the recent months. You can give them feedback here: {{ app_host }}/feedback/{{ inviter.username }} (please open with Google Chrome). 8 |

9 | Guidelines:

10 |

1. There are only a few simple questions. You can leave as much or as little feedback as you choose to.

11 |

2. Remember that feedback should motivate, improve performance and support continuous learning. Please consider this when providing feedback.

12 |

13 | The "Give feedback" period will close by {{ period.approval_start_utc | datetimeformat(invitee) }}.

14 | {% endblock %} -------------------------------------------------------------------------------- /adaero/templates/email/review.html.j2: -------------------------------------------------------------------------------- 1 | {% extends 'email/base.html.j2' %} 2 | {% block content %} 3 |

4 | Hi {{ user.first_name }},

5 |

6 | Thank you for requesting feedback. You can access your feedback here: {{ app_host }}/feedback-about-me (please open with Google Chrome). Some things to consider that will help you get the most value from your feedback are:

7 |

* Schedule time to debrief with your manager

8 |

* Identify the areas you are motivated to work on and put a plan in place to act on the feedback

9 |

* Remember your strengths, these are often your differentiators so you should find opportunities to use them

10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /adaero/templates/email/tm_confirmation.html.j2: -------------------------------------------------------------------------------- 1 | {% extends 'email/base.html.j2' %} 2 | {% block content %} 3 |

4 | Hi {{ talent_manager.first_name }},

5 |

6 | This is an automated response from the 360 Feedback system. Emails with the subject "{{ subject }}" were sent out to {{ num_emails }} email addresses at {{ datetime_sent_utc | datetimeformat(talent_manager) }}.

7 | {% endblock %} 8 | -------------------------------------------------------------------------------- /adaero/text.py: -------------------------------------------------------------------------------- 1 | from copy import copy 2 | from datetime import datetime 3 | 4 | from adaero.constants import ANSWER_CHAR_LIMIT 5 | 6 | 7 | def check_input(input_): 8 | utcstr = datetime.utcnow() 9 | if len(input_) > ANSWER_CHAR_LIMIT: 10 | return ( 11 | u"%s: Character limit of %s has been exceeded by %s. Please " 12 | u"reduce your answer size." 13 | % (utcstr, ANSWER_CHAR_LIMIT, len(input_) - ANSWER_CHAR_LIMIT) 14 | ) 15 | return None 16 | 17 | 18 | def interpolate_template(template, **kwargs): 19 | data = copy(template) 20 | for key in data.keys(): 21 | if isinstance(template[key], str): 22 | data[key] = template[key].format(**kwargs) 23 | return data 24 | -------------------------------------------------------------------------------- /adaero/views/__init__.py: -------------------------------------------------------------------------------- 1 | from pyramid.static import static_view 2 | from pyramid.security import DENY_ALL 3 | 4 | static_view = static_view("adaero:static", use_subpath=True) 5 | 6 | # Safety net in case a view does not inherit from Root 7 | __acl__ = [DENY_ALL] 8 | 9 | 10 | class Root(object): 11 | __acl__ = [DENY_ALL] 12 | 13 | 14 | def includeme(config): 15 | """Pyramid convention that allows invocation of a function prior to 16 | server start and is found through `config.scan` in the main function""" 17 | config.add_route("catchall_static", "/*subpath") 18 | config.add_view("adaero.views.static_view", route_name="catchall_static") 19 | -------------------------------------------------------------------------------- /adaero/views/auth.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from pyramid.httpexceptions import HTTPUnauthorized, HTTPOk 4 | from pyramid.security import ( 5 | NO_PERMISSION_REQUIRED, 6 | forget, 7 | remember, 8 | Allow, 9 | Authenticated, 10 | ) 11 | 12 | from logging import getLogger as get_logger 13 | from rest_toolkit import resource 14 | 15 | from adaero.constants import ALLOW_PASSWORDLESS_ACCESS_KEY, BUSINESS_UNIT_KEY 16 | from adaero.config import get_config_value 17 | from adaero.security import ldapauth, ANGULAR_2_XSRF_TOKEN_COOKIE_NAME 18 | from adaero.views import Root 19 | 20 | 21 | log = get_logger(__name__) 22 | 23 | 24 | def _build_user_data_response(request, username): 25 | request.response.status_int = 200 26 | request.response.set_cookie( 27 | ANGULAR_2_XSRF_TOKEN_COOKIE_NAME, request.session.get_csrf_token() 28 | ) 29 | unit_name = get_config_value(request.registry.settings, BUSINESS_UNIT_KEY) 30 | return { 31 | "success": True, 32 | "data": { 33 | "displayName": request.user.display_name, 34 | "title": request.user.position, 35 | "principals": request.effective_principals, 36 | "businessUnit": unit_name, 37 | }, 38 | } 39 | 40 | 41 | @resource("/api/v1/login") 42 | class Login(Root): 43 | def __init__(self, request): # pylint disable=unused-argument 44 | pass 45 | 46 | 47 | @Login.POST(permission=NO_PERMISSION_REQUIRED, require_csrf=False) 48 | def login(request): 49 | username = request.json_body["username"] 50 | password = request.json_body["password"] 51 | if not get_config_value( 52 | request.registry.settings, ALLOW_PASSWORDLESS_ACCESS_KEY 53 | ) and not request.ldapsource.auth_user(username, password): 54 | raise HTTPUnauthorized 55 | remember(request, username) 56 | return _build_user_data_response(request, username) 57 | 58 | 59 | @resource("/api/v1/user-data") 60 | class UserData(Root): 61 | 62 | __acl__ = [(Allow, Authenticated, "read")] 63 | 64 | def __init__(self, request): # pylint disable=unused-argument 65 | pass 66 | 67 | 68 | @UserData.GET(permission="read") 69 | def get_user_data(request): 70 | username = request.authenticated_userid 71 | if not username: 72 | raise HTTPUnauthorized 73 | return _build_user_data_response(request, username) 74 | 75 | 76 | @resource("/api/v1/logout") 77 | class Logout(Root): 78 | def __init__(self, request): # pylint disable=unused-argument 79 | pass 80 | 81 | 82 | @Logout.POST(permission=NO_PERMISSION_REQUIRED, require_csrf=False) 83 | def logout(request): 84 | headers = forget(request) 85 | return HTTPOk(headers=headers) 86 | -------------------------------------------------------------------------------- /adaero/views/error.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from pyramid.httpexceptions import HTTPBadRequest, HTTPInternalServerError 4 | from pyramid.view import view_config, notfound_view_config, forbidden_view_config 5 | 6 | from logging import getLogger as get_logger 7 | 8 | log = get_logger(__name__) 9 | 10 | 11 | @view_config(context=HTTPBadRequest, renderer="json") 12 | def bad_request(exc, request): 13 | request.response.status_code = 400 14 | return {"message": exc.explanation} 15 | 16 | 17 | @view_config(context=HTTPInternalServerError, renderer="json") 18 | def internal_error(exc, request): 19 | request.response.status_code = 400 20 | if getattr(exc, "explanation"): 21 | message = exc.explanation 22 | else: 23 | message = "%s - Internal error on server" % datetime.utcnow() 24 | return {"message": message} 25 | 26 | 27 | @notfound_view_config(renderer="json") 28 | def not_found(exc, request): 29 | log.info("Page Not Found: %s", request.path_url) 30 | request.response.status_code = 404 31 | return {"message": exc.explanation} 32 | 33 | 34 | @forbidden_view_config(renderer="json") 35 | def forbidden(request): 36 | if request.unauthenticated_userid: 37 | log.warning("Forbidden: %s for %s", request.path_url, request.user.username) 38 | request.response.status_int = 403 39 | payload = {"message": "You are not allowed to perform this action"} 40 | if request.registry.settings.get("adaero.debug_all"): 41 | payload["user"] = request.user.to_dict() 42 | return payload 43 | else: 44 | request.response.status_int = 401 45 | return {"message": "You must login to perform this action"} 46 | -------------------------------------------------------------------------------- /adaero/views/feedback.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from logging import getLogger as get_logger 4 | from pyramid.httpexceptions import HTTPNotFound 5 | from pyramid.security import Allow, Authenticated 6 | from rest_toolkit import resource 7 | from sqlalchemy.orm import joinedload 8 | 9 | from adaero import constants 10 | from adaero.config import get_config_value 11 | from adaero.forms import build_feedback_payload, update_feedback_form 12 | from adaero.models import ExternalInvite, FeedbackForm, Nominee, Period 13 | from adaero.history import fetch_feedback_history 14 | from adaero.security import EMPLOYEE_ROLE, EXTERNAL_BUSINESS_UNIT_ROLE 15 | from adaero.views import Root 16 | 17 | log = get_logger(__name__) 18 | 19 | 20 | @resource("/api/v1/feedback/{username:\w+}/") 21 | class FeedbackFormResource(Root): 22 | 23 | __acl__ = [(Allow, Authenticated, ("read", "update"))] 24 | 25 | def __init__(self, request): # pylint disable=unused-argument 26 | """Pre-check that the `request.user` is allowed to give feedback 27 | to `request.matchdict['username']`.""" 28 | location = get_config_value( 29 | request.registry.settings, constants.HOMEBASE_LOCATION_KEY 30 | ) 31 | self.current_period = Period.get_current_period( 32 | request.dbsession, 33 | options=(joinedload("template").joinedload("rows").joinedload("question")), 34 | ) 35 | 36 | self.current_nominees = ( 37 | request.dbsession.query(Nominee) 38 | .options(joinedload("user")) 39 | .filter(Nominee.period == self.current_period) 40 | ) 41 | 42 | if self.current_period.subperiod(location) != Period.ENTRY_SUBPERIOD: 43 | raise HTTPNotFound(explanation="Currently not in the entry " "period.") 44 | 45 | self.to_username = request.matchdict["username"] 46 | self.from_username = request.user.username 47 | 48 | if self.to_username == self.from_username: 49 | raise HTTPNotFound(explanation="Cannot use feedback on self.") 50 | 51 | self.nominee = self.current_nominees.filter( 52 | Nominee.username == self.to_username 53 | ).one_or_none() 54 | 55 | if not self.nominee: 56 | raise HTTPNotFound( 57 | explanation='Nominee "%s" does not exist.' % self.to_username 58 | ) 59 | 60 | if EXTERNAL_BUSINESS_UNIT_ROLE in request.effective_principals: 61 | exists = ( 62 | request.dbsession.query(ExternalInvite) 63 | .filter( 64 | ExternalInvite.from_username == self.to_username, 65 | ExternalInvite.to_username == self.from_username, 66 | ExternalInvite.period_id == self.current_period.id, 67 | ) 68 | .one_or_none() 69 | ) 70 | if not exists: 71 | raise HTTPNotFound( 72 | explanation='User "%s" did not invite you ' 73 | "for feedback." % self.to_username 74 | ) 75 | 76 | self.form = ( 77 | request.dbsession.query(FeedbackForm) 78 | .options(joinedload("answers").joinedload("question")) 79 | .filter( 80 | FeedbackForm.period_id == self.current_period.id, 81 | FeedbackForm.to_username == self.to_username, 82 | FeedbackForm.from_username == self.from_username, 83 | FeedbackForm.is_summary == False, 84 | ) # noqa 85 | .one_or_none() 86 | ) 87 | 88 | 89 | @FeedbackFormResource.GET(permission="read") 90 | def view_feedback(context, request): 91 | return {"form": build_feedback_payload(context, request, False)} 92 | 93 | 94 | @FeedbackFormResource.PUT(permission="update") 95 | def put_feedback(context, request): 96 | return update_feedback_form(context, request, False) 97 | 98 | 99 | @resource("/api/v1/feedback-about-me") 100 | class FeedbackAboutMeResource(Root): 101 | 102 | __acl__ = [(Allow, EMPLOYEE_ROLE, "read")] 103 | 104 | def __init__(self, request): # pylint disable=unused-argument 105 | pass 106 | 107 | 108 | @FeedbackAboutMeResource.GET(permission="read") 109 | def get_feedback_about_me(_, request): 110 | return fetch_feedback_history( 111 | request.dbsession, 112 | request.user.username, 113 | request.registry.settings, 114 | fetch_full=True, 115 | ) 116 | -------------------------------------------------------------------------------- /adaero/views/metadata.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from pyramid.security import NO_PERMISSION_REQUIRED 4 | from pyramid.httpexceptions import HTTPFound 5 | from rest_toolkit import resource 6 | 7 | from adaero import constants 8 | from adaero.config import get_config_value 9 | from adaero.views import Root 10 | 11 | 12 | @resource("/api/v1/metadata") 13 | class Metadata(Root): 14 | def __init__(self, request): # pylint disable=unused-argument 15 | pass 16 | 17 | 18 | @Metadata.GET(permission=NO_PERMISSION_REQUIRED) 19 | def get_metadata(request): 20 | """ 21 | Return data that can be used to personalize the current user's UI 22 | """ 23 | is_pwl_access = bool( 24 | get_config_value( 25 | request.registry.settings, constants.ALLOW_PASSWORDLESS_ACCESS_KEY 26 | ) 27 | ) 28 | unit_name = get_config_value(request.registry.settings, constants.BUSINESS_UNIT_KEY) 29 | login_password_message = get_config_value( 30 | request.registry.settings, constants.LOGIN_PASSWORD_MSG_KEY 31 | ) 32 | login_username_message = get_config_value( 33 | request.registry.settings, constants.LOGIN_USERNAME_MSG_KEY 34 | ) 35 | support_email = get_config_value( 36 | request.registry.settings, constants.SUPPORT_EMAIL_KEY 37 | ) 38 | display_name = get_config_value( 39 | request.registry.settings, constants.COMPANY_NAME_KEY 40 | ) 41 | return { 42 | "metadata": { 43 | "businessUnit": unit_name, 44 | "displayName": display_name, 45 | "loginPasswordMessage": login_password_message, 46 | "loginUsernameMessage": login_username_message, 47 | "passwordlessAccess": is_pwl_access, 48 | "supportEmail": support_email, 49 | } 50 | } 51 | 52 | 53 | @resource("/api/v1/logo.png") 54 | class Logo(Root): 55 | def __init__(self, request): # pylint disable=unused-argument 56 | pass 57 | 58 | 59 | @Logo.GET(permission=NO_PERMISSION_REQUIRED) 60 | def redirect_logo(request): 61 | logo_filename = get_config_value( 62 | request.registry.settings, constants.LOGO_FILENAME_KEY, "logo.png" 63 | ) 64 | is_https = get_config_value( 65 | request.registry.settings, constants.SERVED_ON_HTTPS_KEY 66 | ) 67 | raise HTTPFound( 68 | request.route_url( 69 | "catchall_static", 70 | subpath="assets/%s" % str(logo_filename), 71 | _scheme="https" if is_https else None, 72 | ) 73 | ) 74 | -------------------------------------------------------------------------------- /architecture.dot: -------------------------------------------------------------------------------- 1 | strict graph { 2 | { 3 | "Web Frontend" [shape=box] 4 | "API Backend" [shape=box] 5 | "Database" [shape=cylinder] 6 | "LDAP" [shape=cylinder] 7 | "User" [shape=point] 8 | } 9 | "API Backend" -- "Database" 10 | "API Backend" -- "LDAP" [label="Only for authentication, NOT authorization"] 11 | "User" -- "Web Frontend" 12 | "Web Frontend" -- "API Backend" 13 | } -------------------------------------------------------------------------------- /architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/man-group/adaero/991515895fd0063662755e0051577e1b7053a9fd/architecture.png -------------------------------------------------------------------------------- /docker/dev/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2.4' 2 | services: 3 | webserver: 4 | image: adaero-dev-frontend:latest 5 | volumes: 6 | - ../../frontend/src:/opt/app-root/src/src 7 | ports: 8 | - "4200:4200" 9 | networks: 10 | - web 11 | depends_on: 12 | - webapp 13 | healthcheck: 14 | test: ["CMD-SHELL", "wget http://localhost:4200"] 15 | interval: 5s 16 | timeout: 5s 17 | retries: 3 18 | logging: 19 | driver: "none" 20 | webapp: 21 | image: adaero-app:latest 22 | networks: 23 | - web 24 | - db 25 | - ldap 26 | volumes: 27 | - ../../:/opt/app-root/src 28 | depends_on: 29 | db: 30 | condition: service_healthy 31 | ldap: 32 | condition: service_healthy 33 | db: 34 | image: postgres:12 35 | environment: 36 | - POSTGRES_PASSWORD=mysecretpassword 37 | ports: 38 | - "5432:5432" 39 | networks: 40 | - db 41 | healthcheck: 42 | test: ["CMD-SHELL", "psql -U postgres -c \"select 'OK';\""] 43 | interval: 5s 44 | timeout: 5s 45 | retries: 3 46 | logging: 47 | driver: "none" 48 | ldap: 49 | image: osixia/openldap:latest 50 | volumes: 51 | - ../shared/ldif:/container/service/slapd/assets/config/bootstrap/ldif/custom 52 | networks: 53 | - ldap 54 | ports: 55 | - "389:389" 56 | healthcheck: 57 | test: ["CMD-SHELL", "ldapsearch -x -H ldap://localhost -b dc=example,dc=org -D \"cn=admin,dc=example,dc=org\" -w admin"] 58 | interval: 5s 59 | timeout: 5s 60 | retries: 3 61 | command: --copy-service 62 | ldap-ui: 63 | image: osixia/phpldapadmin:latest 64 | environment: 65 | - PHPLDAPADMIN_LDAP_HOSTS=ldap 66 | ports: 67 | - "6443:443" 68 | networks: 69 | - ldap 70 | depends_on: 71 | - ldap 72 | logging: 73 | driver: "none" 74 | 75 | networks: 76 | ldap: 77 | web: 78 | db: 79 | 80 | 81 | -------------------------------------------------------------------------------- /docker/prod/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2.4' 2 | services: 3 | webserver: 4 | image: nginx 5 | volumes: 6 | - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro 7 | - ./nginx/adaero.conf:/etc/nginx/sites-enabled/adaero.conf:ro 8 | ports: 9 | - "8080:8080" 10 | networks: 11 | - web 12 | depends_on: 13 | - webapp 14 | webapp: 15 | image: adaero-app:latest 16 | networks: 17 | - web 18 | - db 19 | - ldap 20 | depends_on: 21 | db: 22 | condition: service_healthy 23 | ldap: 24 | condition: service_healthy 25 | db: 26 | image: postgres:12 27 | environment: 28 | - POSTGRES_PASSWORD=mysecretpassword 29 | ports: 30 | - "5432:5432" 31 | networks: 32 | - db 33 | healthcheck: 34 | test: ["CMD-SHELL", "psql -U postgres -c \"select 'OK';\""] 35 | interval: 5s 36 | timeout: 5s 37 | retries: 3 38 | ldap: 39 | image: osixia/openldap:latest 40 | volumes: 41 | # - ./ldif:/container/service/slapd/assets/config/bootstrap/ldif 42 | - ../shared/ldif:/container/service/slapd/assets/config/bootstrap/ldif/custom 43 | networks: 44 | - ldap 45 | ports: 46 | - "389:389" 47 | healthcheck: 48 | test: ["CMD-SHELL", "ldapsearch -x -H ldap://localhost -b dc=example,dc=org -D \"cn=admin,dc=example,dc=org\" -w admin"] 49 | interval: 5s 50 | timeout: 5s 51 | retries: 3 52 | command: --copy-service --loglevel debug 53 | ldap-ui: 54 | image: osixia/phpldapadmin:latest 55 | environment: 56 | - PHPLDAPADMIN_LDAP_HOSTS=ldap 57 | ports: 58 | - "6443:443" 59 | networks: 60 | - ldap 61 | depends_on: 62 | - ldap 63 | 64 | networks: 65 | ldap: 66 | web: 67 | db: 68 | 69 | 70 | -------------------------------------------------------------------------------- /docker/prod/nginx/adaero.conf: -------------------------------------------------------------------------------- 1 | upstream webapp-site { 2 | server webapp:8080; 3 | } 4 | 5 | server { 6 | listen 8080; 7 | 8 | location / { 9 | proxy_set_header Host $http_host; 10 | proxy_set_header X-Real-IP $remote_addr; 11 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 12 | proxy_set_header X-Forwarded-Proto $scheme; 13 | 14 | client_max_body_size 10m; 15 | client_body_buffer_size 128k; 16 | proxy_connect_timeout 60s; 17 | proxy_send_timeout 90s; 18 | proxy_read_timeout 90s; 19 | proxy_buffering off; 20 | proxy_temp_file_write_size 64k; 21 | proxy_pass http://webapp-site; 22 | proxy_redirect off; 23 | } 24 | } -------------------------------------------------------------------------------- /docker/prod/nginx/nginx.conf: -------------------------------------------------------------------------------- 1 | # nginx.conf 2 | 3 | user www-data; 4 | worker_processes 4; 5 | pid /var/run/nginx.pid; 6 | 7 | events { 8 | worker_connections 1024; 9 | # multi_accept on; 10 | } 11 | 12 | http { 13 | 14 | ## 15 | # Basic Settings 16 | ## 17 | 18 | sendfile on; 19 | tcp_nopush on; 20 | tcp_nodelay on; 21 | keepalive_timeout 65; 22 | types_hash_max_size 2048; 23 | # server_tokens off; 24 | 25 | # server_names_hash_bucket_size 64; 26 | # server_name_in_redirect off; 27 | 28 | include /etc/nginx/mime.types; 29 | default_type application/octet-stream; 30 | 31 | ## 32 | # Logging Settings 33 | ## 34 | 35 | access_log /var/log/nginx/access.log; 36 | error_log /var/log/nginx/error.log; 37 | 38 | ## 39 | # Gzip Settings 40 | ## 41 | 42 | gzip on; 43 | gzip_disable "msie6"; 44 | 45 | ## 46 | # Virtual Host Configs 47 | ## 48 | 49 | include /etc/nginx/conf.d/*.conf; 50 | include /etc/nginx/sites-enabled/*; 51 | } -------------------------------------------------------------------------------- /docker/shared/ldif/01-data.ldif: -------------------------------------------------------------------------------- 1 | dn: ou=People,dc=example,dc=org 2 | changetype: add 3 | objectClass: organizationalunit 4 | objectClass: top 5 | ou: People 6 | 7 | dn: uid=alovelace,ou=People,dc=example,dc=org 8 | changetype: add 9 | objectClass: person 10 | objectClass: inetOrgPerson 11 | objectClass: organizationalPerson 12 | objectClass: posixAccount 13 | objectClass: top 14 | uid: alovelace 15 | homeDirectory: /home/alovelace 16 | userpassword: password 17 | givenname: Ada 18 | cn: Ada Lovelace 19 | sn: Lovelace 20 | mail: alovelace@example.com 21 | l: London 22 | ou: Payroll 23 | ou: People 24 | uidNumber: 4000 25 | gidNumber: 1000 26 | o: Engineering 27 | departmentNumber: 123 28 | title: Software Engineer I 29 | manager: uid=dthomas,ou=People,dc=example,dc=org 30 | 31 | dn: uid=bsmith,ou=People,dc=example,dc=org 32 | changetype: add 33 | objectClass: person 34 | objectClass: inetOrgPerson 35 | objectClass: organizationalPerson 36 | objectClass: posixAccount 37 | objectClass: top 38 | uid: bsmith 39 | homeDirectory: /home/bsmith 40 | userpassword: password 41 | givenname: Bob 42 | cn: Bob Smith 43 | sn: Smith 44 | mail: bsmith@example.com 45 | l: Boston 46 | ou: Product Testing 47 | ou: People 48 | uidNumber: 5000 49 | gidNumber: 1000 50 | o: Engineering 51 | departmentNumber: 123 52 | title: Software Engineer II 53 | manager: uid=dthomas,ou=People,dc=example,dc=org 54 | 55 | dn: uid=cdalton,ou=People,dc=example,dc=org 56 | changetype: add 57 | objectClass: person 58 | objectClass: inetOrgPerson 59 | objectClass: organizationalPerson 60 | objectClass: posixAccount 61 | objectClass: top 62 | uid: cdalton 63 | homeDirectory: /home/cdalton 64 | userpassword: password 65 | givenname: Charles 66 | cn: Charles Dalton 67 | sn: Dalton 68 | mail: cdalton@example.com 69 | l: London 70 | ou: Payroll 71 | ou: People 72 | uidNumber: 6000 73 | gidNumber: 1000 74 | o: Engineering 75 | departmentNumber: 123 76 | title: Software Engineer III 77 | manager: uid=dthomas,ou=People,dc=example,dc=org 78 | 79 | dn: uid=dthomas,ou=People,dc=example,dc=org 80 | changetype: add 81 | objectClass: person 82 | objectClass: inetOrgPerson 83 | objectClass: organizationalPerson 84 | objectClass: posixAccount 85 | objectClass: top 86 | uid: dthomas 87 | homeDirectory: /home/dthomas 88 | userpassword: password 89 | givenname: Dan 90 | cn: Dan Thomas 91 | sn: Thomas 92 | mail: dthomas@example.com 93 | l: London 94 | ou: Accounting 95 | ou: People 96 | uidNumber: 7000 97 | gidNumber: 1000 98 | o: Engineering 99 | departmentNumber: 123 100 | title: Team Leader 101 | manager: uid=eforshaw,ou=People,dc=example,dc=org 102 | 103 | dn: uid=eforshaw,ou=People,dc=example,dc=org 104 | changetype: add 105 | objectClass: person 106 | objectClass: inetOrgPerson 107 | objectClass: organizationalPerson 108 | objectClass: posixAccount 109 | objectClass: top 110 | uid: eforshaw 111 | homeDirectory: /home/eforshaw 112 | userpassword: password 113 | givenname: Elaine 114 | cn: Elaine Forshaw 115 | sn: Forshaw 116 | mail: eforshaw@example.com 117 | l: London 118 | ou: Accounting 119 | ou: People 120 | uidNumber: 8000 121 | gidNumber: 1000 122 | o: Engineering 123 | departmentNumber: 123 124 | title: Vice President 125 | 126 | -------------------------------------------------------------------------------- /docs/login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/man-group/adaero/991515895fd0063662755e0051577e1b7053a9fd/docs/login.png -------------------------------------------------------------------------------- /docs/user-guide.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/man-group/adaero/991515895fd0063662755e0051577e1b7053a9fd/docs/user-guide.pdf -------------------------------------------------------------------------------- /docs/welcome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/man-group/adaero/991515895fd0063662755e0051577e1b7053a9fd/docs/welcome.png -------------------------------------------------------------------------------- /frontend/.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | 8 | # dependencies 9 | /node_modules 10 | 11 | # IDEs and editors 12 | /.idea 13 | .project 14 | .classpath 15 | .c9/ 16 | *.launch 17 | .settings/ 18 | *.sublime-workspace 19 | 20 | # IDE - VSCode 21 | .vscode/* 22 | !.vscode/settings.json 23 | !.vscode/tasks.json 24 | !.vscode/launch.json 25 | !.vscode/extensions.json 26 | 27 | # misc 28 | /.sass-cache 29 | /connect.lock 30 | /coverage 31 | /libpeerconnection.log 32 | npm-debug.log 33 | testem.log 34 | /typings 35 | 36 | # e2e 37 | /e2e/*.js 38 | /e2e/*.map 39 | 40 | # System Files 41 | .DS_Store 42 | Thumbs.db 43 | -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:10 2 | 3 | # Create app directory 4 | WORKDIR /usr/src/app 5 | 6 | # Install app dependencies 7 | # A wildcard is used to ensure both package.json AND package-lock.json are copied 8 | # where available (npm@5+) 9 | COPY package*.json ./ 10 | 11 | RUN npm install 12 | # If you are building your code for production 13 | # RUN npm ci --only=production 14 | 15 | # Bundle app source 16 | COPY . . 17 | 18 | EXPOSE 4200 19 | CMD [ "npm", "run", "start" ] 20 | 21 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # adaero-frontend 2 | 3 | ## Development server 4 | 5 | Run `npm start` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files. All HTTP calls to `/api` are proxied, so please have the `adaero` Pyramid backend running in development mode. 6 | 7 | ## Code scaffolding 8 | 9 | Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`. 10 | 11 | ## Build 12 | 13 | Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. Use the `-prod` flag for a production build. 14 | 15 | ## Running unit tests 16 | 17 | Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io). 18 | 19 | ## Running end-to-end tests 20 | 21 | Run `ng e2e` to execute the end-to-end tests via [Protractor](http://www.protractortest.org/). 22 | 23 | ## Further help 24 | 25 | To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI README](https://github.com/angular/angular-cli/blob/master/README.md). 26 | -------------------------------------------------------------------------------- /frontend/angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "my-app": { 7 | "root": "", 8 | "sourceRoot": "src", 9 | "projectType": "application", 10 | "architect": { 11 | "build": { 12 | "builder": "@angular-devkit/build-angular:browser", 13 | "options": { 14 | "aot": true, 15 | "outputPath": "dist", 16 | "index": "src/index.html", 17 | "main": "src/main.ts", 18 | "tsConfig": "src/tsconfig.app.json", 19 | "polyfills": "src/polyfills.ts", 20 | "assets": [ 21 | "src/assets", 22 | "src/favicon.ico" 23 | ], 24 | "styles": [ 25 | "src/styles.scss" 26 | ], 27 | "scripts": [] 28 | }, 29 | "configurations": { 30 | "production": { 31 | "budgets": [ 32 | { 33 | "type": "anyComponentStyle", 34 | "maximumWarning": "6kb" 35 | } 36 | ], 37 | "optimization": true, 38 | "outputHashing": "all", 39 | "sourceMap": false, 40 | "extractCss": true, 41 | "namedChunks": false, 42 | "aot": true, 43 | "extractLicenses": true, 44 | "vendorChunk": false, 45 | "buildOptimizer": true, 46 | "fileReplacements": [ 47 | { 48 | "replace": "src/environments/environment.ts", 49 | "with": "src/environments/environment.prod.ts" 50 | } 51 | ] 52 | } 53 | } 54 | }, 55 | "serve": { 56 | "builder": "@angular-devkit/build-angular:dev-server", 57 | "options": { 58 | "browserTarget": "my-app:build" 59 | }, 60 | "configurations": { 61 | "production": { 62 | "browserTarget": "my-app:build:production" 63 | } 64 | } 65 | }, 66 | "extract-i18n": { 67 | "builder": "@angular-devkit/build-angular:extract-i18n", 68 | "options": { 69 | "browserTarget": "my-app:build" 70 | } 71 | }, 72 | "test": { 73 | "builder": "@angular-devkit/build-angular:karma", 74 | "options": { 75 | "main": "src/test.ts", 76 | "karmaConfig": "./karma.conf.js", 77 | "polyfills": "src/polyfills.ts", 78 | "tsConfig": "src/tsconfig.spec.json", 79 | "scripts": [], 80 | "styles": [ 81 | "src/styles.scss" 82 | ], 83 | "assets": [ 84 | "src/assets", 85 | "src/favicon.ico" 86 | ] 87 | } 88 | }, 89 | "lint": { 90 | "builder": "@angular-devkit/build-angular:tslint", 91 | "options": { 92 | "tsConfig": [ 93 | "src/tsconfig.app.json", 94 | "src/tsconfig.spec.json" 95 | ], 96 | "exclude": [ 97 | "**/node_modules/**" 98 | ] 99 | } 100 | } 101 | } 102 | }, 103 | "my-app-e2e": { 104 | "root": "e2e", 105 | "sourceRoot": "e2e", 106 | "projectType": "application", 107 | "architect": { 108 | "e2e": { 109 | "builder": "@angular-devkit/build-angular:protractor", 110 | "options": { 111 | "protractorConfig": "./protractor.conf.js", 112 | "devServerTarget": "my-app:serve" 113 | } 114 | }, 115 | "lint": { 116 | "builder": "@angular-devkit/build-angular:tslint", 117 | "options": { 118 | "tsConfig": [ 119 | "e2e/tsconfig.e2e.json" 120 | ], 121 | "exclude": [ 122 | "**/node_modules/**" 123 | ] 124 | } 125 | } 126 | } 127 | } 128 | }, 129 | "defaultProject": "my-app", 130 | "schematics": { 131 | "@schematics/angular:component": { 132 | "prefix": "app", 133 | "style": "scss" 134 | }, 135 | "@schematics/angular:directive": { 136 | "prefix": "app" 137 | } 138 | }, 139 | "cli": { 140 | "analytics": false 141 | } 142 | } -------------------------------------------------------------------------------- /frontend/browserslist: -------------------------------------------------------------------------------- 1 | # This file is used by the build system to adjust CSS and JS output to support the specified browsers below. 2 | # For additional information regarding the format and rule options, please see: 3 | # https://github.com/browserslist/browserslist#queries 4 | 5 | # You can see what browsers were selected by your queries by running: 6 | # npx browserslist 7 | 8 | > 0.5% 9 | last 2 versions 10 | Firefox ESR 11 | not dead 12 | not IE 9-11 # For IE 9-11 support, remove 'not'. -------------------------------------------------------------------------------- /frontend/e2e/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { AppPage } from './app.po'; 2 | 3 | describe('my-app App', () => { 4 | let page: AppPage; 5 | 6 | beforeEach(() => { 7 | page = new AppPage(); 8 | }); 9 | 10 | it('should display welcome message', () => { 11 | page.navigateTo(); 12 | expect(page.getParagraphText()).toEqual('Welcome to app!'); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /frontend/e2e/app.po.ts: -------------------------------------------------------------------------------- 1 | import { browser, by, element } from 'protractor'; 2 | 3 | export class AppPage { 4 | navigateTo() { 5 | return browser.get('/'); 6 | } 7 | 8 | getParagraphText() { 9 | return element(by.css('app-root h1')).getText(); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /frontend/e2e/tsconfig.e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/e2e", 5 | "baseUrl": "./", 6 | "module": "commonjs", 7 | "target": "es5", 8 | "types": [ 9 | "jasmine", 10 | "jasminewd2", 11 | "node" 12 | ] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /frontend/karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | basePath: '', 7 | frameworks: ['jasmine', '@angular-devkit/build-angular'], 8 | plugins: [ 9 | require('karma-jasmine'), 10 | require('karma-firefox-launcher'), 11 | require('karma-jasmine-html-reporter'), 12 | require('karma-coverage-istanbul-reporter'), 13 | require('@angular-devkit/build-angular/plugins/karma') 14 | ], 15 | client:{ 16 | clearContext: false // leave Jasmine Spec Runner output visible in browser 17 | }, 18 | coverageIstanbulReporter: { 19 | dir: require('path').join(__dirname, 'coverage'), reports: [ 'html', 'lcovonly' ], 20 | fixWebpackSourcePaths: true 21 | }, 22 | 23 | reporters: ['progress', 'kjhtml'], 24 | port: 9876, 25 | colors: true, 26 | logLevel: config.LOG_INFO, 27 | autoWatch: true, 28 | browsers: ['Firefox'], 29 | singleRun: false 30 | }); 31 | }; 32 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "adaero-frontend", 3 | "version": "0.0.0", 4 | "license": "AGPL 3.0", 5 | "scripts": { 6 | "ng": "ng", 7 | "start": "ng serve --watch --host 0.0.0.0 --disable-host-check --poll 1 --proxy-config proxy.conf.json", 8 | "local-dev-server": "ng serve --watch --host 0.0.0.0 --disable-host-check --proxy-config proxy.local.conf.json", 9 | "build": "ng build", 10 | "dev-dist": "ng build && cp -r dist/* ../adaero/static/", 11 | "prod-dist": "ng build --prod && cp -r dist/* ../adaero/static/", 12 | "test": "ng test", 13 | "lint": "ng lint", 14 | "e2e": "ng e2e" 15 | }, 16 | "private": true, 17 | "dependencies": { 18 | "@angular-devkit/build-angular": "~0.901.0", 19 | "@angular/animations": "^9.1.0", 20 | "@angular/cli": "^9.1.0", 21 | "@angular/common": "^9.1.0", 22 | "@angular/compiler": "^9.1.0", 23 | "@angular/compiler-cli": "^9.1.0", 24 | "@angular/core": "^9.1.0", 25 | "@angular/forms": "^9.1.0", 26 | "@angular/localize": "^9.1.0", 27 | "@angular/platform-browser": "^9.1.0", 28 | "@angular/platform-browser-dynamic": "^9.1.0", 29 | "@angular/platform-server": "^9.1.0", 30 | "@angular/router": "^9.1.0", 31 | "@ng-bootstrap/ng-bootstrap": "^5.3.0", 32 | "bootstrap": "^4.3.0", 33 | "core-js": "^2.6.11", 34 | "jquery": "^1.9.1", 35 | "node-sass": "^4.13.1", 36 | "popper.js": "^1.16.1", 37 | "rxjs": "^6.5.5", 38 | "tslib": "^1.10.0", 39 | "zone.js": "~0.10.2" 40 | }, 41 | "devDependencies": { 42 | "@angular/language-service": "^9.1.0", 43 | "@types/jasmine": "~2.5.53", 44 | "@types/jasminewd2": "^2.0.8", 45 | "@types/node": "^12.11.1", 46 | "codelyzer": "^5.1.2", 47 | "copy-webpack-plugin": "^4.6.0", 48 | "jasmine-core": "~2.6.2", 49 | "jasmine-spec-reporter": "~4.1.0", 50 | "karma": "~1.7.0", 51 | "karma-chrome-launcher": "~2.1.1", 52 | "karma-cli": "~1.0.1", 53 | "karma-coverage-istanbul-reporter": "^1.4.3", 54 | "karma-firefox-launcher": "^1.3.0", 55 | "karma-jasmine": "~1.1.0", 56 | "karma-jasmine-html-reporter": "^0.2.2", 57 | "protractor": "~5.1.2", 58 | "ts-node": "~3.2.0", 59 | "tslint": "~5.7.0", 60 | "typescript": "~3.8.3" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /frontend/protractor.conf.js: -------------------------------------------------------------------------------- 1 | // Protractor configuration file, see link for more information 2 | // https://github.com/angular/protractor/blob/master/lib/config.ts 3 | 4 | const { SpecReporter } = require('jasmine-spec-reporter'); 5 | 6 | exports.config = { 7 | allScriptsTimeout: 11000, 8 | specs: [ 9 | './e2e/**/*.e2e-spec.ts' 10 | ], 11 | capabilities: { 12 | 'browserName': 'chrome' 13 | }, 14 | directConnect: true, 15 | baseUrl: 'http://localhost:4200/', 16 | framework: 'jasmine', 17 | jasmineNodeOpts: { 18 | showColors: true, 19 | defaultTimeoutInterval: 30000, 20 | print: function() {} 21 | }, 22 | onPrepare() { 23 | require('ts-node').register({ 24 | project: 'e2e/tsconfig.e2e.json' 25 | }); 26 | jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } })); 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /frontend/proxy.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "/api": { 3 | "target": "http://webapp:8080", 4 | "secure": false 5 | }, 6 | "/assets": { 7 | "target": "http://webapp:8080", 8 | "secure": false 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /frontend/proxy.local.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "/api": { 3 | "target": "http://localhost:8080", 4 | "secure": false 5 | }, 6 | "/assets": { 7 | "target": "http://localhost:8080", 8 | "secure": false 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /frontend/src/app/app-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { RouterModule, Routes } from '@angular/router'; 3 | 4 | import { 5 | GiveFeedbackComponent, 6 | NomineesListComponent, 7 | LoginComponent, 8 | SelfNominateComponent, 9 | StatsComponent, 10 | SummariseFeedbackComponent, 11 | FeedbackAboutMeComponent, 12 | TalentManagerPanelComponent, 13 | AuthenticatedComponent, 14 | FeedbackHistoryViewComponent, 15 | ExternalInviteComponent 16 | } from './components/views'; 17 | 18 | import { AuthGuardService, AnonGuardService, PendingChangesGuardService } from './guards'; 19 | 20 | const routes: Routes = [ 21 | { path: '', redirectTo: '/feedback', pathMatch: 'full' }, 22 | { path: 'login', canActivate: [AnonGuardService], component: LoginComponent }, 23 | { 24 | path: '', 25 | component: AuthenticatedComponent, 26 | canActivate: [AuthGuardService], 27 | children: [ 28 | { path: 'self-nominate', component: SelfNominateComponent }, 29 | { path: 'feedback-about-me', component: FeedbackAboutMeComponent }, 30 | { path: 'feedback', component: NomineesListComponent }, 31 | { path: 'invite-outside-reviewers', component: ExternalInviteComponent }, 32 | { 33 | path: 'feedback/:username', 34 | canDeactivate: [PendingChangesGuardService], 35 | component: GiveFeedbackComponent 36 | }, 37 | { path: 'team-feedback', component: StatsComponent, data: {isCompanyWide: false} }, 38 | { path: 'company-feedback', component: StatsComponent, data: {isCompanyWide: true}}, 39 | { 40 | path: 'team-feedback/:username/summarise', 41 | canDeactivate: [PendingChangesGuardService], 42 | component: SummariseFeedbackComponent 43 | }, 44 | { 45 | path: 'team-feedback/:username/history', 46 | component: FeedbackHistoryViewComponent 47 | }, 48 | { path: 'talent-manager-panel', component: TalentManagerPanelComponent } 49 | 50 | ] 51 | } 52 | ]; 53 | 54 | @NgModule({ 55 | imports: [RouterModule.forRoot(routes)], 56 | exports: [RouterModule] 57 | }) 58 | export class AppRoutingModule { } 59 | -------------------------------------------------------------------------------- /frontend/src/app/app.component.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/app/app.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/man-group/adaero/991515895fd0063662755e0051577e1b7053a9fd/frontend/src/app/app.component.scss -------------------------------------------------------------------------------- /frontend/src/app/app.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed, async } from '@angular/core/testing'; 2 | import { RouterTestingModule } from '@angular/router/testing'; 3 | import { HttpClientModule } from '@angular/common/http'; 4 | 5 | import { AppComponent } from './app.component'; 6 | import { ApiService } from './services/api.service'; 7 | describe('AppComponent', () => { 8 | beforeEach(async(() => { 9 | TestBed.configureTestingModule({ 10 | imports: [ 11 | HttpClientModule, 12 | RouterTestingModule 13 | ], 14 | declarations: [ 15 | AppComponent 16 | ], 17 | providers: [ 18 | ApiService 19 | ] 20 | }).compileComponents(); 21 | })); 22 | it('should create the app', async(() => { 23 | const fixture = TestBed.createComponent(AppComponent); 24 | const app = fixture.debugElement.componentInstance; 25 | expect(app).toBeTruthy(); 26 | })); 27 | }); 28 | -------------------------------------------------------------------------------- /frontend/src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import {ApiService, MetadataPayload, UserData} from './services/api.service'; 3 | import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; 4 | import { ModalComponent } from './components/widgets'; 5 | import { HttpErrorResponse } from '@angular/common/http'; 6 | 7 | @Component({ 8 | selector: 'app-root', 9 | templateUrl: './app.component.html', 10 | styleUrls: ['./app.component.scss'] 11 | }) 12 | export class AppComponent { 13 | constructor(public api: ApiService, public modal: NgbModal) { 14 | api.error$.subscribe( 15 | (error: HttpErrorResponse) => { 16 | const c = modal.open(ModalComponent); 17 | c.componentInstance.confirmText = 'OK'; 18 | api.getMetadata().subscribe((result: MetadataPayload) => { 19 | c.componentInstance.dialog = `Sorry, an error has occured and the requested action has failed. If you think this should have worked, 20 | please email ${result.metadata.supportEmail} with the following information`; 21 | c.componentInstance.metadata = `${error.error.message ? error.error.message : error.message}`; 22 | return c.result.then(() => { 23 | return true; 24 | }, () => { 25 | return false; 26 | }); 27 | }); 28 | } 29 | ); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /frontend/src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { BrowserModule } from '@angular/platform-browser'; 2 | import { LOCALE_ID, NgModule } from '@angular/core'; 3 | import { FormsModule } from '@angular/forms'; 4 | import { HttpClientModule, HttpClientXsrfModule } from '@angular/common/http'; 5 | 6 | import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; 7 | 8 | import { AppComponent } from './app.component'; 9 | 10 | import { 11 | GiveFeedbackComponent, 12 | NomineesListComponent, 13 | LoginComponent, 14 | SelfNominateComponent, 15 | StatsComponent, 16 | SummariseFeedbackComponent, 17 | FeedbackAboutMeComponent, 18 | TalentManagerPanelComponent, 19 | } from './components/views'; 20 | 21 | import { 22 | FeedbackFormComponent, 23 | } from './components/widgets/'; 24 | 25 | import { AppRoutingModule } from './app-routing.module'; 26 | 27 | import { ApiService } from './services/api.service'; 28 | import { CookieService } from './services/cookie.service'; 29 | import { AuthGuardService, AnonGuardService, PendingChangesGuardService } from './guards'; 30 | import { NomineeFilterPipe } from './pipes/nominee-filter.pipe'; 31 | import { ModalComponent } from './components/widgets/modal/modal.component'; 32 | import { AuthenticatedComponent } from './components/views/authenticated/authenticated.component'; 33 | import { FeedbackHistoryComponent } from './components/widgets/feedback-history/feedback-history.component'; 34 | import { FeedbackHistoryViewComponent } from './components/views/feedback-history-view/feedback-history-view.component'; 35 | import { ExternalInviteComponent } from './components/views/external-invite/external-invite.component'; 36 | 37 | @NgModule({ 38 | declarations: [ 39 | // views 40 | AppComponent, 41 | LoginComponent, 42 | AuthenticatedComponent, 43 | GiveFeedbackComponent, 44 | NomineesListComponent, 45 | SelfNominateComponent, 46 | StatsComponent, 47 | SummariseFeedbackComponent, 48 | FeedbackAboutMeComponent, 49 | TalentManagerPanelComponent, 50 | FeedbackHistoryViewComponent, 51 | ExternalInviteComponent, 52 | 53 | // components 54 | FeedbackFormComponent, 55 | ModalComponent, 56 | FeedbackHistoryComponent, 57 | 58 | // pipes 59 | NomineeFilterPipe, 60 | 61 | 62 | 63 | ], 64 | entryComponents: [ 65 | ModalComponent, 66 | ], 67 | imports: [ 68 | BrowserModule, 69 | HttpClientModule, 70 | HttpClientXsrfModule, 71 | FormsModule, 72 | NgbModule, 73 | // local modules 74 | AppRoutingModule, 75 | ], 76 | providers: [ 77 | ApiService, 78 | AnonGuardService, 79 | AuthGuardService, 80 | PendingChangesGuardService, 81 | CookieService, 82 | { provide: LOCALE_ID, useValue: 'en'} 83 | ], 84 | bootstrap: [AppComponent] 85 | }) 86 | export class AppModule { } 87 | -------------------------------------------------------------------------------- /frontend/src/app/components/views/authenticated/authenticated.component.html: -------------------------------------------------------------------------------- 1 | 17 |
18 |
19 | 42 |
43 |
44 | 45 |
46 |
47 |
48 |
49 | -------------------------------------------------------------------------------- /frontend/src/app/components/views/authenticated/authenticated.component.scss: -------------------------------------------------------------------------------- 1 | @import '~styles.scss'; 2 | 3 | .wrapper { 4 | border-right: 0.1em solid black; 5 | } 6 | 7 | #sidebar { 8 | padding: 0.5em; 9 | height: 88vh; 10 | .nav-pills, .nav-link { 11 | border-radius: 0em; 12 | color: black; 13 | } 14 | .active { 15 | background-color: $grey; 16 | font-weight: bold; 17 | } 18 | } 19 | 20 | #content { 21 | padding: 1em; 22 | height: 100%; 23 | } 24 | 25 | #logged-in-user-info { 26 | padding: 0em 0.5em; 27 | color: white; 28 | text-align: right; 29 | } 30 | 31 | .nav > li > a { 32 | color: black; 33 | } 34 | 35 | .nav > li > a:hover, .nav > li > a:focus { 36 | font-weight: bold; 37 | } 38 | 39 | @media print { 40 | nav, .wrapper { 41 | display: none; 42 | } 43 | } -------------------------------------------------------------------------------- /frontend/src/app/components/views/authenticated/authenticated.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { AuthenticatedComponent } from './authenticated.component'; 4 | 5 | describe('AuthenticatedComponent', () => { 6 | let component: AuthenticatedComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ AuthenticatedComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(AuthenticatedComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /frontend/src/app/components/views/authenticated/authenticated.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { ApiService, UserData, MetadataPayload, Metadata } from '../../../services/api.service'; 3 | 4 | @Component({ 5 | selector: 'app-authenticated', 6 | templateUrl: './authenticated.component.html', 7 | styleUrls: ['./authenticated.component.scss'] 8 | }) 9 | export class AuthenticatedComponent implements OnInit { 10 | 11 | user: UserData; 12 | public metadata: Metadata; 13 | constructor(private api: ApiService) { } 14 | 15 | ngOnInit() { 16 | this.fetchData(); 17 | } 18 | 19 | fetchData() { 20 | this.api.getUserData().subscribe((result) => { 21 | this.user = result; 22 | }); 23 | this.api.getMetadata().subscribe( 24 | (result: MetadataPayload) => { 25 | this.metadata = result.metadata; 26 | }, 27 | ); 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /frontend/src/app/components/views/external-invite/external-invite.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |

Invite additional reviewers

5 |

6 | You are welcome to invite reviewers from across {{ this.metadata.companyName }} by entering their email address below. An email will be sent, asking them to give you feedback. 7 |

8 | 9 | 12 | 13 | 14 | 17 | 18 | 19 |

Your existing invites:

20 |
    21 |
  • {{ invitee.displayName }} <{{ invitee.email }}>, {{ invitee.businessUnit }}, {{ invitee.department }}
  • 22 |
23 |

If you wish to send a reminder email, then simply re-enter their email address below.

24 |
25 |
26 |
27 | 28 | 30 |
31 | 32 |
33 |
34 | 35 |

{{ this.status.heading }}

36 |

{{ this.status.body }}

37 |
38 |
39 |
40 | Loading Data... 41 | -------------------------------------------------------------------------------- /frontend/src/app/components/views/external-invite/external-invite.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/man-group/adaero/991515895fd0063662755e0051577e1b7053a9fd/frontend/src/app/components/views/external-invite/external-invite.component.scss -------------------------------------------------------------------------------- /frontend/src/app/components/views/external-invite/external-invite.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { ExternalInviteComponent } from './external-invite.component'; 4 | 5 | describe('ExternalInviteComponent', () => { 6 | let component: ExternalInviteComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ ExternalInviteComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(ExternalInviteComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /frontend/src/app/components/views/external-invite/external-invite.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, ViewChild } from '@angular/core'; 2 | import { NgForm } from '@angular/forms'; 3 | import { ApiService, ExternalFeedbackStatusPayload, MetadataPayload, Metadata } from '../../../services/api.service'; 4 | 5 | @Component({ 6 | selector: 'app-external-invite', 7 | templateUrl: './external-invite.component.html', 8 | styleUrls: ['./external-invite.component.scss'] 9 | }) 10 | export class ExternalInviteComponent implements OnInit { 11 | 12 | successMsg: string; 13 | errorMsg: string; 14 | metadata: Metadata; 15 | status: ExternalFeedbackStatusPayload | {}; 16 | constructor(private api: ApiService) { } 17 | 18 | ngOnInit() { 19 | this.fetchStatus(); 20 | this.api.getMetadata().subscribe( 21 | (result: MetadataPayload) => { 22 | this.metadata = result.metadata; 23 | }, 24 | ); 25 | } 26 | 27 | fetchStatus() { 28 | this.api.getExternalInviteStatus().subscribe((payload) => { 29 | this.status = payload; 30 | }); 31 | } 32 | 33 | onSubmit(form: NgForm) { 34 | this.successMsg = null; 35 | this.errorMsg = null; 36 | this.api.sendExternalInvite(form.value.email).subscribe(() => { 37 | this.successMsg = 'Invite successfully sent!'; 38 | }, (error) => { 39 | this.errorMsg = error.error ? error.error.message : error.message; 40 | }); 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /frontend/src/app/components/views/feedback-about-me/feedback-about-me.component.html: -------------------------------------------------------------------------------- 1 | 5 | 6 | -------------------------------------------------------------------------------- /frontend/src/app/components/views/feedback-about-me/feedback-about-me.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/man-group/adaero/991515895fd0063662755e0051577e1b7053a9fd/frontend/src/app/components/views/feedback-about-me/feedback-about-me.component.scss -------------------------------------------------------------------------------- /frontend/src/app/components/views/feedback-about-me/feedback-about-me.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { FeedbackAboutMeComponent } from './feedback-about-me.component'; 4 | 5 | describe('FeedbackAboutMeComponent', () => { 6 | let component: FeedbackAboutMeComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ FeedbackAboutMeComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(FeedbackAboutMeComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /frontend/src/app/components/views/feedback-about-me/feedback-about-me.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { ApiService, FeedbackHistoryPayload } from '../../../services/api.service'; 3 | 4 | @Component({ 5 | selector: 'app-feedback-about-me', 6 | templateUrl: './feedback-about-me.component.html', 7 | styleUrls: ['./feedback-about-me.component.scss'] 8 | }) 9 | export class FeedbackAboutMeComponent implements OnInit { 10 | 11 | data: FeedbackHistoryPayload | {}; 12 | constructor(public api: ApiService) { } 13 | 14 | readonly title = 'Review my feedback'; 15 | readonly description = [`Find below a summary of your feedback. Your colleagues were given the opportunity to provide feedback. \ 16 | Your Line Manager then reviewed the feedback and provided the summary presented below.`, 17 | `Managers can view historical feedback about their Direct Reports spanning the last three feedback cycles. \ 18 | Staff can see their full history of feedback.`, 19 | `If you would like to discuss your feedback you should reach out to either your Line Manager or your Talent Manager.`]; 20 | 21 | ngOnInit() { 22 | this.api.getFeedbackAboutMe().subscribe(data => { 23 | this.data = data; 24 | }); 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /frontend/src/app/components/views/feedback-history-view/feedback-history-view.component.html: -------------------------------------------------------------------------------- 1 | 5 | -------------------------------------------------------------------------------- /frontend/src/app/components/views/feedback-history-view/feedback-history-view.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/man-group/adaero/991515895fd0063662755e0051577e1b7053a9fd/frontend/src/app/components/views/feedback-history-view/feedback-history-view.component.scss -------------------------------------------------------------------------------- /frontend/src/app/components/views/feedback-history-view/feedback-history-view.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { FeedbackHistoryViewComponent } from './feedback-history-view.component'; 4 | 5 | describe('FeedbackHistoryViewComponent', () => { 6 | let component: FeedbackHistoryViewComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ FeedbackHistoryViewComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(FeedbackHistoryViewComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /frontend/src/app/components/views/feedback-history-view/feedback-history-view.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { ActivatedRoute } from '@angular/router'; 3 | import { ApiService, FeedbackHistoryPayload } from '../../../services/api.service'; 4 | import { Observable } from 'rxjs'; 5 | import { map } from 'rxjs/operators'; 6 | 7 | @Component({ 8 | selector: 'app-feedback-history-view', 9 | templateUrl: './feedback-history-view.component.html', 10 | styleUrls: ['./feedback-history-view.component.scss'] 11 | }) 12 | export class FeedbackHistoryViewComponent implements OnInit { 13 | 14 | data: FeedbackHistoryPayload; 15 | readonly description = [`Find below a history of their feedback summaries. Colleagues were given the opportunity to provide feedback. \ 16 | You or a previous line manager then reviewed the feedback and provided the summaries presented below.`, 17 | `Managers can view historical feedback about their Direct Reports spanning the last three feedback cycles. \ 18 | Staff can see their full history of feedback.`]; 19 | 20 | constructor(public api: ApiService, private route: ActivatedRoute) { } 21 | 22 | title(): string { 23 | if (this.data) { 24 | return `Feedback about ${this.data.feedback.displayName}`; 25 | } 26 | } 27 | 28 | ngOnInit() { 29 | this.route.params.pipe( 30 | map((p => p.username)) 31 | ).subscribe((username: String) => { 32 | this.api.getFeedbackHistory(username).subscribe((data: FeedbackHistoryPayload) => { 33 | this.data = data; 34 | }); 35 | }); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /frontend/src/app/components/views/give-feedback/give-feedback.component.html: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /frontend/src/app/components/views/give-feedback/give-feedback.component.scss: -------------------------------------------------------------------------------- 1 | #feedback-form-footer { 2 | color: lightslategray; 3 | font-size: smaller; 4 | padding-top: 1em; 5 | } 6 | 7 | #feedback-form-info { 8 | border-top: lightgray 1px solid; 9 | border-bottom: lightgray 1px solid; 10 | padding-top: 0.5em; 11 | padding-bottom: 0.5em; 12 | } 13 | 14 | #feedback-form-title { 15 | padding-bottom: 0.5em; 16 | } -------------------------------------------------------------------------------- /frontend/src/app/components/views/give-feedback/give-feedback.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, OnInit, HostListener, ViewChild } from '@angular/core'; 2 | import { NgForm } from '@angular/forms'; 3 | import { ActivatedRoute, Router } from '@angular/router'; 4 | import { Observable, Subscribable } from 'rxjs'; 5 | import { map } from 'rxjs/operators'; 6 | 7 | import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; 8 | import { ApiService, GiveFeedbackPayload, FeedbackFormItem, FeedbackForm } from '../../../services/api.service'; 9 | import { FeedbackFormComponent, FormState } from '../../widgets'; 10 | import { ComponentCanDeactivate } from '../../../guards/pending-changes-guard.service'; 11 | 12 | @Component({ 13 | selector: 'app-give-feedback', 14 | templateUrl: './give-feedback.component.html', 15 | styleUrls: ['./give-feedback.component.scss'] 16 | }) 17 | export class GiveFeedbackComponent implements ComponentCanDeactivate, OnInit { 18 | 19 | public formStates = FormState; 20 | form: FeedbackForm; 21 | username: String; 22 | formState: FormState = FormState.Editing; 23 | successButtonLink = '/feedback'; 24 | @ViewChild('formComponent', { static: true }) formComponent: FeedbackFormComponent; 25 | 26 | readonly formInfo = `

Thank you for taking the time to provide feedback on one of your colleagues.

27 |

You will be asked to answer some simple questions:

28 |
    29 |
  • You should base your responses on your actual experience of working with this colleague.
  • 30 |
  • Feedback is most useful when you share specific examples, of observed behaviours 31 | and describe the impact this behaviour had.
  • 32 |
  • Feedback should motivate, improve performance and support continuous learning. 33 | Please consider this when providing your input.
  • 34 |
`; 35 | 36 | readonly successTitle = 'Feedback successfully saved!'; 37 | readonly successButton = 'Give feedback'; 38 | 39 | 40 | constructor(private api: ApiService, private route: ActivatedRoute, public modal: NgbModal) { } 41 | 42 | ngOnInit() { 43 | this.fetchData(); 44 | } 45 | 46 | @HostListener('window:beforeunload') 47 | canDeactivate(): boolean { 48 | if (this.formComponent.templateForm) { 49 | return !this.formComponent.templateForm.dirty; 50 | } else { 51 | return true; 52 | } 53 | } 54 | 55 | successInfo(): string { 56 | if (this.form) { 57 | return `Thank you for taking the time to provide feedback on one of your colleagues. 58 | You can continue to give feedback until ${this.form.endDate}.`; 59 | } 60 | } 61 | 62 | saveCaption(): string { 63 | if (this.form) { 64 | return `Please kindly double check your feedback before proceeding: once you hit the Save button, 65 | your feedback will be saved and available for editing until the end of the "Give feedback" period at ${this.form.endDate}. 66 | After this time, it will automatically be submitted and released to your colleague's manager.` ; 67 | } 68 | } 69 | 70 | fetchData() { 71 | this.route.params.pipe(map(p => p.username)).subscribe((username: String) => { 72 | this.username = username; 73 | this.api.getFeedbackForm(username).subscribe((data: GiveFeedbackPayload) => { 74 | this.form = data.form; 75 | }); 76 | }); 77 | } 78 | 79 | onSubmit(form: NgForm) { 80 | this.formState = FormState.Submitting; 81 | this.api.putFeedbackForm(this.username, this.form.items).subscribe((isSuccess: any) => { 82 | this.formState = FormState.Success; 83 | }, 84 | err => { 85 | this.formState = FormState.Editing; 86 | console.log('Request for PUT failed'); 87 | throw err; 88 | }); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /frontend/src/app/components/views/index.ts: -------------------------------------------------------------------------------- 1 | export * from './give-feedback/give-feedback.component'; 2 | export * from './feedback-about-me/feedback-about-me.component'; 3 | export * from './nominees-list/nominees-list.component'; 4 | export * from './login/login.component'; 5 | export * from './self-nominate/self-nominate.component'; 6 | export * from './stats/stats.component'; 7 | export * from './summarise-feedback/summarise-feedback.component'; 8 | export * from './talent-manager-panel/talent-manager-panel.component'; 9 | export * from './authenticated/authenticated.component'; 10 | export * from './feedback-history-view/feedback-history-view.component'; 11 | export * from './external-invite/external-invite.component'; 12 | -------------------------------------------------------------------------------- /frontend/src/app/components/views/login/login.component.html: -------------------------------------------------------------------------------- 1 | 11 |
12 |

Sign In

13 |
14 | 17 |
18 |
19 | 22 |
23 |
24 | 27 |
28 |
29 |
30 | 31 | 32 | {{ this.metadata?.loginUsernameMessage }} 33 |
34 |
35 | 36 | 37 | {{ this.metadata?.loginPasswordMessage }} 38 |
39 |
40 | 44 |
45 | 46 |
47 |
48 | -------------------------------------------------------------------------------- /frontend/src/app/components/views/login/login.component.scss: -------------------------------------------------------------------------------- 1 | @import '~styles.scss'; 2 | 3 | .container { 4 | padding-top: 1em; 5 | } 6 | 7 | #login-button { 8 | margin-top: 1em; 9 | } 10 | -------------------------------------------------------------------------------- /frontend/src/app/components/views/login/login.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { FormsModule } from '@angular/forms'; 3 | import { RouterTestingModule } from '@angular/router/testing'; 4 | 5 | import { ApiService } from '../../../services/api.service'; 6 | import { HttpClientModule } from '@angular/common/http'; 7 | import { LoginComponent } from './login.component'; 8 | 9 | describe('LoginComponent', () => { 10 | let component: LoginComponent; 11 | let fixture: ComponentFixture; 12 | 13 | beforeEach(async(() => { 14 | TestBed.configureTestingModule({ 15 | imports: [ FormsModule, HttpClientModule, RouterTestingModule ], 16 | declarations: [ LoginComponent ], 17 | providers: [ ApiService ] 18 | }) 19 | .compileComponents(); 20 | })); 21 | 22 | beforeEach(() => { 23 | fixture = TestBed.createComponent(LoginComponent); 24 | component = fixture.componentInstance; 25 | fixture.detectChanges(); 26 | }); 27 | 28 | it('should create', () => { 29 | expect(component).toBeTruthy(); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /frontend/src/app/components/views/login/login.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, OnInit, OnChanges } from '@angular/core'; 2 | import { HttpErrorResponse } from '@angular/common/http'; 3 | import { NgForm } from '@angular/forms'; 4 | import { Router, ActivatedRoute } from '@angular/router'; 5 | 6 | import { ApiService, MetadataPayload, Metadata } from '../../../services/api.service'; 7 | import { CookieService } from '../../../services/cookie.service'; 8 | 9 | 10 | // avoiding having password persisted in javascript 11 | class LoginForm { 12 | username?: string; 13 | rememberMe?: boolean; 14 | } 15 | 16 | @Component({ 17 | templateUrl: './login.component.html', 18 | styleUrls: ['./login.component.scss'], 19 | selector: 'app-login' 20 | }) 21 | export class LoginComponent implements OnInit, OnChanges { 22 | 23 | public message: string; 24 | public warnMessage: string; 25 | public metadata: Metadata; 26 | form: LoginForm = { rememberMe: false }; 27 | redirectUrl = '/feedback'; 28 | submitting = false; 29 | isDisconnected = false; 30 | 31 | constructor(public api: ApiService, private cookie: CookieService, private router: Router, private route: ActivatedRoute) {} 32 | 33 | ngOnInit() { 34 | const username = this.cookie.getCookie('username'); 35 | if (username) { 36 | this.form.rememberMe = true; 37 | this.form.username = username; 38 | } 39 | if (this.route.snapshot.queryParams.logoutSuccess) { 40 | this.message = 'You have successfully logged out.'; 41 | } 42 | if (this.route.snapshot.queryParams.from) { 43 | this.redirectUrl = this.route.snapshot.queryParams.from; 44 | } 45 | this.api.getMetadata().subscribe( 46 | (result: MetadataPayload) => { 47 | this.metadata = result.metadata; 48 | if (result.metadata.passwordlessAccess) { 49 | this.warnMessage = 'UNSAFE PASSWORDLESS ACCESS IS ENABLED ON WEB SERVER. THIS SHOULD NOT BE ON IN PRODUCTION!'; 50 | } 51 | }, 52 | () => { 53 | this.isDisconnected = true; 54 | } 55 | ); 56 | } 57 | 58 | ngOnChanges() { 59 | if (this.form.rememberMe) { 60 | this.cookie.setCookie('username', this.form.username); 61 | } 62 | } 63 | 64 | onRememberMeToggle(value: boolean) { 65 | this.form.rememberMe = value; 66 | if (value) { 67 | this.cookie.setCookie('username', this.form.username); 68 | } else { 69 | this.cookie.deleteCookie('username'); 70 | } 71 | } 72 | 73 | private setErrorMessage(error: HttpErrorResponse) { 74 | switch (error.status) { 75 | case 401: 76 | this.message = `Incorrect username and/or password. Please email ${this.metadata.supportEmail} if you have anymore issues`; 77 | break; 78 | case 404: 79 | this.message = `User with Windows username "${this.form.username}" not found. If you think this 80 | should not be the case, please email ${this.metadata.supportEmail} to synchronise User models with LDAP.`; 81 | break; 82 | case 500: 83 | this.message = `Backend service unable to serve the request. Please email ${this.metadata.supportEmail}.`; 84 | break; 85 | default: 86 | this.message = `Unable to login. Please email ${this.metadata.supportEmail} if you have any more issues.`; 87 | } 88 | } 89 | 90 | onSubmit(loginForm: NgForm) { 91 | this.submitting = true; 92 | this.message = null; 93 | this.api.login(loginForm.value.username, loginForm.value.password).subscribe( 94 | _ => { 95 | this.router.navigate([this.redirectUrl]); 96 | if (this.form.rememberMe) { 97 | this.cookie.setCookie('username', this.form.username); 98 | } 99 | this.submitting = false; 100 | }, 101 | err => { 102 | this.setErrorMessage(err); 103 | this.submitting = false; 104 | } 105 | ); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /frontend/src/app/components/views/nominees-list/nominees-list.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |

Colleagues that have requested feedback

5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | Give feedback 30 | 31 | 32 | 33 | 34 | View and edit 35 | 36 | 37 | 38 | 39 |
NamePositionDepartmentManagerAction
No nominees found!
{{employee.displayName}}{{employee.position}}{{employee.department}}{{employee.managerDisplayName}}
40 |
41 | 42 |
43 |

{{ data.heading }}

44 |

{{ data.body }}

45 | 46 | 47 | 48 |
49 |
50 |
51 |
52 | Loading Data... 53 | 54 | -------------------------------------------------------------------------------- /frontend/src/app/components/views/nominees-list/nominees-list.component.scss: -------------------------------------------------------------------------------- 1 | .nominee-header { 2 | margin: auto; 3 | } 4 | .nominee-action-header { 5 | text-align: center; 6 | margin: auto; 7 | } 8 | 9 | #nominee-quick-search-input { 10 | margin-bottom: 1em; 11 | } -------------------------------------------------------------------------------- /frontend/src/app/components/views/nominees-list/nominees-list.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, OnInit } from '@angular/core'; 2 | import { ApiService, NomineePayload, MessageTemplatePayload } from '../../../services/api.service'; 3 | import { Observable } from 'rxjs'; 4 | 5 | @Component({ 6 | selector: 'app-nominees-list', 7 | templateUrl: './nominees-list.component.html', 8 | styleUrls: ['./nominees-list.component.scss'] 9 | }) 10 | export class NomineesListComponent implements OnInit { 11 | constructor(public api: ApiService) {} 12 | 13 | data: Observable<{} | NomineePayload | MessageTemplatePayload>; 14 | 15 | ngOnInit() { 16 | this.data = this.api.getNominees(); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /frontend/src/app/components/views/self-nominate/self-nominate.component.html: -------------------------------------------------------------------------------- 1 |
2 |

{{ this.data.heading }}

3 |

{{ this.data.body }}

4 | 5 | 6 | 7 |
-------------------------------------------------------------------------------- /frontend/src/app/components/views/self-nominate/self-nominate.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { RouterTestingModule } from '@angular/router/testing'; 3 | 4 | import { ApiService } from '../../../services/api.service'; 5 | import { HttpClientModule } from '@angular/common/http'; 6 | import { SelfNominateComponent } from './self-nominate.component'; 7 | 8 | describe('SelfNominateComponent', () => { 9 | let component: SelfNominateComponent; 10 | let fixture: ComponentFixture; 11 | 12 | beforeEach(async(() => { 13 | TestBed.configureTestingModule({ 14 | imports: [ HttpClientModule, RouterTestingModule ], 15 | declarations: [ SelfNominateComponent ], 16 | providers: [ ApiService ] 17 | }) 18 | .compileComponents(); 19 | })); 20 | 21 | beforeEach(() => { 22 | fixture = TestBed.createComponent(SelfNominateComponent); 23 | component = fixture.componentInstance; 24 | fixture.detectChanges(); 25 | }); 26 | 27 | it('should create', () => { 28 | expect(component).toBeTruthy(); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /frontend/src/app/components/views/self-nominate/self-nominate.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, OnInit } from '@angular/core'; 2 | import { Router } from '@angular/router'; 3 | 4 | import { ApiService } from '../../../services/api.service'; 5 | 6 | @Component({ 7 | templateUrl: './self-nominate.component.html', 8 | selector: 'app-self-nominate' 9 | }) 10 | export class SelfNominateComponent implements OnInit { 11 | isLoaded = false; 12 | data = null; 13 | endpoint = '/self-nominate'; 14 | 15 | constructor(private api: ApiService, private router: Router) {} 16 | 17 | ngOnInit() { 18 | this.fetchTemplate(); 19 | } 20 | 21 | fetchTemplate() { 22 | this.api.fetchTemplate(this.endpoint).subscribe( 23 | (data) => { 24 | this.data = data; 25 | this.isLoaded = true; 26 | } 27 | ); 28 | } 29 | 30 | onClick() { 31 | if (this.data.canNominate) { 32 | this.api.selfNominate().subscribe( 33 | (data) => { 34 | this.data = data; 35 | this.isLoaded = true; 36 | } 37 | ); 38 | } else { 39 | this.router.navigate([this.data.buttonLink]); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /frontend/src/app/components/views/stats/stats.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 20 | 21 | 22 |
Feedback History
18 | {{ row[0].displayName }} 19 |
23 |
24 |
25 |
26 | 27 | 28 | 29 | 33 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 58 | 65 | 66 | 67 |
30 | {{ periodName }} 31 | in progress 32 | 34 | Action for {{ data.stats.periods[data.stats.periods.length-1] }} 35 |
{{ data.stats.periodColumns[0] }}{{ data.stats.periodColumns[1] }}
50 | 51 |
52 | {{ cell }} 53 |
54 |
55 | {{ cell }} 56 |
57 |
59 | 64 |
68 |
69 |
70 |
71 |
72 |
73 | Legend: 74 |
75 |
76 |
    77 |
  • 78 | 0 Did not give any feedback 79 |
  • 80 |
  • 81 | 5 Has given feedback to 5 colleagues 82 |
  • 83 |
  • 84 | -1 Did not request feedback 85 |
  • 86 |
  • 87 | 0 Requested feedback, but did not receive any feedback 88 |
  • 89 |
  • 90 | 3 Requested feedback, received feedback from 3 colleagues 91 |
  • 92 |
93 |
94 |
95 |
96 | Loading Data... -------------------------------------------------------------------------------- /frontend/src/app/components/views/stats/stats.component.scss: -------------------------------------------------------------------------------- 1 | @import '~styles.scss'; 2 | 3 | $fixedColumnWidth: 5em; 4 | 5 | .stats-header { 6 | text-align: center; 7 | margin: auto; 8 | } 9 | 10 | #name-cell-padding { 11 | padding: 1.5em; 12 | background-color: white; 13 | border-color: white; 14 | } 15 | 16 | .name-cell { 17 | white-space: nowrap; 18 | } 19 | 20 | .stats-cell { 21 | text-align: center; 22 | } 23 | 24 | .stats-counter { 25 | border-radius: 50%; 26 | background: white; 27 | color: white; 28 | margin: auto; 29 | display: inline-block; 30 | text-align: center; 31 | vertical-align: middle; 32 | width: 2em; 33 | height: 2em; 34 | } 35 | 36 | .received { 37 | border: 2px solid green; 38 | } 39 | .nominated { 40 | background: green; 41 | } 42 | 43 | .sent { 44 | border: 2px solid grey; 45 | } 46 | .contributed { 47 | background: grey; 48 | } 49 | 50 | .stats-legend { 51 | display: flex; 52 | padding-top: 1em; 53 | font-size: smaller; 54 | } 55 | 56 | .stats-legend-title { 57 | padding-top: 0.5em; 58 | padding-right: 1em; 59 | padding-bottom: 1em; 60 | padding-left: 0.5em; 61 | } 62 | 63 | .stats-legend-content { 64 | ul { 65 | padding-left: 0em; 66 | list-style-type: none; 67 | li { 68 | padding-top: 0.5em; 69 | padding-bottom: 0.5em; 70 | } 71 | } 72 | } -------------------------------------------------------------------------------- /frontend/src/app/components/views/stats/stats.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { StatsComponent } from './stats.component'; 4 | 5 | describe('StatsComponent', () => { 6 | let component: StatsComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ StatsComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(StatsComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /frontend/src/app/components/views/stats/stats.component.ts: -------------------------------------------------------------------------------- 1 | import { AfterViewChecked, Component, ElementRef, OnInit, ViewChild } from '@angular/core'; 2 | import { ActivatedRoute } from '@angular/router'; 3 | import { ApiService, StatsPayload } from '../../../services/api.service'; 4 | import { Observable } from 'rxjs'; 5 | 6 | @Component({ 7 | selector: 'app-stats', 8 | templateUrl: './stats.component.html', 9 | styleUrls: ['./stats.component.scss'] 10 | }) 11 | export class StatsComponent implements OnInit, AfterViewChecked { 12 | 13 | @ViewChild('statsTable') private statsTable: ElementRef; 14 | @ViewChild('nameTable') private nameTable: ElementRef; 15 | data: Observable; 16 | 17 | constructor(public api: ApiService, private route: ActivatedRoute) { } 18 | 19 | ngOnInit() { 20 | this.route.data.subscribe((routeData) => { 21 | if (!routeData.isCompanyWide) { 22 | this.data = this.api.getTeamStats(); 23 | } else { 24 | this.data = this.api.getCompanyStats(); 25 | } 26 | this.scrollToEnd(); 27 | }); 28 | } 29 | 30 | ngAfterViewChecked() { 31 | this.scrollToEnd(); 32 | this.syncTableCellHeights(); 33 | } 34 | 35 | syncTableCellHeights() { 36 | // because we use two separate tables to be able to have a responsive yet fixed 37 | // first column 38 | try { 39 | const cell = this.statsTable.nativeElement.querySelector('.stats-cell'); 40 | this.nameTable.nativeElement.querySelectorAll('td').forEach(element => { 41 | element.height = `${cell.offsetHeight}px` ; 42 | }); 43 | } catch (err) {} 44 | } 45 | 46 | scrollToEnd() { 47 | try { 48 | this.statsTable.nativeElement.scrollLeft = window.screen.width; 49 | } catch (err) {} 50 | } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /frontend/src/app/components/views/summarise-feedback/summarise-feedback.component.html: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /frontend/src/app/components/views/summarise-feedback/summarise-feedback.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/man-group/adaero/991515895fd0063662755e0051577e1b7053a9fd/frontend/src/app/components/views/summarise-feedback/summarise-feedback.component.scss -------------------------------------------------------------------------------- /frontend/src/app/components/views/summarise-feedback/summarise-feedback.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { SummariseFeedbackComponent } from './summarise-feedback.component'; 4 | 5 | describe('SummariseFeedbackComponent', () => { 6 | let component: SummariseFeedbackComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ SummariseFeedbackComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(SummariseFeedbackComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /frontend/src/app/components/views/summarise-feedback/summarise-feedback.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, ViewChild, HostListener } from '@angular/core'; 2 | import { NgForm } from '@angular/forms'; 3 | import { ActivatedRoute, Router } from '@angular/router'; 4 | import { ApiService, SummaryFeedbackPayload, FeedbackForm } from '../../../services/api.service'; 5 | import { Observable } from 'rxjs'; 6 | import { map } from 'rxjs/operators'; 7 | import { FormState } from '../../../components/widgets/feedback-form/feedback-form.component'; 8 | import { ComponentCanDeactivate } from '../../../guards/pending-changes-guard.service'; 9 | import { FeedbackFormComponent } from '../../widgets'; 10 | 11 | @Component({ 12 | selector: 'app-summarise-feedback', 13 | templateUrl: './summarise-feedback.component.html', 14 | styleUrls: ['./summarise-feedback.component.scss'] 15 | }) 16 | export class SummariseFeedbackComponent implements OnInit { 17 | 18 | public formStates = FormState; 19 | form: FeedbackForm; 20 | username: String; 21 | formState: FormState = FormState.Editing; 22 | successButtonLink = '/team-feedback'; 23 | @ViewChild('formComponent', { static: true }) formComponent: FeedbackFormComponent; 24 | 25 | readonly formInfo = `

For each question, please review, edit & summarize the feedback that has been given:

26 |
    27 |
  • The raw contributions are in the first box which is greyed out
  • 28 |
  • Please enter your summary into the second box, which has been prefilled with the raw contributions.
  • 29 |
`; 30 | 31 | readonly successTitle = 'Summary successfully saved!'; 32 | readonly successButton = 'Back to team stats page'; 33 | 34 | constructor(public api: ApiService, private route: ActivatedRoute) { } 35 | 36 | @HostListener('window:beforeunload') 37 | canDeactivate(): boolean { 38 | if (this.formComponent.templateForm) { 39 | return !this.formComponent.templateForm.dirty; 40 | } else { 41 | return true; 42 | } 43 | } 44 | 45 | ngOnInit() { 46 | this.fetchData(); 47 | } 48 | 49 | successInfo(): string { 50 | if (this.form) { 51 | return `Thank you for taking the time to summarise feedback on ${this.form.employee.displayName}. 52 | You should complete all feedback summaries by ${this.form.endDate}. Failure to meet this deadline 53 | will result in your remaining direct reports receiving no feedback from their colleagues.`; 54 | } 55 | } 56 | 57 | saveCaption(): string { 58 | if (this.form) { 59 | return `Please kindly double check your summary before proceeding: once you hit the Save button, 60 | your feedback will be saved and available for editing until the end of the "Review feedback" period at ${this.form.endDate}. 61 | After this time, it will be automatically released to your direct report(s).` ; 62 | } 63 | } 64 | 65 | fetchData() { 66 | this.route.params.pipe(map(p => p.username)).subscribe((username: String) => { 67 | this.username = username; 68 | this.api.getSummaryFeedback(username).subscribe((data: SummaryFeedbackPayload) => { 69 | this.form = data.summary; 70 | }); 71 | }); 72 | } 73 | 74 | onSubmit(form: NgForm) { 75 | this.formState = FormState.Submitting; 76 | this.api.putSummaryFeedback(this.username, this.form.items).subscribe((isSuccess: any) => { 77 | this.formState = FormState.Success; 78 | }, 79 | err => { 80 | this.formState = FormState.Editing; 81 | console.log('Request to PUT form failed'); 82 | throw err; 83 | }); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /frontend/src/app/components/views/talent-manager-panel/talent-manager-panel.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 5 | 6 | 7 | 9 | 10 |

Send emails

11 |

Initial emails are automatically sent by the system but you can use this page to send the reminders.

12 |
13 | 14 |
15 |

If there is an issue with the system and you would like to send out the initial emails again, you can use these to send them out manually.

16 |
17 | 18 |
19 |
20 |

Manage users

21 |

Download current user population

22 | 23 |

24 | There are currently {{ data?.userCount }} users stored in the database. 25 |

26 |

Download current users

27 |
28 |

Generate population CSV template

29 |

{{ data?.generatePopulationMsg }}

30 |
31 |
32 | 33 |
34 | 35 |
36 |
37 |

Upload new population CSV

38 |

{{ data?.uploadNewPopulationMsg }}

39 |
40 |
41 | 42 |
43 | 44 |
45 |
46 | -------------------------------------------------------------------------------- /frontend/src/app/components/views/talent-manager-panel/talent-manager-panel.component.scss: -------------------------------------------------------------------------------- 1 | .email-btn { 2 | margin-bottom: 0.5em; 3 | } -------------------------------------------------------------------------------- /frontend/src/app/components/views/talent-manager-panel/talent-manager-panel.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { TalentManagerPanelComponent } from './talent-manager-panel.component'; 4 | 5 | describe('TalentManagerPanelComponent', () => { 6 | let component: TalentManagerPanelComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ TalentManagerPanelComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(TalentManagerPanelComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /frontend/src/app/components/widgets/feedback-form/feedback-form.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |
5 |

Feedback on {{ form.employee.displayName }} for {{ form.periodName }}

6 |
7 |
8 |
9 |
10 |
11 |

{{ row.caption }}

12 | 13 | 14 |
15 |
Contributions by peers (in random order):
16 | 17 |
18 |
19 |
Summary:
20 |
21 | 22 |

{{row.answer}}

23 |
24 | 25 |
26 | 29 |
30 | 31 |
32 |

{{ successTitle }}

33 |

{{ successInfo }}

34 | {{ successButton }} 35 |
36 |
37 |
38 |
39 | Loading Data... 40 | 41 | -------------------------------------------------------------------------------- /frontend/src/app/components/widgets/feedback-form/feedback-form.component.scss: -------------------------------------------------------------------------------- 1 | #feedback-form-footer { 2 | color: lightslategray; 3 | font-size: smaller; 4 | padding-top: 1em; 5 | } 6 | 7 | #feedback-form-info { 8 | border-top: lightgray 1px solid; 9 | border-bottom: lightgray 1px solid; 10 | padding-top: 0.5em; 11 | padding-bottom: 0.5em; 12 | } 13 | 14 | #feedback-form-title { 15 | padding-bottom: 0.5em; 16 | } 17 | 18 | #feedback-form-form { 19 | padding-top: 0.5em; 20 | } 21 | 22 | .readonly-answer { 23 | white-space: pre-line; 24 | } 25 | 26 | label { 27 | font-weight: bold; 28 | } 29 | 30 | @media print { 31 | .caption, .raw-answer, .summary-label { 32 | display: none; 33 | } 34 | } -------------------------------------------------------------------------------- /frontend/src/app/components/widgets/feedback-form/feedback-form.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { FeedbackFormComponent } from './feedback-form.component'; 4 | 5 | describe('FeedbackFormComponent', () => { 6 | let component: FeedbackFormComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ FeedbackFormComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(FeedbackFormComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /frontend/src/app/components/widgets/feedback-form/feedback-form.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, Output, EventEmitter, ViewChild } from '@angular/core'; 2 | import { NgForm } from '@angular/forms'; 3 | import { SummaryFeedbackPayload, FeedbackForm } from '../../../services/api.service'; 4 | 5 | export enum FormState { 6 | Editing, 7 | Submitting, 8 | Success, 9 | } 10 | 11 | @Component({ 12 | selector: 'app-feedback-form', 13 | templateUrl: './feedback-form.component.html', 14 | styleUrls: ['./feedback-form.component.scss'] 15 | }) 16 | export class FeedbackFormComponent { 17 | 18 | public formStates = FormState; 19 | @Input() form: FeedbackForm; 20 | @Input() public formState: FormState; 21 | @Input() public successButtonLink: string; 22 | @Input() public formInfo: string; 23 | @Input() public saveCaption: string; 24 | @Input() public successTitle: string; 25 | @Input() public successInfo: string; 26 | @Input() public successButton: string; 27 | 28 | @Output() submitRequest = new EventEmitter(); 29 | @ViewChild('f') templateForm: NgForm; 30 | 31 | username: String; 32 | 33 | onSubmit(form: NgForm) { 34 | this.submitRequest.emit(form); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /frontend/src/app/components/widgets/feedback-history/feedback-history.component.html: -------------------------------------------------------------------------------- 1 |
2 |

{{ title }}

3 |

{{ d }}

4 | 5 | 9 | 10 |
11 |
12 | 13 | 14 |
15 |
16 |
17 |
18 |
19 |
20 | Loading Data... -------------------------------------------------------------------------------- /frontend/src/app/components/widgets/feedback-history/feedback-history.component.scss: -------------------------------------------------------------------------------- 1 | .form-control[readonly] { 2 | background: white; 3 | } 4 | 5 | .history-info { 6 | white-space: pre-line; 7 | } -------------------------------------------------------------------------------- /frontend/src/app/components/widgets/feedback-history/feedback-history.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { FeedbackHistoryComponent } from './feedback-history.component'; 4 | 5 | describe('FeedbackHistoryComponent', () => { 6 | let component: FeedbackHistoryComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ FeedbackHistoryComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(FeedbackHistoryComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /frontend/src/app/components/widgets/feedback-history/feedback-history.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, Input } from '@angular/core'; 2 | import { FeedbackHistoryPayload } from '../../../services/api.service'; 3 | 4 | @Component({ 5 | selector: 'app-feedback-history', 6 | templateUrl: './feedback-history.component.html', 7 | styleUrls: ['./feedback-history.component.scss'] 8 | }) 9 | export class FeedbackHistoryComponent implements OnInit { 10 | 11 | @Input() public data: FeedbackHistoryPayload; 12 | @Input() public title: string; 13 | @Input() public description: string; 14 | 15 | constructor() { } 16 | 17 | ngOnInit() { 18 | } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /frontend/src/app/components/widgets/index.ts: -------------------------------------------------------------------------------- 1 | export * from './feedback-form/feedback-form.component'; 2 | export * from './modal/modal.component'; 3 | -------------------------------------------------------------------------------- /frontend/src/app/components/widgets/modal/modal.component.html: -------------------------------------------------------------------------------- 1 | 5 | -------------------------------------------------------------------------------- /frontend/src/app/components/widgets/modal/modal.component.scss: -------------------------------------------------------------------------------- 1 | .metadata { 2 | font-size: 1em; 3 | font-family: monospace; 4 | } -------------------------------------------------------------------------------- /frontend/src/app/components/widgets/modal/modal.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { ModalComponent } from './modal.component'; 4 | 5 | describe('ModalComponent', () => { 6 | let component: ModalComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ ModalComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(ModalComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /frontend/src/app/components/widgets/modal/modal.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, Output } from '@angular/core'; 2 | import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; 3 | 4 | @Component({ 5 | selector: 'app-modal', 6 | templateUrl: './modal.component.html', 7 | styleUrls: ['./modal.component.scss'] 8 | }) 9 | export class ModalComponent { 10 | 11 | @Input() public confirmText: string; 12 | @Input() public cancelText: string; 13 | @Input() public dialog: string; 14 | @Input() public metadata: string; 15 | constructor(public activeModal: NgbActiveModal) { } 16 | 17 | } 18 | -------------------------------------------------------------------------------- /frontend/src/app/guards/anon-guard.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed, inject } from '@angular/core/testing'; 2 | import { HttpClientModule } from '@angular/common/http'; 3 | import { RouterTestingModule } from '@angular/router/testing'; 4 | import { FormsModule } from '@angular/forms'; 5 | 6 | import { AppRoutingModule } from '../app-routing.module'; 7 | import { AnonGuardService } from './anon-guard.service'; 8 | import { ApiService } from '../services/api.service'; 9 | import { 10 | GiveFeedbackComponent, 11 | NomineesListComponent, 12 | LoginComponent, 13 | SelfNominateComponent 14 | } from '../components'; 15 | 16 | describe('AnonGuardService', () => { 17 | beforeEach(() => { 18 | TestBed.configureTestingModule({ 19 | declarations: [ 20 | LoginComponent, 21 | NomineesListComponent, 22 | GiveFeedbackComponent, 23 | SelfNominateComponent 24 | ], 25 | imports: [ 26 | FormsModule, 27 | HttpClientModule, 28 | RouterTestingModule 29 | ], 30 | providers: [ 31 | AnonGuardService, 32 | ApiService 33 | ] 34 | }); 35 | }); 36 | 37 | it('should be created', inject([AnonGuardService], (service: AnonGuardService) => { 38 | expect(service).toBeTruthy(); 39 | })); 40 | }); 41 | -------------------------------------------------------------------------------- /frontend/src/app/guards/anon-guard.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Router, CanActivate } from '@angular/router'; 3 | import { Observable, of } from 'rxjs'; 4 | import { catchError, map } from 'rxjs/operators'; 5 | 6 | 7 | 8 | 9 | import { ApiService } from '../services/api.service'; 10 | 11 | 12 | @Injectable() 13 | export class AnonGuardService implements CanActivate { 14 | 15 | constructor(private api: ApiService, private router: Router) {} 16 | 17 | canActivate() { 18 | console.log('AnonGuard#canActivate called'); 19 | return this.api.getUserData(). 20 | pipe( 21 | map((e) => { 22 | if (e) { 23 | this.router.navigate(['/self-nominate']); 24 | return false; 25 | } 26 | }), 27 | catchError((err) => { 28 | return of(true); 29 | }) 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /frontend/src/app/guards/auth-guard.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed, inject } from '@angular/core/testing'; 2 | import { RouterTestingModule } from '@angular/router/testing'; 3 | 4 | import { AuthGuardService } from './auth-guard.service'; 5 | import { ApiService } from '../services/api.service'; 6 | import { HttpClientModule } from '@angular/common/http'; 7 | 8 | describe('AuthGuardService', () => { 9 | beforeEach(() => { 10 | TestBed.configureTestingModule({ 11 | imports: [ HttpClientModule, RouterTestingModule ], 12 | providers: [AuthGuardService, ApiService] 13 | }); 14 | }); 15 | 16 | it('should be created', inject([AuthGuardService], (service: AuthGuardService) => { 17 | expect(service).toBeTruthy(); 18 | })); 19 | }); 20 | -------------------------------------------------------------------------------- /frontend/src/app/guards/auth-guard.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Router, CanActivate, ActivatedRouteSnapshot } from '@angular/router'; 3 | import { Observable, of } from 'rxjs'; 4 | import { catchError, map } from 'rxjs/operators'; 5 | 6 | 7 | 8 | 9 | import { ApiService } from '../services/api.service'; 10 | 11 | 12 | @Injectable() 13 | export class AuthGuardService implements CanActivate { 14 | 15 | constructor(private api: ApiService, private router: Router) {} 16 | 17 | canActivate(state: ActivatedRouteSnapshot) { 18 | const url = state.firstChild.url.join('/'); 19 | console.log(`AuthGuard#canActivate called from ${url}`); 20 | return this.api.getUserData(). 21 | pipe( 22 | map((e) => { 23 | if (e) { return true; } 24 | }), 25 | catchError((err) => { 26 | this.router.navigate(['/login'], {queryParams: {from: url}}); 27 | return of(false); 28 | }) 29 | ); 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /frontend/src/app/guards/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth-guard.service'; 2 | export * from './anon-guard.service'; 3 | export * from './pending-changes-guard.service'; 4 | -------------------------------------------------------------------------------- /frontend/src/app/guards/pending-changes-guard.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed, inject } from '@angular/core/testing'; 2 | 3 | import { PendingChangesGuardService } from './pending-changes-guard.service'; 4 | 5 | describe('PendingChangesGuardService', () => { 6 | beforeEach(() => { 7 | TestBed.configureTestingModule({ 8 | providers: [PendingChangesGuardService] 9 | }); 10 | }); 11 | 12 | it('should be created', inject([PendingChangesGuardService], (service: PendingChangesGuardService) => { 13 | expect(service).toBeTruthy(); 14 | })); 15 | }); 16 | -------------------------------------------------------------------------------- /frontend/src/app/guards/pending-changes-guard.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, TemplateRef, HostListener } from '@angular/core'; 2 | import { Router, CanDeactivate } from '@angular/router'; 3 | import { Observable } from 'rxjs'; 4 | import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; 5 | import { ModalComponent } from '../components/widgets'; 6 | 7 | export interface ComponentCanDeactivate { 8 | canDeactivate: () => boolean | Observable ; 9 | modal: NgbModal; 10 | } 11 | 12 | @Injectable() 13 | export class PendingChangesGuardService implements CanDeactivate { 14 | 15 | constructor() { } 16 | 17 | canDeactivate(component: ComponentCanDeactivate): boolean | Promise { 18 | if (!component.canDeactivate()) { 19 | // NOTE: this warning message will only be shown when navigating elsewhere within your angular app; 20 | // when navigating away from your angular app, the browser will show a generic warning message 21 | // see http://stackoverflow.com/a/42207299/7307355` 22 | const c = component.modal.open(ModalComponent); 23 | c.componentInstance.confirmText = 'Leave'; 24 | c.componentInstance.cancelText = 'Stay'; 25 | c.componentInstance.dialog = 'You have unsaved changes. Are you sure you want to leave?'; 26 | return c.result.then(() => { 27 | return true; 28 | }, () => { 29 | return false; 30 | }); 31 | } else { 32 | return true; 33 | } 34 | 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /frontend/src/app/pipes/nominee-filter.pipe.spec.ts: -------------------------------------------------------------------------------- 1 | import { NomineeFilterPipe } from './nominee-filter.pipe'; 2 | 3 | describe('NomineeFilterPipe', () => { 4 | it('create an instance', () => { 5 | const pipe = new NomineeFilterPipe(); 6 | expect(pipe).toBeTruthy(); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /frontend/src/app/pipes/nominee-filter.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from '@angular/core'; 2 | import { NomineeItem } from '../services/api.service'; 3 | 4 | @Pipe({ 5 | name: 'nomineeFilter' 6 | }) 7 | export class NomineeFilterPipe implements PipeTransform { 8 | 9 | transform(nominees: NomineeItem[], criteria: string): any { 10 | const lowerCriteria = criteria.toLowerCase(); 11 | return nominees.filter((nominee: NomineeItem) => { 12 | if (criteria.length < 3) { 13 | return true; 14 | } else if (nominee.displayName.toLowerCase().indexOf(lowerCriteria) >= 0) { 15 | return true; 16 | } else if (nominee.position.toLowerCase().indexOf(lowerCriteria) >= 0) { 17 | return true; 18 | } else if (nominee.managerDisplayName.toLowerCase().indexOf(lowerCriteria) >= 0) { 19 | return true; 20 | } else if (nominee.department.toLowerCase().indexOf(lowerCriteria) >= 0) { 21 | return true; 22 | } else { 23 | return false; 24 | } 25 | }); 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /frontend/src/app/services/cookie.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed, inject } from '@angular/core/testing'; 2 | 3 | import { CookieService } from './cookie.service'; 4 | 5 | describe('CookieService', () => { 6 | beforeEach(() => { 7 | TestBed.configureTestingModule({ 8 | providers: [CookieService] 9 | }); 10 | }); 11 | 12 | it('should be created', inject([CookieService], (service: CookieService) => { 13 | expect(service).toBeTruthy(); 14 | })); 15 | }); 16 | -------------------------------------------------------------------------------- /frontend/src/app/services/cookie.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | @Injectable() 4 | export class CookieService { 5 | 6 | constructor() { } 7 | 8 | getCookie(name: string) { 9 | const cookies: Array = document.cookie.split(';'); 10 | const cookieName = `${name}=`; 11 | let c: string; 12 | 13 | for (const cookie of cookies) { 14 | c = cookie.replace(/^\s+/g, ''); 15 | if (c.indexOf(cookieName) === 0) { 16 | return c.substring(cookieName.length, c.length); 17 | } 18 | } 19 | return ''; 20 | } 21 | 22 | deleteCookie(name) { 23 | this.setCookie(name, '', -1); 24 | } 25 | 26 | // number = 60 * 60 * 24 * 28 27 | setCookie(name: string, value: string, maxAgeSeconds: number = 2419200, path: string = '') { 28 | let maxAge = ''; 29 | if (maxAgeSeconds) { 30 | maxAge = `max-age=${maxAgeSeconds}`; 31 | } 32 | const cpath = path ? `; path=${path}` : ''; 33 | document.cookie = `${name}=${value}; ${maxAge}${cpath}`; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /frontend/src/assets/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/man-group/adaero/991515895fd0063662755e0051577e1b7053a9fd/frontend/src/assets/favicon-16x16.png -------------------------------------------------------------------------------- /frontend/src/assets/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/man-group/adaero/991515895fd0063662755e0051577e1b7053a9fd/frontend/src/assets/favicon-32x32.png -------------------------------------------------------------------------------- /frontend/src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/man-group/adaero/991515895fd0063662755e0051577e1b7053a9fd/frontend/src/assets/logo.png -------------------------------------------------------------------------------- /frontend/src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true 3 | }; 4 | -------------------------------------------------------------------------------- /frontend/src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // The file contents for the current environment will overwrite these during build. 2 | // The build system defaults to the dev environment which uses `environment.ts`, but if you do 3 | // `ng build --env=prod` then `environment.prod.ts` will be used instead. 4 | // The list of which env maps to which file can be found in `.angular-cli.json`. 5 | 6 | export const environment = { 7 | production: false 8 | }; 9 | -------------------------------------------------------------------------------- /frontend/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Adaero 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /frontend/src/main.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode } from '@angular/core'; 2 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 3 | 4 | import { AppModule } from './app/app.module'; 5 | import { environment } from './environments/environment'; 6 | 7 | if (environment.production) { 8 | enableProdMode(); 9 | } 10 | 11 | platformBrowserDynamic().bootstrapModule(AppModule) 12 | .catch(err => console.log(err)); 13 | -------------------------------------------------------------------------------- /frontend/src/polyfills.ts: -------------------------------------------------------------------------------- 1 | /*************************************************************************************************** 2 | * Load `$localize` onto the global scope - used if i18n tags appear in Angular templates. 3 | */ 4 | import '@angular/localize/init'; 5 | /** 6 | * This file includes polyfills needed by Angular and is loaded before the app. 7 | * You can add your own extra polyfills to this file. 8 | * 9 | * This file is divided into 2 sections: 10 | * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. 11 | * 2. Application imports. Files imported after ZoneJS that should be loaded before your main 12 | * file. 13 | * 14 | * The current setup is for so-called "evergreen" browsers; the last versions of browsers that 15 | * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), 16 | * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. 17 | * 18 | * Learn more in https://angular.io/guide/browser-support 19 | */ 20 | 21 | /*************************************************************************************************** 22 | * BROWSER POLYFILLS 23 | */ 24 | 25 | 26 | /** IE10 and IE11 requires the following for NgClass support on SVG elements */ 27 | // import 'classlist.js'; // Run `npm install --save classlist.js`. 28 | 29 | /** IE10 and IE11 requires the following for the Reflect API. */ 30 | // import 'core-js/es6/reflect'; 31 | 32 | 33 | /** Evergreen browsers require these. **/ 34 | // Used for reflect-metadata in JIT. If you use AOT (and only Angular decorators), you can remove. 35 | import 'core-js/es7/reflect'; 36 | 37 | 38 | /** 39 | * Required to support Web Animations `@angular/platform-browser/animations`. 40 | * Needed for: All but Chrome, Firefox and Opera. http://caniuse.com/#feat=web-animation 41 | **/ 42 | // import 'web-animations-js'; // Run `npm install --save web-animations-js`. 43 | 44 | 45 | 46 | /*************************************************************************************************** 47 | * Zone JS is required by Angular itself. 48 | */ 49 | import 'zone.js/dist/zone'; // Included with Angular CLI. 50 | 51 | 52 | 53 | /*************************************************************************************************** 54 | * APPLICATION IMPORTS 55 | */ 56 | 57 | /** 58 | * Date, currency, decimal and percent pipes. 59 | * Needed for: All but Chrome, Firefox, Edge, IE11 and Safari 10 60 | */ 61 | // import 'intl'; // Run `npm install --save intl`. 62 | /** 63 | * Need to import at least one locale-data with intl. 64 | */ 65 | // import 'intl/locale-data/jsonp/en'; 66 | -------------------------------------------------------------------------------- /frontend/src/styles.scss: -------------------------------------------------------------------------------- 1 | $grey: #f2f2f2; 2 | $grey-blue: #656c7e; 3 | 4 | // TODO: Move bootstrap import back to angular-cli.json once angular-cli is fixed https://github.com/angular/angular-cli/issues/9020 5 | @import '../node_modules/bootstrap/scss/bootstrap.scss'; 6 | @import url(https://fonts.googleapis.com/css?family=Open+Sans); 7 | 8 | body { 9 | font-family: 'Open Sans', sans-serif; 10 | } 11 | #navbar { 12 | background: $grey-blue; 13 | padding: 1rem; 14 | } 15 | 16 | .navbar-header { 17 | padding-left: 0.5em; 18 | padding-top: 0.25em; 19 | padding-bottom: 0.25em; 20 | display: flex; 21 | align-items: stretch; 22 | } 23 | 24 | .unit-name-box { 25 | border-left: white 2px solid; 26 | padding-left: 0.75em; 27 | margin-left: 1.5em; 28 | display: flex; 29 | flex-direction: column; 30 | align-items: center; 31 | justify-content: center; 32 | } 33 | .unit-name { 34 | color: white; 35 | width: 100px; 36 | padding-left: 0.5em; 37 | } 38 | -------------------------------------------------------------------------------- /frontend/src/test.ts: -------------------------------------------------------------------------------- 1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 2 | 3 | import 'zone.js/dist/long-stack-trace-zone'; 4 | import 'zone.js/dist/proxy.js'; 5 | import 'zone.js/dist/sync-test'; 6 | import 'zone.js/dist/jasmine-patch'; 7 | import 'zone.js/dist/async-test'; 8 | import 'zone.js/dist/fake-async-test'; 9 | import { getTestBed } from '@angular/core/testing'; 10 | import { 11 | BrowserDynamicTestingModule, 12 | platformBrowserDynamicTesting 13 | } from '@angular/platform-browser-dynamic/testing'; 14 | 15 | // Unfortunately there's no typing for the `__karma__` variable. Just declare it as any. 16 | declare const __karma__: any; 17 | declare const require: any; 18 | 19 | // Prevent Karma from running prematurely. 20 | __karma__.loaded = function () {}; 21 | 22 | // First, initialize the Angular testing environment. 23 | getTestBed().initTestEnvironment( 24 | BrowserDynamicTestingModule, 25 | platformBrowserDynamicTesting() 26 | ); 27 | // Then we find all the tests. 28 | const context = require.context('./', true, /\.spec\.ts$/); 29 | // And load the modules. 30 | context.keys().map(context); 31 | // Finally, start Karma to run the tests. 32 | __karma__.start(); 33 | -------------------------------------------------------------------------------- /frontend/src/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/app", 5 | "baseUrl": "./", 6 | "types": [] 7 | }, 8 | "files": [ 9 | "main.ts", 10 | "polyfills.ts" 11 | ], 12 | "include": [ 13 | "src/**/*.d.ts" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /frontend/src/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/spec", 5 | "baseUrl": "./", 6 | "types": [ 7 | "jasmine", 8 | "node" 9 | ] 10 | }, 11 | "files": [ 12 | "test.ts", 13 | "polyfills.ts" 14 | ], 15 | "include": [ 16 | "**/*.spec.ts", 17 | "**/*.d.ts" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /frontend/src/typings.d.ts: -------------------------------------------------------------------------------- 1 | /* SystemJS module definition */ 2 | declare var module: NodeModule; 3 | interface NodeModule { 4 | id: string; 5 | } 6 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "downlevelIteration": true, 5 | "outDir": "./dist/out-tsc", 6 | "sourceMap": true, 7 | "declaration": false, 8 | "moduleResolution": "node", 9 | "emitDecoratorMetadata": true, 10 | "experimentalDecorators": true, 11 | "target": "es2015", 12 | "typeRoots": [ 13 | "node_modules/@types" 14 | ], 15 | "lib": [ 16 | "es2017", 17 | "dom" 18 | ], 19 | "module": "esnext", 20 | "baseUrl": "./" 21 | } 22 | } -------------------------------------------------------------------------------- /frontend/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rulesDirectory": [ 3 | "node_modules/codelyzer" 4 | ], 5 | "rules": { 6 | "arrow-return-shorthand": true, 7 | "callable-types": true, 8 | "class-name": true, 9 | "comment-format": [ 10 | true, 11 | "check-space" 12 | ], 13 | "curly": true, 14 | "eofline": true, 15 | "forin": true, 16 | "import-blacklist": [ 17 | true, 18 | "rxjs/Rx" 19 | ], 20 | "import-spacing": true, 21 | "indent": [ 22 | true, 23 | "spaces" 24 | ], 25 | "interface-over-type-literal": true, 26 | "label-position": true, 27 | "max-line-length": [ 28 | true, 29 | 140 30 | ], 31 | "member-access": false, 32 | "member-ordering": [ 33 | true, 34 | { 35 | "order": [ 36 | "static-field", 37 | "instance-field", 38 | "static-method", 39 | "instance-method" 40 | ] 41 | } 42 | ], 43 | "no-arg": true, 44 | "no-bitwise": true, 45 | "no-console": [ 46 | true, 47 | "debug", 48 | "info", 49 | "time", 50 | "timeEnd", 51 | "trace" 52 | ], 53 | "no-construct": true, 54 | "no-debugger": true, 55 | "no-duplicate-super": true, 56 | "no-empty": false, 57 | "no-empty-interface": true, 58 | "no-eval": true, 59 | "no-inferrable-types": [ 60 | true, 61 | "ignore-params" 62 | ], 63 | "no-misused-new": true, 64 | "no-non-null-assertion": true, 65 | "no-shadowed-variable": true, 66 | "no-string-literal": false, 67 | "no-string-throw": true, 68 | "no-switch-case-fall-through": true, 69 | "no-trailing-whitespace": true, 70 | "no-unnecessary-initializer": true, 71 | "no-unused-expression": true, 72 | "no-use-before-declare": true, 73 | "no-var-keyword": true, 74 | "object-literal-sort-keys": false, 75 | "one-line": [ 76 | true, 77 | "check-open-brace", 78 | "check-catch", 79 | "check-else", 80 | "check-whitespace" 81 | ], 82 | "prefer-const": true, 83 | "quotemark": [ 84 | true, 85 | "single" 86 | ], 87 | "radix": true, 88 | "semicolon": [ 89 | true, 90 | "always" 91 | ], 92 | "triple-equals": [ 93 | true, 94 | "allow-null-check" 95 | ], 96 | "typedef-whitespace": [ 97 | true, 98 | { 99 | "call-signature": "nospace", 100 | "index-signature": "nospace", 101 | "parameter": "nospace", 102 | "property-declaration": "nospace", 103 | "variable-declaration": "nospace" 104 | } 105 | ], 106 | "typeof-compare": true, 107 | "unified-signatures": true, 108 | "variable-name": false, 109 | "whitespace": [ 110 | true, 111 | "check-branch", 112 | "check-decl", 113 | "check-operator", 114 | "check-separator", 115 | "check-type" 116 | ], 117 | "directive-selector": [ 118 | true, 119 | "attribute", 120 | "app", 121 | "camelCase" 122 | ], 123 | "component-selector": [ 124 | true, 125 | "element", 126 | "app", 127 | "kebab-case" 128 | ], 129 | "no-inputs-metadata-property": true, 130 | "no-outputs-metadata-property": true, 131 | "no-host-metadata-property": true, 132 | "no-input-rename": true, 133 | "no-output-rename": true, 134 | "use-lifecycle-interface": true, 135 | "use-pipe-transform-interface": true, 136 | "component-class-suffix": true, 137 | "directive-class-suffix": true, 138 | "invoke-injectable": true 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /gunicorn_starter.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | gunicorn --paste /usr/src/app/adaero/example.ini -w 2 --threads 2 -b 0.0.0.0:8080 -------------------------------------------------------------------------------- /install-packages.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Bash "strict mode", to help catch problems and bugs in the shell 4 | # script. Every bash script you write should include this. See 5 | # http://redsymbol.net/articles/unofficial-bash-strict-mode/ for 6 | # details. 7 | set -euo pipefail 8 | 9 | # Tell apt-get we're never going to be able to give manual 10 | # feedback: 11 | export DEBIAN_FRONTEND=noninteractive 12 | 13 | # Update the package listing, so we know what package exist: 14 | apt-get update 15 | 16 | # Install security updates: 17 | apt-get -y upgrade 18 | 19 | # Install a new package, without unnecessary recommended packages: 20 | apt-get -y install --no-install-recommends \ 21 | python3.6 curl ca-certificates \ 22 | autoconf build-essential \ 23 | libxml2-dev libxslt-dev python3-dev \ 24 | libldap2-dev libsasl2-dev slapd ldap-utils 25 | 26 | # Delete cached files we don't need anymore: 27 | apt-get clean 28 | rm -rf /var/lib/apt/lists/* 29 | -------------------------------------------------------------------------------- /population.dot: -------------------------------------------------------------------------------- 1 | strict digraph { 2 | { 3 | l [label="LDAP", shape=cylinder] 4 | generate_population_process [style=filled, fillcolor=grey, label="talent manager invokes /api/v1/generate-population.csv via web frontend"] 5 | raw_csv [label="Raw staff list CSV", shape=note] 6 | modify_csv_process [style=filled, fillcolor=grey, label="talent manager manually edits CSV"] 7 | processed_csv [label="Curated staff list CSV", shape=note] 8 | upload_csv_process [style=filled, fillcolor=grey, label="talent manager uploaded processed CSV using /api/v1/upload-new-population-csv via web frontend"] 9 | database [shape=cylinder, label="Databse with staff information fixed at point of time of CSV upload"] 10 | } 11 | l -> generate_population_process 12 | generate_population_process -> raw_csv 13 | raw_csv -> modify_csv_process 14 | modify_csv_process -> processed_csv 15 | processed_csv -> upload_csv_process 16 | upload_csv_process -> database 17 | } -------------------------------------------------------------------------------- /population.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/man-group/adaero/991515895fd0063662755e0051577e1b7053a9fd/population.png -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/man-group/adaero/991515895fd0063662755e0051577e1b7053a9fd/setup.cfg -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # Always prefer setuptools over distutils 2 | from setuptools import setup, find_packages 3 | 4 | from os import path 5 | 6 | here = path.abspath(path.dirname(__file__)) 7 | 8 | description = 'A platform for managing peer-to-peer feedback within an organisation.' 9 | 10 | def readme(): 11 | try: 12 | with open('README.md') as f: 13 | return f.read() 14 | except FileNotFoundError: 15 | return "" 16 | 17 | setup( 18 | name='adaero', 19 | 20 | version='1.0.0', 21 | 22 | description=description, 23 | long_description=readme(), 24 | long_description_content_type='text/markdown', 25 | # The project's main homepage. 26 | url='https://github.com/man-group/adaero', 27 | download_url='https://github.com/man-group/adaero/archive/v1.0.0.tar.gz', 28 | 29 | # Author details 30 | author='MAN Alpha Tech', 31 | author_email='ManAlphaTech@man.com', 32 | 33 | # Choose your license 34 | license='AGPL 3.0', 35 | 36 | # See https://pypi.python.org/pypi?%3Aaction=list_classifiers 37 | classifiers=[ 38 | 'Development Status :: 4 - Beta', 39 | 'Programming Language :: Python :: 3.7', 40 | 'Programming Language :: Python :: 3.6', 41 | 'Programming Language :: Python :: 2.7', 42 | ], 43 | 44 | # What does your project relate to? 45 | keywords='feedback', 46 | 47 | # You can just specify the packages manually here if your project is 48 | # simple. Or you can use find_packages(). 49 | packages=find_packages(exclude=['contrib', 'docs', 'tests']), 50 | 51 | # Alternatively, if you want to distribute just a my_module.py, uncomment 52 | # this: 53 | # py_modules=["my_module"], 54 | 55 | # List run-time dependencies here. These will be installed by pip when 56 | # your project is installed. For an analysis of "install_requires" vs pip's 57 | # requirements files see: 58 | # https://packaging.python.org/en/latest/requirements.html 59 | install_requires=[ 60 | 'pyramid', 61 | 'pyramid-beaker', 62 | 'waitress', 63 | 'python-ldap', 64 | 'pyramid_tm', 65 | 'SQLAlchemy', 66 | 'transaction', 67 | 'zope.sqlalchemy==1.1', 68 | 'pycrypto', 69 | 'rest_toolkit', 70 | 'python-dateutil', 71 | 'apscheduler', 72 | 'Paste', 73 | 'beautifulsoup4', 74 | 'lxml', 75 | 'pytz', 76 | 'alembic', 77 | 'unicodecsv', 78 | 'jinja2', 79 | 'click', 80 | 'pandas', 81 | 'gunicorn', 82 | 'psycopg2-binary' 83 | ], 84 | 85 | # List additional groups of dependencies here (e.g. development 86 | # dependencies). You can install these using the following syntax, 87 | # for example: 88 | # $ pip install -e .[dev,test] 89 | extras_require={ 90 | 'test': [ 91 | 'pytest', 92 | 'pytest-cov', 93 | 'mock', 94 | 'python-dateutil', 95 | 'WebTest', 96 | 'faker', 97 | 'click', 98 | 'freezegun', 99 | 'pytest-xdist', 100 | 'tox' 101 | ], 102 | 'oracle': [ 103 | 'cx_Oracle' 104 | ], 105 | 'postgres': [ 106 | 'psycopg2' 107 | ] 108 | }, 109 | 110 | # To provide executable scripts, use entry points in preference to the 111 | # "scripts" keyword. Entry points provide cross-platform support and allow 112 | # pip to create the appropriate form of executable for the target platform. 113 | entry_points={ 114 | 'console_scripts': [ 115 | 'adaero=adaero.scripts.feedback_app:main', 116 | 'configure_db=adaero.scripts.configure_db:cli', 117 | ], 118 | 'paste.app_factory': [ 119 | 'main = adaero:main' 120 | ] 121 | }, 122 | ) 123 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/man-group/adaero/991515895fd0063662755e0051577e1b7053a9fd/tests/__init__.py -------------------------------------------------------------------------------- /tests/integration/README.md: -------------------------------------------------------------------------------- 1 | # Integration Tests 2 | 3 | Integration tests for this package. They are comprehensive - input to tests that have their names prepended 4 | with `_web` are essentially HTTP requests against a running Pyramid server connected to the dev oracle database. 5 | -------------------------------------------------------------------------------- /tests/integration/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/man-group/adaero/991515895fd0063662755e0051577e1b7053a9fd/tests/integration/__init__.py -------------------------------------------------------------------------------- /tests/integration/conftest.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | import pytest 4 | import transaction 5 | from pyramid import testing 6 | from sqlalchemy.dialects.oracle import LONG 7 | from sqlalchemy.ext.compiler import compiles 8 | import sqlalchemy.orm.session 9 | 10 | from tests.settings import DEFAULT_TEST_SETTINGS 11 | from adaero.models import ( 12 | FeedbackQuestion, 13 | FeedbackTemplateRow, 14 | FeedbackTemplate, 15 | Period, 16 | Nominee, 17 | FeedbackForm, 18 | FeedbackAnswer, 19 | User, 20 | ExternalInvite, 21 | ) 22 | 23 | from .constants import TEST_UTCNOW 24 | 25 | 26 | def days_from_utcnow(days, offset=0): 27 | return TEST_UTCNOW + timedelta(days=days + offset) 28 | 29 | 30 | def pytest_addoption(parser): 31 | parser.addoption("--use-sqlite3", default=False, action="store_true") 32 | 33 | 34 | # backport from pytest-xdist 1.15.0 35 | @pytest.fixture(scope="session") 36 | def worker_id(request): 37 | if hasattr(request.config, "slaveinput"): 38 | return request.config.slaveinput["slaveid"] 39 | else: 40 | return "master" 41 | 42 | 43 | @compiles(LONG, "sqlite") 44 | def compile_oracle_long(element, compiler, **kw): 45 | """Handles Oracle LONG datatype as text in sqlite.""" 46 | return compiler.visit_text(element, **kw) 47 | 48 | 49 | @pytest.yield_fixture(scope="session") 50 | def dbsession(request, worker_id): 51 | """ 52 | Properly setup, yield and teardown an Oracle backed SQLAlchemy session 53 | 54 | Make sure this is in sync with func_scoped_dbsession 55 | """ 56 | settings = DEFAULT_TEST_SETTINGS 57 | if request.config.getoption("--use-sqlite3"): 58 | settings["adaero.use_local_sqlite3"] = True 59 | settings["adaero.worker_id"] = worker_id 60 | 61 | config = testing.setUp(settings=settings) 62 | config.include("adaero.models") 63 | settings = config.get_settings() 64 | 65 | from adaero.models import get_engine, get_session_factory, get_tm_session 66 | 67 | engine = get_engine(settings) 68 | session_factory = get_session_factory(engine) 69 | session = get_tm_session(session_factory, transaction.manager) 70 | 71 | from adaero.models.all import Base, SEQUENCES 72 | 73 | Base.metadata.drop_all(engine) 74 | 75 | for seq in SEQUENCES: 76 | seq.create(engine) 77 | Base.metadata.create_all(engine) 78 | 79 | yield session 80 | 81 | testing.tearDown() 82 | transaction.abort() 83 | Base.metadata.drop_all(engine) 84 | 85 | 86 | @pytest.yield_fixture 87 | def func_scoped_dbsession(dbsession): 88 | yield dbsession 89 | drop_everything(dbsession) 90 | 91 | 92 | def drop_everything_but_users(dbsession_): 93 | with transaction.manager: 94 | dbsession_.query(FeedbackAnswer).delete() 95 | dbsession_.query(FeedbackForm).delete() 96 | dbsession_.query(FeedbackTemplateRow).delete() 97 | dbsession_.query(FeedbackQuestion).delete() 98 | dbsession_.query(Nominee).delete() 99 | dbsession_.query(ExternalInvite).delete() 100 | dbsession_.query(Period).delete() 101 | dbsession_.query(FeedbackTemplate).delete() 102 | 103 | 104 | def drop_everything(dbsession_): 105 | drop_everything_but_users(dbsession_) 106 | with transaction.manager: 107 | dbsession_.query(User).delete() 108 | 109 | 110 | def dummy_request(dbsession): 111 | return testing.DummyRequest(dbsession=dbsession, user={}) 112 | 113 | 114 | def next_day_generator(start=datetime.now(), step_backwards=False): 115 | if step_backwards: 116 | offset = -timedelta(days=1) 117 | else: 118 | offset = +timedelta(days=1) 119 | dt = start 120 | while 1: 121 | yield dt 122 | dt = dt + offset 123 | 124 | 125 | def get_dbsession(context): 126 | # Uses `scoped_session` to fine to call as many times desired and 127 | # will use existing connection pool 128 | if isinstance(context, sqlalchemy.orm.session.Session): 129 | return context 130 | return context.app.registry["dbsession_factory"]() 131 | -------------------------------------------------------------------------------- /tests/integration/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/man-group/adaero/991515895fd0063662755e0051577e1b7053a9fd/tests/integration/models/__init__.py -------------------------------------------------------------------------------- /tests/integration/models/test_feedback.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import transaction 3 | 4 | from adaero.models import User, FeedbackAnswer, FeedbackForm 5 | from adaero.models.all import CheckError 6 | from adaero.constants import ANSWER_CHAR_LIMIT 7 | from adaero.security import ldapauth 8 | 9 | from ..views.test_manager import add_test_data_for_stats 10 | from ..constants import ( 11 | TEST_LDAP_FULL_DETAILS, 12 | TEST_EMPLOYEE_2_USERNAME, 13 | QUESTION_IDS_AND_TEMPLATES, 14 | TEST_PERIOD_ID, 15 | TEST_MANAGER_USERNAME, 16 | TEST_TALENT_MANAGER_USERNAME, 17 | ) 18 | from ...settings import DEFAULT_TEST_SETTINGS 19 | 20 | from ..conftest import drop_everything 21 | 22 | 23 | @pytest.yield_fixture(scope="module") 24 | def stats_session(dbsession): 25 | ldapsource = ldapauth.build_ldapauth_from_settings(DEFAULT_TEST_SETTINGS) 26 | with transaction.manager: 27 | for user_details in TEST_LDAP_FULL_DETAILS.values(): 28 | dbsession.add(User.create_from_ldap_details(ldapsource, user_details)) 29 | add_test_data_for_stats(dbsession) 30 | yield dbsession 31 | drop_everything(dbsession) 32 | 33 | 34 | def test_unable_to_create_two_summaries_for_a_given_period(stats_session): # noqa: E501 35 | # above setup means there is an existing summary for 36 | # TEST_EMPLOYEE_2_USERNAME 37 | dbsession = stats_session 38 | with pytest.raises(CheckError): 39 | with transaction.manager: 40 | manager_form = FeedbackForm( 41 | to_username=TEST_EMPLOYEE_2_USERNAME, 42 | from_username=TEST_MANAGER_USERNAME, 43 | period_id=TEST_PERIOD_ID, 44 | is_summary=True, 45 | ) 46 | answers = [ 47 | FeedbackAnswer( 48 | question_id=QUESTION_IDS_AND_TEMPLATES[0][0], content="Better not" 49 | ), 50 | FeedbackAnswer( 51 | question_id=QUESTION_IDS_AND_TEMPLATES[1][0], 52 | content="override existing", 53 | ), 54 | FeedbackAnswer( 55 | question_id=QUESTION_IDS_AND_TEMPLATES[2][0], content="answers!!!" 56 | ), 57 | ] 58 | manager_form.answers = answers 59 | dbsession.add(manager_form) 60 | 61 | 62 | def test_able_to_insert_long_utf8_text(stats_session): 63 | dbsession = stats_session 64 | with transaction.manager: 65 | manager_form = FeedbackForm( 66 | to_username=TEST_MANAGER_USERNAME, 67 | from_username=TEST_TALENT_MANAGER_USERNAME, 68 | period_id=TEST_PERIOD_ID, 69 | is_summary=False, 70 | ) 71 | answers = [ 72 | FeedbackAnswer( 73 | question_id=QUESTION_IDS_AND_TEMPLATES[0][0], 74 | content="" * ANSWER_CHAR_LIMIT, 75 | ) 76 | ] 77 | manager_form.answers = answers 78 | dbsession.add(manager_form) 79 | -------------------------------------------------------------------------------- /tests/integration/views/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/man-group/adaero/991515895fd0063662755e0051577e1b7053a9fd/tests/integration/views/__init__.py -------------------------------------------------------------------------------- /tests/integration/views/company_stats.csv: -------------------------------------------------------------------------------- 1 | employee_id,last_name,first_name,email,business_unit,location,line_manager_name,line_manager_email,2014-Q1_contributed,2017-Q4_contributed,2014-Q1_received,2017-Q4_received 2 | 123456,Boggs,Barney,bboggs@example.com,Alpha,London,Charles Dalton,cdalton@example.com,2,0,-1,0 3 | 321321,Dalton,Charles,cdalton@example.com,Alpha,Hong Kong,Lady Lovelace,llovelace@example.com,0,0,-1,-1 4 | 878787,Dodson,Dominic,ddodson@example.com,Alpha,London,Charles Dalton,cdalton@example.com,0,0,-1,-1 5 | 999999,Lovelace,Lady,llovelace@example.com,Alpha,London,,,3,10,8,2 6 | 123123,Sholes,Șarah,ssholes@example.com,Alpha,London,Charles Dalton,cdalton@example.com,7,2,3,4 7 | -------------------------------------------------------------------------------- /tests/integration/views/test_auth.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from adaero.security import ( 4 | ANGULAR_2_XSRF_TOKEN_COOKIE_NAME, 5 | ANGULAR_2_XSRF_TOKEN_HEADER_NAME, 6 | ) 7 | from ..constants import TEST_MANAGER_USERNAME, TEST_PASSWORD, TEST_LDAP_FULL_DETAILS 8 | from .conftest import successfully_login 9 | 10 | 11 | def test_login(ldap_mocked_app_with_users): 12 | """GIVEN I am unauthenticated 13 | WHEN I login with the correct credentials 14 | THEN I am returned an XSRF token as a cookie and my user details""" 15 | 16 | app = ldap_mocked_app_with_users 17 | 18 | response = app.post_json( 19 | "/api/v1/login", 20 | {"username": "hacker", "password": "ishouldnotwork"}, 21 | expect_errors=True, 22 | ) 23 | 24 | assert ANGULAR_2_XSRF_TOKEN_COOKIE_NAME not in app.cookies 25 | assert response.status_code == 401, response.json 26 | 27 | response = app.post_json( 28 | "/api/v1/login", {"username": TEST_MANAGER_USERNAME, "password": TEST_PASSWORD} 29 | ) 30 | 31 | manager_details = TEST_LDAP_FULL_DETAILS[TEST_MANAGER_USERNAME] 32 | display_name = " ".join([manager_details["givenName"], manager_details["sn"]]) 33 | title = TEST_LDAP_FULL_DETAILS[TEST_MANAGER_USERNAME]["title"] 34 | generated_principals = [ 35 | "system.Everyone", 36 | "system.Authenticated", 37 | "cdalton", 38 | "role:employee", 39 | "role:manager", 40 | "direct_report:ssholes", 41 | "direct_report:bboggs", 42 | "direct_report:ddodson", 43 | ] 44 | 45 | assert True is response.json_body["success"] 46 | assert display_name == response.json_body["data"]["displayName"] 47 | assert title == response.json_body["data"]["title"] 48 | assert "Alpha" == response.json_body["data"]["businessUnit"] 49 | assert sorted(generated_principals) == sorted( 50 | response.json_body["data"]["principals"] 51 | ) 52 | assert ANGULAR_2_XSRF_TOKEN_COOKIE_NAME in app.cookies 53 | 54 | 55 | def test_logout(ldap_mocked_app_with_users): 56 | app = ldap_mocked_app_with_users 57 | successfully_login(app, TEST_MANAGER_USERNAME) 58 | csrf_token = app.cookies[ANGULAR_2_XSRF_TOKEN_COOKIE_NAME] 59 | app.post("/api/v1/logout", headers={ANGULAR_2_XSRF_TOKEN_HEADER_NAME: csrf_token}) 60 | 61 | 62 | def test_user_data(ldap_mocked_app_with_users): 63 | app = ldap_mocked_app_with_users 64 | 65 | failed_resp = app.get("/api/v1/user-data", expect_errors=True) 66 | assert 401 == failed_resp.status_code 67 | 68 | app.post_json( 69 | "/api/v1/login", {"username": TEST_MANAGER_USERNAME, "password": TEST_PASSWORD} 70 | ) 71 | 72 | response = app.get("/api/v1/user-data") 73 | manager_details = TEST_LDAP_FULL_DETAILS[TEST_MANAGER_USERNAME] 74 | display_name = " ".join([manager_details["givenName"], manager_details["sn"]]) 75 | title = TEST_LDAP_FULL_DETAILS[TEST_MANAGER_USERNAME]["title"] 76 | generated_principals = [ 77 | "system.Everyone", 78 | "system.Authenticated", 79 | "cdalton", 80 | "role:employee", 81 | "role:manager", 82 | "direct_report:ssholes", 83 | "direct_report:bboggs", 84 | "direct_report:ddodson", 85 | ] 86 | 87 | assert True is response.json_body["success"] 88 | assert display_name == response.json_body["data"]["displayName"] 89 | assert title == response.json_body["data"]["title"] 90 | assert "Alpha" == response.json_body["data"]["businessUnit"] 91 | assert sorted(generated_principals) == sorted( 92 | response.json_body["data"]["principals"] 93 | ) 94 | assert ANGULAR_2_XSRF_TOKEN_COOKIE_NAME in app.cookies 95 | -------------------------------------------------------------------------------- /tests/integration/views/test_metadata.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | import copy 3 | import os 4 | 5 | import pytest 6 | import webtest 7 | 8 | import adaero.constants 9 | 10 | from tests.settings import DEFAULT_TEST_SETTINGS 11 | 12 | 13 | def test_can_get_correct_metadata_from_config(): 14 | app = webtest.TestApp(adaero.main({}, **DEFAULT_TEST_SETTINGS)) 15 | response = app.get("/api/v1/metadata") 16 | assert not response.json_body["metadata"]["passwordlessAccess"] 17 | dev_settings = copy.copy(DEFAULT_TEST_SETTINGS) 18 | dev_settings[adaero.constants.ALLOW_PASSWORDLESS_ACCESS_KEY] = True 19 | app = webtest.TestApp(adaero.main({}, **dev_settings)) 20 | response = app.get("/api/v1/metadata") 21 | assert response.json_body["metadata"]["passwordlessAccess"] 22 | 23 | 24 | def test_can_get_correct_metadata_from_environment(): 25 | app = webtest.TestApp(adaero.main({}, **DEFAULT_TEST_SETTINGS)) 26 | response = app.get("/api/v1/metadata") 27 | assert not response.json_body["metadata"]["passwordlessAccess"] 28 | os.environ["ALLOW_PASSWORDLESS_ACCESS"] = "true" 29 | response = app.get("/api/v1/metadata") 30 | assert response.json_body["metadata"]["passwordlessAccess"] 31 | 32 | 33 | def test_can_get_correct_logo(): # noqa: E501 34 | dev_settings = copy.copy(DEFAULT_TEST_SETTINGS) 35 | dev_settings[adaero.constants.LOGO_FILENAME_KEY] = "test.png" 36 | app = webtest.TestApp(adaero.main({}, **dev_settings)) 37 | resp = app.get('/api/v1/logo.png') 38 | assert resp.status_code == 302 39 | assert resp.headers['Location'] == 'https://localhost/assets/%s' \ 40 | % dev_settings[adaero.constants.LOGO_FILENAME_KEY] 41 | -------------------------------------------------------------------------------- /tests/scripts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/man-group/adaero/991515895fd0063662755e0051577e1b7053a9fd/tests/scripts/__init__.py -------------------------------------------------------------------------------- /tests/scripts/configure_db.py: -------------------------------------------------------------------------------- 1 | import click 2 | from faker import Faker 3 | import transaction 4 | 5 | from adaero.models import ( 6 | get_session_factory, 7 | get_tm_session, 8 | Period, 9 | User, 10 | Nominee, 11 | generate_period_dates, 12 | ) 13 | from adaero.scripts.configure_db import ( 14 | cli, 15 | ENGINE_KEY, 16 | SUBPERIOD_CHOICES, 17 | SUBPERIOD_KEY, 18 | ) 19 | 20 | from tests.integration.views.conftest import add_template, add_test_period_with_template 21 | 22 | fake = Faker() 23 | 24 | 25 | @cli.command() 26 | @click.pass_context 27 | def add_test_periods(ctx): 28 | engine = ctx.obj[ENGINE_KEY] 29 | session_factory = get_session_factory(engine) 30 | dbsession = get_tm_session(session_factory, transaction.manager) 31 | QUESTION_IDS_AND_TEMPLATES = [ 32 | (1, u"What should {display_name} CONTINUE doing?", None), 33 | (10, u"What should {display_name} STOP doing or do less of?", None), 34 | (3, u"What should {display_name} START doing or do more of?", None), 35 | ( 36 | 7, 37 | u"What is the general perception of {display_name}?", 38 | u"There are often general perceptions of a person that are not " 39 | u"always based on your direct experiences. This is can still be " 40 | u"very helpful input to provide.", 41 | ), 42 | ] 43 | template_id = add_template(dbsession, QUESTION_IDS_AND_TEMPLATES, 1) 44 | add_test_period_with_template( 45 | dbsession, 46 | Period.ENROLLMENT_SUBPERIOD, 47 | template_id, 48 | 1, 49 | u"Q1 2018", 50 | add_nominees=False, 51 | ) 52 | add_test_period_with_template( 53 | dbsession, 54 | Period.ENROLLMENT_SUBPERIOD, 55 | template_id, 56 | 2, 57 | u"Q4 2017", 58 | offset_from_utc_now_days=-30, 59 | add_nominees=False, 60 | ) 61 | add_test_period_with_template( 62 | dbsession, 63 | Period.ENROLLMENT_SUBPERIOD, 64 | template_id, 65 | 3, 66 | u"Q3 2017", 67 | offset_from_utc_now_days=-60, 68 | add_nominees=False, 69 | ) 70 | add_test_period_with_template( 71 | dbsession, 72 | Period.ENROLLMENT_SUBPERIOD, 73 | template_id, 74 | 4, 75 | u"Q2 2017", 76 | offset_from_utc_now_days=-90, 77 | add_nominees=False, 78 | ) 79 | add_test_period_with_template( 80 | dbsession, 81 | Period.ENROLLMENT_SUBPERIOD, 82 | template_id, 83 | 5, 84 | u"Q1 2017", 85 | offset_from_utc_now_days=-120, 86 | add_nominees=False, 87 | ) 88 | add_test_period_with_template( 89 | dbsession, 90 | Period.ENROLLMENT_SUBPERIOD, 91 | template_id, 92 | 6, 93 | u"Q4 2016", 94 | offset_from_utc_now_days=-150, 95 | add_nominees=False, 96 | ) 97 | 98 | 99 | @cli.command() 100 | @click.pass_context 101 | def reset_email_flags(ctx): 102 | engine = ctx.obj[ENGINE_KEY] 103 | session_factory = get_session_factory(engine) 104 | dbsession = get_tm_session(session_factory, transaction.manager) 105 | with transaction.manager: 106 | period = Period.get_current_period(dbsession) 107 | print("resetting email flags for period {.name}") 108 | period.enrol_email_last_sent = None 109 | period.enrol_reminder_email_last_sent = None 110 | period.entry_email_last_sent = None 111 | period.entry_reminder_email_last_sent = None 112 | period.review_email_last_sent = None 113 | period.feedback_available_mail_last_sent = None 114 | dbsession.merge(period) 115 | 116 | 117 | if __name__ == "__main__": 118 | cli() 119 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | from tests.integration import constants 2 | 3 | DEFAULT_TEST_SETTINGS = { 4 | "ahl.web.debug_all": True, 5 | "adaero.use_local_sqlite3": True, 6 | "adaero.company_name": "Example Org.", 7 | "adaero.business_unit": "Alpha", 8 | "adaero.homebase_location": "London", 9 | "adaero.production_hostname": constants.TEST_PRODUCTION_HOSTNAME, 10 | "adaero.production_user": constants.TEST_PRODUCTION_USER, 11 | "adaero.served_on_https": True, 12 | "adaero.talent_manager_usernames": '["llovelace"]', 13 | "adaero.reload_users_on_app_start": False, 14 | "adaero.load_talent_managers_on_app_start": False, 15 | "adaero.staff_override_usernames": None, 16 | "adaero.support_email": "support@example.org", 17 | "adaero.reply_email": "no-reply@example.org", 18 | "adaero.ldap_uri": "ldap://localhost:9999", 19 | "adaero.ldap_user_bind_template": "{username}@localhost.test", 20 | "adaero.ldap_search_bind_dn": "admin", 21 | "adaero.ldap_search_password": "admin_pw", 22 | "adaero.ldap_username_key": constants.TEST_USERNAME_KEY, 23 | "adaero.ldap_manager_key": constants.TEST_MANAGER_KEY, 24 | "adaero.ldap_location_key": constants.TEST_LOCATION_KEY, 25 | "adaero.ldap_uid_key": constants.TEST_UID_KEY, 26 | "adaero.ldap_department_key": constants.TEST_DEPARTMENT_KEY, 27 | "adaero.ldap_business_unit_key": constants.TEST_BUSINESS_UNIT_KEY, 28 | "adaero.ldap_base_dn": "O=foo", 29 | "adaero.ldap_dn_username_attribute": "cn", 30 | "adaero.ldap_dn_username_regex": r"CN=([\w\s\\\,\(\)]*),OU=", 31 | "adaero.enable_send_email": True, 32 | "beaker.session.type": "memory", 33 | "beaker.session.secret": "secret", 34 | "rest_toolkit.debug": True, 35 | } 36 | -------------------------------------------------------------------------------- /tests/unit/README.md: -------------------------------------------------------------------------------- 1 | # Unit Tests 2 | 3 | Unit tests for this package 4 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/man-group/adaero/991515895fd0063662755e0051577e1b7053a9fd/tests/unit/__init__.py -------------------------------------------------------------------------------- /tests/unit/population.csv: -------------------------------------------------------------------------------- 1 | first_name,last_name,position,employee_id,business_unit,email,department,is_staff,manager_id 2 | Alice,Alison,Marketing Co-Ordinator,939393,Bravo,aalison@example.com,App Development,True, 3 | Barney,Boggs,Developer,123456,Alpha,bboggs@example.com,App Development,True,321321 4 | Charles,Dalton,Boss,321321,Alpha,cdalton@example.com,App Development,True,999999 5 | Dominic,Dodson,Developer,878787,Alpha,ddodson@example.com,App Development,True,321321 6 | Other,Employee,OE,102938,Alpha,otheremployee@example.com,Compliance - Bravo,True,102939 7 | Lady,Lovelace,Bigger boss,999999,Alpha,llovelace@example.com,App Development,True,135790 8 | Central,Manager,CEO,135790,Central,centralmanager@man.com,Central Management,True, 9 | Other,Manager,OM,102939,Alpha,othermanager@example.com,Compliance - Bravo,True,102939 10 | Șarah,Sholes,Business Manager,123123,Alpha,ssholes@example.com,App Development,True,321321 11 | Outside,TM,Talent Manager,989898,HR,outsidetm@man.com,Human Resources,True, -------------------------------------------------------------------------------- /tests/unit/test_config.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import pyramid.testing 3 | import os 4 | 5 | from adaero import config 6 | from adaero import constants 7 | 8 | DEFAULT_SETTINGS = { 9 | constants.CHECK_AND_SEND_EMAIL_INT_KEY: "60", 10 | constants.LOAD_USER_EMAIL_LIST_KEY: '["bob", "dan"]', 11 | constants.ALLOW_PASSWORDLESS_ACCESS_KEY: True, 12 | } 13 | 14 | 15 | @pytest.mark.parametrize( 16 | "envvar, envvar_val, key, expected", 17 | ( 18 | ( 19 | "CHECK_AND_SEND_EMAIL_INTERVAL_S", 20 | "90", 21 | constants.CHECK_AND_SEND_EMAIL_INT_KEY, 22 | "90", 23 | ), 24 | (None, None, constants.CHECK_AND_SEND_EMAIL_INT_KEY, "60"), 25 | ( 26 | "ALLOW_PASSWORDLESS_ACCESS", 27 | "true", 28 | constants.ALLOW_PASSWORDLESS_ACCESS_KEY, 29 | True, 30 | ), 31 | ( 32 | "ALLOW_PASSWORDLESS_ACCESS", 33 | "false", 34 | constants.ALLOW_PASSWORDLESS_ACCESS_KEY, 35 | False, 36 | ), 37 | (None, None, constants.ALLOW_PASSWORDLESS_ACCESS_KEY, True), 38 | (None, None, constants.LOAD_USER_EMAIL_LIST_KEY, '["bob", "dan"]'), 39 | ), 40 | ) 41 | def test_get_config_value(envvar, envvar_val, key, expected): 42 | configuration = pyramid.testing.setUp(settings=DEFAULT_SETTINGS) 43 | settings = configuration.get_settings() 44 | if envvar: 45 | os.environ[envvar] = envvar_val 46 | generated = config.get_config_value(settings, key) 47 | assert expected == generated 48 | if envvar: 49 | os.environ.pop(envvar) 50 | -------------------------------------------------------------------------------- /tests/unit/test_date.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | from datetime import datetime 4 | 5 | import pytest 6 | 7 | import adaero.date 8 | from adaero.models import User 9 | 10 | 11 | @pytest.mark.parametrize( 12 | "value, man_location, format_, expected", 13 | ( 14 | ( 15 | datetime(2016, 3, 2, 10, 24, 29, 123), 16 | "London", 17 | "%Y-%m-%d %H:%M (%Z)", 18 | "2016-03-02 10:00 (GMT)", 19 | ), 20 | ( 21 | datetime(2016, 3, 2, 10, 29, 0, 0), 22 | "London", 23 | "%Y-%m-%d %H:%M (%Z)", 24 | "2016-03-02 10:00 (GMT)", 25 | ), 26 | ( 27 | datetime(2016, 3, 2, 14, 42, 29, 123), 28 | "London", 29 | "%Y-%m-%d %H:%M (%Z)", 30 | "2016-03-02 14:30 (GMT)", 31 | ), 32 | ( 33 | datetime(2016, 3, 2, 15, 58, 29, 123), 34 | "London", 35 | "%Y-%m-%d %H:%M (%Z)", 36 | "2016-03-02 15:30 (GMT)", 37 | ), 38 | ( 39 | datetime(2016, 3, 2, 15, 59, 29, 123), 40 | "London", 41 | "%Y-%m-%d %H:%M (%Z)", 42 | "2016-03-02 15:30 (GMT)", 43 | ), 44 | ( 45 | datetime(2016, 3, 2, 16, 0, 0, 0), 46 | "London", 47 | "%Y-%m-%d %H:%M (%Z)", 48 | "2016-03-02 16:00 (GMT)", 49 | ), 50 | ( 51 | datetime(2016, 3, 2, 16, 1, 0, 0), 52 | "London", 53 | "%Y-%m-%d %H:%M (%Z)", 54 | "2016-03-02 16:00 (GMT)", 55 | ), 56 | # daylight savings 57 | ( 58 | datetime(2017, 3, 26, 0, 59, 0, 0), 59 | "London", 60 | "%Y-%m-%d %H:%M (%Z)", 61 | "2017-03-26 00:30 (GMT)", 62 | ), 63 | ( 64 | datetime(2017, 3, 26, 9, 9, 0, 0), 65 | "London", 66 | "%Y-%m-%d %H:%M (%Z)", 67 | "2017-03-26 09:00 (BST)", 68 | ), 69 | ( 70 | datetime(2017, 3, 26, 2, 5, 0, 0), 71 | "London", 72 | "%Y-%m-%d %H:%M (%Z)", 73 | "2017-03-26 02:00 (BST)", 74 | ), 75 | # all other locations 76 | ( 77 | datetime(2018, 1, 11, 19, 30, 29, 123), 78 | "Hong Kong", 79 | "%Y-%m-%d %H:%M (%Z)", 80 | "2018-01-11 19:30 (HKT)", 81 | ), 82 | ( 83 | datetime(2018, 1, 11, 6, 35, 29, 123), 84 | "New York", 85 | "%Y-%m-%d %H:%M (%Z)", 86 | "2018-01-11 06:30 (EST)", 87 | ), 88 | ( 89 | datetime(2018, 1, 11, 11, 50, 29, 123), 90 | "Oxford", 91 | "%Y-%m-%d %H:%M (%Z)", 92 | "2018-01-11 11:30 (GMT)", 93 | ), 94 | ( 95 | datetime(2018, 1, 11, 19, 50, 29, 123), 96 | "Shanghai", 97 | "%Y-%m-%d %H:%M (%Z)", 98 | "2018-01-11 19:30 (CST)", 99 | ), 100 | ( 101 | datetime(2018, 1, 11, 11, 50, 29, 123), 102 | "GBR", 103 | "%Y-%m-%d %H:%M (%Z)", 104 | "2018-01-11 11:30 (GMT)", 105 | ), 106 | ( 107 | datetime(2018, 1, 11, 6, 50, 29, 123), 108 | "Boston", 109 | "%Y-%m-%d %H:%M (%Z)", 110 | "2018-01-11 06:30 (EST)", 111 | ), 112 | ( 113 | datetime(2018, 1, 11, 11, 50, 29, 123), 114 | None, 115 | "%Y-%m-%d %H:%M (%Z)", 116 | "2018-01-11 11:30 (GMT)", 117 | ), 118 | (datetime(2018, 1, 3, 0, 0, 0, 0), None, None, "2 January 18:00 (GMT)"), 119 | (datetime(2017, 3, 26, 0, 0, 0, 0), "Boston", None, "25 March 18:00 (EDT)"), 120 | (datetime(2018, 3, 26, 0, 0, 0, 0), "Pfäffikon", None, "25 March 18:00 (CET)"), 121 | ( 122 | datetime(2018, 3, 26, 11, 0, 0, 0), 123 | "Pfäffikon", 124 | None, 125 | "25 March 18:00 (CEST)", 126 | ), 127 | (datetime(2018, 1, 3, 9, 0, 0, 0), None, None, "2 January 18:00 (GMT)"), 128 | (datetime(2017, 3, 26, 10, 0, 0, 0), "Boston", None, "25 March 18:00 (EDT)"), 129 | ( 130 | datetime(2016, 3, 2, 10, 24, 29, 123), 131 | "Invalid location", 132 | "%Y-%m-%d %H:%M (%Z)", 133 | "2016-03-02 10:00 (GMT)", 134 | ), 135 | ), 136 | ) 137 | def test_datetimeformat_works(value, man_location, format_, expected): 138 | user = User(username="foo", location=man_location) 139 | if format_: 140 | assert expected == adaero.date.datetimeformat(value, user, format_) 141 | else: 142 | assert expected == adaero.date.datetimeformat(value, user) 143 | -------------------------------------------------------------------------------- /tests/unit/test_period.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta, time 2 | 3 | from freezegun import freeze_time 4 | import pytest 5 | 6 | import adaero.date 7 | from adaero.models.period import Period 8 | 9 | TEST_YEAR = 2017 10 | TEST_MONTH = 2 11 | TEST_DAY = 15 12 | TEST_DATETIME = datetime(year=TEST_YEAR, month=TEST_MONTH, day=TEST_DAY) 13 | 14 | TEST_DAYLIGHT_SAVINGS = datetime(year=2017, month=4, day=15) 15 | 16 | 17 | def date_offset_by(days, hours, mins, dt=TEST_DATETIME): 18 | return dt + timedelta(days=days, hours=hours, minutes=mins) 19 | 20 | 21 | @pytest.mark.parametrize( 22 | "period, expected, location, time, dt", 23 | [ 24 | ( 25 | Period( 26 | enrollment_start_utc=date_offset_by(0, 9, 0), 27 | entry_start_utc=date_offset_by(2, 9, 0), 28 | approval_start_utc=date_offset_by(3, 9, 0), 29 | approval_end_utc=date_offset_by(4, 9, 0), 30 | ), 31 | Period.INACTIVE_SUBPERIOD, 32 | adaero.date.LONDON, 33 | time(hour=8, minute=59), 34 | TEST_DATETIME, 35 | ), 36 | # daylight savings 37 | ( 38 | Period( 39 | enrollment_start_utc=date_offset_by(0, 9, 0, TEST_DAYLIGHT_SAVINGS), 40 | entry_start_utc=date_offset_by(2, 9, 0, TEST_DAYLIGHT_SAVINGS), 41 | approval_start_utc=date_offset_by(3, 9, 0, TEST_DAYLIGHT_SAVINGS), 42 | approval_end_utc=date_offset_by(4, 9, 0, TEST_DAYLIGHT_SAVINGS), 43 | ), 44 | Period.ENROLLMENT_SUBPERIOD, 45 | adaero.date.LONDON, 46 | time(hour=8, minute=59), 47 | TEST_DAYLIGHT_SAVINGS, 48 | ), 49 | ( 50 | Period( 51 | enrollment_start_utc=date_offset_by(0, 9, 0), 52 | entry_start_utc=date_offset_by(1, 9, 0), 53 | approval_start_utc=date_offset_by(2, 9, 0), 54 | approval_end_utc=date_offset_by(3, 9, 0), 55 | ), 56 | Period.ENROLLMENT_SUBPERIOD, 57 | adaero.date.LONDON, 58 | time(hour=9, minute=00), 59 | TEST_DATETIME, 60 | ), 61 | ( 62 | Period( 63 | enrollment_start_utc=date_offset_by(-1, 9, 0), 64 | entry_start_utc=date_offset_by(0, 9, 0), 65 | approval_start_utc=date_offset_by(2, 9, 0), 66 | approval_end_utc=date_offset_by(3, 9, 0), 67 | ), 68 | Period.ENROLLMENT_SUBPERIOD, 69 | adaero.date.LONDON, 70 | time(hour=8, minute=59), 71 | TEST_DATETIME, 72 | ), 73 | ( 74 | Period( 75 | enrollment_start_utc=date_offset_by(-2, 9, 0), 76 | entry_start_utc=date_offset_by(0, 9, 0), 77 | approval_start_utc=date_offset_by(1, 9, 0), 78 | approval_end_utc=date_offset_by(2, 9, 0), 79 | ), 80 | Period.ENTRY_SUBPERIOD, 81 | adaero.date.LONDON, 82 | time(hour=9, minute=59), 83 | TEST_DATETIME, 84 | ), 85 | ( 86 | Period( 87 | enrollment_start_utc=date_offset_by(-10, 9, 0), 88 | entry_start_utc=date_offset_by(-9, 9, 0), 89 | approval_start_utc=date_offset_by(0, 9, 0), 90 | approval_end_utc=date_offset_by(3, 9, 0), 91 | ), 92 | Period.ENTRY_SUBPERIOD, 93 | adaero.date.BOSTON, 94 | time(hour=9, minute=59), 95 | TEST_DATETIME, 96 | ), 97 | ( 98 | Period( 99 | enrollment_start_utc=date_offset_by(-10, 9, 0), 100 | entry_start_utc=date_offset_by(-9, 9, 0), 101 | approval_start_utc=date_offset_by(0, 9, 0), 102 | approval_end_utc=date_offset_by(3, 9, 0), 103 | ), 104 | Period.APPROVAL_SUBPERIOD, 105 | adaero.date.BOSTON, 106 | time(hour=14, minute=59), 107 | TEST_DATETIME, 108 | ), 109 | ( 110 | Period( 111 | enrollment_start_utc=date_offset_by(-100, 9, 0), 112 | entry_start_utc=date_offset_by(-99, 9, 0), 113 | approval_start_utc=date_offset_by(-90, 9, 0), 114 | approval_end_utc=date_offset_by(0, 9, 0), 115 | ), 116 | Period.APPROVAL_SUBPERIOD, 117 | adaero.date.HONG_KONG, 118 | time(hour=0, minute=12), 119 | TEST_DATETIME, 120 | ), 121 | ( 122 | Period( 123 | enrollment_start_utc=date_offset_by(-100, 9, 0), 124 | entry_start_utc=date_offset_by(-99, 9, 0), 125 | approval_start_utc=date_offset_by(-90, 9, 0), 126 | approval_end_utc=date_offset_by(0, 9, 0), 127 | ), 128 | Period.REVIEW_SUBPERIOD, 129 | adaero.date.HONG_KONG, 130 | time(hour=1, minute=10), 131 | TEST_DATETIME, 132 | ), 133 | ], 134 | ) 135 | def test_subperiod_is_correct(period, expected, location, time, dt): 136 | dt = datetime.combine(dt, time) 137 | with freeze_time(dt): 138 | assert expected == period.subperiod(location) 139 | -------------------------------------------------------------------------------- /tests/unit/test_text.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from datetime import datetime 3 | 4 | from freezegun import freeze_time 5 | import pytest 6 | 7 | from adaero.constants import ANSWER_CHAR_LIMIT 8 | from adaero.text import check_input 9 | 10 | TEST_DATE = datetime(2017, 10, 6, 22, 5) 11 | 12 | 13 | @freeze_time(TEST_DATE) 14 | @pytest.mark.parametrize( 15 | "input_, expected", 16 | ( 17 | (u"yes \n\n\n", None), 18 | ( 19 | u"x" * (ANSWER_CHAR_LIMIT + 32), 20 | u"%s: Character limit of %s has been " 21 | u"exceeded by %s. Please reduce your " 22 | u"answer size." % (TEST_DATE, ANSWER_CHAR_LIMIT, 32), 23 | ), 24 | ), 25 | ) 26 | def test_check_input(input_, expected): 27 | assert expected == check_input(input_) 28 | --------------------------------------------------------------------------------