[
  {
    "path": ".editorconfig",
    "content": "# top-most EditorConfig file\nroot = true\n\n# Unix-style newlines with a newline ending every file\n[*]\nend_of_line = lf\ninsert_final_newline = true\n\n# Matches multiple files with brace expansion notation\n# Set default charset\n[*.{py}]\ncharset = utf-8\n\n# 4 space indentation\n[*.py]\nindent_style = space\nindent_size = 4\ntrim_trailing_whitespace = true"
  },
  {
    "path": ".github/CONTRIBUTING.md",
    "content": "# How to contribute\n\nSo you want to contribute to Mycroft?\nThis should be as easy as possible for you but there are a few things to consider when contributing.\nThe following guidelines for contribution should be followed if you want to submit a pull request.\n\n## How to prepare\n\n* You need a [GitHub account](https://github.com/signup/free)\n* Submit an [issue ticket](https://github.com/MycroftAI/mycroft/issues) for your issue if there is not one yet.\n\t* Describe the issue and include steps to reproduce if it's a bug.\n\t* Ensure to mention the earliest version that you know is affected.\n* If you are able and want to fix this, fork the repository on GitHub\n\n\n## Make Changes\n\n  1. [Fork the Project](https://help.github.com/articles/fork-a-repo/)\n  2. [Create a new Issue](https://help.github.com/articles/creating-an-issue/)\n  3. Create a **feature** or **bugfix** branch based on **dev** with your issue identifier. For example, if your issue identifier is: **issue-123** then you will create either: **feature/issue-123** or **bugfix/issue-123**. Use **feature** prefix for issues related to new functionalities or enhancements and **bugfix** in case of bugs found on the **dev** branch\n  4. Make sure you stick to the coding style and OO patterns that are used already.\n  5. Document code using [Google-style docstrings](http://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_google.html).  Our automated documentation tools expect that format.  All functions and class methods that are expected to be called externally should include a docstring.  (And those that aren't [should be prefixed with a single underscore](https://docs.python.org/2/tutorial/classes.html#private-variables-and-class-local-references)).\n  6. Make commits in logical units and describe them properly. Use your issue identifier at the very begin of each commit. For instance:\n`git commit -m \"Issues-123 - Fixing 'A' sound on Spelling Skill\"`\n  7. Before committing, format your code following the PEP8 rules and organize your imports removing unused libs. To check whether you are following these rules, install pep8 and run `pep8 mycroft test` while in the `mycroft-core` folder. This will check for formatting issues in the `mycroft` and `test` folders.\n  8. Once you have committed everything and are done with your branch, you have to rebase your code with **dev**. Do the following steps:\n      1. Make sure you do not have any changes left on your branch\n      2. Checkout on dev branch and make sure it is up-to-date\n      3. Checkout your branch and rebase it with dev\n      4. Resolve any conflicts you have\n      5. You will have to force your push since the historical base has changed\n      6. Suggested steps are:\n ```\ngit checkout dev\ngit fetch\ngit reset --hard origin/dev\ngit checkout <your_branch_name>\ngit rebase dev\ngit push -f\n```\n  9. If possible, create unit tests for your changes\n     * [Unit Tests for most contributions](https://github.com/MycroftAI/mycroft-core/tree/dev/test)\n     * [Intent Tests for new skills](https://docs.mycroft.ai/development/creating-a-skill#testing-your-skill)\n     * We utilize TRAVIS-CI, which will test each pull request. To test locally you can run: `./start.sh unittest`\n  10. Once everything is OK, you can finally [create a Pull Request (PR) on Github](https://help.github.com/articles/using-pull-requests/) in order to be reviewed and merged.\n\n**Note**: Even if you have write access to the master branch, do not work directly on master!\n\n## Submit Changes\n\n* Push your changes to a topic branch in your fork of the repository.\n* Open a pull request to the original repository and choose the right original branch you want to patch.\n\t_Advanced users may install the `hub` gem and use the [`hub pull-request` command](https://github.com/defunkt/hub#git-pull-request)._\n* If not done in commit messages (which you really should do) please reference and update your issue with the code changes. But _please do not close the issue yourself_.\n* Even if you have write access to the repository, do not directly push or merge pull-requests. Let another team member review your pull request and approve.\n\n# Additional Resources\n\n* [General GitHub documentation](http://help.github.com/)\n* [GitHub pull request documentation](https://help.github.com/articles/about-pull-requests/)\n* [Read the Issue Guidelines by @necolas](https://github.com/necolas/issue-guidelines/blob/master/CONTRIBUTING.md) for more details\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE.md",
    "content": "# How to submit an Issue to a Mycroft repository\n\nWhen submitting an Issue to a Mycroft repository, please follow these guidelines to help us help you. \n\n## Be clear about the software, hardware and version you are running\n\nFor example: \n\n* I'm running a Mark 1\n* With version 0.9.10 of the Mycroft software\n* With the standard Wake Word\n\n## Try to provide steps that we can use to replicate the Issue\n\nFor example: \n\n1. Burn the 0.9.10 image to Micro SD card using Etcher\n2. Seat the Micro SD card in the RPi 3\n3. Boot Picroft\n4. Wait 3 minutes\n5. The red light will come on indicating that the RPi 3 is overheating\n6. Running `htop` via the command line indicates a number of Zombie'd processes\n\n## Be as specific as possible about the expected condition, and the deviation from expected condition. \n\nThis is called _object-deviation format_. Specify the object, then the deviation of the object from an expected condition. \n\nExample 1: \n\n* When I say \"Hey Mycroft, set your eyes to cadet blue\", the eyes turn purple instead of blue. \n\nExample 2: \n\n* When I say \"Hey Mycroft, what time is it in Paris\", the time spoken is out by one hour - it's not observing daylight savings time. \n\nExample 3: \n\n* When I run `msm default` on my Mark 1, I receive lots of Git 'locked file' errors on the command line. \n\n## Provide log files or other output to help us see the error\n\nWe will normally require log files or other troubleshooting information to assist you with your Issue. \n\nThis [documentation](https://mycroft.ai/documentation/troubleshooting/) explains how to find log files. \n\nAs of version 0.9.10, the [Support Skill](https://github.com/MycroftAI/skill-support) also helps to automate gathering support information. \n\nSimply say: \n\n* \"Create a support ticket\" _or_\n* \"You're not working!\" _or_\n* \"Send me debug info\"\n\nand the Skill will put together a support package which you can email to us. \n\n## Upload any files to the Issue that will be useful in helping us to investigate\n\nPlease ensure you upload any relevant files - such as screenshots - which will aid us investigating. \n"
  },
  {
    "path": ".github/PULL_REQUEST_TEMPLATE.md",
    "content": "## Description\n(Description of what the PR does, such as fixes # {issue number})\n\n## How to test\n(Description of how to validate or test this PR)\n\n## Contributor license agreement signed?\nCLA [ ] (Whether you have signed a [CLA - Contributor Licensing Agreement](https://mycroft.ai/cla/)\n"
  },
  {
    "path": ".github/SUPPORT.md",
    "content": "# How to get support with Mycroft software, hardware and products\n\nThere are multiple ways to seek support with Mycroft software, hardware and products.\n\n## Forum\n\nWe maintain a [Forum](https://community.mycroft.ai) which is regularly monitored. \nFeel free to post questions, bugs, and requests for assistance in the relevant Forum Topic. \n\n## Chat\n\nMycroft staff are regularly available in our [Chat](https://chat.mycroft.ai) platform. \nThere are specific rooms available for different projects and products. \n\n## Contact\n\nYou can contact us via [our online form](https://mycroft.ai/contact), or give a call. \n\n## GitHub\n\nWe welcome you raising Issues and Pull Requests on our public GitHub repositories. \nSee the [CONTRIBUTING.md](CONTRIBUTING.md) file for more information.\n\n## Helping us to help you\n\nOur [documentation](https://mycroft.ai/documentation/troubleshooting/) contains troubleshooting information, and information on log files and other files that we may need to help us help you.\n\n\n"
  },
  {
    "path": ".gitignore",
    "content": "# See http://help.github.com/ignore-files/ for more about ignoring files.\n\n# compiled output\n/dist\n/tmp\n/out-tsc\n**/*.egg-info\n\n# dependencies\n**/node_modules\n\n# python notebooks\n*.ipynb\n\n# IDEs and editors\n**/.idea\n.project\n.classpath\n.c9/\n*.launch\n.settings/\n*.sublime-workspace\n__pycache__/\n\n# IDE - VSCode\n.vscode/*\n!.vscode/settings.json\n!.vscode/tasks.json\n!.vscode/launch.json\n!.vscode/extensions.json\n\n# misc\n/.sass-cache\n/connect.lock\n/coverage\n/libpeerconnection.log\nnpm-debug.log\nyarn-error.log\ntestem.log\n/typings\n\n# System Files\n.DS_Store\nThumbs.db\n\n\n"
  },
  {
    "path": ".pre-commit-config.yaml",
    "content": "repos:\n-   repo: https://github.com/pre-commit/pre-commit-hooks\n    rev: v2.3.0\n    hooks:\n    -   id: check-yaml\n    -   id: end-of-file-fixer\n    -   id: trailing-whitespace\n-   repo: https://github.com/psf/black\n    rev: 22.3.0\n    hooks:\n    -   id: black\n"
  },
  {
    "path": "AUTHORS",
    "content": "The Mycroft Server was initially developed by Mycroft AI Inc\n\nIt lives on as an open source project with many contributors, a self-updating\nlist is at: https://github.com/mycroftai/selene-backend/graphs/contributors"
  },
  {
    "path": "CODE_OF_CONDUCT.md",
    "content": "# Contributor Covenant Code of Conduct\n\n## Our Pledge\n\nIn the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.\n\n## Our Standards\n\nExamples of behavior that contributes to creating a positive environment include:\n\n* Using welcoming and inclusive language\n* Being respectful of differing viewpoints and experiences\n* Gracefully accepting constructive criticism\n* Focusing on what is best for the community\n* Showing empathy towards other community members\n\nExamples of unacceptable behavior by participants include:\n\n* The use of sexualized language or imagery and unwelcome sexual attention or advances\n* Trolling, insulting/derogatory comments, and personal or political attacks\n* Public or private harassment\n* Publishing others' private information, such as a physical or electronic address, without explicit permission\n* Other conduct which could reasonably be considered inappropriate in a professional setting\n\n## Our Responsibilities\n\nProject maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.\n\nProject maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.\n\n## Scope\n\nThis Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers.\n\n## Enforcement\n\nInstances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at kathy.reid@mycroft.ai. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.\n\nProject maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.\n\n## Attribution\n\nThis Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version]\n\n[homepage]: http://contributor-covenant.org\n[version]: http://contributor-covenant.org/version/1/4/\n"
  },
  {
    "path": "Dockerfile",
    "content": "# Multi-stage Dockerfile for running Selene APIs or their test suites.\n#\n# ASSUMPTION:\n#   This Dockerfile assumes its resulting containers will run on a Docker network.  A Postgres container named\n#   \"selene-db\" and a Redis container named \"selene-cache\" also need to be running on this network.  To create the\n#   network and the Postgres/Redis containers, use the following commands:\n#       docker network create --driver bridge <network name>\n#       docker run -d --net <network name> --name selene-cache redis:6\n#       docker run -d -e POSTGRES_PASSWORD=selene --net <network name> --name selene-db postgres:10\n#   The DB_HOST environment variable is set to the name of the Postgres container and the REDIS_HOST environment\n#   variable is set to the name of he Redis container.  When running images created from this Dockerfile, include the\n#   \"--net <network name>\" argument.\n\n# Build steps that apply to all of the selene applications.\nFROM python:3.9 as base-build\nRUN apt-get update && apt-get -y install gcc git libsndfile-dev\nRUN curl -sSL https://install.python-poetry.org | python3 -\nENV PATH ${PATH}:/root/.local/bin\nRUN poetry --version\nRUN mkdir -p /root/allure /opt/selene/selene-backend /root/code-quality /var/log/mycroft\nWORKDIR /opt/selene/selene-backend\nENV DB_HOST selene-db\nENV DB_NAME mycroft\nENV DB_PASSWORD adam\nENV DB_USER selene\nENV JWT_ACCESS_SECRET access-secret\nENV JWT_REFRESH_SECRET refresh-secret\nENV REDIS_HOST selene-cache\nENV REDIS_PORT 6379\nENV SALT testsalt\nENV SELENE_ENVIRONMENT dev\n\n# Put the copy of the shared library code in its own section to avoid reinstalling base software every time\nFROM base-build as selene-base\nCOPY shared shared\n\n# Code quality scripts and user agreements are stored in the MycroftAI/devops repository.  This repository is private.\n# builds for publicly available images should not use this build stage.\n#\n# The GitHub API key is sensitive information and can change depending on who is running the application.\n# It is used here to clone the private MycroftAI/devops repository.\nFROM selene-base as devops-build\nARG github_api_key\nENV GITHUB_API_KEY=$github_api_key\nRUN mkdir -p /opt/mycroft\nWORKDIR /opt/mycroft\nRUN git clone https://${github_api_key}@github.com/MycroftAI/devops.git\nWORKDIR /opt/mycroft/devops/jenkins\nRUN poetry install\n\n# Run a linter and code formatter against the API specified in the build argument\nFROM devops-build as api-code-check\nARG api_name\nWORKDIR /opt/selene/selene-backend\nCOPY api/${api_name} api/${api_name}\nWORKDIR /opt/selene/selene-backend/api/${api_name}\nRUN poetry install\nENV PYTHONPATH=$PYTHONPATH:/opt/selene/selene-backend/api/${api_name}\nWORKDIR /opt/mycroft/devops/jenkins\nENTRYPOINT [\"poetry\", \"run\", \"python\", \"-m\", \"pipeline.code_check\", \"--repository\", \"selene-backend\", \"--base-dir\", \"/opt/selene\"]\n\n# Bootstrap the Selene database as it will be needed to run any Selene applications.\nFROM devops-build as db-bootstrap\nENV POSTGRES_PASSWORD selene\nWORKDIR /opt/selene/selene-backend\nCOPY db db\nWORKDIR /opt/selene/selene-backend/db\nRUN poetry install\nRUN mkdir -p /tmp/selene\nENTRYPOINT [\"poetry\", \"run\", \"python\", \"scripts/bootstrap_mycroft_db.py\", \"--ci\"]\n\n# Run the tests defined in the Account API\nFROM selene-base as account-api-test\nARG stripe_api_key\nENV ACCOUNT_BASE_URL https://account.mycroft.test\nENV PANTACOR_API_TOKEN pantacor-token\nENV PANTACOR_API_BASE_URL pantacor.test.url\nENV PYTHONPATH=$PYTHONPATH:/opt/selene/selene-backend/api/account\nENV STRIPE_PRIVATE_KEY $stripe_api_key\nCOPY api/account api/account\nWORKDIR /opt/selene/selene-backend/api/account\nRUN poetry install\nWORKDIR /opt/selene/selene-backend/api/account/tests\nENTRYPOINT [\"poetry\", \"run\", \"behave\", \"-f\", \"allure_behave.formatter:AllureFormatter\", \"-o\", \"/root/allure/allure-result\"]\n\n# Run the tests defined in the Single Sign On API\nFROM selene-base as sso-api-test\nARG github_client_id\nARG github_client_secret\nENV PYTHONPATH=$PYTHONPATH:/opt/selene/selene-backend/api/sso\nENV JWT_RESET_SECRET reset-secret\n# The GitHub client ID and secret are sensitive information and can change depending on who is running the application.\n# They are used here to facilitate user authentication using a GitHub account.\nENV GITHUB_CLIENT_ID $github_client_id\nENV GITHUB_CLIENT_SECRET $github_client_secret\nCOPY api/sso api/sso\nWORKDIR /opt/selene/selene-backend/api/sso\nRUN poetry install\nWORKDIR /opt/selene/selene-backend/api/sso/tests\nENTRYPOINT [\"poetry\", \"run\", \"behave\", \"-f\", \"allure_behave.formatter:AllureFormatter\", \"-o\", \"/root/allure/allure-result\"]\n\n# Run the tests defined in the Public Device API\nFROM selene-base as public-api-test\nRUN mkdir -p /opt/selene/data\nARG google_stt_key\nARG stt_api_key\nARG wolfram_alpha_key\nENV GOOGLE_APPLICATION_CREDENTIALS=\"/root/secrets/transcription-test-363101-6532632520e1.json\"\nENV GOOGLE_STT_KEY $google_stt_key\nENV PANTACOR_API_TOKEN pantacor-token\nENV PANTACOR_API_BASE_URL pantacor.test.url\nENV PYTHONPATH=$PYTHONPATH:/opt/selene/selene-backend/api/public\nENV GOOGLE_STT_KEY $google_stt_key\nENV SENDGRID_API_KEY test_sendgrid_key\nENV WOLFRAM_ALPHA_KEY $wolfram_alpha_key\nENV WOLFRAM_ALPHA_URL https://api.wolframalpha.com\nCOPY api/public api/public\nWORKDIR /opt/selene/selene-backend/api/public\nRUN poetry install\nWORKDIR /opt/selene/selene-backend/api/public/tests\nENTRYPOINT [\"poetry\", \"run\", \"behave\", \"-f\", \"allure_behave.formatter:AllureFormatter\", \"-o\", \"/root/allure/allure-result\"]\n"
  },
  {
    "path": "Jenkinsfile",
    "content": "pipeline {\n    agent any\n    options {\n        // Running builds concurrently could cause a race condition with\n        // building the Docker image.\n        disableConcurrentBuilds()\n        buildDiscarder(logRotator(numToKeepStr: '5'))\n        ansiColor('xterm')\n    }\n    environment {\n        // Some branches have a \"/\" in their name (e.g. feature/new-and-cool)\n        // Some commands, such as those that deal with directories, don't\n        // play nice with this naming convention.  Define an alias for the\n        // branch name that can be used in these scenarios.\n        BRANCH_ALIAS = sh(\n            script: 'echo $BRANCH_NAME | sed -e \"s#/#-#g\"',\n            returnStdout: true\n        ).trim()\n        DOCKER_BUILDKIT=1\n        //spawns GITHUB_USR and GITHUB_PSW environment variables\n        GITHUB_API_KEY=credentials('38b2e4a6-167a-40b2-be6f-d69be42c8190')\n        GITHUB_CLIENT_ID=credentials('380f58b1-8a33-4a9d-a67b-354a9b0e792e')\n        GITHUB_CLIENT_SECRET=credentials('71626c21-de59-4450-bfad-5034fd596fb2')\n        GOOGLE_STT_KEY=credentials('287949f8-2ada-4450-8806-1fe2dd8e4c4d')\n        STRIPE_KEY=credentials('9980e41f-d418-49af-9d62-341d1246f555')\n        WOLFRAM_ALPHA_KEY=credentials('f718e0a1-c19c-4c7f-af88-0689738ccaa1')\n    }\n    stages {\n        stage('Lint & Format') {\n            // Run PyLint and Black to check code quality.\n            when {\n                anyOf {\n                    changeRequest target: 'dev'\n                    changeRequest target: 'master'\n                }\n            }\n            steps {\n                labelledShell label: 'Account API Setup', script: \"\"\"\n                     docker build \\\n                        --build-arg github_api_key=${GITHUB_API_KEY} \\\n                        --build-arg api_name=account \\\n                        --target api-code-check --no-cache \\\n                        -t selene-linter:${BRANCH_ALIAS} .\n                \"\"\"\n                labelledShell label: 'Account API Check', script: \"\"\"\n                    docker run selene-linter:${BRANCH_ALIAS} --poetry-dir api/account --pull-request=${BRANCH_NAME}\n                \"\"\"\n                labelledShell label: 'Single Sign On API Setup', script: \"\"\"\n                     docker build \\\n                        --build-arg github_api_key=${GITHUB_API_KEY} \\\n                        --build-arg api_name=sso \\\n                        --target api-code-check --no-cache \\\n                        -t selene-linter:${BRANCH_ALIAS} .\n                \"\"\"\n                labelledShell label: 'Single Sign On API Check', script: \"\"\"\n                    docker run selene-linter:${BRANCH_ALIAS} --poetry-dir api/sso --pull-request=${BRANCH_NAME}\n                \"\"\"\n                labelledShell label: 'Public API Setup', script: \"\"\"\n                     docker build \\\n                        --build-arg github_api_key=${GITHUB_API_KEY} \\\n                        --build-arg api_name=public \\\n                        --target api-code-check --no-cache \\\n                        --label job=${JOB_NAME} \\\n                        -t selene-linter:${BRANCH_ALIAS} .\n                \"\"\"\n                labelledShell label: 'Public API Check', script: \"\"\"\n                    docker run selene-linter:${BRANCH_ALIAS} --poetry-dir api/public --pull-request=${BRANCH_NAME}\n                \"\"\"\n            }\n        }\n        stage('Bootstrap DB') {\n            when {\n                anyOf {\n                    branch 'dev'\n                    branch 'master'\n                    changeRequest target: 'dev'\n                    changeRequest target: 'master'\n                }\n            }\n            steps {\n                labelledShell label: 'Building Docker image', script: \"\"\"\n                    docker build \\\n                        --target db-bootstrap \\\n                        --build-arg github_api_key=${GITHUB_API_KEY} \\\n                        --label job=${JOB_NAME} \\\n                        -t selene-db:${BRANCH_ALIAS} .\n                \"\"\"\n                timeout(time: 5, unit: 'MINUTES')\n                {\n                    labelledShell label: 'Run database bootstrap script', script: \"\"\"\n                        docker run \\\n                            -v '${HOME}/selene:/tmp/selene' \\\n                            --net selene-net selene-db:${BRANCH_ALIAS}\n                    \"\"\"\n                }\n            }\n        }\n        stage('Account API Tests') {\n            when {\n                anyOf {\n                    branch 'dev'\n                    branch 'master'\n                    changeRequest target: 'dev'\n                    changeRequest target: 'master'\n                }\n            }\n            steps {\n                labelledShell label: 'Building Docker image', script: \"\"\"\n                    docker build \\\n                        --build-arg stripe_api_key=${STRIPE_KEY} \\\n                        --target account-api-test \\\n                        --label job=${JOB_NAME} \\\n                        -t selene-account:${BRANCH_ALIAS} .\n                \"\"\"\n                timeout(time: 5, unit: 'MINUTES')\n                {\n                    sh 'mkdir -p $HOME/selene/$BRANCH_ALIAS/allure'\n                    labelledShell label: 'Running behave tests', script: \"\"\"\n                        docker run \\\n                            --net selene-net \\\n                            -v '$HOME/selene/$BRANCH_ALIAS/allure/:/root/allure' \\\n                            --label job=${JOB_NAME} \\\n                            selene-account:${BRANCH_ALIAS}\n                    \"\"\"\n                }\n            }\n            post {\n                always {\n                    sh 'docker run \\\n                        -v \"$HOME/selene/$BRANCH_ALIAS/allure:/root/allure\" \\\n                        --entrypoint=/bin/bash \\\n                        --label build=${JOB_NAME} \\\n                        selene-account:${BRANCH_ALIAS} \\\n                        -x -c \"chown $(id -u $USER):$(id -g $USER) \\\n                        -R /root/allure/\"'\n                }\n            }\n        }\n        stage('Single Sign On API Tests') {\n            when {\n                anyOf {\n                    branch 'dev'\n                    branch 'master'\n                    changeRequest target: 'dev'\n                    changeRequest target: 'master'\n                }\n            }\n            steps {\n                labelledShell label: 'Building Docker image', script: \"\"\"\n                    docker build \\\n                        --build-arg github_client_id=${GITHUB_CLIENT_ID} \\\n                        --build-arg github_client_secret=${GITHUB_CLIENT_SECRET} \\\n                        --target sso-api-test \\\n                        --label job=${JOB_NAME} \\\n                        -t selene-sso:${BRANCH_ALIAS} .\n                \"\"\"\n                timeout(time: 2, unit: 'MINUTES')\n                {\n                    labelledShell label: 'Running behave tests', script: \"\"\"\n                        docker run \\\n                            --net selene-net \\\n                            -v '$HOME/selene/$BRANCH_ALIAS/allure/:/root/allure' \\\n                            selene-sso:${BRANCH_ALIAS}\n                    \"\"\"\n                }\n            }\n            post {\n                always {\n                    sh 'docker run \\\n                        -v \"$HOME/selene/$BRANCH_ALIAS/allure:/root/allure\" \\\n                        --entrypoint=/bin/bash \\\n                        --label build=${JOB_NAME} \\\n                        selene-sso:${BRANCH_ALIAS} \\\n                        -x -c \"chown $(id -u $USER):$(id -g $USER) \\\n                        -R /root/allure/\"'\n                }\n            }\n        }\n        stage('Public Device API Tests') {\n            when {\n                anyOf {\n                    branch 'dev'\n                    branch 'master'\n                    changeRequest target: 'dev'\n                    changeRequest target: 'master'\n                }\n            }\n            steps {\n                labelledShell label: 'Building Docker image', script: \"\"\"\n                    docker build \\\n                        --build-arg wolfram_alpha_key=${WOLFRAM_ALPHA_KEY} \\\n                        --build-arg google_stt_key=${GOOGLE_STT_KEY} \\\n                        --target public-api-test \\\n                        --label job=${JOB_NAME} \\\n                        -t selene-public:${BRANCH_ALIAS} .\n                \"\"\"\n                timeout(time: 2, unit: 'MINUTES')\n                {\n                    labelledShell label: 'Running behave tests', script: \"\"\"\n                        docker run \\\n                            --net selene-net \\\n                            -v '$HOME/selene/$BRANCH_ALIAS/allure/:/root/allure' \\\n                            -v '$HOME/selene/secrets/:/root/secrets' \\\n                            selene-public:${BRANCH_ALIAS}\n                    \"\"\"\n                }\n            }\n            post {\n                always {\n                    sh 'docker run \\\n                        -v \"$HOME/selene/$BRANCH_ALIAS/allure:/root/allure\" \\\n                        --entrypoint=/bin/bash \\\n                        --label build=${JOB_NAME} \\\n                        selene-account:${BRANCH_ALIAS} \\\n                        -x -c \"chown $(id -u $USER):$(id -g $USER) \\\n                        -R /root/allure/\"'\n                }\n            }\n        }\n    }\n    post {\n        always {\n            sh 'rm -rf allure-result/*'\n            sh 'mkdir -p $HOME/selene/$BRANCH_ALIAS/allure/allure-result'\n            sh 'mv $HOME/selene/$BRANCH_ALIAS/allure/allure-result allure-result'\n            // This directory should now be empty, rmdir will intentionally fail if not.\n            sh 'rmdir $HOME/selene/$BRANCH_ALIAS/allure'\n            script {\n                allure([\n                    includeProperties: false,\n                    jdk: '',\n                    properties: [],\n                    reportBuildPolicy: 'ALWAYS',\n                    results: [[path: 'allure-result']]\n                ])\n            }\n            sh(\n                label: 'Cleanup lingering docker containers and images.',\n                script: \"\"\"\n                    docker container prune --force;\n                    docker image prune --force;\n                \"\"\"\n            )\n        }\n        success {\n            // Docker images should remain upon failure for troubleshooting purposes.  However,\n            // if the stage is successful, there is no reason to look back at the Docker image.  In theory\n            // broken builds will eventually be fixed so this step should run eventually for every PR\n            sh(\n                label: 'Delete Docker Image on Success',\n                script: '''\n                    docker image prune --all --force --filter label=job=${JOB_NAME};\n                '''\n            )\n        }\n    }\n}\n"
  },
  {
    "path": "LICENSE",
    "content": "                    GNU AFFERO GENERAL PUBLIC LICENSE\n                       Version 3, 19 November 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The GNU Affero General Public License is a free, copyleft license for\nsoftware and other kinds of works, specifically designed to ensure\ncooperation with the community in the case of network server software.\n\n  The licenses for most software and other practical works are designed\nto take away your freedom to share and change the works.  By contrast,\nour General Public Licenses are intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\n  Developers that use our General Public Licenses protect your rights\nwith two steps: (1) assert copyright on the software, and (2) offer\nyou this License which gives you legal permission to copy, distribute\nand/or modify the software.\n\n  A secondary benefit of defending all users' freedom is that\nimprovements made in alternate versions of the program, if they\nreceive widespread use, become available for other developers to\nincorporate.  Many developers of free software are heartened and\nencouraged by the resulting cooperation.  However, in the case of\nsoftware used on network servers, this result may fail to come about.\nThe GNU General Public License permits making a modified version and\nletting the public access it on a server without ever releasing its\nsource code to the public.\n\n  The GNU Affero General Public License is designed specifically to\nensure that, in such cases, the modified source code becomes available\nto the community.  It requires the operator of a network server to\nprovide the source code of the modified version running there to the\nusers of that server.  Therefore, public use of a modified version, on\na publicly accessible server, gives the public access to the source\ncode of the modified version.\n\n  An older license, called the Affero General Public License and\npublished by Affero, was designed to accomplish similar goals.  This is\na different license, not a version of the Affero GPL, but Affero has\nreleased a new version of the Affero GPL which permits relicensing under\nthis license.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                       TERMS AND CONDITIONS\n\n  0. Definitions.\n\n  \"This License\" refers to version 3 of the GNU Affero General Public License.\n\n  \"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n  \"The Program\" refers to any copyrightable work licensed under this\nLicense.  Each licensee is addressed as \"you\".  \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\n  To \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy.  The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\n  A \"covered work\" means either the unmodified Program or a work based\non the Program.\n\n  To \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy.  Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\n  To \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies.  Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\n  An interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License.  If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n  1. Source Code.\n\n  The \"source code\" for a work means the preferred form of the work\nfor making modifications to it.  \"Object code\" means any non-source\nform of a work.\n\n  A \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\n  The \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form.  A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\n  The \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities.  However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work.  For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\n  The Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\n  The Corresponding Source for a work in source code form is that\nsame work.\n\n  2. Basic Permissions.\n\n  All rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met.  This License explicitly affirms your unlimited\npermission to run the unmodified Program.  The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work.  This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\n  You may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force.  You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright.  Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\n  Conveying under any other circumstances is permitted solely under\nthe conditions stated below.  Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n  3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\n  No covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\n  When you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n  4. Conveying Verbatim Copies.\n\n  You may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\n  You may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n  5. Conveying Modified Source Versions.\n\n  You may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n    a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n\n    b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under section\n    7.  This requirement modifies the requirement in section 4 to\n    \"keep intact all notices\".\n\n    c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy.  This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged.  This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n\n    d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\n  A compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit.  Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n  6. Conveying Non-Source Forms.\n\n  You may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n    a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n\n    b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the\n    Corresponding Source from a network server at no charge.\n\n    c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source.  This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n\n    d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge.  You need not require recipients to copy the\n    Corresponding Source along with the object code.  If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source.  Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n\n    e) Convey the object code using peer-to-peer transmission, provided\n    you inform other peers where the object code and Corresponding\n    Source of the work are being offered to the general public at no\n    charge under subsection 6d.\n\n  A separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\n  A \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling.  In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage.  For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product.  A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n  \"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source.  The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\n  If you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information.  But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\n  The requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed.  Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\n  Corresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n  7. Additional Terms.\n\n  \"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law.  If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\n  When you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit.  (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.)  You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\n  Notwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n    a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n\n    b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n\n    c) Prohibiting misrepresentation of the origin of that material, or\n    requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n\n    d) Limiting the use for publicity purposes of names of licensors or\n    authors of the material; or\n\n    e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n\n    f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions of\n    it) with contractual assumptions of liability to the recipient, for\n    any liability that these contractual assumptions directly impose on\n    those licensors and authors.\n\n  All other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10.  If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term.  If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\n  If you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\n  Additional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n  8. Termination.\n\n  You may not propagate or modify a covered work except as expressly\nprovided under this License.  Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\n  However, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\n  Moreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\n  Termination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License.  If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n  9. Acceptance Not Required for Having Copies.\n\n  You are not required to accept this License in order to receive or\nrun a copy of the Program.  Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance.  However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work.  These actions infringe copyright if you do\nnot accept this License.  Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n  10. Automatic Licensing of Downstream Recipients.\n\n  Each time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License.  You are not responsible\nfor enforcing compliance by third parties with this License.\n\n  An \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations.  If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\n  You may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License.  For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n  11. Patents.\n\n  A \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based.  The\nwork thus licensed is called the contributor's \"contributor version\".\n\n  A contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version.  For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\n  Each contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\n  In the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement).  To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\n  If you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients.  \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\n  If, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\n  A patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License.  You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\n  Nothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n  12. No Surrender of Others' Freedom.\n\n  If conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all.  For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n  13. Remote Network Interaction; Use with the GNU General Public License.\n\n  Notwithstanding any other provision of this License, if you modify the\nProgram, your modified version must prominently offer all users\ninteracting with it remotely through a computer network (if your version\nsupports such interaction) an opportunity to receive the Corresponding\nSource of your version by providing access to the Corresponding Source\nfrom a network server at no charge, through some standard or customary\nmeans of facilitating copying of software.  This Corresponding Source\nshall include the Corresponding Source for any work covered by version 3\nof the GNU General Public License that is incorporated pursuant to the\nfollowing paragraph.\n\n  Notwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU General Public License into a single\ncombined work, and to convey the resulting work.  The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the work with which it is combined will remain governed by version\n3 of the GNU General Public License.\n\n  14. Revised Versions of this License.\n\n  The Free Software Foundation may publish revised and/or new versions of\nthe GNU Affero General Public License from time to time.  Such new versions\nwill be similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\n  Each version is given a distinguishing version number.  If the\nProgram specifies that a certain numbered version of the GNU Affero General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation.  If the Program does not specify a version number of the\nGNU Affero General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\n  If the Program specifies that a proxy can decide which future\nversions of the GNU Affero General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\n  Later license versions may give you additional or different\npermissions.  However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n  15. Disclaimer of Warranty.\n\n  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n  16. Limitation of Liability.\n\n  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n  17. Interpretation of Sections 15 and 16.\n\n  If the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU Affero General Public License as published by\n    the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU Affero General Public License for more details.\n\n    You should have received a copy of the GNU Affero General Public License\n    along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\n  If your software can interact with users remotely through a computer\nnetwork, you should also make sure that it provides a way for users to\nget its source.  For example, if your program is a web application, its\ninterface could display a \"Source\" link that leads users to an archive\nof the code.  There are many ways you could offer source, and different\nsolutions will be better for different programs; see section 13 for the\nspecific requirements.\n\n  You should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU AGPL, see\n<https://www.gnu.org/licenses/>."
  },
  {
    "path": "README.md",
    "content": "[![License](https://img.shields.io/badge/License-GNU_AGPL%203.0-blue.svg)](LICENSE)\n[![CLA](https://img.shields.io/badge/CLA%3F-Required-blue.svg)](https://mycroft.ai/cla)\n[![Team](https://img.shields.io/badge/Team-Mycroft_Backend-violetblue.svg)](https://github.com/MycroftAI/contributors/blob/master/team/Mycroft%20Backend.md)\n![Status](https://img.shields.io/badge/-Production_ready-green.svg)\n\n[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](http://makeapullrequest.com)\n[![Join chat](https://img.shields.io/badge/Mattermost-join_chat-brightgreen.svg)](https://chat.mycroft.ai)\n[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)\n\n\n\nSelene -- Mycroft's Server Backend\n==========\n\nSelene provides the services used by [Mycroft Core](https://github.com/mycroftai/mycroft-core) to manage devices, skills\nand settings.  It consists of two repositories.  This one contains Python and SQL representing the database definition,\ndata access layer, APIs and scripts.  The second repository, [Selene UI](https://github.com/mycroftai/selene-ui),\ncontains Angular web applications that use the APIs defined in this repository.\n\nThere are four APIs defined in this repository, account management, single sign on, skill marketplace and device.\nThe first three support account.mycroft.ai (aka home.mycroft.ai), sso.mycroft.ai, and market.mycroft.ai, respectively.\nThe device API is how devices running Mycroft Core communicate with the server. Also included in this repository is\na package containing batch scripts for maintenance and the definition of the database schema.\n\nEach API is designed to run independently of the others. Code common to each of the APIs, such as the Data Access Layer,\ncan be found in the \"shared\" directory.  The shared code is an independent Python package required by each of the APIs.\nEach API has its own Pipfile so that it can be run in its own virtual environment.\n\n## Installation\nThe Python code utilizes features introduced in Python 3.7, such as data classes.\n[Pipenv](https://pipenv.readthedocs.io/en/latest/) is used for virtual environment and package management.\nIf you prefer to use pip and pyenv (or virtualenv), you can find the required libraries in the files named \"Pipfile\".\nThese instructions will use pipenv commands.\n\nIf the Selene applications will be servicing a large number of devices (enterprise usage, for example), it is\nrecommended that each of the applications run on their own server or virtual machine. This configuration makes it\neasier to scale and monitor each application independently.  However, all applications can be run on a single server.\nThis configuration could be more practical for a household running a handful of devices.\n\nThese instructions will assume a multi-server setup for several thousand devices. To run on a single server servicing a\nsmall number of devices, the recommended system requirements are 4 CPU, 8GB RAM and 100GB of disk.  There are a lot of\nmanual steps in this section that will eventually be replaced with an installation script.\n\nAll Selene applications are time zone agnostic.  It is recommended that the time zone on any server running Selene be UTC.\n\nIt is recommended to create an application specific user. In these instructions this user will be `mycroft`.\n\n### Postgres DB\n* Recommended server configuration: [Ubuntu 18.04 LTS (server install)](https://releases.ubuntu.com/bionic/), 2 CPU, 4GB RAM, 50GB disk.\n* Use the package management system to install Python 3.7, Python 3 pip and PostgreSQL 10\n```\nsudo apt-get install postgresql python3.7 python python3-pip\n```\n* Set Postgres to start on boot\n```\nsudo systemctl enable postgresql\n```\n* Clone the selene-backend and documentation repositories\n```\nsudo mkdir -p /opt/selene\nsudo chown -R mycroft:users /opt/selene\ncd /opt/selene\ngit clone https://github.com/MycroftAI/selene-backend.git\n```\n* Create the virtual environment for the database code\n```\nsudo python3.7 -m pip install pipenv\ncd /opt/selene/selene-backend/db\npipenv install\n```\n* Download files from geonames.org used to populate the geography schema tables\n```\nmkdir -p /opt/selene/data\ncd /opt/selene/data\nwget http://download.geonames.org/export/dump/countryInfo.txt\nwget http://download.geonames.org/export/dump/timeZones.txt\nwget http://download.geonames.org/export/dump/admin1CodesASCII.txt\nwget http://download.geonames.org/export/dump/cities500.zip\n```\n* Add environment variables containing these passwords for the bootstrap script\n```\nexport DB_PASSWORD=<selene user password>\nexport POSTGRES_PASSWORD=<postgres user password>\n```\n* Generate secure passwords for the postgres user and selene user on the database\n```\nsudo -u postgres psql -c \"ALTER USER postgres PASSWORD '$POSTGRES_PASSWORD'\"\nsudo -u postgres psql -c \"CREATE ROLE selene WITH LOGIN ENCRYPTED PASSWORD '$DB_PASSWORD'\"\n```\n* Run the bootstrap script\n```\ncd /opt/selene/selene-backend/db/scripts\npipenv run python bootstrap_mycroft_db.py\n```\n  * Note: if you get an authentication error you can temporarily edit `/etc/postgresql/<version>/main/pg_hba.conf` replacing the following lines:\n  ```\n  # \"local\" is for Unix domain socket connections only\n  local   all             all                                     trust\n  # IPv4 local connections:\n  host    all             all             127.0.0.1/32            trust\n  ```\n* By default, Postgres only listens on localhost.  This will not do for a multi-server setup.  Change the\n`listen_addresses` value in the `posgresql.conf` file to the private IP of the database server.  This file is owned by\nthe `postgres` user so use the following command to edit it (substituting vi for your favorite editor)\n```\nsudo -u postgres vi /etc/postgres/10/main/postgresql.conf\n```\n* By default, Postgres only allows connections from localhost.  This will not do for a multi-server setup either.  Add\nan entry to the `pg_hba.conf` file for each server that needs to access this database.  This file is also owned by\nthe `postgres` user so use the following command to edit it (substituting vi for your favorite editor)\n```\nsudo -u postgres vi /etc/postgres/10/main/pg_hba.conf\n```\n* Instructions on how to update the `pg_hba.conf` file can be found in\n[Postgres' documentation](https://www.postgresql.org/docs/10/auth-pg-hba-conf.html).  Below is an example for reference.\n```\n# IPv4 Selene connections\nhost    mycroft         selene          <private IP address>/32          md5\n```\n* Restart Postgres for the `postgres.conf` and `pg_hba.conf` changes to take effect.\n```\nsudo systemctl restart postgresql\n```\n\n### Redis DB\n\n* Recommended server configuration: Ubuntu 18.04 LTS, 1 CPU, 1GB RAM, 5GB disk.\nSo as to not reinvent the wheel, here are some easy-to-follow instructions for\n[installing Redis on Ubuntu 18.04](https://www.digitalocean.com/community/tutorials/how-to-install-and-secure-redis-on-ubuntu-18-04).\n* By default, Redis only listens on local host. For multi-server setups, one additional step is to change the \"bind\" variable in `/etc/redis/redis.conf` to be the private IP of the Redis host.\n\n### APIs\n\nThe majority of the setup for each API is the same.  This section defines the steps common to all APIs. Steps specific\nto each API will be defined in their respective sections.\n* Add an application user to the VM. Either give this user sudo privileges or execute the sudo commands below as a user\nwith sudo privileges.  These instructions will assume a user name of \"mycroft\"\n* Use the package management system to install Python 3.7, Python 3 pip and Python 3.7 Developer Tools\n```\nsudo apt install python3.7 python3-pip python3.7-dev\nsudo python3.7 -m pip install pipenv\n```\n* Setup the Backend Application Directory\n```\nsudo mkdir -p /opt/selene\nsudo chown -R mycroft:users /opt/selene\n```\n* Setup the Log Directory\n```\nsudo mkdir -p /var/log/mycroft\nsudo chown -R mycroft:users /var/log/mycroft\n```\n* Clone the Selene Backend Repository\n```\ncd /opt/selene\ngit clone https://github.com/MycroftAI/selene-backend.git\n```\n* If running in a test environment, be sure to checkout the \"test\" branch of the repository\n\n#### Single Sign On API\nRecommended server configuration: Ubuntu 18.04 LTS, 1 CPU, 1GB RAM, 5GB disk\n* Create the virtual environment and install the requirements for the application\n```\ncd /opt/selene/selene-backend/api/sso\npipenv install\n```\n\n#### Account API\n* Recommended server configuration: Ubuntu 18.04 LTS, 1 CPU, 1GB RAM, 5GB disk\n* Create the virtual environment and install the requirements for the application\n```\ncd /opt/selene/selene-backend/api/account\npipenv install\n```\n\n#### Marketplace API\n* Recommended server configuration: Ubuntu 18.04 LTS, 1 CPU, 1GB RAM, 10GB disk\n* Create the virtual environment and install the requirements for the application\n```\ncd /opt/selene/selene-backend/api/market\npipenv install\n```\n\n#### Device API\n* Recommended server configuration: Ubuntu 18.04 LTS, 2 CPU, 2GB RAM, 50GB disk\n* Create the virtual environment and install the requirements for the application\n```\ncd /opt/selene/selene-backend/api/public\npipenv install\n```\n\n#### Precise API\n* Recommended server configuration: Ubuntu 18.04 LTS, 1 CPU, 1GB RAM, 5GB disk\n* Create the virtual environment and install the requirements for the application\n```\ncd /opt/selene/selene-backend/api/precise\npipenv install\n```\n### Running the APIs\nEach API is configured to run on port 5000.  This is not a problem if each is running in its own VM but will be an\nissue if all APIs are running on the same server, or if port 5000 is already in use.  To address these scenarios,\nchange the port numbering in the uwsgi.ini file for each API.\n\n#### Single Sign On API\n* The SSO application uses three JWTs for authentication. First is an access key, which is required to authenticate a\nuser for API calls.  Second is a refresh key that automatically refreshes the access key when it expires.  Third is a\nreset key, which is used in a password reset scenario.  Generate a secret key for each JWT.\n* Any data that can identify a user is encrypted.  Generate a salt that will be used with the encryption algorithm.\n* Access to the Github API is required to support logging in with your Github account.  Details can be found\n[here](https://developer.github.com/v3/guides/basics-of-authentication/).\n* The password reset functionality sends an email to the user with a link to reset their password.  Selene uses\nSendGrid to send these emails so a SendGrid account and API key are required.\n* Define a systemd service to run the API.  The service defines environment variables that use the secret and API keys\ngenerated in previous steps.\n```\nsudo vim /etc/systemd/system/sso_api.service\n```\n```\n[Unit]\nDescription=Mycroft Single Sign On Api\nAfter=network.target\n\n[Service]\nUser=mycroft\nGroup=www-data\nRestart=always\nType=simple\nWorkingDirectory=/opt/selene/selene-backend/api/sso\nExecStart=/usr/local/bin/pipenv run uwsgi --ini uwsgi.ini\nEnvironment=DB_HOST=<IP address or name of database host>\nEnvironment=DB_NAME=mycroft\nEnvironment=DB_PASSWORD=<selene database user password>\nEnvironment=DB_PORT=5432\nEnvironment=DB_USER=selene\nEnvironment=GITHUB_CLIENT_ID=<github client id>\nEnvironment=GITHUB_CLIENT_SECRET=<github client secret>\nEnvironment=JWT_ACCESS_SECRET=<access secret>\nEnvironment=JWT_REFRESH_SECRET=<refresh secret>\nEnvironment=JWT_RESET_SECRET=<reset secret>\nEnvironment=SALT=<salt value>\nEnvironment=SELENE_ENVIRONMENT=<test/prod>\nEnvironment=SENDGRID_API_KEY=<sendgrid API key>\nEnvironment=SSO_BASE_URL=<base url for single sign on application>\n\n[Install]\nWantedBy=multi-user.target\n```\n* Start the sso_api service and set it to start on boot\n```\nsudo systemctl start sso_api.service\nsudo systemctl enable sso_api.service\n```\n\n#### Account API\n* The account API uses the same authentication mechanism as the single sign on API.  The JWT_ACCESS_SECRET,\nJWT_REFRESH_SECRET and SALT environment variables must be the same values as those on the single sign on API.\n* This application uses the Redis database so the service needs to know where it resides.\n* Define a systemd service to run the API.  The service defines environment variables that use the secret and API keys\ngenerated in previous steps.\n```\nsudo vim /etc/systemd/system/account_api.service\n```\n```\n[Unit]\nDescription=Mycroft Account API\nAfter=network.target\n\n[Service]\nUser=mycroft\nGroup=www-data\nRestart=always\nType=simple\nWorkingDirectory=/opt/selene/selene-backend/api/account\nExecStart=/usr/local/bin/pipenv run uwsgi --ini uwsgi.ini\nEnvironment=DB_HOST=<db host IP address or name>\nEnvironment=DB_NAME=mycroft\nEnvironment=DB_PASSWORD=<selene user database password>\nEnvironment=DB_PORT=5432\nEnvironment=DB_USER=selene\nEnvironment=JWT_ACCESS_SECRET=<same as value for single sign on>\nEnvironment=JWT_REFRESH_SECRET=<same as value for single sign on>\nEnvironment=OAUTH_BASE_URL=<url for oauth service>\nEnvironment=REDIS_HOST=<IP address or name of redis host>\nEnvironment=REDIS_PORT=6379\nEnvironment=SELENE_ENVIRONMENT=<test/prod>\nEnvironment=SALT=<same as value for single sign on>\n\n[Install]\nWantedBy=multi-user.target\n```\n* Start the account_api service and set it to start on boot\n```\nsudo systemctl start account_api.service\nsudo systemctl enable account_api.service\n```\n\n#### Marketplace API\n* The marketplace API uses the same authentication mechanism as the single sign on API.  The JWT_ACCESS_SECRET,\nJWT_REFRESH_SECRET and SALT environment variables must be the same values as those on the single sign on API.\n* This application uses the Redis database so the service needs to know where it resides.\n* Define a systemd service to run the API.  The service defines environment variables that use the secret and API keys\ngenerated in previous steps.\n```\nsudo vim /etc/systemd/system/market_api.service\n```\n```\n[Unit]\nDescription=Mycroft Marketplace API\nAfter=network.target\n\n[Service]\nUser=mycroft\nGroup=www-data\nRestart=always\nType=simple\nWorkingDirectory=/opt/selene/selene-backend/api/market\nExecStart=/usr/local/bin/pipenv run uwsgi --ini uwsgi.ini\nEnvironment=DB_HOST=<db host IP address or name>\nEnvironment=DB_NAME=mycroft\nEnvironment=DB_PASSWORD=<selene user database password>\nEnvironment=DB_PORT=5432\nEnvironment=DB_USER=selene\nEnvironment=JWT_ACCESS_SECRET=<same as value for single sign on>\nEnvironment=JWT_REFRESH_SECRET=<same as value for single sign on>\nEnvironment=OAUTH_BASE_URL=<url for oauth service>\nEnvironment=REDIS_HOST=<IP address or name of redis host>\nEnvironment=REDIS_PORT=6379\nEnvironment=SELENE_ENVIRONMENT=<test/prod>\nEnvironment=SALT=<same as value for single sign on>\n\n[Install]\nWantedBy=multi-user.target\n```\n* Start the market_api service and set it to start on boot\n```\nsudo systemctl start market_api.service\nsudo systemctl enable market_api.service\n```\n* The marketplace API assumes that the skills it supplies to the web application are in the Postgres database. To get\nthem there, a script needs to be run to download them from Github.  The script requires the GITHUB_USER, GITHUB_PASSWORD,\nDB_HOST, DB_NAME, DB_USER and DB_PASSWORD environment variables to run.  Use the same values as those in the service\ndefinition files.\n```\ncd /opt/selene/selene-backend/batch\npipenv install\npipenv run python load_skill_display_data.py --core-version <specify core version, e.g. 19.02>\n```\n\n#### Device API\n* The device API uses the same authentication mechanism as the single sign on API.  The JWT_ACCESS_SECRET,\nJWT_REFRESH_SECRET and SALT environment variables must be the same values as those on the single sign on API.\n* This application uses the Redis database so the service needs to know where it resides.\n* The weather skill requires a key to the Open Weather Map API\n* The speech to text engine requires a key to Google's STT API.\n* The Wolfram Alpha skill requires an API key to the Wolfram Alpha API\n* Define a systemd service to run the API.  The service defines environment variables that use the secret and API keys\ngenerated in previous steps.\n```\nsudo vim /etc/systemd/system/public_api.service\n```\n```\n[Unit]\nDescription=Mycroft Public API\nAfter=network.target\n\n[Service]\nUser=mycroft\nGroup=www-data\nRestart=always\nType=simple\nWorkingDirectory=/opt/selene/selene-backend/api/public\nExecStart=/usr/local/bin/pipenv run uwsgi --ini uwsgi.ini\nEnvironment=DB_HOST=<db host IP address or name>\nEnvironment=DB_NAME=mycroft\nEnvironment=DB_PASSWORD=<selene user database password>\nEnvironment=DB_PORT=5432\nEnvironment=DB_USER=selene\nEnvironment=EMAIL_SERVICE_HOST=<email host>\nEnvironment=EMAIL_SERVICE_PORT=<email port>\nEnvironment=EMAIL_SERVICE_USER=<email user>\nEnvironment=EMAIL_SERVICE_PASSWORD=<email password>\nEnvironment=GOOGLE_STT_KEY=<Google STT API key>\nEnvironment=JWT_ACCESS_SECRET=<same as value for single sign on>\nEnvironment=JWT_REFRESH_SECRET=<same as value for single sign on>\nEnvironment=OAUTH_BASE_URL=<url for oauth service>\nEnvironment=OWM_KEY=<Open Weather Map API Key>\nEnvironment=OWM_URL=https://api.openweathermap.org/data/2.5\nEnvironment=REDIS_HOST=<IP address or name of redis host>\nEnvironment=REDIS_PORT=6379\nEnvironment=SELENE_ENVIRONMENT=<test/prod>\nEnvironment=SALT=<same as value for single sign on>\nEnvironment=WOLFRAM_ALPHA_KEY=<Wolfram Alpha API Key\nEnvironment=WOLFRAM_ALPHA_URL=https://api.wolframalpha.com\n\n[Install]\nWantedBy=multi-user.target\n```\n* Start the public_api service and set it to start on boot\n```\nsudo systemctl start public_api.service\nsudo systemctl enable public_api.service\n```\n\n### Testing the endpoints\n\nBefore we continue, let's make sure that your endpoints are operational - for this we'll use the `public_api` endpoint as an example.\n\n1. As we do not yet have a http router configured, we must change the `uwsgi` configuration for the endpoint we want to test. This is contained in: `/opt/selene/selene-backend/api/public/uwsgi.ini`. Here we want to replace\n    ```\n    socket = :$PORT\n    ```\n    with\n    ```\n    http = :$PORT\n    ```\n    then restart the service:\n    ```\n    sudo systemctl restart public_api.service\n    ```\n\n2. Check the status of the systemd service:\n    ```\n    systemctl status public_api.service\n    ```\n    Should report the service as \"active (running)\"\n\n3. Send a GET request from a remote device:\n    ```\n    curl -v http://$IP_ADDRESS:$PORT/code?state=this-is-a-test\n    ```\n    You can also monitor this from the service logs by running:\n    ```\n    journalctl -u public_api.service -f\n    ```\n\n## Other Considerations\n### DNS\nThere are multiple ways to setup DNS.  This document will not dictate how to do so for Selene.  However, here is an\nexample, based on how DNS is setup at Mycroft AI...\n\nEach application runs on its own sub-domain.  Assuming a top level domain of \"mycroft.ai\" the subdomains are:\n* account.mycroft.ai\n* api.mycroft.ai\n* market.mycroft.ai\n* sso.mycroft.ai\n\nThe APIs that support the web applications are directories within the sub-domain (e.g. account.mycroft.ai/api).  Since\nthe device API is externally facing, it is versioned.  It's subdirectory must be \"v1\".\n\n### Reverse Proxy\nThere are multiple tools available for setting up a reverse proxy that will point your DNS entries to your APIs. As such, the decision on how to set this up will be left to the user.\n\n### SSL\nIt is recommended that Selene applications be run using HTTPS.  To do this an SSL certificate is necessary.\n\n[Let's Encrypt](https://letsencrypt.org) is a great way to easily set up SSL certificates for free.\n\n## What About the GUI???\nOnce the database and API setup is complete, the next step is to setup the GUI, The README file for the\n[Selene UI](https://github.com/mycroftai/selene-ui) repository contains the instructions for setting up the web\napplications.\n\n## Getting Involved\n\nThis is an open source project and we would love your help. We have prepared a [contributing](.github/CONTRIBUTING.md)\nguide to help you get started.\n\nIf this is your first PR or you're not sure where to get started,\nsay hi in [Mycroft Chat](https://chat.mycroft.ai/) and a team member would be happy to guide you.\nJoin the [Mycroft Forum](https://community.mycroft.ai/) for questions and answers.\n"
  },
  {
    "path": "api/account/account_api/__init__.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n"
  },
  {
    "path": "api/account/account_api/api.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\n\"\"\"Entry point for the API that supports the Mycroft Marketplace.\"\"\"\nfrom flask import Flask\n\nfrom selene.api import get_base_config, selene_api, SeleneResponse\nfrom selene.api.endpoints import (\n    AccountEndpoint,\n    AgreementsEndpoint,\n    ValidateEmailEndpoint,\n)\nfrom selene.util.cache import SeleneCache\nfrom selene.util.log import configure_selene_logger\nfrom .endpoints import (\n    AccountDefaultsEndpoint,\n    CityEndpoint,\n    CountryEndpoint,\n    EmailAddressChangeEndpoint,\n    DeviceEndpoint,\n    DeviceCountEndpoint,\n    GeographyEndpoint,\n    MembershipEndpoint,\n    RegionEndpoint,\n    PairingCodeEndpoint,\n    PasswordChangeEndpoint,\n    PreferencesEndpoint,\n    SkillsEndpoint,\n    SkillOauthEndpoint,\n    SkillSettingsEndpoint,\n    SoftwareUpdateEndpoint,\n    SshKeyValidatorEndpoint,\n    TimezoneEndpoint,\n    VerifyEmailAddressEndpoint,\n    VoiceEndpoint,\n    WakeWordEndpoint,\n)\n\nconfigure_selene_logger(\"account_api\")\n\n\n# Define the Flask application\nacct = Flask(__name__)\nacct.config.from_object(get_base_config())\nacct.response_class = SeleneResponse\nacct.register_blueprint(selene_api)\nacct.config[\"SELENE_CACHE\"] = SeleneCache()\n\naccount_endpoint = AccountEndpoint.as_view(\"account_endpoint\")\nacct.add_url_rule(\n    \"/api/account\", view_func=account_endpoint, methods=[\"GET\", \"PATCH\", \"DELETE\"]\n)\n\nagreements_endpoint = AgreementsEndpoint.as_view(\"agreements_endpoint\")\nacct.add_url_rule(\n    \"/api/agreement/<string:agreement_type>\",\n    view_func=agreements_endpoint,\n    methods=[\"GET\"],\n)\n\ncity_endpoint = CityEndpoint.as_view(\"city_endpoint\")\nacct.add_url_rule(\"/api/cities\", view_func=city_endpoint, methods=[\"GET\"])\n\ncountry_endpoint = CountryEndpoint.as_view(\"country_endpoint\")\nacct.add_url_rule(\"/api/countries\", view_func=country_endpoint, methods=[\"GET\"])\n\ndefaults_endpoint = AccountDefaultsEndpoint.as_view(\"defaults_endpoint\")\nacct.add_url_rule(\n    \"/api/defaults\", view_func=defaults_endpoint, methods=[\"GET\", \"PATCH\", \"POST\"]\n)\n\ndevice_endpoint = DeviceEndpoint.as_view(\"device_endpoint\")\nacct.add_url_rule(\n    \"/api/devices\",\n    defaults={\"device_id\": None},\n    view_func=device_endpoint,\n    methods=[\"GET\"],\n)\nacct.add_url_rule(\"/api/devices\", view_func=device_endpoint, methods=[\"POST\"])\nacct.add_url_rule(\n    \"/api/devices/<string:device_id>\",\n    view_func=device_endpoint,\n    methods=[\"DELETE\", \"GET\", \"PATCH\"],\n)\n\ndevice_count_endpoint = DeviceCountEndpoint.as_view(\"device_count_endpoint\")\nacct.add_url_rule(\"/api/device-count\", view_func=device_count_endpoint, methods=[\"GET\"])\n\nchange_email_endpoint = EmailAddressChangeEndpoint.as_view(\"change_email_endpoint\")\nacct.add_url_rule(\"/api/change-email\", view_func=change_email_endpoint, methods=[\"PUT\"])\n\nchange_password_endpoint = PasswordChangeEndpoint.as_view(\"change_password_endpoint\")\nacct.add_url_rule(\n    \"/api/change-password\", view_func=change_password_endpoint, methods=[\"PUT\"]\n)\n\ngeography_endpoint = GeographyEndpoint.as_view(\"geography_endpoint\")\nacct.add_url_rule(\"/api/geographies\", view_func=geography_endpoint, methods=[\"GET\"])\n\nmembership_endpoint = MembershipEndpoint.as_view(\"membership_endpoint\")\nacct.add_url_rule(\"/api/memberships\", view_func=membership_endpoint, methods=[\"GET\"])\n\npairing_code_endpoint = PairingCodeEndpoint.as_view(\"pairing_code_endpoint\")\nacct.add_url_rule(\n    \"/api/pairing-code/<string:pairing_code>\",\n    view_func=pairing_code_endpoint,\n    methods=[\"GET\"],\n)\n\npreferences_endpoint = PreferencesEndpoint.as_view(\"preferences_endpoint\")\nacct.add_url_rule(\n    \"/api/preferences\", view_func=preferences_endpoint, methods=[\"GET\", \"PATCH\", \"POST\"]\n)\n\nregion_endpoint = RegionEndpoint.as_view(\"region_endpoint\")\nacct.add_url_rule(\"/api/regions\", view_func=region_endpoint, methods=[\"GET\"])\n\nsetting_endpoint = SkillSettingsEndpoint.as_view(\"setting_endpoint\")\nacct.add_url_rule(\n    \"/api/skills/<string:skill_family_name>/settings\",\n    view_func=setting_endpoint,\n    methods=[\"GET\", \"PUT\"],\n)\n\nskill_endpoint = SkillsEndpoint.as_view(\"skill_endpoint\")\nacct.add_url_rule(\"/api/skills\", view_func=skill_endpoint, methods=[\"GET\"])\n\nskill_oauth_endpoint = SkillOauthEndpoint.as_view(\"skill_oauth_endpoint\")\nacct.add_url_rule(\n    \"/api/skills/oauth/<int:oauth_id>\", view_func=skill_oauth_endpoint, methods=[\"GET\"]\n)\n\nsoftware_update_endpoint = SoftwareUpdateEndpoint.as_view(\"software_update_endpoint\")\nacct.add_url_rule(\n    \"/api/software-update\", view_func=software_update_endpoint, methods=[\"PATCH\"]\n)\n\nssh_key_validation_endpoint = SshKeyValidatorEndpoint.as_view(\n    \"ssh_key_validation_endpoint\"\n)\nacct.add_url_rule(\n    \"/api/ssh-key\",\n    view_func=ssh_key_validation_endpoint,\n    methods=[\"GET\"],\n)\n\ntimezone_endpoint = TimezoneEndpoint.as_view(\"timezone_endpoint\")\nacct.add_url_rule(\"/api/timezones\", view_func=timezone_endpoint, methods=[\"GET\"])\n\nvalidate_email_endpoint = ValidateEmailEndpoint.as_view(\"validate_email_endpoint\")\nacct.add_url_rule(\n    \"/api/validate-email\", view_func=validate_email_endpoint, methods=[\"GET\"]\n)\n\nverify_email_endpoint = VerifyEmailAddressEndpoint.as_view(\"verify_email_endpoint\")\nacct.add_url_rule(\"/api/verify-email\", view_func=verify_email_endpoint, methods=[\"PUT\"])\n\nvoice_endpoint = VoiceEndpoint.as_view(\"voice_endpoint\")\nacct.add_url_rule(\"/api/voices\", view_func=voice_endpoint, methods=[\"GET\"])\n\nwake_word_endpoint = WakeWordEndpoint.as_view(\"wake_word_endpoint\")\nacct.add_url_rule(\"/api/wake-words\", view_func=wake_word_endpoint, methods=[\"GET\"])\n"
  },
  {
    "path": "api/account/account_api/endpoints/__init__.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Public API into the endpoints package.\"\"\"\n\nfrom .preferences import PreferencesEndpoint\nfrom .change_email_address import EmailAddressChangeEndpoint\nfrom .change_password import PasswordChangeEndpoint\nfrom .city import CityEndpoint\nfrom .country import CountryEndpoint\nfrom .defaults import AccountDefaultsEndpoint\nfrom .device import DeviceEndpoint\nfrom .device_count import DeviceCountEndpoint\nfrom .geography import GeographyEndpoint\nfrom .membership import MembershipEndpoint\nfrom .pairing_code import PairingCodeEndpoint\nfrom .region import RegionEndpoint\nfrom .skills import SkillsEndpoint\nfrom .skill_oauth import SkillOauthEndpoint\nfrom .skill_settings import SkillSettingsEndpoint\nfrom .software_update import SoftwareUpdateEndpoint\nfrom .ssh_key_validator import SshKeyValidatorEndpoint\nfrom .timezone import TimezoneEndpoint\nfrom .verify_email_address import VerifyEmailAddressEndpoint\nfrom .voice_endpoint import VoiceEndpoint\nfrom .wake_word_endpoint import WakeWordEndpoint\n"
  },
  {
    "path": "api/account/account_api/endpoints/change_email_address.py",
    "content": "#  Mycroft Server - Backend\n#  Copyright (c) 2022 Mycroft AI Inc\n#  SPDX-License-Identifier: \tAGPL-3.0-or-later\n#  #\n#  This file is part of the Mycroft Server.\n#  #\n#  The Mycroft Server is free software: you can redistribute it and/or\n#  modify it under the terms of the GNU Affero General Public License as\n#  published by the Free Software Foundation, either version 3 of the\n#  License, or (at your option) any later version.\n#  #\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n#  GNU Affero General Public License for more details.\n#  #\n#  You should have received a copy of the GNU Affero General Public License\n#  along with this program. If not, see <https://www.gnu.org/licenses/>.\n#\n\"\"\"Defines the password change endpoint for the account API.\n\nThis endpoint does not update the email address in the database.  The user needs to\nverify the email address is correct before the change is applied.  See the\nverify_email_address module in this package for the verification step.\n\"\"\"\nfrom binascii import a2b_base64, b2a_base64\nfrom http import HTTPStatus\nfrom os import environ\n\nfrom selene.api import APIError, SeleneEndpoint\nfrom selene.util.email import EmailMessage, SeleneMailer, validate_email_address\n\n\nclass EmailAddressChangeEndpoint(SeleneEndpoint):\n    \"\"\"Adds authentication to the common password changing endpoint.\"\"\"\n\n    def put(self):\n        \"\"\"Executes an HTTP PUT request.\"\"\"\n        self._authenticate()\n        new_email_address = self._validate_request()\n        self._send_notification()\n        self._send_verification_email(new_email_address)\n\n        return \"\", HTTPStatus.NO_CONTENT\n\n    def _validate_request(self) -> str:\n        \"\"\"Validates the content of the API request.\n\n        :returns: A validated and normalized email address\n        :raises: APIError when email address is invalid\n        \"\"\"\n        request_token = self.request.json[\"token\"]\n        new_email_address = a2b_base64(request_token).decode()\n        normalized_address, error = validate_email_address(new_email_address)\n        if error is not None:\n            raise APIError(error)\n\n        return normalized_address\n\n    def _send_notification(self):\n        \"\"\"Notifies the current email address' owner of the requested change.\"\"\"\n        _, error = validate_email_address(self.account.email_address)\n        if error is None:\n            email = EmailMessage(\n                recipient=self.account.email_address,\n                sender=\"Mycroft AI<no-reply@mycroft.ai>\",\n                subject=\"Email Address Changed\",\n                template_file_name=\"email_change.html\",\n            )\n            mailer = SeleneMailer(email)\n            mailer.send(using_jinja=True)\n\n    @staticmethod\n    def _send_verification_email(new_email_address):\n        \"\"\"Sends an email with a link for email verification to the requested address.\n\n        :param new_email_address: the recipient of the verification email\n        \"\"\"\n        base_url = environ[\"ACCOUNT_BASE_URL\"]\n        token = b2a_base64(new_email_address.encode(), newline=False).decode()\n        url = f\"{base_url}/verify-email?token={token}\"\n        email = EmailMessage(\n            recipient=new_email_address,\n            sender=\"Mycroft AI<no-reply@mycroft.ai>\",\n            subject=\"Email Change Verification\",\n            template_file_name=\"email_verification.html\",\n            template_variables=dict(email_verification_url=url),\n        )\n        mailer = SeleneMailer(email)\n        mailer.send(using_jinja=True)\n"
  },
  {
    "path": "api/account/account_api/endpoints/change_password.py",
    "content": "#  Mycroft Server - Backend\n#  Copyright (c) 2022 Mycroft AI Inc\n#  SPDX-License-Identifier: \tAGPL-3.0-or-later\n#  #\n#  This file is part of the Mycroft Server.\n#  #\n#  The Mycroft Server is free software: you can redistribute it and/or\n#  modify it under the terms of the GNU Affero General Public License as\n#  published by the Free Software Foundation, either version 3 of the\n#  License, or (at your option) any later version.\n#  #\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n#  GNU Affero General Public License for more details.\n#  #\n#  You should have received a copy of the GNU Affero General Public License\n#  along with this program. If not, see <https://www.gnu.org/licenses/>.\n#\n\"\"\"Defines the password change endpoint for the account API.\"\"\"\nfrom selene.api.endpoints import PasswordChangeEndpoint as CommonPasswordChangeEndpoint\nfrom selene.util.email import EmailMessage, SeleneMailer\n\n\nclass PasswordChangeEndpoint(CommonPasswordChangeEndpoint):\n    \"\"\"Adds authentication to the common password changing endpoint.\"\"\"\n\n    @property\n    def account_id(self):\n        return self.account.id\n\n    def _send_email(self):\n        email = EmailMessage(\n            recipient=self.account.email_address,\n            sender=\"Mycroft AI<no-reply@mycroft.ai>\",\n            subject=\"Password Changed\",\n            template_file_name=\"password_change.html\",\n        )\n        mailer = SeleneMailer(email)\n        mailer.send(using_jinja=True)\n"
  },
  {
    "path": "api/account/account_api/endpoints/city.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Account API endpoint for retrieving city geographical information.\"\"\"\nfrom http import HTTPStatus\n\nfrom selene.api import SeleneEndpoint\nfrom selene.data.geography import CityRepository\n\n\nclass CityEndpoint(SeleneEndpoint):\n    \"\"\"Retrieve a city in a region\"\"\"\n\n    def get(self):\n        \"\"\"Process an HTTP GET request.\"\"\"\n        region_id = self.request.args[\"region\"]\n        city_repository = CityRepository(self.db)\n        cities = city_repository.get_cities_by_region(region_id=region_id)\n\n        for city in cities:\n            city.longitude = float(city.longitude)\n            city.latitude = float(city.latitude)\n\n        return cities, HTTPStatus.OK\n"
  },
  {
    "path": "api/account/account_api/endpoints/country.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom http import HTTPStatus\n\nfrom selene.api import SeleneEndpoint\nfrom selene.data.geography import CountryRepository\n\n\nclass CountryEndpoint(SeleneEndpoint):\n    def get(self):\n        country_repository = CountryRepository(self.db)\n        countries = country_repository.get_countries()\n\n        return countries, HTTPStatus.OK\n"
  },
  {
    "path": "api/account/account_api/endpoints/defaults.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Account API endpoint for account defaults.\"\"\"\nfrom http import HTTPStatus\nfrom flask import json\nfrom schematics import Model\nfrom schematics.types import StringType\n\nfrom selene.api import SeleneEndpoint\nfrom selene.data.device import DefaultsRepository\nfrom selene.util.log import get_selene_logger\n\n_log = get_selene_logger(__name__)\n\n\nclass DefaultsRequest(Model):\n    \"\"\"Data model of the POST request.\"\"\"\n\n    city = StringType()\n    country = StringType()\n    region = StringType()\n    timezone = StringType()\n    voice = StringType()\n    wake_word = StringType()\n\n\nclass AccountDefaultsEndpoint(SeleneEndpoint):\n    \"\"\"Handle account default HTTP requests.\"\"\"\n\n    def __init__(self):\n        super().__init__()\n        self.defaults = None\n\n    def get(self):\n        \"\"\"Process a HTTP GET request.\"\"\"\n        self._authenticate()\n        self._get_defaults()\n        if self.defaults is None:\n            response_data = \"\"\n            response_code = HTTPStatus.NO_CONTENT\n        else:\n            response_data = self.defaults\n            response_code = HTTPStatus.OK\n\n        return response_data, response_code\n\n    def _get_defaults(self):\n        \"\"\"Get the account defaults from the database.\"\"\"\n        default_repository = DefaultsRepository(self.db, self.account.id)\n        self.defaults = default_repository.get_account_defaults()\n        if self.defaults is not None and self.defaults.wake_word.name is not None:\n            self.defaults.wake_word.name = self.defaults.wake_word.name.title()\n\n    def post(self):\n        \"\"\"Process a HTTP POST request.\"\"\"\n        self._authenticate()\n        defaults = self._validate_request()\n        self._upsert_defaults(defaults)\n\n        return \"\", HTTPStatus.NO_CONTENT\n\n    def patch(self):\n        \"\"\"Process an HTTP PATCH request.\"\"\"\n        self._authenticate()\n        defaults = self._validate_request()\n        self._upsert_defaults(defaults)\n\n        return \"\", HTTPStatus.NO_CONTENT\n\n    def _validate_request(self) -> dict:\n        \"\"\"Validate the data on the POST/PATCH request\"\"\"\n        request_data = json.loads(self.request.data)\n        defaults = DefaultsRequest()\n        defaults.city = request_data.get(\"city\")\n        defaults.country = request_data.get(\"country\")\n        defaults.region = request_data.get(\"region\")\n        defaults.timezone = request_data.get(\"timezone\")\n        defaults.voice = request_data[\"voice\"]\n        defaults.wake_word = request_data[\"wakeWord\"]\n        defaults.validate()\n\n        return defaults.to_native()\n\n    def _upsert_defaults(self, defaults: dict):\n        \"\"\"Apply the changes in the request to the database.\"\"\"\n        defaults_repository = DefaultsRepository(self.db, self.account.id)\n        wake_word_default = defaults.get(\"wake_word\")\n        if wake_word_default is not None:\n            defaults[\"wake_word\"] = defaults[\"wake_word\"].lower()\n        defaults_repository.upsert(defaults)\n"
  },
  {
    "path": "api/account/account_api/endpoints/device.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Account API endpoint for retrieving and maintaining device information.\"\"\"\nfrom dataclasses import asdict\nfrom datetime import datetime, timedelta\nfrom http import HTTPStatus\nfrom typing import List, Optional\n\nfrom flask import json\nfrom schematics import Model\nfrom schematics.exceptions import ValidationError\nfrom schematics.types import BooleanType, StringType\n\nfrom selene.api import SeleneEndpoint\nfrom selene.api.etag import ETagManager\nfrom selene.api.pantacor import get_pantacor_pending_deployment, update_pantacor_config\nfrom selene.api.public_endpoint import delete_device_login\nfrom selene.data.device import Device, DeviceRepository, Geography, GeographyRepository\nfrom selene.util.cache import (\n    DEVICE_LAST_CONTACT_KEY,\n    DEVICE_PAIRING_CODE_KEY,\n    DEVICE_PAIRING_TOKEN_KEY,\n    SeleneCache,\n)\nfrom selene.util.db import use_transaction\nfrom selene.util.log import get_selene_logger\n\nONE_DAY = 86400\nCONNECTED = \"Connected\"\nDISCONNECTED = \"Disconnected\"\nDORMANT = \"Dormant\"\n\n_log = get_selene_logger(__name__)\n\n\ndef validate_pairing_code(pairing_code):\n    \"\"\"Ensure the pairing code exists in the cache of valid pairing codes.\"\"\"\n    cache_key = DEVICE_PAIRING_CODE_KEY.format(pairing_code=pairing_code)\n    cache = SeleneCache()\n    pairing_cache = cache.get(cache_key)\n\n    if pairing_cache is None:\n        raise ValidationError(\"pairing code not found\")\n\n\nclass UpdateDeviceRequest(Model):\n    \"\"\"Schematic for a request to update a device.\"\"\"\n\n    city = StringType(required=True)\n    country = StringType(required=True)\n    name = StringType(required=True)\n    placement = StringType()\n    region = StringType(required=True)\n    timezone = StringType(required=True)\n    wake_word = StringType(required=True, deserialize_from=\"wakeWord\")\n    voice = StringType(required=True)\n    auto_update = BooleanType(deserialize_from=\"autoUpdate\")\n    ssh_public_key = StringType(deserialize_from=\"sshPublicKey\")\n    release_channel = StringType(deserialize_from=\"releaseChannel\")\n\n\nclass NewDeviceRequest(UpdateDeviceRequest):\n    \"\"\"Schematic for a request to add a device.\"\"\"\n\n    pairing_code = StringType(\n        required=True,\n        deserialize_from=\"pairingCode\",\n        validators=[validate_pairing_code],\n    )\n\n\nclass DeviceEndpoint(SeleneEndpoint):\n    \"\"\"Retrieve and maintain device information for the Account API\"\"\"\n\n    _device_repository = None\n\n    def __init__(self):\n        super().__init__()\n        self.devices = None\n        self.validated_request = None\n        self.cache = self.config[\"SELENE_CACHE\"]\n        self.etag_manager: ETagManager = ETagManager(self.cache, self.config)\n        self.pantacor_channels = dict(\n            myc200_dev_test=\"Development\",\n            myc200_beta_qa_test=\"Beta QA\",\n            myc200_beta=\"Beta\",\n            myc200_stable=\"Stable\",\n            myc200_lts=\"LTS\",\n        )\n\n    @property\n    def device_repository(self):\n        \"\"\"Lazily instantiate the device repository.\"\"\"\n        if self._device_repository is None:\n            self._device_repository = DeviceRepository(self.db)\n\n        return self._device_repository\n\n    def get(self, device_id: str):\n        \"\"\"Process an HTTP GET request.\"\"\"\n        self._authenticate()\n        if device_id is None:\n            response_data = self._get_devices()\n        else:\n            response_data = self._get_device(device_id)\n\n        return response_data, HTTPStatus.OK\n\n    def _get_devices(self) -> List[dict]:\n        \"\"\"Get a list of the devices belonging to the account in the request JWT\n\n        :return: list of devices to be returned to the UI.\n        \"\"\"\n        devices = self.device_repository.get_devices_by_account_id(self.account.id)\n        response_data = []\n        for device in devices:\n            response_device = self._format_device_for_response(device)\n            response_data.append(response_device)\n\n        return response_data\n\n    def _get_device(self, device_id: str) -> dict:\n        \"\"\"Get the device information for a specific device.\n\n        :param device_id: Identifier of the device to retrieve\n        :return: device information to return to the UI\n        \"\"\"\n        device = self.device_repository.get_device_by_id(device_id)\n        response_data = self._format_device_for_response(device)\n\n        return response_data\n\n    def _format_device_for_response(self, device: Device) -> dict:\n        \"\"\"Convert device object into a response object for this endpoint.\n\n        :param device: the device data retrieved from the database.\n        :return: device information formatted for the UI\n        \"\"\"\n        pantacor_config = self._format_pantacor_config(device.pantacor_config)\n        device_status, disconnect_duration = self._format_device_status(device)\n        formatted_device = asdict(device)\n        formatted_device[\"pantacor_config\"].update(pantacor_config)\n        formatted_device[\"wake_word\"].update(name=device.wake_word.name.title())\n        formatted_device.update(\n            status=device_status,\n            disconnect_duration=disconnect_duration,\n            voice=formatted_device.pop(\"text_to_speech\"),\n        )\n\n        return formatted_device\n\n    def _format_pantacor_config(self, config) -> dict[str, str]:\n        \"\"\"Converts Pantacor config values in the database into displayable values.\n\n        :param config: Pantacor config database values\n        :returns: Pantacor config displayable values\n        \"\"\"\n        formatted_config = dict(deployment_id=None)\n        manual_update = config.auto_update is not None and not config.auto_update\n        if manual_update:\n            formatted_config.update(\n                deployment_id=get_pantacor_pending_deployment(config.pantacor_id)\n            )\n        if config.release_channel is not None:\n            formatted_config.update(\n                release_channel=self.pantacor_channels.get(config.release_channel)\n            )\n\n        return formatted_config\n\n    def _format_device_status(self, device: Device) -> tuple[str, Optional[str]]:\n        \"\"\"Determines the status of the device being returned.\n\n        :param device: The device to determine the status of\n        :return: status of the device and the duration of disconnect (if applicable)\n        \"\"\"\n        last_contact_age = self._get_device_last_contact(device)\n        device_status = self._determine_device_status(last_contact_age)\n        if device_status == DISCONNECTED:\n            disconnect_duration = self._determine_disconnect_duration(last_contact_age)\n        else:\n            disconnect_duration = None\n\n        return device_status, disconnect_duration\n\n    def _get_device_last_contact(self, device: Device) -> timedelta:\n        \"\"\"Get the last time the device contacted the backend.\n\n        The timestamp returned by this method will be used to determine if a\n        device is active or not.\n\n        The device table has a last contacted column but it is only updated\n        daily via batch script.  The real-time values are kept in Redis.\n        If the Redis query returns nothing, the device hasn't contacted the\n        backend yet.  This could be because it was just activated. Give the\n        device a couple of minutes to make that first call to the backend.\n\n        :param device: the device data retrieved from the database.\n        :return: the timestamp the device was last seen by Selene\n        \"\"\"\n        last_contact_ts = self.cache.get(\n            DEVICE_LAST_CONTACT_KEY.format(device_id=device.id)\n        )\n        if last_contact_ts is None:\n            if device.last_contact_ts is None:\n                last_contact_age = datetime.utcnow() - device.add_ts\n            else:\n                last_contact_age = datetime.utcnow() - device.last_contact_ts\n        else:\n            last_contact_ts = last_contact_ts.decode()\n            last_contact_ts = datetime.strptime(last_contact_ts, \"%Y-%m-%d %H:%M:%S.%f\")\n            last_contact_age = datetime.utcnow() - last_contact_ts\n\n        return last_contact_age\n\n    @staticmethod\n    def _determine_device_status(last_contact_age: timedelta) -> str:\n        \"\"\"Derive device status from the last time device contacted servers.\n\n        :param last_contact_age: amount of time since the device was last seen\n        :return: the status of the device\n        \"\"\"\n        if last_contact_age <= timedelta(seconds=120):\n            device_status = CONNECTED\n        elif timedelta(seconds=120) < last_contact_age < timedelta(days=30):\n            device_status = DISCONNECTED\n        else:\n            device_status = DORMANT\n\n        return device_status\n\n    @staticmethod\n    def _determine_disconnect_duration(last_contact_age: timedelta) -> str:\n        \"\"\"Derive device status from the last time device contacted servers.\n\n        :param last_contact_age: amount of time since the device was last seen\n        :return human readable amount of time since the device was last seen\n        \"\"\"\n        disconnect_duration = \"unknown\"\n        days, _ = divmod(last_contact_age, timedelta(days=1))\n        if days:\n            disconnect_duration = str(days) + \" days\"\n        else:\n            hours, remaining = divmod(last_contact_age, timedelta(hours=1))\n            if hours:\n                disconnect_duration = str(hours) + \" hours\"\n            else:\n                minutes, _ = divmod(remaining, timedelta(minutes=1))\n                if minutes:\n                    disconnect_duration = str(minutes) + \" minutes\"\n\n        return disconnect_duration\n\n    def post(self):\n        \"\"\"Handle a HTTP POST request.\"\"\"\n        self._authenticate()\n        self._validate_request()\n        self._pair_device()\n\n        return \"\", HTTPStatus.NO_CONTENT\n\n    @use_transaction\n    def _pair_device(self):\n        \"\"\"Add the paired device to the database.\"\"\"\n        cache_key = DEVICE_PAIRING_CODE_KEY.format(\n            pairing_code=self.validated_request[\"pairing_code\"]\n        )\n        pairing_data = self._get_pairing_data(cache_key)\n        device_id = self._add_device()\n        pairing_data[\"uuid\"] = device_id\n        self.cache.delete(cache_key)\n        self._build_pairing_token(pairing_data)\n\n    def _get_pairing_data(self, cache_key) -> dict:\n        \"\"\"Checking if there's one pairing session for the pairing code.\n\n        :return: the pairing code information from the Redis database\n        \"\"\"\n        pairing_cache = self.cache.get(cache_key)\n        pairing_data = json.loads(pairing_cache)\n\n        return pairing_data\n\n    def _add_device(self) -> str:\n        \"\"\"Creates a device and associate it to a pairing session.\n\n        :return: the database identifier of the new device\n        \"\"\"\n        self._ensure_geography_exists()\n        device_id = self.device_repository.add(self.account.id, self.validated_request)\n\n        return device_id\n\n    def _build_pairing_token(self, pairing_data: dict):\n        \"\"\"Add a pairing token to the Redis database.\n\n        :param pairing_data: the pairing data retrieved from Redis\n        \"\"\"\n        self.cache.set_with_expiration(\n            key=DEVICE_PAIRING_TOKEN_KEY.format(pairing_token=pairing_data[\"token\"]),\n            value=json.dumps(pairing_data),\n            expiration=ONE_DAY,\n        )\n\n    def delete(self, device_id: str):\n        \"\"\"Handle an HTTP DELETE request.\n\n        :param device_id: database identifier of a device\n        \"\"\"\n        self._authenticate()\n        self._delete_device(device_id)\n\n        return \"\", HTTPStatus.NO_CONTENT\n\n    def _delete_device(self, device_id: str):\n        \"\"\"Delete the specified device from the database.\n\n        There are other tables related to the device table in the database.  This\n        method assumes that the child tables contain \"delete cascade\" clauses.\n\n        :param device_id: database identifier of a device\n        \"\"\"\n        self.device_repository.remove(device_id)\n        delete_device_login(device_id, self.cache)\n\n    def patch(self, device_id: str):\n        \"\"\"Handle a HTTP PATCH request.\n\n        :param device_id: database identifier of a device\n        \"\"\"\n        self._authenticate()\n        self._validate_request()\n        self._update_device(device_id)\n        self.etag_manager.expire_device_etag_by_device_id(device_id)\n        self.etag_manager.expire_device_location_etag_by_device_id(device_id)\n        self.etag_manager.expire_device_setting_etag_by_device_id(device_id)\n\n        return \"\", HTTPStatus.NO_CONTENT\n\n    def _validate_request(self):\n        \"\"\"Validate the contents of the HTTP POST request.\"\"\"\n        if self.request.method == \"POST\":\n            device = NewDeviceRequest(self.request.json)\n        else:\n            device = UpdateDeviceRequest(self.request.json)\n        device.validate()\n        self.validated_request = device.to_native()\n        self.validated_request.update(\n            wake_word=self.validated_request[\"wake_word\"].lower()\n        )\n        if self.validated_request[\"release_channel\"] is not None:\n            self.validated_request.update(\n                release_channel=self.validated_request[\"release_channel\"].lower()\n            )\n\n    def _ensure_geography_exists(self):\n        \"\"\"If the requested geography is not linked to the account, add it.\n\n        :return: database identifier for the geography\n        \"\"\"\n        geography = Geography(\n            city=self.validated_request.pop(\"city\"),\n            country=self.validated_request.pop(\"country\"),\n            region=self.validated_request.pop(\"region\"),\n            time_zone=self.validated_request.pop(\"timezone\"),\n        )\n        geography_repository = GeographyRepository(self.db, self.account.id)\n        geography_id = geography_repository.get_geography_id(geography)\n        if geography_id is None:\n            geography_id = geography_repository.add(geography)\n\n        self.validated_request.update(geography_id=geography_id)\n\n    @use_transaction\n    def _update_device(self, device_id: str):\n        \"\"\"Update the device attributes on the database based on the request.\n\n        If the device's continuous delivery is managed by Pantacor, attempt the\n        Pantacor API calls first.  That way, if they fail, the database updates won't\n        happen and we won't get stuck in a half-updated state.\n\n        :param device_id: database identifier of a device\n        \"\"\"\n        device = self.device_repository.get_device_by_id(device_id)\n        if device.pantacor_config.pantacor_id is not None:\n            self._update_pantacor_config(device)\n        self._ensure_geography_exists()\n        self.device_repository.update_device_from_account(\n            self.account.id, device_id, self.validated_request\n        )\n\n    def _update_pantacor_config(self, device: Device):\n        \"\"\"Update the Pantacor configuration on the database based on the request.\n\n        :param device: data object representing a Mycroft-enabled device\n        \"\"\"\n        new_pantacor_config = dict(\n            auto_update=self.validated_request.pop(\"auto_update\"),\n            release_channel=self.validated_request.pop(\"release_channel\"),\n            ssh_public_key=self.validated_request.pop(\"ssh_public_key\"),\n        )\n        pantacor_channel_name = self._convert_release_channel(\n            new_pantacor_config[\"release_channel\"]\n        )\n        new_pantacor_config.update(release_channel=pantacor_channel_name)\n        old_pantacor_config = asdict(device.pantacor_config)\n        update_pantacor_config(old_pantacor_config, new_pantacor_config)\n        self.device_repository.update_pantacor_config(device.id, new_pantacor_config)\n\n    def _convert_release_channel(self, release_channel: str) -> str:\n        \"\"\"Converts the channel sent in the request to one recognized by Pantacor.\n\n        :param release_channel: the value of the release channel in the request\n        :returns: the release channel as recognized by Pantacor\n        \"\"\"\n        pantacor_channel_name = None\n        for channel_name, channel_display in self.pantacor_channels.items():\n            if channel_display.lower() == release_channel:\n                pantacor_channel_name = channel_name\n\n        _log.info(\"pantacor channel name: %s\", pantacor_channel_name)\n        return pantacor_channel_name\n"
  },
  {
    "path": "api/account/account_api/endpoints/device_count.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom http import HTTPStatus\nfrom selene.api import SeleneEndpoint\nfrom selene.data.device import DeviceRepository\n\n\nclass DeviceCountEndpoint(SeleneEndpoint):\n    def get(self):\n        self._authenticate()\n        device_count = self._get_devices()\n\n        return dict(deviceCount=device_count), HTTPStatus.OK\n\n    def _get_devices(self):\n        device_repository = DeviceRepository(self.db)\n        device_count = device_repository.get_account_device_count(self.account.id)\n\n        return device_count\n"
  },
  {
    "path": "api/account/account_api/endpoints/geography.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom http import HTTPStatus\n\nfrom selene.api import SeleneEndpoint\nfrom selene.data.device import GeographyRepository\n\n\nclass GeographyEndpoint(SeleneEndpoint):\n    def get(self):\n        self._authenticate()\n        response_data = self._build_response_data()\n\n        return response_data, HTTPStatus.OK\n\n    def _build_response_data(self):\n        geography_repository = GeographyRepository(self.db, self.account.id)\n        geographies = geography_repository.get_account_geographies()\n\n        response_data = []\n        for geography in geographies:\n            response_data.append(\n                dict(id=geography.id, name=geography.country, user_defined=True)\n            )\n\n        return response_data\n"
  },
  {
    "path": "api/account/account_api/endpoints/membership.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom http import HTTPStatus\n\nfrom selene.api import SeleneEndpoint\nfrom selene.data.account import MembershipRepository\n\n\nclass MembershipEndpoint(SeleneEndpoint):\n    def get(self):\n        membership_repository = MembershipRepository(self.db)\n        membership_types = membership_repository.get_membership_types()\n        for membership_type in membership_types:\n            membership_type.rate = float(membership_type.rate)\n\n        return membership_types, HTTPStatus.OK\n"
  },
  {
    "path": "api/account/account_api/endpoints/pairing_code.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom http import HTTPStatus\n\nfrom selene.api import SeleneEndpoint\n\n\nclass PairingCodeEndpoint(SeleneEndpoint):\n    def __init__(self):\n        super(PairingCodeEndpoint, self).__init__()\n        self.cache = self.config[\"SELENE_CACHE\"]\n\n    def get(self, pairing_code):\n        self._authenticate()\n        pairing_code_is_valid = self._get_pairing_data(pairing_code)\n\n        return dict(isValid=pairing_code_is_valid), HTTPStatus.OK\n\n    def _get_pairing_data(self, pairing_code: str) -> bool:\n        \"\"\"Checking if there's one pairing session for the pairing code.\"\"\"\n        pairing_code_is_valid = False\n        cache_key = \"pairing.code:\" + pairing_code\n        pairing_cache = self.cache.get(cache_key)\n        if pairing_cache is not None:\n            pairing_code_is_valid = True\n\n        return pairing_code_is_valid\n"
  },
  {
    "path": "api/account/account_api/endpoints/preferences.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom dataclasses import asdict\nfrom http import HTTPStatus\n\nfrom schematics import Model\nfrom schematics.types import StringType\n\nfrom selene.api import SeleneEndpoint\nfrom selene.api.etag import ETagManager\nfrom selene.data.device import AccountPreferences, PreferenceRepository\n\n\nclass PreferencesRequest(Model):\n    date_format = StringType(required=True, choices=[\"DD/MM/YYYY\", \"MM/DD/YYYY\"])\n    measurement_system = StringType(required=True, choices=[\"Imperial\", \"Metric\"])\n    time_format = StringType(required=True, choices=[\"12 Hour\", \"24 Hour\"])\n\n\nclass PreferencesEndpoint(SeleneEndpoint):\n    def __init__(self):\n        super(PreferencesEndpoint, self).__init__()\n        self.preferences = None\n        self.cache = self.config[\"SELENE_CACHE\"]\n        self.etag_manager: ETagManager = ETagManager(self.cache, self.config)\n\n    def get(self):\n        self._authenticate()\n        self._get_preferences()\n        if self.preferences is None:\n            response_data = \"\"\n            response_code = HTTPStatus.NO_CONTENT\n        else:\n            response_data = asdict(self.preferences)\n            response_code = HTTPStatus.OK\n\n        return response_data, response_code\n\n    def _get_preferences(self):\n        preference_repository = PreferenceRepository(self.db, self.account.id)\n        self.preferences = preference_repository.get_account_preferences()\n\n    def post(self):\n        self._authenticate()\n        self._validate_request()\n        self._upsert_preferences()\n        self.etag_manager.expire_device_setting_etag_by_account_id(self.account.id)\n        return \"\", HTTPStatus.NO_CONTENT\n\n    def patch(self):\n        self._authenticate()\n        self._validate_request()\n        self._upsert_preferences()\n        self.etag_manager.expire_device_setting_etag_by_account_id(self.account.id)\n        return \"\", HTTPStatus.NO_CONTENT\n\n    def _validate_request(self):\n        self.preferences = PreferencesRequest()\n        self.preferences.date_format = self.request.json[\"dateFormat\"]\n        self.preferences.measurement_system = self.request.json[\"measurementSystem\"]\n        self.preferences.time_format = self.request.json[\"timeFormat\"]\n        self.preferences.validate()\n\n    def _upsert_preferences(self):\n        preferences_repository = PreferenceRepository(self.db, self.account.id)\n        preferences = AccountPreferences(**self.preferences.to_native())\n        preferences_repository.upsert(preferences)\n"
  },
  {
    "path": "api/account/account_api/endpoints/region.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom http import HTTPStatus\n\nfrom selene.api import SeleneEndpoint\nfrom selene.data.geography import RegionRepository\n\n\nclass RegionEndpoint(SeleneEndpoint):\n    def get(self):\n        country_id = self.request.args[\"country\"]\n        region_repository = RegionRepository(self.db)\n        regions = region_repository.get_regions_by_country(country_id)\n\n        return regions, HTTPStatus.OK\n"
  },
  {
    "path": "api/account/account_api/endpoints/skill_oauth.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nimport os\n\nimport requests\n\nfrom selene.api import SeleneEndpoint\n\n\nclass SkillOauthEndpoint(SeleneEndpoint):\n    def __init__(self):\n        super(SkillOauthEndpoint, self).__init__()\n        self.oauth_base_url = os.environ[\"OAUTH_BASE_URL\"]\n\n    def get(self, oauth_id):\n        self._authenticate()\n        return self._get_oauth_url(oauth_id)\n\n    def _get_oauth_url(self, oauth_id):\n        url = \"{base_url}/auth/{oauth_id}/auth_url?uuid={account_id}\".format(\n            base_url=self.oauth_base_url, oauth_id=oauth_id, account_id=self.account.id\n        )\n        response = requests.get(url)\n        return response.text, response.status_code\n"
  },
  {
    "path": "api/account/account_api/endpoints/skill_settings.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\n\"\"\"Endpoint to return the skill settings for a given skill family.\"\"\"\nfrom http import HTTPStatus\n\nfrom flask import json, Response\n\nfrom selene.api import SeleneEndpoint\nfrom selene.api.etag import ETagManager\nfrom selene.data.skill import SkillSettingRepository, AccountSkillSetting\n\n\nclass SkillSettingsEndpoint(SeleneEndpoint):\n    _setting_repository = None\n\n    def __init__(self):\n        super(SkillSettingsEndpoint, self).__init__()\n        self.account_skills = None\n        self.family_settings = None\n        self.etag_manager: ETagManager = ETagManager(\n            self.config[\"SELENE_CACHE\"], self.config\n        )\n\n    @property\n    def setting_repository(self):\n        \"\"\"Only instantiate the SkillSettingsRepository if needed.\"\"\"\n        if self._setting_repository is None:\n            self._setting_repository = SkillSettingRepository(self.db)\n\n        return self._setting_repository\n\n    def get(self, skill_family_name):\n        \"\"\"Process an HTTP GET request\"\"\"\n        self._authenticate()\n        self.family_settings = self.setting_repository.get_family_settings(\n            self.account.id, skill_family_name\n        )\n        self._parse_selection_options()\n        response_data = self._build_response_data()\n\n        # The response object is manually built here to bypass the\n        # camel case conversion so settings are displayed correctly\n        return Response(\n            response=json.dumps(response_data),\n            status=HTTPStatus.OK,\n            content_type=\"application/json\",\n        )\n\n    def _parse_selection_options(self):\n        \"\"\"Parse the dropdown options string into a list of options.\n\n        Drop-down options are defined in a skill's settingsmeta.json as such:\n            <label 1>|<value 1>;<label 2>|<value 2>;<label 3>|<value 3>...etc\n        Convert this string into a dictionary where the key is the label and\n        the value is the value.\n        \"\"\"\n        for skill_settings in self.family_settings:\n            if skill_settings.settings_definition is not None:\n                for section in skill_settings.settings_definition[\"sections\"]:\n                    for field in section[\"fields\"]:\n                        if field[\"type\"] == \"select\":\n                            parsed_options = []\n                            for option in field[\"options\"].split(\";\"):\n                                option_display, option_value = option.split(\"|\")\n                                parsed_options.append(\n                                    dict(display=option_display, value=option_value)\n                                )\n                            field[\"options\"] = parsed_options\n\n    def _build_response_data(self):\n        \"\"\"Build the object to return to the UI.\"\"\"\n        response_data = []\n        for skill_settings in self.family_settings:\n            # The UI will throw an error if settings display is null due to how\n            # the skill settings data structures are defined.\n            if skill_settings.settings_definition is None:\n                skill_settings.settings_definition = dict(sections=[])\n            response_skill = dict(\n                settingsDisplay=skill_settings.settings_definition,\n                settingsValues=skill_settings.settings_values,\n                deviceNames=skill_settings.device_names,\n            )\n            response_data.append(response_skill)\n\n        return response_data\n\n    def put(self, skill_family_name):\n        \"\"\"Process a HTTP PUT request\"\"\"\n        self._authenticate()\n        self._update_settings_values()\n\n        return \"\", HTTPStatus.OK\n\n    def _update_settings_values(self):\n        \"\"\"Update the value of the settings column on the device_skill table,\"\"\"\n        for new_skill_settings in self.request.json[\"skillSettings\"]:\n            account_skill_settings = AccountSkillSetting(\n                settings_definition=new_skill_settings[\"settingsDisplay\"],\n                settings_values=new_skill_settings[\"settingsValues\"],\n                device_names=new_skill_settings[\"deviceNames\"],\n            )\n            self.setting_repository.update_skill_settings(\n                self.account.id, account_skill_settings, self.request.json[\"skillIds\"]\n            )\n        self.etag_manager.expire_skill_etag_by_account_id(self.account.id)\n"
  },
  {
    "path": "api/account/account_api/endpoints/skills.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom http import HTTPStatus\n\nfrom selene.api import SeleneEndpoint\nfrom selene.data.skill import SkillRepository\n\n\nclass SkillsEndpoint(SeleneEndpoint):\n    def get(self):\n        self._authenticate()\n        response_data = self._build_response_data()\n\n        return response_data, HTTPStatus.OK\n\n    def _build_response_data(self):\n        skill_repository = SkillRepository(self.db)\n        skills = skill_repository.get_skills_for_account(self.account.id)\n\n        response_data = {}\n        for skill in skills:\n            try:\n                response_skill = response_data[skill.family_name]\n            except KeyError:\n                response_data[skill.family_name] = dict(\n                    family_name=skill.family_name,\n                    market_id=skill.market_id,\n                    name=skill.display_name or skill.family_name,\n                    has_settings=skill.has_settings,\n                    skill_ids=skill.skill_ids,\n                )\n            else:\n                response_skill[\"skill_ids\"].extend(skill.skill_ids)\n                if response_skill[\"market_id\"] is None:\n                    response_skill[\"market_id\"] = skill.market_id\n\n        return sorted(response_data.values(), key=lambda x: x[\"name\"])\n"
  },
  {
    "path": "api/account/account_api/endpoints/software_update.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2021 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Endpoint to process a user's request to apply an software update on their device.\"\"\"\nfrom http import HTTPStatus\n\nfrom schematics import Model\nfrom schematics.types import StringType\n\nfrom selene.api import SeleneEndpoint\nfrom selene.api.pantacor import apply_pantacor_update\n\n\nclass SoftwareUpdateRequest(Model):\n    \"\"\"Schematic for a request to update software on a device.\"\"\"\n\n    deployment_id = StringType(required=True)\n\n\nclass SoftwareUpdateEndpoint(SeleneEndpoint):\n    \"\"\"Send a request to Pantacor to update a device.\"\"\"\n\n    def patch(self):\n        \"\"\"Handle a HTTP PATCH request.\"\"\"\n        self._authenticate()\n        self._validate_request()\n        apply_pantacor_update(self.request.json[\"deploymentId\"])\n        return \"\", HTTPStatus.NO_CONTENT\n\n    def _validate_request(self):\n        \"\"\"Validate the contents of the PATCH request.\"\"\"\n        request_validator = SoftwareUpdateRequest()\n        request_validator.deployment_id = self.request.json[\"deploymentId\"]\n        request_validator.validate()\n"
  },
  {
    "path": "api/account/account_api/endpoints/ssh_key_validator.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2021 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Endpoint to validate the contents of the SSH public key.\"\"\"\nfrom urllib.parse import unquote_plus\nfrom http import HTTPStatus\n\nfrom selene.api import SeleneEndpoint\nfrom selene.util.ssh import validate_rsa_public_key\n\n\nclass SshKeyValidatorEndpoint(SeleneEndpoint):\n    \"\"\"Validate the contents of an SSH public key.\"\"\"\n\n    def get(self):\n        \"\"\"Handle and HTTP GET request.\n\n        The SSH key is encoded in the UI because it can contain characters that are\n        reserved for URL delimiting.\n        \"\"\"\n        self._authenticate()\n        decoded_ssh_key = unquote_plus(self.request.args[\"key\"])\n        ssh_key_is_valid = validate_rsa_public_key(decoded_ssh_key)\n\n        return dict(isValid=ssh_key_is_valid), HTTPStatus.OK\n"
  },
  {
    "path": "api/account/account_api/endpoints/timezone.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom http import HTTPStatus\n\nfrom selene.api import SeleneEndpoint\nfrom selene.data.geography import TimezoneRepository\n\n\nclass TimezoneEndpoint(SeleneEndpoint):\n    def get(self):\n        country_id = self.request.args[\"country\"]\n        timezone_repository = TimezoneRepository(self.db)\n        timezones = timezone_repository.get_timezones_by_country(country_id)\n\n        for timezone in timezones:\n            timezone.dst_offset = float(timezone.dst_offset)\n            timezone.gmt_offset = float(timezone.gmt_offset)\n\n        return timezones, HTTPStatus.OK\n"
  },
  {
    "path": "api/account/account_api/endpoints/verify_email_address.py",
    "content": "#  Mycroft Server - Backend\n#  Copyright (c) 2022 Mycroft AI Inc\n#  SPDX-License-Identifier: \tAGPL-3.0-or-later\n#  #\n#  This file is part of the Mycroft Server.\n#  #\n#  The Mycroft Server is free software: you can redistribute it and/or\n#  modify it under the terms of the GNU Affero General Public License as\n#  published by the Free Software Foundation, either version 3 of the\n#  License, or (at your option) any later version.\n#  #\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n#  GNU Affero General Public License for more details.\n#  #\n#  You should have received a copy of the GNU Affero General Public License\n#  along with this program. If not, see <https://www.gnu.org/licenses/>.\n#\n\"\"\"Account API endpoint to be called when a user is verifying their email address.\"\"\"\n\nfrom binascii import a2b_base64\nfrom http import HTTPStatus\n\nfrom selene.api import SeleneEndpoint, APIError\nfrom selene.data.account import AccountRepository\nfrom selene.util.email import validate_email_address\n\n\nclass VerifyEmailAddressEndpoint(SeleneEndpoint):\n    \"\"\"Updates a user's email address after they have verified it.\"\"\"\n\n    def put(self):\n        \"\"\"Processes an HTTP PUT request to update the email address.\"\"\"\n        self._authenticate()\n        email_address = self._validate_email_address()\n        self._update_account(email_address)\n\n        return \"\", HTTPStatus.NO_CONTENT\n\n    def _validate_email_address(self) -> str:\n        \"\"\"Validates that the email address is well formatted and reachable.\n\n        By this point in the email address change process, this validation has\n        already been done.  It is done again here as a protection against malicious\n        calls to this endpoint.\n\n        :returns: a normalized version of the email address in the request\n        :raises: an API error if the email address validation fails\n        \"\"\"\n        encoded_email_address = self.request.json[\"token\"]\n        email_address = a2b_base64(encoded_email_address).decode()\n        normalized_email_address, error = validate_email_address(email_address)\n        if error is not None:\n            raise APIError(f\"invalid email address: {error}\")\n\n        return normalized_email_address\n\n    def _update_account(self, email_address: str):\n        \"\"\"Updates the email address on the DB now that it has been verified.\n\n        :param email_address: the email address to apply to the account.account table\n        \"\"\"\n        account_repo = AccountRepository(self.db)\n        account_repo.update_email_address(self.account.id, email_address)\n"
  },
  {
    "path": "api/account/account_api/endpoints/voice_endpoint.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom http import HTTPStatus\n\nfrom selene.api import SeleneEndpoint\nfrom selene.data.device import TextToSpeechRepository\n\n\nclass VoiceEndpoint(SeleneEndpoint):\n    def get(self):\n        tts_repository = TextToSpeechRepository(self.db)\n        voices = tts_repository.get_voices()\n\n        response_data = []\n        for voice in voices:\n            response_data.append(\n                dict(id=voice.id, name=voice.display_name, user_defined=False)\n            )\n\n        return response_data, HTTPStatus.OK\n"
  },
  {
    "path": "api/account/account_api/endpoints/wake_word_endpoint.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/\n\"\"\"Account API endpoint to return a list of available wake words.\"\"\"\n\nfrom http import HTTPStatus\n\nfrom selene.api import SeleneEndpoint\nfrom selene.data.wake_word import WakeWordRepository\n\n\nclass WakeWordEndpoint(SeleneEndpoint):\n    \"\"\"Return a list of available wake words\"\"\"\n\n    def get(self):\n        \"\"\"Handle a HTTP GET request.\"\"\"\n        self._authenticate()\n        response_data = self._build_response_data()\n\n        return response_data, HTTPStatus.OK\n\n    def _build_response_data(self):\n        \"\"\"Build the response to the HTTP GET request.\"\"\"\n        response_data = []\n        wake_word_repository = WakeWordRepository(self.db)\n        wake_words = wake_word_repository.get_wake_words_for_web()\n        for wake_word in wake_words:\n            response_data.append(\n                dict(id=wake_word.id, name=wake_word.name, user_defined=False,)\n            )\n\n        return response_data\n"
  },
  {
    "path": "api/account/pyproject.toml",
    "content": "[tool.poetry]\nname = \"account\"\nversion = \"0.1.0\"\ndescription = \"API to support account.mycroft.ai\"\nauthors = [\"Chris Veilleux <veilleux.chris@gmail.com>\"]\nlicense = \"GNU AGPL 3.0\"\n\n[tool.poetry.dependencies]\npython = \"^3.9\"\n# Version 1.0 of flask required because later versions do not allow lists to be passed as API repsonses.  The Google\n# STT endpoint passes a list of transcriptions to the device.  Changing this to return a dictionary would break the\n# API's V1 contract with Mycroft Core.\n#\n# To make flask 1.0 work, older versions of itsdangerous, jinja2, markupsafe and werkszeug are required.\nflask = \"<1.1\"\nitsdangerous = \"<=2.0.1\"\njinja2 = \"<=2.10.1\"\nmarkupsafe = \"<=2.0.1\"\nschematics = \"*\"\nstripe = \"*\"\nselene = {path = \"./../../shared\",  develop = true}\nuwsgi = \"*\"\nwerkzeug = \"<=2.0.3\"\n\n[tool.poetry.dev-dependencies]\nallure-behave = \"*\"\nbehave = \"*\"\npyhamcrest = \"*\"\npylint = \"*\"\n\n[build-system]\nrequires = [\"poetry-core>=1.0.0\"]\nbuild-backend = \"poetry.core.masonry.api\"\n"
  },
  {
    "path": "api/account/tests/features/add_device.feature",
    "content": "Feature: Account API -- Pair a device\n  Test the device add endpoint\n\n  Scenario: Add a device\n    Given an account\n    And the account is authenticated\n    And a device pairing code\n    When an API request is sent to add a device\n    Then the request will be successful\n    And the device is added to the database\n    And the pairing code is removed from cache\n    And the pairing token is added to cache\n"
  },
  {
    "path": "api/account/tests/features/agreements.feature",
    "content": "Feature: Account API -- Get the active agreements\n  We need to be able to retrieve an agreement and display it on the web app.\n\n  Scenario: Multiple versions of an agreement exist\n     When API request for Privacy Policy is made\n     Then the request will be successful\n     And Privacy Policy version 999 is returned\n\n\n  Scenario: Retrieve Terms of Use\n     When API request for Terms of Use is made\n     Then the request will be successful\n     And Terms of Use version 999 is returned\n"
  },
  {
    "path": "api/account/tests/features/authentication.feature",
    "content": "Feature: Account API - Authentication with JWTs\n  Some of the API endpoints contain information that is specific to a user.\n  To ensure that information is seen only by the user that owns it, we will\n  use a login mechanism coupled with authentication tokens to securely identify\n  a user.\n\n  The code executed in these tests is embedded in every view call. These tests\n  apply to any endpoint that requires authentication.  These tests are meant to\n  be the only place authentication logic needs to be tested.\n\n  Scenario: Request for user data includes valid access token\n    Given an account with a valid access token\n    When a user requests their profile\n    Then the request will be successful\n    And the authentication tokens will remain unchanged\n\n  Scenario: Access token expired\n    Given an account with an expired access token\n    When a user requests their profile\n    Then the request will be successful\n    And the authentication tokens will be refreshed\n\n  Scenario: Access token missing but refresh token valid\n    Given an account with a refresh token but no access token\n    When a user requests their profile\n    Then the request will be successful\n    And the authentication tokens will be refreshed\n\n  Scenario: Both access and refresh tokens expired\n    Given an account with expired access and refresh tokens\n    When a user requests their profile\n    Then the request will fail with an unauthorized error\n"
  },
  {
    "path": "api/account/tests/features/environment.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Setup the environment for the account API behavioral tests.\"\"\"\nfrom datetime import datetime\n\nfrom behave import fixture, use_fixture\n\nfrom account_api.api import acct\nfrom selene.data.metric import AccountActivityRepository\nfrom selene.testing.account import add_account, remove_account\nfrom selene.testing.account_geography import add_account_geography\nfrom selene.testing.agreement import add_agreements, remove_agreements\nfrom selene.testing.tagging import remove_wake_word_files\nfrom selene.testing.text_to_speech import add_text_to_speech, remove_text_to_speech\nfrom selene.testing.wake_word import add_wake_word, remove_wake_word\nfrom selene.util.cache import SeleneCache\nfrom selene.util.db import connect_to_db\n\n\n@fixture\ndef acct_api_client(context):\n    \"\"\"Add a test fixture representing the account API.\"\"\"\n    acct.testing = True\n    context.client_config = acct.config\n    context.client = acct.test_client()\n\n    yield context.client\n\n\ndef before_all(context):\n    \"\"\"Setup static test data before any tests run.\n\n    This is data that does not change from test to test so it only needs to be setup\n    and torn down once.\n    \"\"\"\n    use_fixture(acct_api_client, context)\n    context.db = connect_to_db(context.client_config[\"DB_CONNECTION_CONFIG\"])\n    add_agreements(context)\n    context.wake_word = add_wake_word(context.db)\n\n\ndef after_all(context):\n    \"\"\"Clean up static test data after all tests have run.\n\n    This is data that does not change from test to test so it only needs to be setup\n    and torn down once.\n    \"\"\"\n    remove_wake_word(context.db, context.wake_word)\n    remove_agreements(\n        context.db, [context.privacy_policy, context.terms_of_use, context.open_dataset]\n    )\n\n\ndef before_scenario(context, _):\n    \"\"\"Setup data that could change during a scenario so each test starts clean.\"\"\"\n    account = add_account(context.db)\n    context.accounts = dict(foo=account)\n    context.geography_id = add_account_geography(context.db, account)\n    context.voice = add_text_to_speech(context.db)\n    acct_activity_repository = AccountActivityRepository(context.db)\n    context.account_activity = acct_activity_repository.get_activity_by_date(\n        datetime.utcnow().date()\n    )\n\n\ndef after_scenario(context, _):\n    \"\"\"Cleanup data that could change during a scenario so next scenario starts fresh.\n\n    The database is setup with cascading deletes that take care of cleaning up[\n    referential integrity for us.  All we have to do here is delete the account\n    and all rows on all tables related to that account will also be deleted.\n    \"\"\"\n    for account in context.accounts.values():\n        remove_account(context.db, account)\n    remove_text_to_speech(context.db, context.voice)\n    _clean_cache()\n    if hasattr(context, \"wake_word_file\"):\n        remove_wake_word_files(context.db, context.wake_word_file)\n\n\ndef _clean_cache():\n    \"\"\"Remove testing data from the Redis database.\"\"\"\n    cache = SeleneCache()\n    cache.delete(\"pairing.token:this is a token\")\n"
  },
  {
    "path": "api/account/tests/features/pantacor_update.feature",
    "content": "Feature: Account API -- Interact with the Pantacor API\n  Devices that use Pantacor to manage the software running on them have a set of\n  additional attributes that can be updated using the Pantacor API\n\n  Scenario: Indicate to user that software update is available\n    Given an account\n    And the account is authenticated\n    And a device using Pantacor for continuous delivery\n    And the device has pending deployment from Pantacor\n    When the user requests to view the device\n    Then the request will be successful\n    And the response contains the pending deployment ID\n\n  Scenario: User elects to apply a software update\n    Given an account\n    And the account is authenticated\n    And a device using Pantacor for continuous delivery\n    And the device has pending deployment from Pantacor\n    When the user selects to apply the update\n    Then the request will be successful\n\n  Scenario: User enters a valid SSH key\n    Given an account\n    And the account is authenticated\n    And a device using Pantacor for continuous delivery\n    When the user enters a well formed RSA SSH key\n    Then the request will be successful\n    And the response indicates that the SSH key is properly formatted\n\n  Scenario: User enters an invalid SSH key\n    Given an account\n    And the account is authenticated\n    And a device using Pantacor for continuous delivery\n    When the user enters a malformed RSA SSH key\n    Then the request will be successful\n    And the response indicates that the SSH key is malformed\n"
  },
  {
    "path": "api/account/tests/features/profile.feature",
    "content": "Feature: Account API -- Manage account profiles\n  Test the ability of the account API to retrieve and manage a user's profile\n  settings.\n\n  Scenario: Retrieve authenticated user's account\n    Given an account with a monthly membership\n    When a user requests their profile\n    Then the request will be successful\n    And user profile is returned\n\n  Scenario: user with free account opts into a membership\n    Given an account without a membership\n    And the account is authenticated\n    When a monthly membership is added\n    Then the request will be successful\n    And the account should have a monthly membership\n    And the new member will be reflected in the account activity metrics\n\n  Scenario: user opts out monthly membership\n    Given an account with a monthly membership\n    When the membership is cancelled\n    Then the request will be successful\n    And the account should have no membership\n    And the deleted member will be reflected in the account activity metrics\n\n  Scenario: user changes from a monthly membership to yearly membership\n    Given an account with a monthly membership\n    When the membership is changed to yearly\n    Then the request will be successful\n    And the account should have a yearly membership\n\n  Scenario: user opts into the open dataset\n    Given an account opted out of the Open Dataset agreement\n    And the account is authenticated\n    When the user opts into the open dataset\n    Then the request will be successful\n    And the account will have a open dataset agreement\n    And the new agreement will be reflected in the account activity metrics\n\n  Scenario: user opts out of the open dataset\n    Given an account opted into the Open Dataset agreement\n    And the account is authenticated\n    When the user opts out of the open dataset\n    Then the request will be successful\n    And the account will not have a open dataset agreement\n    And the deleted agreement will be reflected in the account activity metrics\n\n  Scenario: User changes password\n    Given a user who authenticates with a password\n    And the account is authenticated\n    When the user changes their password\n    Then the request will be successful\n    And the password on the account will be changed\n    And an password change notification will be sent\n\n  Scenario: User changes email address\n    Given a user who authenticates with a password\n    And the account is authenticated\n    When the user changes their email address\n    Then the request will be successful\n    And an email change notification will be sent to the old email address\n    And an email change verification message will be sent to the new email address\n\n  Scenario: User changes email address to a value is assigned to an existing account\n    Given a user who authenticates with a password\n    And the account is authenticated\n    When the user changes their email address to that of an existing account\n    Then the request will be successful\n    And a duplicate email address error is returned\n"
  },
  {
    "path": "api/account/tests/features/remove_account.feature",
    "content": "Feature: Account API -- Delete an account\n  Test the API call to delete an account and all its related data from the database.\n\n  Scenario: Successful account deletion\n    Given an account\n    And the account is authenticated\n    When a user requests to delete their account\n    Then the request will be successful\n    And the user's account is deleted\n    And the deleted account will be reflected in the account activity metrics\n\n  Scenario: Membership removed upon account deletion\n    Given an account with a monthly membership\n    When a user requests to delete their account\n    Then the request will be successful\n    And the membership is removed from stripe\n\n  Scenario: Wake word files removed upon account deletion\n    Given an account opted into the Open Dataset agreement\n    And a wake word sample contributed by the user\n    And the account is authenticated\n    When a user requests to delete their account\n    Then the request will be successful\n    And the wake word contributions are flagged for deletion\n"
  },
  {
    "path": "api/account/tests/features/steps/add_device.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Python code to support the add device feature.\"\"\"\nimport json\n\nfrom behave import given, when, then  # pylint: disable=no-name-in-module\nfrom hamcrest import assert_that, equal_to, none, not_none\n\nfrom selene.data.device import DeviceRepository\nfrom selene.util.cache import (\n    DEVICE_PAIRING_CODE_KEY,\n    DEVICE_PAIRING_TOKEN_KEY,\n    SeleneCache,\n)\nfrom selene.util.db import connect_to_db\n\n\n@given(\"a device pairing code\")\ndef set_device_pairing_code(context):\n    \"\"\"Add dummy data to the Redis cache for the test.\"\"\"\n    pairing_data = dict(\n        code=\"ABC123\",\n        packaging_type=\"pantacor\",\n        state=\"this is a state\",\n        token=\"this is a token\",\n        expiration=84600,\n    )\n    cache = SeleneCache()\n    cache.set_with_expiration(\n        \"pairing.code:ABC123\", json.dumps(pairing_data), expiration=86400\n    )\n    context.pairing_data = pairing_data\n    context.pairing_code = \"ABC123\"\n\n\n@when(\"an API request is sent to add a device\")\ndef add_device(context):\n    \"\"\"Call the endpoint to add a device based on user input.\"\"\"\n    device = dict(\n        city=\"Kansas City\",\n        country=\"United States\",\n        name=\"Selene Test Device\",\n        pairingCode=context.pairing_code,\n        placement=\"Mycroft Offices\",\n        region=\"Missouri\",\n        timezone=\"America/Chicago\",\n        wakeWord=\"hey selene\",\n        voice=\"Selene Test Voice\",\n    )\n    response = context.client.post(\n        \"/api/devices\", data=json.dumps(device), content_type=\"application/json\"\n    )\n    context.response = response\n\n\n@then(\"the pairing code is removed from cache\")\ndef validate_pairing_code_removal(context):\n    \"\"\"Ensure that the endpoint removed the pairing code entry from the cache.\"\"\"\n    cache = SeleneCache()\n    pairing_data = cache.get(\n        DEVICE_PAIRING_CODE_KEY.format(pairing_code=context.pairing_code)\n    )\n    assert_that(pairing_data, none())\n\n\n@then(\"the device is added to the database\")\ndef validate_response(context):\n    \"\"\"Ensure that the database was updated as expected.\"\"\"\n    account = context.accounts[\"foo\"]\n    db = connect_to_db(context.client_config[\"DB_CONNECTION_CONFIG\"])\n    device_repository = DeviceRepository(db)\n    devices = device_repository.get_devices_by_account_id(account.id)\n    device = None\n    for device in devices:\n        if device.name == \"Selene Test Device\":\n            break\n    assert_that(device, not_none())\n    assert_that(device.name, equal_to(\"Selene Test Device\"))\n    assert_that(device.placement, equal_to(\"Mycroft Offices\"))\n    assert_that(device.account_id, equal_to(account.id))\n    context.device_id = device.id\n\n\n@then(\"the pairing token is added to cache\")\ndef validate_pairing_token(context):\n    \"\"\"Validate the pairing token data was added to the cache as expected.\"\"\"\n    cache = SeleneCache()\n    pairing_data = cache.get(\n        DEVICE_PAIRING_TOKEN_KEY.format(pairing_token=\"this is a token\")\n    )\n    pairing_data = json.loads(pairing_data)\n\n    assert_that(pairing_data[\"uuid\"], equal_to(context.device_id))\n    assert_that(pairing_data[\"state\"], equal_to(context.pairing_data[\"state\"]))\n    assert_that(pairing_data[\"token\"], equal_to(context.pairing_data[\"token\"]))\n"
  },
  {
    "path": "api/account/tests/features/steps/agreements.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom dataclasses import asdict\nimport json\n\nfrom behave import then, when\nfrom hamcrest import assert_that, equal_to\n\nfrom selene.data.account import PRIVACY_POLICY, TERMS_OF_USE\n\n\n@when(\"API request for {agreement} is made\")\ndef call_agreement_endpoint(context, agreement):\n    if agreement == PRIVACY_POLICY:\n        url = \"/api/agreement/privacy-policy\"\n    elif agreement == TERMS_OF_USE:\n        url = \"/api/agreement/terms-of-use\"\n    else:\n        raise ValueError(\"invalid agreement type\")\n\n    context.response = context.client.get(url)\n\n\n@then(\"{agreement} version {version} is returned\")\ndef validate_response(context, agreement, version):\n    response_data = json.loads(context.response.data)\n    if agreement == PRIVACY_POLICY:\n        expected_response = asdict(context.privacy_policy)\n    elif agreement == TERMS_OF_USE:\n        expected_response = asdict(context.terms_of_use)\n    else:\n        raise ValueError(\"invalid agreement type\")\n\n    del expected_response[\"effective_date\"]\n    assert_that(response_data, equal_to(expected_response))\n"
  },
  {
    "path": "api/account/tests/features/steps/authentication.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom behave import given, then\nfrom hamcrest import assert_that, equal_to, is_not\n\nfrom selene.testing.api import (\n    generate_access_token,\n    generate_refresh_token,\n    set_access_token_cookie,\n    set_refresh_token_cookie,\n    validate_token_cookies,\n)\nfrom selene.util.auth import AuthenticationToken\n\nEXPIRE_IMMEDIATELY = 0\n\n\n@given(\"an account with a valid access token\")\ndef use_account_with_valid_access_token(context):\n    context.username = \"foo\"\n    context.access_token = generate_access_token(context)\n    set_access_token_cookie(context)\n    context.refresh_token = generate_refresh_token(context)\n    set_refresh_token_cookie(context)\n\n\n@given(\"an account with an expired access token\")\ndef generate_expired_access_token(context):\n    context.username = \"foo\"\n    context.access_token = generate_access_token(context, duration=EXPIRE_IMMEDIATELY)\n    set_access_token_cookie(context, duration=EXPIRE_IMMEDIATELY)\n    context.refresh_token = generate_refresh_token(context)\n    set_refresh_token_cookie(context)\n    context.old_refresh_token = context.refresh_token.jwt\n\n\n@given(\"an account with a refresh token but no access token\")\ndef generate_refresh_token_only(context):\n    context.username = \"foo\"\n    context.refresh_token = generate_refresh_token(context)\n    set_refresh_token_cookie(context)\n    context.old_refresh_token = context.refresh_token.jwt\n\n\n@given(\"an account with expired access and refresh tokens\")\ndef expire_both_tokens(context):\n    context.username = \"foo\"\n    context.access_token = generate_access_token(context, duration=EXPIRE_IMMEDIATELY)\n    set_access_token_cookie(context, duration=EXPIRE_IMMEDIATELY)\n    context.refresh_token = generate_refresh_token(context, duration=EXPIRE_IMMEDIATELY)\n    set_refresh_token_cookie(context, duration=EXPIRE_IMMEDIATELY)\n\n\n@then(\"the authentication tokens will remain unchanged\")\ndef check_for_no_new_cookie(context):\n    cookies = context.response.headers.getlist(\"Set-Cookie\")\n    assert_that(cookies, equal_to([]))\n\n\n@then(\"the authentication tokens will be refreshed\")\ndef check_for_new_cookies(context):\n    validate_token_cookies(context)\n    assert_that(context.refresh_token, is_not(equal_to(context.old_refresh_token)))\n    refresh_token = AuthenticationToken(context.client_config[\"REFRESH_SECRET\"], 0)\n    refresh_token.jwt = context.refresh_token\n    refresh_token.validate()\n    assert_that(refresh_token.is_valid, equal_to(True), \"refresh token valid\")\n    assert_that(refresh_token.is_expired, equal_to(False), \"refresh token expired\")\n    assert_that(refresh_token.account_id, equal_to(context.accounts[\"foo\"].id))\n"
  },
  {
    "path": "api/account/tests/features/steps/common.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom http import HTTPStatus\n\nfrom behave import given, then\nfrom hamcrest import assert_that, equal_to, is_in\n\nfrom selene.testing.api import (\n    generate_access_token,\n    generate_refresh_token,\n    set_access_token_cookie,\n    set_refresh_token_cookie,\n)\n\n\n@given(\"an account\")\ndef define_account(context):\n    context.username = \"foo\"\n\n\n@given(\"the account is authenticated\")\ndef use_account_with_valid_access_token(context):\n    context.access_token = generate_access_token(context)\n    set_access_token_cookie(context)\n    context.refresh_token = generate_refresh_token(context)\n    set_refresh_token_cookie(context)\n\n\n@then(\"the request will be successful\")\ndef check_request_success(context):\n    assert_that(\n        context.response.status_code, is_in([HTTPStatus.OK, HTTPStatus.NO_CONTENT])\n    )\n\n\n@then(\"the request will fail with {error_type} error\")\ndef check_for_bad_request(context, error_type):\n    if error_type == \"a bad request\":\n        assert_that(context.response.status_code, equal_to(HTTPStatus.BAD_REQUEST))\n    elif error_type == \"an unauthorized\":\n        assert_that(context.response.status_code, equal_to(HTTPStatus.UNAUTHORIZED))\n    else:\n        raise ValueError(\"unsupported error_type\")\n"
  },
  {
    "path": "api/account/tests/features/steps/pantacor_update.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Step functions for applying a software update via the account API.\"\"\"\n\nimport json\nfrom unittest.mock import MagicMock, patch\n\nfrom behave import given, then, when  # pylint: disable=no-name-in-module\nfrom hamcrest import assert_that, equal_to\n\nfrom selene.testing.device import add_device, add_pantacor_config\n\n\n@given(\"a device using Pantacor for continuous delivery\")\ndef add_pantacor_device(context):\n    \"\"\"Add a device with a Pantacor config and software update set to manual.\"\"\"\n    context.device_id = add_device(\n        context.db, context.accounts[\"foo\"].id, context.geography_id\n    )\n    add_pantacor_config(context.db, context.device_id)\n\n\n@given(\"the device has pending deployment from Pantacor\")\ndef add_pantacor_deployment_id(context):\n    \"\"\"Add a dummy deployment ID to the context for use later in tests.\"\"\"\n    context.deployment_id = \"test_deployment_id\"\n\n\n@when(\"the user selects to apply the update\")\ndef apply_software_update(context):\n    \"\"\"Make an API call to apply the software update.\n\n    The Pantacor API code is patched because there is currently no way to call it\n    reliably with a test device.\n    \"\"\"\n    with patch(\"requests.request\") as request_patch:\n        apply_update_response = MagicMock(spec=[\"ok\", \"content\"])\n        apply_update_response.ok = True\n        apply_update_response.content = '{\"response\":\"ok\"}'.encode()\n        request_patch.side_effect = [apply_update_response]\n        request_data = dict(deploymentId=context.deployment_id)\n        response = context.client.patch(\n            \"/api/software-update\",\n            data=json.dumps(request_data),\n            content_type=\"application/json\",\n        )\n    context.response = response\n\n\n@when(\"the user enters a malformed RSA SSH key\")\ndef validate_invalid_ssh_key(context):\n    \"\"\"Make an API call to check the validity of a RSA SSH key.\"\"\"\n    response = context.client.get(\n        \"/api/ssh-key?key=foo\", content_type=\"application/json\"\n    )\n    context.response = response\n\n\n@when(\"the user enters a well formed RSA SSH key\")\ndef validate_valid_ssh_key(context):\n    \"\"\"Make an API call to check the validity of a RSA SSH key.\"\"\"\n    response = context.client.get(\n        \"/api/ssh-key?key=ssh-rsa%20AAAAB3NzaC1yc2EAAAADAQABAAACAQDEwmtmRho==%20foo\",\n        content_type=\"application/json\",\n    )\n    context.response = response\n\n\n@when(\"the user requests to view the device\")\ndef get_device(context):\n    \"\"\"Make an API call to get device data, including a software update ID.\n\n    The Pantacor API code is patched because there is currently no way to call it\n    reliably with a test device.\n    \"\"\"\n    with patch(\"requests.request\") as request_patch:\n        api_response = MagicMock(spec=[\"ok\", \"content\"])\n        api_response.ok = True\n        deployment = dict(id=\"test_deployment_id\")\n        get_deployment_content = dict(items=[deployment])\n        api_response.content = json.dumps(get_deployment_content).encode()\n        request_patch.side_effect = [api_response]\n        response = context.client.get(\n            \"/api/devices/\" + context.device_id, content_type=\"application/json\"\n        )\n    context.response = response\n\n\n@then(\"the response contains the pending deployment ID\")\ndef check_for_deployment_id(context):\n    \"\"\"Check the response of the device query to ensure the update ID is populated.\"\"\"\n    device_attributes = context.response.json\n    assert_that(\n        device_attributes[\"pantacorConfig\"][\"deploymentId\"],\n        equal_to(\"test_deployment_id\"),\n    )\n\n\n@then(\"the response indicates that the SSH key is malformed\")\ndef check_for_malformed_ssh_key(context):\n    \"\"\"Ensure the response indicates the SSH key passed on the URL is invalid\"\"\"\n    response = context.response\n    assert_that(response.json, equal_to(dict(isValid=False)))\n\n\n@then(\"the response indicates that the SSH key is properly formatted\")\ndef check_for_well_formed_ssh_key(context):\n    \"\"\"Ensure the response indicates the SSH key passed on the URL is valid\"\"\"\n    response = context.response\n    assert_that(response.json, equal_to(dict(isValid=True)))\n"
  },
  {
    "path": "api/account/tests/features/steps/profile.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Step functions for maintaining an account profile via the account API.\"\"\"\n\nimport json\nfrom binascii import b2a_base64\nfrom datetime import datetime\nfrom os import environ\nfrom unittest.mock import patch\n\nfrom behave import given, then, when  # pylint: disable=no-name-in-module\nfrom hamcrest import (\n    assert_that,\n    equal_to,\n    greater_than,\n    has_item,\n    is_in,\n    none,\n    not_none,\n    starts_with,\n)\n\nfrom selene.data.account import (\n    AccountRepository,\n    PRIVACY_POLICY,\n    TERMS_OF_USE,\n    OPEN_DATASET,\n)\nfrom selene.data.metric import AccountActivityRepository\nfrom selene.testing.account_activity import check_account_metrics\nfrom selene.testing.api import (\n    generate_access_token,\n    generate_refresh_token,\n    set_access_token_cookie,\n    set_refresh_token_cookie,\n)\nfrom selene.testing.membership import MONTHLY_MEMBERSHIP, YEARLY_MEMBERSHIP\nfrom selene.util.email import EmailMessage\n\nBAR_EMAIL_ADDRESS = \"bar@mycroft.ai\"\nSTRIPE_METHOD = \"Stripe\"\nVISA_TOKEN = \"tok_visa\"\n\n\n@given(\"an account with a monthly membership\")\ndef add_membership_to_account(context):\n    \"\"\"Use the API to add a monthly membership on Stripe\n\n    The API is used so that the Stripe API can be interacted with.\n    \"\"\"\n    context.username = \"foo\"\n    context.access_token = generate_access_token(context)\n    set_access_token_cookie(context)\n    context.refresh_token = generate_refresh_token(context)\n    set_refresh_token_cookie(context)\n    _add_membership_via_api(context)\n    acct_repository = AccountRepository(context.db)\n    membership = acct_repository.get_active_account_membership(\n        context.accounts[\"foo\"].id\n    )\n    context.accounts[\"foo\"].membership = membership\n\n\n@given(\"an account without a membership\")\ndef get_account_no_membership(context):\n    \"\"\"Set context username to one with no membership.\"\"\"\n    context.username = \"foo\"\n\n\n@given(\"an account opted {in_or_out} the Open Dataset agreement\")\ndef set_account_open_dataset(context, in_or_out):\n    \"\"\"Expire open dataset agreement (assumes default is active agreement).\"\"\"\n    context.username = \"foo\"\n    if in_or_out == \"out of\":\n        account = context.accounts[\"foo\"]\n        account_repo = AccountRepository(context.db)\n        account_repo.expire_open_dataset_agreement(account.id)\n\n\n@given(\"a user who authenticates with a password\")\ndef setup_user(context):\n    \"\"\"Set user context for use in other steps.\"\"\"\n    context.username = \"foo\"\n    context.password = \"barfoo\"\n\n\n@when(\"the user changes their password\")\ndef call_password_change_endpoint(context):\n    \"\"\"Call the password change endpoint for the single sign on API.\"\"\"\n    change_password_request = dict(\n        password=b2a_base64(context.password.encode()).decode()\n    )\n    with patch(\"account_api.endpoints.change_password.SeleneMailer\") as email_mock:\n        response = context.client.put(\n            \"/api/change-password\",\n            data=json.dumps(change_password_request),\n            content_type=\"application/json\",\n        )\n        context.response = response\n        context.email_mock = email_mock\n\n\n@when(\"the user changes their email address\")\ndef call_email_address_change_endpoint(context):\n    \"\"\"Call the password change endpoint for the single sign on API.\"\"\"\n    context.new_email_address = \"bar@mycroft.ai\"\n    encoded_email_address = context.new_email_address.encode()\n    context.email_verification_token = b2a_base64(\n        encoded_email_address, newline=False\n    ).decode()\n    change_email_request = dict(token=context.email_verification_token)\n    with patch(\"account_api.endpoints.change_email_address.SeleneMailer\") as email_mock:\n        response = context.client.put(\n            \"/api/change-email\",\n            data=json.dumps(change_email_request),\n            content_type=\"application/json\",\n        )\n        context.response = response\n        context.email_mock = email_mock\n\n\n@when(\"the user changes their email address to that of an existing account\")\ndef call_email_validation_endpoint(context):\n    \"\"\"Call the email validation endpoint on the account API.\"\"\"\n    existing_account = context.accounts[\"foo\"]\n    email_address = existing_account.email_address.encode()\n    token = b2a_base64(email_address).decode()\n\n    context.client.content_type = \"application/json\"\n    response = context.client.get(\n        f\"/api/validate-email?platform=Internal&token={token}\"\n    )\n    context.response = response\n\n\n@when(\"a user requests their profile\")\ndef call_account_endpoint(context):\n    \"\"\"Issue API call to retrieve account profile.\"\"\"\n    context.response = context.client.get(\n        \"/api/account\", content_type=\"application/json\"\n    )\n\n\n@when(\"a monthly membership is added\")\ndef add_monthly_membership(context):\n    \"\"\"Issue API call to add a monthly membership to an account.\"\"\"\n    context.response = _add_membership_via_api(context)\n\n\n@when(\"the membership is cancelled\")\ndef cancel_membership(context):\n    \"\"\"Issue API call to cancel and account's membership.\"\"\"\n    membership_data = dict(action=\"cancel\")\n    context.response = context.client.patch(\n        \"/api/account\",\n        data=json.dumps(dict(membership=membership_data)),\n        content_type=\"application/json\",\n    )\n\n\ndef _add_membership_via_api(context):\n    \"\"\"Helper function to add account membership via API call\"\"\"\n    membership_data = dict(\n        action=\"add\",\n        membershipType=MONTHLY_MEMBERSHIP,\n        paymentMethod=STRIPE_METHOD,\n        paymentToken=VISA_TOKEN,\n    )\n    return context.client.patch(\n        \"/api/account\",\n        data=json.dumps(dict(membership=membership_data)),\n        content_type=\"application/json\",\n    )\n\n\n@when(\"the membership is changed to yearly\")\ndef change_to_yearly_account(context):\n    \"\"\"Issue API call to change a monthly membership to a yearly membership.\"\"\"\n    membership_data = dict(action=\"update\", membershipType=YEARLY_MEMBERSHIP)\n    context.response = context.client.patch(\n        \"/api/account\",\n        data=json.dumps(dict(membership=membership_data)),\n        content_type=\"application/json\",\n    )\n\n\n@when(\"the user opts {in_or_out} the open dataset\")\ndef set_open_dataset_status(context, in_or_out):\n    \"\"\"Issue API call to opt into or out of the open dataset agreement.\"\"\"\n    if in_or_out not in (\"into\", \"out of\"):\n        raise ValueError('User can only opt \"into\" or \"out of\" the agreement')\n    context.response = context.client.patch(\n        \"/api/account\",\n        data=json.dumps(dict(openDataset=in_or_out == \"into\")),\n        content_type=\"application/json\",\n    )\n\n\n@then(\"user profile is returned\")\ndef validate_response(context):\n    \"\"\"Check results of API call.\"\"\"\n    response_data = context.response.json\n    utc_date = datetime.utcnow().date()\n    account = context.accounts[\"foo\"]\n    assert_that(response_data[\"emailAddress\"], equal_to(account.email_address))\n    assert_that(response_data[\"membership\"][\"type\"], equal_to(\"Monthly Membership\"))\n    assert_that(response_data[\"membership\"][\"duration\"], none())\n    assert_that(response_data[\"membership\"], has_item(\"id\"))\n\n    assert_that(len(response_data[\"agreements\"]), equal_to(3))\n    for agreement in response_data[\"agreements\"]:\n        assert_that(\n            agreement[\"type\"], is_in([PRIVACY_POLICY, TERMS_OF_USE, OPEN_DATASET])\n        )\n        assert_that(\n            agreement[\"acceptDate\"], equal_to(str(utc_date.strftime(\"%B %d, %Y\")))\n        )\n        assert_that(agreement, has_item(\"id\"))\n\n\n@then(\"the account should have a monthly membership\")\ndef validate_monthly_account(context):\n    \"\"\"Check that the monthly membership information for an account is accurate.\"\"\"\n    acct_repository = AccountRepository(context.db)\n    membership = acct_repository.get_active_account_membership(\n        context.accounts[\"foo\"].id\n    )\n    assert_that(membership.type, equal_to(MONTHLY_MEMBERSHIP))\n    assert_that(membership.payment_account_id, starts_with(\"cus\"))\n    assert_that(membership.start_date, equal_to(datetime.utcnow().date()))\n    assert_that(membership.end_date, none())\n\n\n@then(\"the account should have no membership\")\ndef validate_absence_of_membership(context):\n    \"\"\"Check for the absence of a membership on an account.\"\"\"\n    acct_repository = AccountRepository(context.db)\n    membership = acct_repository.get_active_account_membership(\n        context.accounts[\"foo\"].id\n    )\n    assert_that(membership, none())\n\n\n@then(\"the account should have a yearly membership\")\ndef yearly_account(context):\n    \"\"\"Check that the yearly membership information for an account is accurate.\"\"\"\n    acct_repository = AccountRepository(context.db)\n    membership = acct_repository.get_active_account_membership(\n        context.accounts[\"foo\"].id\n    )\n    assert_that(membership.type, equal_to(YEARLY_MEMBERSHIP))\n    assert_that(membership.payment_account_id, starts_with(\"cus\"))\n\n\n@then(\"the new member will be reflected in the account activity metrics\")\ndef check_new_member_account_metrics(context):\n    \"\"\"Ensure a new membership is accurately reflected in the metrics.\"\"\"\n    check_account_metrics(context, \"members\", \"members_added\")\n\n\n@then(\"the deleted member will be reflected in the account activity metrics\")\ndef check_expired_member_account_metrics(context):\n    \"\"\"Ensure that the account deletion is recorded in the metrics schema.\"\"\"\n    acct_activity_repository = AccountActivityRepository(context.db)\n    account_activity = acct_activity_repository.get_activity_by_date(\n        datetime.utcnow().date()\n    )\n    if context.account_activity is None:\n        assert_that(account_activity.members, greater_than(0))\n        assert_that(account_activity.members_expired, equal_to(1))\n    else:\n        # Membership was added in a previous step so rather than the membership being\n        # decreased by one, it would net to being the same after the expiration.\n        assert_that(\n            account_activity.members,\n            equal_to(context.account_activity.members),\n        )\n        assert_that(\n            account_activity.members_expired,\n            equal_to(context.account_activity.members_expired + 1),\n        )\n\n\n@then(\"the account {will_or_wont} have a open dataset agreement\")\ndef check_for_open_dataset_agreement(context, will_or_wont):\n    \"\"\"Check the status of the open dataset agreement for an account.\"\"\"\n    account_repo = AccountRepository(context.db)\n    account = account_repo.get_account_by_id(context.accounts[\"foo\"].id)\n    agreements = [agreement.type for agreement in account.agreements]\n    if will_or_wont == \"will\":\n        assert_that(OPEN_DATASET, is_in(agreements))\n    elif will_or_wont == \"will not\":\n        assert_that(OPEN_DATASET, not is_in(agreements))\n    else:\n        raise ValueError('Valid values are only \"will\" or \"won\\'t\"')\n\n\n@then(\"the new agreement will be reflected in the account activity metrics\")\ndef check_new_open_dataset_account_metrics(context):\n    \"\"\"Ensure a new agreement is accurately reflected in the metrics.\"\"\"\n    check_account_metrics(context, \"open_dataset\", \"open_dataset_added\")\n\n\n@then(\"the deleted agreement will be reflected in the account activity metrics\")\ndef check_deleted_open_dataset_account_metrics(context):\n    \"\"\"Ensure a new agreement is accurately reflected in the metrics.\"\"\"\n    check_account_metrics(context, \"open_dataset\", \"open_dataset_deleted\")\n\n\n@then(\"the password on the account will be changed\")\ndef check_new_password(context):\n    \"\"\"Retrieves the account with the new password to verify it was changed.\"\"\"\n    acct_repository = AccountRepository(context.db)\n    test_account = context.accounts[\"foo\"]\n    account = acct_repository.get_account_from_credentials(\n        test_account.email_address, context.password\n    )\n    assert_that(account, not_none())\n\n\n@then(\"a duplicate email address error is returned\")\ndef check_for_duplicate_account_error(context):\n    \"\"\"Check the API response for an \"account exists\" error.\"\"\"\n    response = context.response\n    assert_that(response.json[\"accountExists\"], equal_to(True))\n\n\n@then(\"an password change notification will be sent\")\ndef check_password_change_notification_sent(context):\n    \"\"\"Ensures the email change notification message was sent.\n\n    Using a mock for email as we don't want to be sending emails every time the tests\n    run.\n    \"\"\"\n    email_mock = context.email_mock\n    notification_email = EmailMessage(\n        recipient=context.accounts[\"foo\"].email_address,\n        sender=\"Mycroft AI<no-reply@mycroft.ai>\",\n        subject=\"Password Changed\",\n        template_file_name=\"password_change.html\",\n    )\n    email_mock.assert_any_call(notification_email)\n\n\n@then(\"an email change notification will be sent to the old email address\")\ndef check_email_change_notification_sent(context):\n    \"\"\"Ensures the email change notification message was sent.\n\n    Using a mock for email as we don't want to be sending emails every time the tests\n    run.\n    \"\"\"\n    email_mock = context.email_mock\n    notification_email = EmailMessage(\n        recipient=context.accounts[\"foo\"].email_address,\n        sender=\"Mycroft AI<no-reply@mycroft.ai>\",\n        subject=\"Email Address Changed\",\n        template_file_name=\"email_change.html\",\n    )\n    email_mock.assert_any_call(notification_email)\n\n\n@then(\"an email change verification message will be sent to the new email address\")\ndef check_new_email_verification_sent(context):\n    \"\"\"Ensures the new email verification message was sent.\n\n    Using a mock for email as we don't want to be sending emails every time the tests\n    run.\n    \"\"\"\n    email_mock = context.email_mock\n    url = (\n        f\"{environ['ACCOUNT_BASE_URL']}/verify-email?\"\n        + f\"token={context.email_verification_token}\"\n    )\n    verification_email = EmailMessage(\n        recipient=context.new_email_address,\n        sender=\"Mycroft AI<no-reply@mycroft.ai>\",\n        subject=\"Email Change Verification\",\n        template_file_name=\"email_verification.html\",\n        template_variables=dict(email_verification_url=url),\n    )\n    email_mock.assert_any_call(verification_email)\n"
  },
  {
    "path": "api/account/tests/features/steps/remove_account.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Step functions for the account deletion functionality in the single sign on API\"\"\"\nimport os\nfrom datetime import datetime\n\nimport stripe\nfrom behave import given, then, when  # pylint: disable=no-name-in-module\nfrom hamcrest import assert_that, equal_to, is_in, none\nfrom stripe.error import InvalidRequestError\n\nfrom selene.data.account import AccountRepository\nfrom selene.data.metric import AccountActivityRepository\nfrom selene.data.tagging import (\n    PENDING_DELETE_STATUS,\n    TaggingFileLocation,\n    TaggingFileLocationRepository,\n    WakeWordFile,\n    WakeWordFileRepository,\n)\n\n\n@given(\"a wake word sample contributed by the user\")\ndef add_wake_word_sample(context):\n    \"\"\"Add a sample wake word file to the database to be queried by future steps.\"\"\"\n    file_repository = WakeWordFileRepository(context.db)\n    location_repository = TaggingFileLocationRepository(context.db)\n    location = TaggingFileLocation(server=\"127.0.0.1\", directory=\"/opt/selene/data\")\n    location.id = location_repository.add(location)\n\n    wake_word_file = WakeWordFile(\n        wake_word=context.wake_word,\n        name=\"test.wav\",\n        origin=\"mycroft\",\n        submission_date=datetime.utcnow().date(),\n        account_id=context.accounts[\"foo\"].id,\n        status=\"uploaded\",\n        location=location,\n    )\n    file_repository.add(wake_word_file)\n    file_repository.change_file_status(wake_word_file, PENDING_DELETE_STATUS)\n    context.wake_word_file = wake_word_file\n\n\n@when(\"a user requests to delete their account\")\ndef call_account_endpoint(context):\n    \"\"\"Issue API call to delete an account.\"\"\"\n    context.response = context.client.delete(\"/api/account\")\n\n\n@then(\"the user's account is deleted\")\ndef account_deleted(context):\n    \"\"\"Ensure account no longer exists in database.\"\"\"\n    acct_repository = AccountRepository(context.db)\n    deleted_account = context.accounts[\"foo\"]\n    account_in_db = acct_repository.get_account_by_id(deleted_account.id)\n    assert_that(account_in_db, none())\n\n\n@then(\"the membership is removed from stripe\")\ndef check_stripe(context):\n    \"\"\"Ensure an account with a membership is no longer charged.\"\"\"\n    account = context.accounts[\"foo\"]\n    stripe.api_key = os.environ[\"STRIPE_PRIVATE_KEY\"]\n    subscription_not_found = False\n    try:\n        stripe.Subscription.retrieve(account.membership.payment_account_id)\n    except InvalidRequestError:\n        subscription_not_found = True\n    assert_that(subscription_not_found, equal_to(True))\n\n\n@then(\"the deleted account will be reflected in the account activity metrics\")\ndef check_db_for_account_metrics(context):\n    \"\"\"Ensure that the account deletion is recorded in the metrics schema.\"\"\"\n    acct_activity_repository = AccountActivityRepository(context.db)\n    account_activity = acct_activity_repository.get_activity_by_date(\n        datetime.utcnow().date()\n    )\n    if context.account_activity is None:\n        assert_that(account_activity.accounts_deleted, equal_to(1))\n    else:\n        assert_that(\n            account_activity.accounts_deleted,\n            equal_to(context.account_activity.accounts_deleted + 1),\n        )\n\n\n@then(\"the wake word contributions are flagged for deletion\")\ndef check_wake_word_file_status(context):\n    \"\"\"An account that contributed wake word samples has those samples removed.\"\"\"\n    deleted_account = context.accounts[\"foo\"]\n    file_repository = WakeWordFileRepository(context.db)\n    files_pending_delete = file_repository.get_pending_delete()\n    assert_that(deleted_account.id, is_in(files_pending_delete))\n"
  },
  {
    "path": "api/account/uwsgi.ini",
    "content": "[uwsgi]\nmaster = true\nmodule = account_api.api:acct\nprocesses = 4\nsocket = :5000\ndie-on-term = true\nlazy = true\nlazy-apps = true\n"
  },
  {
    "path": "api/market/market_api/__init__.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n"
  },
  {
    "path": "api/market/market_api/api.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\n\"\"\"Entry point for the API that supports the Mycroft Marketplace.\"\"\"\nfrom flask import Flask\n\nfrom selene.api import get_base_config, selene_api, SeleneResponse\nfrom selene.api.endpoints import AccountEndpoint\nfrom selene.util.cache import SeleneCache\nfrom selene.util.log import configure_selene_logger\nfrom .endpoints import (\n    AvailableSkillsEndpoint,\n    SkillDetailEndpoint,\n    SkillInstallEndpoint,\n    SkillInstallStatusEndpoint,\n)\n\nconfigure_selene_logger(\"market_api\")\n\n# Define the Flask application\nmarket = Flask(__name__)\nmarket.config.from_object(get_base_config())\nmarket.response_class = SeleneResponse\nmarket.register_blueprint(selene_api)\nmarket.config[\"SELENE_CACHE\"] = SeleneCache()\n\n# Define the API and its endpoints.\naccount_endpoint = AccountEndpoint.as_view(\"account_endpoint\")\nmarket.add_url_rule(\"/api/account\", view_func=account_endpoint, methods=[\"GET\"])\n\navailable_endpoint = AvailableSkillsEndpoint.as_view(\"available_endpoint\")\nmarket.add_url_rule(\n    \"/api/skills/available\", view_func=available_endpoint, methods=[\"GET\"]\n)\n\nstatus_endpoint = SkillInstallStatusEndpoint.as_view(\"status_endpoint\")\nmarket.add_url_rule(\"/api/skills/status\", view_func=status_endpoint, methods=[\"GET\"])\n\nskill_detail_endpoint = SkillDetailEndpoint.as_view(\"skill_detail_endpoint\")\nmarket.add_url_rule(\n    \"/api/skills/<string:skill_display_id>\",\n    view_func=skill_detail_endpoint,\n    methods=[\"GET\"],\n)\n\ninstall_endpoint = SkillInstallEndpoint.as_view(\"install_endpoint\")\nmarket.add_url_rule(\"/api/skills/install\", view_func=install_endpoint, methods=[\"PUT\"])\n"
  },
  {
    "path": "api/market/market_api/endpoints/__init__.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom .available_skills import AvailableSkillsEndpoint\nfrom .skill_detail import SkillDetailEndpoint\nfrom .skill_install import SkillInstallEndpoint\nfrom .skill_install_status import SkillInstallStatusEndpoint\n"
  },
  {
    "path": "api/market/market_api/endpoints/available_skills.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\n\"\"\"Endpoint to provide skill summary data to the marketplace.\"\"\"\nfrom collections import defaultdict\nfrom http import HTTPStatus\nfrom typing import List\n\nfrom selene.api import SeleneEndpoint\nfrom selene.data.skill import SkillDisplay, SkillDisplayRepository\nfrom selene.util.log import get_selene_logger\n\n_log = get_selene_logger(__name__)\n\n\nclass AvailableSkillsEndpoint(SeleneEndpoint):\n    \"\"\"Marketplace endpoint to get all the skills available in the market.\"\"\"\n\n    authentication_required = False\n\n    def __init__(self):\n        super().__init__()\n        self.available_skills: List[SkillDisplay] = []\n        self.response_skills: List[dict] = []\n        self.skills_in_manifests = defaultdict(list)\n\n    def get(self):\n        \"\"\"Handles a HTTP GET request.\"\"\"\n        self._get_available_skills()\n        self._build_response_data()\n        self.response = (dict(skills=self.response_skills), HTTPStatus.OK)\n\n        return self.response\n\n    def _get_available_skills(self):\n        \"\"\"Retrieve all skills in the skill repository.\n\n        The data is retrieved from a database table that is populated with\n        the contents of a JSON object in the mycroft-skills-data Github\n        repository.  The JSON object contains metadata about each skill.\n        \"\"\"\n        display_repo = SkillDisplayRepository(self.db)\n        self.available_skills = display_repo.get_display_data_for_skills()\n\n    def _build_response_data(self):\n        \"\"\"Build the data to include in the response.\"\"\"\n        if self.request.query_string:\n            skills_to_include = self._filter_skills()\n        else:\n            skills_to_include = self.available_skills\n        self._reformat_skills(skills_to_include)\n        self._sort_skills()\n\n    def _filter_skills(self) -> list:\n        \"\"\"If search criteria exist, only return those skills that match.\"\"\"\n        skills_to_include = []\n\n        query_string = self.request.query_string.decode()\n        search_term = query_string.lower().split(\"=\")[1]\n        for skill in self.available_skills:\n            display_data = skill.display_data\n            search_term_match = (\n                search_term is None\n                or search_term in display_data[\"title\"].lower()\n                or search_term in display_data[\"description\"].lower()\n                or search_term in display_data[\"short_desc\"].lower()\n                or search_term in [c.lower() for c in display_data[\"categories\"]]\n                or search_term in [t.lower() for t in display_data[\"tags\"]]\n                or search_term in [t.lower() for t in display_data[\"examples\"]]\n            )\n            if search_term_match:\n                skills_to_include.append(skill)\n\n        return skills_to_include\n\n    def _reformat_skills(self, skills_to_include: List[SkillDisplay]):\n        \"\"\"Build the response data from the skill service response\"\"\"\n        for skill in skills_to_include:\n            skill_info = dict(\n                displayName=skill.display_data.get(\"display_name\"),\n                icon=skill.display_data.get(\"icon\"),\n                iconImage=skill.display_data.get(\"icon_img\"),\n                isMycroftMade=False,\n                isSystemSkill=False,\n                marketCategory=\"Undefined\",\n                id=skill.id,\n                summary=skill.display_data.get(\"short_desc\"),\n                trigger=None,\n            )\n            examples = skill.display_data.get(\"examples\")\n            if examples is not None and examples:\n                skill_info.update(trigger=skill.display_data[\"examples\"][0])\n            tags = skill.display_data.get(\"tags\")\n            if tags is not None and \"system\" in tags:\n                skill_info.update(isSystemSkill=True)\n            categories = skill.display_data.get(\"categories\")\n            if categories is not None and categories:\n                skill_info.update(marketCategory=categories[0])\n            skill_credits = skill.display_data.get(\"credits\")\n            if skill_credits is not None:\n                credits_names = [credit.get(\"name\") for credit in skill_credits]\n                if \"Mycroft AI\" in credits_names:\n                    skill_info.update(isMycroftMade=True)\n            self.response_skills.append(skill_info)\n\n    def _sort_skills(self):\n        \"\"\"Sort the skills in alphabetical order\"\"\"\n        sorted_skills = sorted(\n            self.response_skills, key=lambda skill: skill[\"displayName\"]\n        )\n        self.response_skills = sorted_skills\n"
  },
  {
    "path": "api/market/market_api/endpoints/skill_detail.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\n\"\"\"View to return detailed information about a skill\"\"\"\nfrom http import HTTPStatus\n\nfrom markdown import markdown\n\nfrom selene.api import SeleneEndpoint\nfrom selene.data.skill import SkillDisplay, SkillDisplayRepository\n\n\nclass SkillDetailEndpoint(SeleneEndpoint):\n    \"\"\"Supply the data that will populate the skill detail page.\"\"\"\n\n    authentication_required = False\n\n    def __init__(self):\n        super().__init__()\n        self.skill_display_id = None\n        self.response_skill = None\n        self.manifest_skills = []\n\n    def get(self, skill_display_id):\n        \"\"\"Process an HTTP GET request\"\"\"\n        self.skill_display_id = skill_display_id\n        skill_display = self._get_skill_details()\n        self._build_response_data(skill_display)\n        self.response = (self.response_skill, HTTPStatus.OK)\n\n        return self.response\n\n    def _get_skill_details(self) -> SkillDisplay:\n        \"\"\"Build the data to include in the response.\"\"\"\n        display_repository = SkillDisplayRepository(self.db)\n        skill_display = display_repository.get_display_data_for_skill(\n            self.skill_display_id\n        )\n\n        return skill_display\n\n    def _build_response_data(self, skill_display: SkillDisplay):\n        \"\"\"Make some modifications to the response skill for the marketplace\"\"\"\n        self.response_skill = dict(\n            categories=skill_display.display_data.get(\"categories\"),\n            credits=skill_display.display_data.get(\"credits\"),\n            description=markdown(\n                skill_display.display_data.get(\"description\"), output_format=\"html5\"\n            ),\n            displayName=skill_display.display_data[\"display_name\"],\n            icon=skill_display.display_data.get(\"icon\"),\n            iconImage=skill_display.display_data.get(\"icon_img\"),\n            isSystemSkill=False,\n            worksOnMarkOne=(\n                \"all\" in skill_display.display_data[\"platforms\"]\n                or \"platform_mark1\" in skill_display.display_data[\"platforms\"]\n            ),\n            worksOnMarkTwo=(\n                \"all\" in skill_display.display_data[\"platforms\"]\n                or \"platform_mark2\" in skill_display.display_data[\"platforms\"]\n            ),\n            worksOnPicroft=(\n                \"all\" in skill_display.display_data[\"platforms\"]\n                or \"platform_picroft\" in skill_display.display_data[\"platforms\"]\n            ),\n            worksOnKDE=(\n                \"all\" in skill_display.display_data[\"platforms\"]\n                or \"platform_plasmoid\" in skill_display.display_data[\"platforms\"]\n            ),\n            repositoryUrl=skill_display.display_data.get(\"repo\"),\n            summary=markdown(\n                skill_display.display_data[\"short_desc\"], output_format=\"html5\"\n            ),\n            triggers=skill_display.display_data[\"examples\"],\n        )\n        if skill_display.display_data[\"tags\"] is not None:\n            if \"system\" in skill_display.display_data[\"tags\"]:\n                self.response_skill[\"isSystemSkill\"] = True\n"
  },
  {
    "path": "api/market/market_api/endpoints/skill_install.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\n\"\"\"\nMarketplace endpoint to add or remove a skill\n\nThis endpoint configures the install skill on a user's device(s) to add or\nremove the skill.\n\"\"\"\nimport ast\nfrom http import HTTPStatus\nfrom typing import List\n\nfrom schematics import Model\nfrom schematics.types import StringType\n\nfrom selene.api import ETagManager, SeleneEndpoint\nfrom selene.data.skill import (\n    AccountSkillSetting,\n    SkillDisplayRepository,\n    SkillSettingRepository,\n)\nfrom selene.util.log import get_selene_logger\n\nINSTALL_SECTION = \"to_install\"\nUNINSTALL_SECTION = \"to_remove\"\n\n_log = get_selene_logger(__name__)\n\n\nclass InstallRequest(Model):\n    \"\"\"Defines the expected state of the request JSON data\"\"\"\n\n    setting_section = StringType(\n        required=True, choices=[INSTALL_SECTION, UNINSTALL_SECTION]\n    )\n    skill_display_id = StringType(required=True)\n\n\nclass SkillInstallEndpoint(SeleneEndpoint):\n    \"\"\"Install a skill on user device(s).\"\"\"\n\n    _settings_repo = None\n\n    def __init__(self):\n        super().__init__()\n        self.installer_settings: List[AccountSkillSetting] = []\n        self.skill_name = None\n        self.etag_manager = ETagManager(self.config[\"SELENE_CACHE\"], self.config)\n\n    @property\n    def settings_repo(self):\n        \"\"\"Lazy instantiation of the skill settings repository.\"\"\"\n        if self._settings_repo is None:\n            self._settings_repo = SkillSettingRepository(self.db)\n\n        return self._settings_repo\n\n    def put(self):\n        \"\"\"Handle an HTTP PUT request\"\"\"\n        self._authenticate()\n        self._validate_request()\n        self._get_skill_name()\n        self.installer_settings = self.settings_repo.get_installer_settings(\n            self.account.id\n        )\n        self._apply_update()\n        self.etag_manager.expire_skill_etag_by_account_id(self.account.id)\n\n        return \"\", HTTPStatus.NO_CONTENT\n\n    def _validate_request(self):\n        \"\"\"Ensure the data passed in the request is as expected.\n\n        :raises schematics.exceptions.ValidationError if the validation fails\n        \"\"\"\n        install_request = InstallRequest()\n        install_request.setting_section = self.request.json[\"section\"]\n        install_request.skill_display_id = self.request.json[\"skillDisplayId\"]\n        install_request.validate()\n\n    def _get_skill_name(self):\n        \"\"\"Get the name of the skill being installed/removed from the DB\n\n        The installer skill expects the skill name found in the \"name\" field\n        of the skill display JSON.\n        \"\"\"\n        display_repo = SkillDisplayRepository(self.db)\n        skill_display = display_repo.get_display_data_for_skill(\n            self.request.json[\"skillDisplayId\"]\n        )\n        self.skill_name = skill_display.display_data[\"name\"]\n\n    def _apply_update(self):\n        \"\"\"Add the skill in the request to the installer skill settings.\n\n        This is designed to change the installer skill settings for all\n        devices associated with an account.  It will be updated in the\n        future to target specific devices.\n        \"\"\"\n        section = self.request.json[\"section\"]\n        for settings in self.installer_settings:\n            setting_value = settings.settings_values.get(section, [])\n            if isinstance(setting_value, str):\n                setting_value = ast.literal_eval(setting_value)\n            setting_value.append(dict(name=self.skill_name))\n            settings.settings_values[section] = setting_value\n            self._update_skill_settings(settings)\n\n    def _update_skill_settings(self, new_skill_settings):\n        \"\"\"Update the DB with the new installer skill settings.\"\"\"\n        self.settings_repo.update_skill_settings(\n            self.account.id, new_skill_settings, None\n        )\n"
  },
  {
    "path": "api/market/market_api/endpoints/skill_install_status.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom collections import defaultdict\nfrom dataclasses import asdict\nfrom http import HTTPStatus\nfrom typing import List\n\nfrom selene.api import SeleneEndpoint\nfrom selene.data.device import DeviceSkillRepository, ManifestSkill\nfrom selene.util.auth import AuthenticationError\n\nVALID_STATUS_VALUES = (\"failed\", \"installed\", \"installing\", \"uninstalling\")\n\n\nclass SkillInstallStatusEndpoint(SeleneEndpoint):\n    authentication_required = False\n\n    def __init__(self):\n        super(SkillInstallStatusEndpoint, self).__init__()\n        self.installed_skills = defaultdict(list)\n\n    def get(self):\n        try:\n            self._authenticate()\n        except AuthenticationError:\n            self.response = (\"\", HTTPStatus.NO_CONTENT)\n        else:\n            self._get_installed_skills()\n            response_data = self._build_response_data()\n            self.response = (response_data, HTTPStatus.OK)\n\n        return self.response\n\n    def _get_installed_skills(self):\n        skill_repo = DeviceSkillRepository(self.db)\n        installed_skills = skill_repo.get_skill_manifest_for_account(self.account.id)\n        for skill in installed_skills:\n            self.installed_skills[skill.skill_id].append(skill)\n\n    def _build_response_data(self) -> dict:\n        install_statuses = {}\n        failure_reasons = {}\n        for skill_id, skills in self.installed_skills.items():\n            skill_aggregator = SkillManifestAggregator(skills)\n            skill_aggregator.aggregate_skill_status()\n            if skill_aggregator.aggregate_skill.install_status == \"failed\":\n                failure_reasons[\n                    skill_id\n                ] = skill_aggregator.aggregate_skill.install_failure_reason\n            install_statuses[skill_id] = skill_aggregator.aggregate_skill.install_status\n\n        return dict(installStatuses=install_statuses, failureReasons=failure_reasons)\n\n\nclass SkillManifestAggregator(object):\n    \"\"\"Base class containing functionality shared by summary and detail\"\"\"\n\n    def __init__(self, installed_skills: List[ManifestSkill]):\n        self.installed_skills = installed_skills\n        self.aggregate_skill = ManifestSkill(**asdict(installed_skills[0]))\n\n    def aggregate_skill_status(self):\n        \"\"\"Aggregate skill data on all devices into a single skill.\n\n        Each skill is represented once on the Marketplace, even though it can\n        be present on multiple devices.\n        \"\"\"\n        self._validate_install_status()\n        self._determine_install_status()\n        if self.aggregate_skill.install_status == \"failed\":\n            self._determine_failure_reason()\n\n    def _validate_install_status(self):\n        for skill in self.installed_skills:\n            if skill.install_status not in VALID_STATUS_VALUES:\n                raise ValueError(\n                    '\"{install_status}\" is not a supported value of the '\n                    \"installation field in the skill manifest\".format(\n                        install_status=skill.install_status\n                    )\n                )\n\n    def _determine_install_status(self):\n        \"\"\"Use skill data from all devices to determine install status.\n\n        When a skill is installed via the Marketplace, it is installed to all\n        devices.  The Marketplace will not mark a skill as \"installed\" until\n        install is complete on all devices.  Until that point, the status will\n        be \"installing\".\n\n        If the install fails on any device, the install will be flagged as a\n        failed install in the Marketplace.\n        \"\"\"\n        failed = [skill.install_status == \"failed\" for skill in self.installed_skills]\n        installing = [s.install_status == \"installing\" for s in self.installed_skills]\n        uninstalling = [\n            skill.install_status == \"uninstalling\" for skill in self.installed_skills\n        ]\n        installed = [s.install_status == \"installed\" for s in self.installed_skills]\n        if any(failed):\n            self.aggregate_skill.install_status = \"failed\"\n        elif any(installing):\n            self.aggregate_skill.install_status = \"installing\"\n        elif any(uninstalling):\n            self.aggregate_skill.install_status = \"uninstalling\"\n        elif all(installed):\n            self.aggregate_skill.install_status = \"installed\"\n\n    def _determine_failure_reason(self):\n        \"\"\"When a skill fails to install, determine the reason\"\"\"\n        for skill in self.installed_skills:\n            if skill.install_status == \"failed\":\n                self.aggregate_skill.failure_reason = skill.install_failure_reason\n                break\n"
  },
  {
    "path": "api/market/pyproject.toml",
    "content": "[tool.poetry]\nname = \"market\"\nversion = \"0.1.0\"\ndescription = \"API for Mycroft Marketplace\"\nauthors = [\"Chris Veilleux <veilleux.chris@gmail.com>\"]\nlicense = \"GNU AGPL 3.0\"\n\n[tool.poetry.dependencies]\npython = \"^3.9\"\nflask = \"*\"\nrequests = \"*\"\npyjwt = \"*\"\nuwsgi = \"*\"\nmarkdown = \"*\"\nselene = {path = \"./../../shared\", develop = true}\n\n[tool.poetry.dev-dependencies]\nbehave = \"*\"\npyhamcrest = \"*\"\nallure-behave = \"*\"\npylint = \"*\"\n\n[build-system]\nrequires = [\"poetry-core>=1.0.0\"]\nbuild-backend = \"poetry.core.masonry.api\"\n"
  },
  {
    "path": "api/market/swagger.yaml",
    "content": "swagger: '2.0'\ninfo:\n  description: >-\n    The marketplace is where users can access the skills available to install on\n    their devices.  In the future, other products like voices and hardware will\n    be available through the store.\n  version: '2018.3'\n  title: Mycroft Marketplace\nhost: market.mycroft.ai\nbasePath: /api\ntags:\n  - name: skill\n    description: >-\n      Browse information about available skills and manage skills on your\n      devices.\nschemes:\n  - https\npaths:\n  /skill/available:\n    get:\n      tags:\n        - skill\n      summary: Retrieve all available skills for devices using Mycroft.\n      description: >-\n        The data retrieved is based on the skill metadata found in the\n        mycroft-skills-data Github repository.\n      parameters:\n        - name: search\n          in: query\n          description: >-\n            Filter skills by comparing the value of this parameter to a skill's\n            title, summary, description, categories and tags.  All skills are\n            returned when this parameter is omitted.\n          required: false\n          type: string\n      produces:\n        - application/json\n      responses:\n        '200':\n          description: successful operation\n          schema:\n            type: array\n            items:\n              $ref: '#/definitions/SkillSummary'\n  '/skill/detail/{skillName}':\n    get:\n      tags:\n        - skill\n      summary: Retrieve more detailed information about a selected skill.\n      description: >-\n        This endpoint provides more information about a skill than is provided\n        in the available skills endpoint.\n      parameters:\n        - name: skillName\n          in: path\n          description: Unique name of the skill to return.\n          required: true\n          type: string\n      produces:\n        - application/json\n      responses:\n        '200':\n          description: successful operation\n          schema:\n            type: array\n            items:\n              $ref: '#/definitions/SkillDetail'\n  '/skill/installations':\n    get:\n      tags:\n        - skill\n      summary: Retrieve the installation status of skills for a user.\n      description: >-\n        When a user is logged in, this endpoint will return the skills known by that user's device(s).  It will communicate the installation status of each skill as it relates to a user's devices.\n      produces:\n        - application/json\n      responses:\n        '200':\n          description: successful operation\n          schema:\n            type: array\n            items:\n              $ref: '#/definitions/Installations'\ndefinitions:\n  SkillSummary:\n    description: >-\n      Subset of the skill metadata used for generating a list of available\n      skills.\n    type: object\n    properties:\n      icon:\n        $ref: '#/definitions/Icon'\n      iconImage:\n        description: >-\n          Url to an icon image.  When provided it will take precedence over the\n          value of the icon field.\n        format: uri\n        type: string\n      isMycroftMade:\n        description: Denotes a skill that is written by someone on the Mycroft team.\n        type: boolean\n      isSystemSkill:\n        description: >-\n          System skills are treated differently than others in that they are all\n          installed on a device by default and cannot be installed.  Volume\n          control is an example.\n        type: boolean\n      marketCategory:\n        description: >-\n          A skill may have many categories.  The first category defined is used\n          when displaying the list of available skills.\n        example: Music\n        type: string\n      name:\n        description: >-\n          uniquely identifying name of skill that is the same as the skill's\n          submodule name in the mycroft-skills Github repository\n        example: spotify\n        type: string\n      summary:\n        description: A short phrase describing the skill's function\n        example: Listen to music from your Spotify account\n        type: string\n      title:\n        example: Spotify\n        type: string\n      trigger:\n        description: Example of a voice command that triggers the skill.\n        example: Play Rush on Spotify\n        type: string\n  SkillDetail:\n    type: object\n    properties:\n      categories:\n        description: All categories related to the skill\n        example:\n          - Music\n          - Entertainment\n        type: array\n        items:\n          type: string\n      credits:\n        $ref: '#/definitions/Credits'\n      description:\n        description: Detailed description of the skill and its capabilities.\n        type: string\n      icon:\n        $ref: '#/definitions/Icon'\n      iconImage:\n        description: >-\n          Url to an icon image.  When provided it will take precedence over the\n          value of the icon field.\n        format: uri\n        type: string\n      isSystemSkill:\n        description: >-\n          System skills are treated differently than others in that they are all\n          installed on a device by default and cannot be installed.  Volume\n          control is an example.\n        type: boolean\n      marketCategory:\n        description: >-\n          A skill may have many categories.  The first category defined is used\n          when displaying the list of available skills.\n        example: Music\n        type: string\n      name:\n        description: >-\n          uniquely identifying name of skill that is the same as the skill's\n          submodule name in the mycroft-skills Github repository\n        example: Spotify\n        type: string\n      platforms:\n        description: Lists the platforms this skill can run on. Defaults to \"any\"\n        example:\n          - Mark I\n          - Mark II\n        type: array\n        items:\n          type: string\n      repositoryUrl:\n        description: A URL representing the Github page for that skill.\n      summary:\n        description: A short phrase describing the skill's function\n        example: Listen to music from your Spotify account\n        type: string\n      title:\n        example: Spotify\n        type: string\n      triggers:\n        description: Examples of a voice commands that trigger the skill.\n        example:\n          - Play Rush on Spotify\n          - Play Discover Weekly\n        type: array\n        items:\n          type: string\n  Credits:\n    type: object\n    properties:\n      name:\n        example: Mycroft AI\n        type: string\n      githubId:\n        type: string\n  Icon:\n    description: >-\n      Identifies a FontAwesome icon representing the skill and the color of the\n      icon\n    type: object\n    properties:\n      name:\n        description: Name of the icon as defined in the FontAwesome library\n        example: music\n        type: string\n      color:\n        description: The color that will be applied to the icon in the UI.\n        example: '#22a7f0'\n        type: string\n        format: hex\n  Installations:\n    description: >-\n      Install status of all skills on a user's device(s) and the reason for installation failures, if there are any.\n    properties:\n      installStatuses:\n        type: object\n      failureReasons:\n        type: object\n"
  },
  {
    "path": "api/market/uwsgi.ini",
    "content": "[uwsgi]\nmaster = true\nmodule = market_api.api:market\nprocesses = 4\nsocket = :5000\ndie-on-term = true\nlazy = true\nlazy-apps = true\n"
  },
  {
    "path": "api/precise/precise_api/__init__.py",
    "content": ""
  },
  {
    "path": "api/precise/precise_api/api.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2020 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\n\"\"\"Entry point for the API that supports the Mycroft Marketplace.\"\"\"\nfrom flask import Flask\n\nfrom selene.api import get_base_config, selene_api, SeleneResponse\nfrom selene.util.log import configure_logger\nfrom .endpoints import AudioFileEndpoint, DesignationEndpoint, TagEndpoint\n\n_log = configure_logger(\"precise_api\")\n\n\n# Define the Flask application\nprecise = Flask(__name__)\nprecise.config.from_object(get_base_config())\nprecise.response_class = SeleneResponse\nprecise.register_blueprint(selene_api)\n\naudio_file_endpoint = AudioFileEndpoint.as_view(\"audio_file_endpoint\")\nprecise.add_url_rule(\n    \"/api/audio/<string:file_name>\", view_func=audio_file_endpoint, methods=[\"GET\"]\n)\n\ndesignation_endpoint = DesignationEndpoint.as_view(\"designation_endpoint\")\nprecise.add_url_rule(\n    \"/api/designation\", view_func=designation_endpoint, methods=[\"GET\"]\n)\n\ntag_endpoint = TagEndpoint.as_view(\"tag_endpoint\")\nprecise.add_url_rule(\"/api/tag\", view_func=tag_endpoint, methods=[\"GET\", \"POST\"])\n"
  },
  {
    "path": "api/precise/precise_api/endpoints/__init__.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2020 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Public API into the Precise API endpoint package.\"\"\"\n\nfrom .audio_file import AudioFileEndpoint\nfrom .designation import DesignationEndpoint\nfrom .tag import TagEndpoint\n"
  },
  {
    "path": "api/precise/precise_api/endpoints/audio_file.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2020 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Precise API endpoint for presenting a URL to the GUI for the audio file.\"\"\"\n\nfrom os import environ\n\nfrom flask import abort, send_from_directory\n\nfrom selene.api import SeleneEndpoint\n\n\nclass AudioFileEndpoint(SeleneEndpoint):\n    \"\"\"Precise API endpoint for presenting a URL to the GUI for the audio file.\"\"\"\n\n    def get(self, file_name):\n        \"\"\"Handle an HTTP GET request.\"\"\"\n        self._authenticate()\n        try:\n            return send_from_directory(environ[\"SELENE_DATA_DIR\"], file_name)\n        except FileNotFoundError:\n            abort(404)\n"
  },
  {
    "path": "api/precise/precise_api/endpoints/designation.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2020 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Precise API endpoint for retrieving file designations.\n\nA designation is a decision made from a set of tags as to what the attributes of a\nsample file are.\n\"\"\"\n\nfrom collections import defaultdict\nfrom datetime import datetime\nfrom http import HTTPStatus\nfrom pathlib import Path\nfrom typing import List\n\nfrom selene.api import SeleneEndpoint\nfrom selene.data.tagging import (\n    FileDesignation,\n    FileDesignationRepository,\n    Tag,\n    TagRepository,\n    TagValue,\n)\n\n\nclass DesignationEndpoint(SeleneEndpoint):\n    \"\"\"Precise API endpoint for tagging a file.\n\n    The HTTP GET request will select all sample files for a specified wake word that\n    have been designated since a specified date.  Optional tag and tag value arguments\n    can be used to filter the result set.\n    \"\"\"\n\n    _tags = None\n\n    @property\n    def tags(self) -> List[Tag]:\n        \"\"\"Get all the possible tags.\n\n        :return a list of all tags and their values\n        \"\"\"\n        if self._tags is None:\n            tag_repository = TagRepository(self.db)\n            tags = tag_repository.get_all()\n            self._tags = sorted(tags, key=lambda tag: tag.priority)\n\n        return self._tags\n\n    def get(self):\n        \"\"\"Handle an HTTP GET request.\"\"\"\n        response_data = self._build_response_data()\n\n        return response_data, HTTPStatus.OK\n\n    def _build_response_data(self):\n        \"\"\"Build the response from data retrieved from the database.\n\n        :return the response\n        \"\"\"\n        designations = self._get_designations()\n        response_data = dict()\n        for designation in designations:\n            tag = self._get_tag(designation)\n            tag_value = self._get_tag_value(designation, tag)\n            if self._include_in_result(tag, tag_value):\n                if tag.name not in response_data:\n                    response_data[tag.name] = defaultdict(list)\n                file_path = Path(designation.file_directory).joinpath(\n                    designation.file_name\n                )\n                response_data[tag.name][tag_value.value].append(str(file_path))\n\n        return response_data\n\n    def _get_designations(self) -> List[FileDesignation]:\n        \"\"\"Retrieve the designations from the database that meet the criteria.\"\"\"\n        wake_word = self.request.args[\"wakeWord\"].replace(\"-\", \" \")\n        start_date = datetime.strptime(self.request.args[\"startDate\"], \"%Y-%m-%d\")\n        designation_repo = FileDesignationRepository(self.db)\n        designations = designation_repo.get_from_date(wake_word, start_date)\n\n        return designations\n\n    def _include_in_result(self, tag: Tag, tag_value: TagValue) -> bool:\n        \"\"\"Use the tag associated with a file to determine inclusion in results set.\n\n        :param tag: The tag designated to the sample file\n        :param tag_value: The value of the tag designated to the sample file.\n        :return a boolean value indicating if the file should be included in response.\n        \"\"\"\n        requested_tag = self.request.args.get(\"tag\")\n        requested_tag_value = self.request.args.get(\"tagValue\")\n        if requested_tag is None:\n            include = True\n        else:\n            include = requested_tag == tag.name and (\n                requested_tag_value is None or requested_tag_value == tag_value.value\n            )\n\n        return include\n\n    def _get_tag(self, designation: FileDesignation) -> Tag:\n        \"\"\"Get the attributes of the tag designated to the file.\n\n        :param designation: Object containing attributes of the designated file.\n        :return: The attributes of the tag\n        :raises ValueError when the designation tag is not valid.\n        \"\"\"\n        for tag in self.tags:\n            if tag.id == designation.tag_id:\n                return tag\n\n        raise ValueError(f\"Tag ID {designation.tag_id} not found\")\n\n    @staticmethod\n    def _get_tag_value(designation: FileDesignation, tag: Tag):\n        \"\"\"Get the attributes of the tag value designated to the file.\n\n        :param designation: Object containing attributes of the designated file.\n        :param tag: The attributes of the tag, which include valid values\n        :return: The attributes of the tag value\n        :raises ValueError when the designation tag value is not valid.\n        \"\"\"\n        for tag_value in tag.values:\n            if tag_value.id == designation.tag_value_id:\n                return tag_value\n\n        raise ValueError(f\"Tag value ID {designation.tag_value_id} not found\")\n"
  },
  {
    "path": "api/precise/precise_api/endpoints/tag.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2020 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Precise API endpoint for tagging a file.\"\"\"\n\nimport getpass\nfrom http import HTTPStatus\nfrom os import environ\nfrom pathlib import Path\nfrom typing import List\n\nfrom flask import jsonify\nfrom schematics import Model\nfrom schematics.types import StringType\n\nfrom selene.api import SeleneEndpoint\nfrom selene.data.tagging import (\n    FileTag,\n    FileTagRepository,\n    SessionRepository,\n    Tag,\n    TaggableFile,\n    Tagger,\n    TaggerRepository,\n    TagRepository,\n    WakeWordFileRepository,\n)\nfrom selene.util.ssh import get_remote_file, SshClientConfig\n\n\nclass TagPostRequest(Model):\n    \"\"\"Define the expected arguments to be passed in the POST request.\"\"\"\n\n    tag_id = StringType(required=True)\n    tag_value = StringType(required=True)\n    file_name = StringType(required=True)\n    session_id = StringType(required=True)\n\n\nclass TagEndpoint(SeleneEndpoint):\n    \"\"\"Precise API endpoint for tagging a file.\n\n    The HTTP GET request will randomly select a type of tag, which will in turn be used\n    to retrieve an audio file that requires the tag.  The selected audio file must not\n    have been tagged in the last hour.  This will prevent the same files from being\n    tagged more times than necessary.  The file will also be copied to local storage\n    for a subsequent API call.\n    \"\"\"\n\n    _tags = None\n\n    @property\n    def tags(self) -> List[Tag]:\n        \"\"\"Get all the possible tags.\n\n        :return a list of all tags and their values\n        \"\"\"\n        if self._tags is None:\n            tag_repository = TagRepository(self.db)\n            tags = tag_repository.get_all()\n            self._tags = sorted(tags, key=lambda tag: tag.priority)\n\n        return self._tags\n\n    def get(self):\n        \"\"\"Handle an HTTP GET request.\"\"\"\n        self._authenticate()\n        session_id = self._ensure_session_exists()\n        response_data, file_to_tag = self._build_response_data(session_id)\n        if response_data:\n            self._copy_audio_file(file_to_tag)\n\n        return response_data, HTTPStatus.OK if response_data else HTTPStatus.NO_CONTENT\n\n    def _ensure_session_exists(self):\n        \"\"\"If no session ID is provided in the request, get it from the database.\"\"\"\n        session_id = self.request.args.get(\"sessionId\")\n        if session_id is None:\n            tagger = self._ensure_tagger_exists()\n            session_repository = SessionRepository(self.db)\n            session_id = session_repository.ensure_session_exists(tagger)\n\n        return session_id\n\n    def _ensure_tagger_exists(self):\n        \"\"\"Get the tagger attributes for this account.\"\"\"\n        tagger = Tagger(entity_type=\"account\", entity_id=self.account.id)\n        tagger_repository = TaggerRepository(self.db)\n        tagger.id = tagger_repository.ensure_tagger_exists(tagger)\n\n        return tagger\n\n    def _build_response_data(self, session_id: str):\n        \"\"\"Build the response from data retrieved from the database.\n\n        :param session_id: Identifier of the user's tagging session\n        :return the response and the taggable file object\n        \"\"\"\n        wake_word = self.request.args[\"wakeWord\"].replace(\"-\", \" \")\n        file_to_tag = self._get_taggable_file(wake_word, session_id)\n        if file_to_tag is None:\n            response_data = \"\"\n        else:\n            tag = self._select_tag(file_to_tag)\n            response_data = dict(\n                audioFileId=file_to_tag.id,\n                audioFileName=file_to_tag.name,\n                sessionId=session_id,\n                tagId=tag.id,\n                tagInstructions=tag.instructions,\n                tagName=(wake_word if tag.name == \"wake word\" else tag.name).title(),\n                tagTitle=tag.title,\n                tagValues=tag.values,\n            )\n\n        return response_data, file_to_tag\n\n    def _get_taggable_file(self, wake_word: str, session_id: str) -> TaggableFile:\n        \"\"\"Get a file that has still requires some tagging for a specified tag type.\n\n        :param wake_word: the wake word being tagged by the user\n        :param session_id: identifier of the user's tagging session\n        :return: dataclass instance representing the file to be tagged\n        \"\"\"\n        file_repository = WakeWordFileRepository(self.db)\n        file_to_tag = file_repository.get_taggable_file(\n            wake_word, len(self.tags), session_id\n        )\n\n        return file_to_tag\n\n    def _select_tag(self, file_to_tag: TaggableFile) -> Tag:\n        \"\"\"Determine which tag to return in the response.\n\n        :param file_to_tag: Attributes of the file that will be tagged by the user\n        :return: the tag to put in the response\n        \"\"\"\n        selected_tag = None\n        for tag in self.tags:\n            if file_to_tag.designations is None:\n                selected_tag = tag\n            elif file_to_tag.tag is None:\n                if tag.id not in file_to_tag.designations:\n                    selected_tag = tag\n            else:\n                if tag.id == file_to_tag.tag:\n                    selected_tag = tag\n            if selected_tag is not None:\n                break\n\n        return selected_tag or self.tags[0]\n\n    @staticmethod\n    def _copy_audio_file(file_to_tag: TaggableFile):\n        \"\"\"Copy the file from the location specified in the database to local storage\n\n        :param file_to_tag: dataclass instance representing the file to be tagged\n        \"\"\"\n        local_path = Path(environ[\"SELENE_DATA_DIR\"]).joinpath(file_to_tag.name)\n        if not local_path.exists():\n            if file_to_tag.location.server == environ[\"PRECISE_SERVER\"]:\n                remote_user = \"precise\"\n                ssh_port = environ[\"PRECISE_SSH_PORT\"]\n            else:\n                remote_user = \"mycroft\"\n                ssh_port = 22\n            ssh_config = SshClientConfig(\n                local_user=getpass.getuser(),\n                remote_server=file_to_tag.location.server,\n                remote_user=remote_user,\n                ssh_port=ssh_port,\n            )\n            remote_path = Path(file_to_tag.location.directory).joinpath(\n                file_to_tag.name\n            )\n            get_remote_file(ssh_config, local_path, remote_path)\n\n    def post(self):\n        \"\"\"Process HTTP POST request for an account.\"\"\"\n        self._authenticate()\n        self._validate_post_request()\n        self._add_tag()\n\n        return jsonify(\"File tagged successfully\"), HTTPStatus.OK\n\n    def _validate_post_request(self):\n        \"\"\"Validate the contents of the request object for a POST request.\"\"\"\n        post_request = TagPostRequest(\n            dict(\n                session_id=self.request.json.get(\"sessionId\"),\n                tag_id=self.request.json.get(\"tagId\"),\n                tag_value=self.request.json.get(\"tagValueId\"),\n                file_name=self.request.json.get(\"audioFileId\"),\n            )\n        )\n        post_request.validate()\n\n    def _add_tag(self):\n        \"\"\"Add the tagging result to the database.\"\"\"\n        file_tag = FileTag(\n            file_id=self.request.json[\"audioFileId\"],\n            session_id=self.request.json[\"sessionId\"],\n            tag_id=self.request.json[\"tagId\"],\n            tag_value_id=self.request.json[\"tagValueId\"],\n        )\n        file_tag_repository = FileTagRepository(self.db)\n        file_tag_repository.add(file_tag)\n"
  },
  {
    "path": "api/precise/pyproject.toml",
    "content": "[tool.poetry]\nname = \"precise\"\nversion = \"0.1.0\"\ndescription = \"API for Precise wake word tagger\"\nauthors = [\"Chris Veilleux <veilleux.chris@gmail.com>\"]\nlicense = \"GNU AGPL 3.0\"\n\n[tool.poetry.dependencies]\npython = \"^3.9\"\nflask = \"*\"\nselene = {path = \"./../../shared\", develop = true}\nuwsgi = \"*\"\n\n[tool.poetry.dev-dependencies]\nbehave = \"*\"\npyhamcrest = \"*\"\npylint = \"*\"\nblack = \"*\"\n\n[build-system]\nrequires = [\"poetry-core>=1.0.0\"]\nbuild-backend = \"poetry.core.masonry.api\"\n"
  },
  {
    "path": "api/precise/uwsgi.ini",
    "content": "[uwsgi]\nmaster = true\nmodule = precise_api.api:precise\nprocesses = 4\nsocket = :5000\ndie-on-term = true\nlazy = true\nlazy-apps = true\n"
  },
  {
    "path": "api/public/__init__.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n"
  },
  {
    "path": "api/public/public_api/__init__.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n"
  },
  {
    "path": "api/public/public_api/api.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Define the Selene Public API\"\"\"\nimport os\n\nfrom flask import Flask\n\nfrom selene.api import SeleneResponse, selene_api\nfrom selene.api.base_config import get_base_config\nfrom selene.api.public_endpoint import check_oauth_token\nfrom selene.util.cache import SeleneCache\nfrom selene.util.log import configure_selene_logger\nfrom .endpoints.audio_transcription import AudioTranscriptionEndpoint\nfrom .endpoints.device import DeviceEndpoint\nfrom .endpoints.device_activate import DeviceActivateEndpoint\nfrom .endpoints.device_code import DeviceCodeEndpoint\nfrom .endpoints.device_email import DeviceEmailEndpoint\nfrom .endpoints.device_location import DeviceLocationEndpoint\nfrom .endpoints.device_metrics import DeviceMetricsEndpoint\nfrom .endpoints.device_oauth import OauthServiceEndpoint\nfrom .endpoints.device_pantacor import DevicePantacorEndpoint\nfrom .endpoints.device_refresh_token import DeviceRefreshTokenEndpoint\nfrom .endpoints.device_setting import DeviceSettingEndpoint\nfrom .endpoints.device_skill import SkillSettingsMetaEndpoint\nfrom .endpoints.device_skill_manifest import DeviceSkillManifestEndpoint\nfrom .endpoints.device_skill_settings import DeviceSkillSettingsEndpoint\nfrom .endpoints.device_skill_settings import DeviceSkillSettingsEndpointV2\nfrom .endpoints.device_subscription import DeviceSubscriptionEndpoint\nfrom .endpoints.geolocation import GeolocationEndpoint\nfrom .endpoints.google_stt import GoogleSTTEndpoint\nfrom .endpoints.oauth_callback import OauthCallbackEndpoint\nfrom .endpoints.open_weather_map import OpenWeatherMapEndpoint\nfrom .endpoints.premium_voice import PremiumVoiceEndpoint\nfrom .endpoints.stripe_webhook import StripeWebHookEndpoint\nfrom .endpoints.wake_word_file import WakeWordFileUpload\nfrom .endpoints.wolfram_alpha import WolframAlphaEndpoint\nfrom .endpoints.wolfram_alpha_simple import WolframAlphaSimpleEndpoint\nfrom .endpoints.wolfram_alpha_spoken import WolframAlphaSpokenEndpoint\nfrom .endpoints.wolfram_alpha_v2 import WolframAlphaV2Endpoint\n\nconfigure_selene_logger(\"public_api\")\n\npublic = Flask(__name__)\npublic.config.from_object(get_base_config())\npublic.config[\"GOOGLE_STT_KEY\"] = os.environ[\"GOOGLE_STT_KEY\"]\npublic.config[\"SELENE_CACHE\"] = SeleneCache()\npublic.response_class = SeleneResponse\npublic.register_blueprint(selene_api)\npublic.add_url_rule(\n    \"/v1/transcribe\",\n    view_func=AudioTranscriptionEndpoint.as_view(\"audio_transcription_api\"),\n    methods=[\"POST\"],\n)\npublic.add_url_rule(\n    \"/v1/device/<string:device_id>/skill/<string:skill_gid>\",\n    view_func=DeviceSkillSettingsEndpoint.as_view(\"device_skill_delete_api\"),\n    methods=[\"DELETE\"],\n)\npublic.add_url_rule(\n    \"/v1/device/<string:device_id>/skill\",\n    view_func=DeviceSkillSettingsEndpoint.as_view(\"device_skill_api\"),\n    methods=[\"GET\", \"PUT\"],\n)\npublic.add_url_rule(\n    \"/v1/device/<string:device_id>/skill/settings\",\n    view_func=DeviceSkillSettingsEndpointV2.as_view(\"skill_settings_api\"),\n    methods=[\"GET\"],\n)\npublic.add_url_rule(\n    \"/v1/device/<string:device_id>/settingsMeta\",\n    view_func=SkillSettingsMetaEndpoint.as_view(\"device_user_skill_api\"),\n    methods=[\"PUT\"],\n)\npublic.add_url_rule(\n    \"/v1/device/<string:device_id>\",\n    view_func=DeviceEndpoint.as_view(\"device_api\"),\n    methods=[\"GET\", \"PATCH\"],\n)\npublic.add_url_rule(\n    \"/v1/device/<string:device_id>/setting\",\n    view_func=DeviceSettingEndpoint.as_view(\"device_settings_api\"),\n    methods=[\"GET\"],\n)\npublic.add_url_rule(\n    \"/v1/device/<string:device_id>/subscription\",\n    view_func=DeviceSubscriptionEndpoint.as_view(\"device_subscription_api\"),\n    methods=[\"GET\"],\n)\npublic.add_url_rule(\n    \"/v1/geolocation\",\n    view_func=GeolocationEndpoint.as_view(\"location_api\"),\n    methods=[\"GET\"],\n)\npublic.add_url_rule(\n    \"/v1/wa\",\n    view_func=WolframAlphaEndpoint.as_view(\"wolfram_alpha_api\"),\n    methods=[\"GET\"],\n)  # TODO: change this path in the API v2\npublic.add_url_rule(\n    \"/v1/owm/<path:path>\",\n    view_func=OpenWeatherMapEndpoint.as_view(\"open_weather_map_api\"),\n    methods=[\"GET\"],\n)  # TODO: change this path in the API v2\npublic.add_url_rule(\n    \"/v1/stt\", view_func=GoogleSTTEndpoint.as_view(\"google_stt_api\"), methods=[\"POST\"]\n)  # TODO: change this path in the API v2\npublic.add_url_rule(\n    \"/v1/device/code\",\n    view_func=DeviceCodeEndpoint.as_view(\"device_code_api\"),\n    methods=[\"GET\"],\n)\npublic.add_url_rule(\n    \"/v1/device/activate\",\n    view_func=DeviceActivateEndpoint.as_view(\"device_activate_api\"),\n    methods=[\"POST\"],\n)\npublic.add_url_rule(\n    \"/v1/device/pantacor\",\n    view_func=DevicePantacorEndpoint.as_view(\"device_pantacor_api\"),\n    methods=[\"POST\"],\n)\npublic.add_url_rule(\n    \"/v1/device/<string:device_id>/message\",\n    view_func=DeviceEmailEndpoint.as_view(\"device_email_api\"),\n    methods=[\"PUT\"],\n)\npublic.add_url_rule(\n    \"/v1/device/<string:device_id>/metric/<path:metric>\",\n    view_func=DeviceMetricsEndpoint.as_view(\"device_metric_api\"),\n    methods=[\"POST\"],\n)\npublic.add_url_rule(\n    \"/v1/auth/token\",\n    view_func=DeviceRefreshTokenEndpoint.as_view(\"refresh_token_api\"),\n    methods=[\"GET\"],\n)\npublic.add_url_rule(\n    \"/v1/wolframAlphaSimple\",\n    view_func=WolframAlphaSimpleEndpoint.as_view(\"wolfram_alpha_simple_api\"),\n    methods=[\"GET\"],\n)\npublic.add_url_rule(\n    \"/v1/wolframAlphaSpoken\",\n    view_func=WolframAlphaSpokenEndpoint.as_view(\"wolfram_alpha_spoken_api\"),\n    methods=[\"GET\"],\n)\npublic.add_url_rule(\n    \"/v1/wolframAlphaFull\",\n    view_func=WolframAlphaV2Endpoint.as_view(\"wolfram_alpha_v2_api\"),\n    methods=[\"GET\"],\n)\npublic.add_url_rule(\n    \"/v1/device/<string:device_id>/location\",\n    view_func=DeviceLocationEndpoint.as_view(\"device_location_api\"),\n    methods=[\"GET\"],\n)\npublic.add_url_rule(\n    \"/v1/device/<string:device_id>/skillJson\",\n    view_func=DeviceSkillManifestEndpoint.as_view(\"skill_manifest_api\"),\n    methods=[\"GET\", \"PUT\"],\n)\npublic.add_url_rule(\n    \"/v1/auth/callback\",\n    view_func=OauthCallbackEndpoint.as_view(\"oauth_callback_api\"),\n    methods=[\"GET\"],\n)\npublic.add_url_rule(\n    \"/v1/device/<string:device_id>/voice\",\n    view_func=PremiumVoiceEndpoint.as_view(\"premium_voice_api\"),\n    methods=[\"GET\"],\n)\npublic.add_url_rule(\n    \"/v1/device/<string:device_id>/<string:oauth_path>/<string:credentials>\",\n    view_func=OauthServiceEndpoint.as_view(\"oauth_api\"),\n    methods=[\"GET\"],\n)\npublic.add_url_rule(\n    \"/v1/user/stripe/webhook\",\n    view_func=StripeWebHookEndpoint.as_view(\"stripe_webhook_api\"),\n    methods=[\"POST\"],\n)\npublic.add_url_rule(\n    \"/v1/device/<string:device_id>/wake-word-file\",\n    view_func=WakeWordFileUpload.as_view(\"wake_word_file\"),\n    methods=[\"POST\"],\n)\n\n\n# This is a workaround to allow the API return 401 when we call a non existent\n# path. Use case: GET /device/{uuid} with empty uuid. Core today uses the 401\n# to validate if it needs to perform a pairing process. We should fix that in a\n# future version because we have to return 404 when we call a non existent path\npublic.before_request(check_oauth_token)\n"
  },
  {
    "path": "api/public/public_api/endpoints/__init__.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n"
  },
  {
    "path": "api/public/public_api/endpoints/audio_transcription.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Public API endpoint for official Mycroft-supported audio transcriptions.\n\nWhen a device is configured to use the Mycroft STT plugin for transcribing audio,\nthis endpoint will be called to do the transcription anonymously.\n\"\"\"\n\nfrom datetime import datetime\nfrom decimal import Decimal\nfrom http import HTTPStatus\nfrom io import BytesIO\nfrom typing import Optional\n\nimport librosa\nfrom google.cloud import speech\n\nfrom selene.api import PublicEndpoint, track_account_activity\nfrom selene.data.account import AccountRepository\nfrom selene.data.metric import SttTranscriptionMetric, TranscriptionMetricRepository\nfrom selene.util.log import get_selene_logger\n\nSAMPLE_RATE = 16000\n\n_log = get_selene_logger(__name__)\n\n\nclass AudioTranscriptionEndpoint(PublicEndpoint):\n    \"\"\"Transcribes audio data to text and responds with the result.\"\"\"\n\n    def __init__(self):\n        super().__init__()\n        self.audio_duration = Decimal(0.0)\n        self.transcription_duration = Decimal(0.0)\n\n    def post(self):\n        \"\"\"Processes an HTTP Post request.\"\"\"\n        self._authenticate()\n        transcription = self._transcribe()\n        self._add_transcription_metric(transcription)\n        if transcription is not None:\n            track_account_activity(self.db, self.device_id)\n\n        return dict(transcription=transcription), HTTPStatus.OK\n\n    def _transcribe(self) -> Optional[str]:\n        \"\"\"Transcribes the audio in the request to text using a transcription service.\n\n        :returns: None if the transcription failed or the transcription\n        \"\"\"\n        response = self._call_transcription_api()\n        transcription = self._get_transcription(response)\n\n        return transcription\n\n    def _call_transcription_api(self) -> Optional[speech.RecognizeResponse]:\n        \"\"\"Calls the configured audio transcription service API.\n\n        :returns: None if the call fails or the result of the API call\n        \"\"\"\n        response = None\n        client = speech.SpeechClient()\n        audio = speech.RecognitionAudio(content=self.request.data)\n        config_values = dict(\n            encoding=speech.RecognitionConfig.AudioEncoding.FLAC,\n            sample_rate_hertz=SAMPLE_RATE,\n            language_code=\"en-US\",\n        )\n        config = speech.RecognitionConfig(**config_values)\n        start_timestamp = datetime.now()\n        try:\n            response = client.recognize(config=config, audio=audio)\n        except Exception:\n            _log.exception(f\"{self.request_id}: Transcription failed.\")\n        finally:\n            end_timestamp = datetime.now()\n        transcription_duration = (end_timestamp - start_timestamp).total_seconds()\n        self.transcription_duration = Decimal(str(transcription_duration))\n\n        return response\n\n    def _get_transcription(\n        self, response: Optional[speech.RecognizeResponse]\n    ) -> Optional[str]:\n        \"\"\"Interrogates the response from the transcription service API.\n\n        :param response: the transcription service API response\n        :return: None if the audio could not be transcribed or the transcription\n        \"\"\"\n        transcription = None\n        if response:\n            highest_confidence = 0\n            for result in response.results:\n                for alternative in result.alternatives:\n                    if alternative.confidence > highest_confidence:\n                        transcription = alternative.transcript\n\n        return transcription\n\n    def _add_transcription_metric(self, transcription: str):\n        \"\"\"Adds metrics for this STT transcription to the database.\"\"\"\n        account_repo = AccountRepository(self.db)\n        account = account_repo.get_account_by_device_id(self.device_id)\n        transcription_metric = SttTranscriptionMetric(\n            account_id=account.id,\n            engine=\"Google Cloud\",\n            success=transcription is not None,\n            audio_duration=Decimal(str(self._determine_audio_duration())),\n            transcription_duration=Decimal(str(self.transcription_duration)),\n        )\n        transcription_metric_repo = TranscriptionMetricRepository(self.db)\n        transcription_metric_repo.add(transcription_metric)\n\n    def _determine_audio_duration(self) -> float:\n        \"\"\"Determines the duration of the audio data for the metrics.\"\"\"\n        with BytesIO(self.request.data) as request_audio:\n            audio, _ = librosa.load(request_audio, sr=SAMPLE_RATE, mono=True)\n            return librosa.get_duration(y=audio, sr=SAMPLE_RATE)\n"
  },
  {
    "path": "api/public/public_api/endpoints/device.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nimport json\nfrom http import HTTPStatus\n\nfrom schematics import Model\nfrom schematics.types import StringType\n\nfrom selene.api import PublicEndpoint\nfrom selene.api import device_etag_key\nfrom selene.data.device import DeviceRepository\n\n\nclass UpdateDevice(Model):\n    coreVersion = StringType(default=\"unknown\")\n    platform = StringType(default=\"unknown\")\n    platform_build = StringType()\n    enclosureVersion = StringType(default=\"unknown\")\n\n\nclass DeviceEndpoint(PublicEndpoint):\n    \"\"\"Return the device entity using the device_id\"\"\"\n\n    def __init__(self):\n        super(DeviceEndpoint, self).__init__()\n\n    def get(self, device_id):\n        self._authenticate(device_id)\n        self._validate_etag(device_etag_key(device_id))\n        device = DeviceRepository(self.db).get_device_by_id(device_id)\n\n        if device is not None:\n            response_data = dict(\n                uuid=device.id,\n                name=device.name,\n                description=device.placement,\n                coreVersion=device.core_version,\n                enclosureVersion=device.enclosure_version,\n                platform=device.platform,\n                user=dict(uuid=device.account_id),\n            )\n            response = response_data, HTTPStatus.OK\n\n            self._add_etag(device_etag_key(device_id))\n        else:\n            response = \"\", HTTPStatus.NO_CONTENT\n\n        return response\n\n    def patch(self, device_id):\n        self._authenticate(device_id)\n        payload = json.loads(self.request.data)\n        update_device = UpdateDevice(payload)\n        update_device.validate()\n        updates = dict(\n            platform=payload.get(\"platform\") or \"unknown\",\n            enclosure_version=payload.get(\"enclosureVersion\") or \"unknown\",\n            core_version=payload.get(\"coreVersion\") or \"unknown\",\n        )\n        DeviceRepository(self.db).update_device_from_core(device_id, updates)\n\n        return \"\", HTTPStatus.OK\n"
  },
  {
    "path": "api/public/public_api/endpoints/device_activate.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\n\"\"\"Endpoint to activate a device and finish the pairing process.\n\nA device will call this endpoint every 10 seconds to determine if the user\nhas completed the device activation process on home.mycroft.ai.  The account\nAPI will set a Redis entry with the a key of \"pairing.token\" and a value of\nthe pairing token generated in the pairing code endpoint.  The device passes\nthe same token in the request body.  When a match is found, the activation\nis complete.\n\"\"\"\nimport json\nfrom http import HTTPStatus\n\nfrom schematics import Model\nfrom schematics.types import StringType\n\nfrom selene.api import generate_device_login, PublicEndpoint\nfrom selene.data.device import DeviceRepository\nfrom selene.util.cache import DEVICE_PAIRING_TOKEN_KEY\n\n\nclass ActivationRequest(Model):\n    \"\"\"Data model of the fields in the request.\"\"\"\n\n    token = StringType(required=True)\n    state = StringType(required=True)\n    platform = StringType(default=\"unknown\")\n    core_version = StringType(default=\"unknown\")\n    enclosure_version = StringType(default=\"unknown\")\n    platform_build = StringType()\n    pantacor_device_id = StringType()\n\n\nclass DeviceActivateEndpoint(PublicEndpoint):\n    \"\"\"API endpoint for activating a device, the last step in device pairing.\"\"\"\n\n    _device_repository = None\n\n    @property\n    def device_repository(self):\n        \"\"\"Lazily load an instance of the device repository\"\"\"\n        if self._device_repository is None:\n            self._device_repository = DeviceRepository(self.db)\n\n        return self._device_repository\n\n    def post(self):\n        \"\"\"Process a HTTP POST request.\"\"\"\n        activation_request = self._validate_request()\n        pairing_session = self._get_pairing_session()\n        if pairing_session is not None:\n            device_id = pairing_session[\"uuid\"]\n            self._activate(device_id, activation_request)\n            response = (generate_device_login(device_id, self.cache), HTTPStatus.OK)\n        else:\n            response = \"\", HTTPStatus.NOT_FOUND\n\n        return response\n\n    def _validate_request(self) -> dict:\n        \"\"\"Validate the contents of the API request against the data model.\"\"\"\n        # TODO: remove this hack when mycroft-core mark-2 branch is merged into dev\n        if \"coreVersion\" in self.request.json:\n            self.request.json[\"core_version\"] = self.request.json[\"coreVersion\"]\n            del self.request.json[\"coreVersion\"]\n        if \"enclosureVersion\" in self.request.json:\n            self.request.json[\"enclosure_version\"] = self.request.json[\n                \"enclosureVersion\"\n            ]\n            del self.request.json[\"enclosureVersion\"]\n        activation_request = ActivationRequest(self.request.json)\n        activation_request.validate()\n\n        return activation_request.to_native()\n\n    def _get_pairing_session(self):\n        \"\"\"Get the pairing session from the cache.\n\n        The request must have same state value as that stored in the\n        pairing session.\n        \"\"\"\n        pairing_session_token = self.request.json[\"token\"]\n        pairing_session_key = DEVICE_PAIRING_TOKEN_KEY.format(\n            pairing_token=pairing_session_token\n        )\n        pairing_session = self.cache.get(pairing_session_key)\n        if pairing_session:\n            pairing_session = json.loads(pairing_session)\n            if self.request.json[\"state\"] == pairing_session[\"state\"]:\n                self.cache.delete(pairing_session_key)\n\n        return pairing_session\n\n    def _activate(self, device_id: str, activation_request: dict):\n        \"\"\"Updates the core version, platform and enclosure_version columns\n\n        :param device_id: internal identifier of the device\n        :param activation_request: validated request data\n        \"\"\"\n        updates = dict(\n            platform=str(activation_request[\"platform\"]),\n            enclosure_version=str(activation_request[\"enclosure_version\"]),\n            core_version=str(activation_request[\"core_version\"]),\n        )\n        self.device_repository.update_device_from_core(device_id, updates)\n"
  },
  {
    "path": "api/public/public_api/endpoints/device_code.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Endpoint to generate a pairing code and return it to the device.\n\nThe response returned to the device consists of:\n    code: A six character string generated from a limited set of characters\n        (ACEFHJKLMNPRTUVWXY3479) chosen to be easily distinguished when\n        spoken or viewed on a device’s display.\n\n    expiration: An integer representing the number of seconds in a day,\n        which is the amount of time until a pairing code expires.\n\n    state: A string generated by the device using uuid4. Used by device to\n        identify the pairing session.\n\n    token: A SHA512 hash of a string generated by the API using uuid4.\n        Used by the API as a unique identifier for the pairing session.\n\"\"\"\nimport hashlib\nimport json\nimport random\nimport uuid\nfrom http import HTTPStatus\n\nfrom selene.api import PublicEndpoint\nfrom selene.util.cache import DEVICE_PAIRING_CODE_KEY\nfrom selene.util.log import get_selene_logger\n\n# Avoid using ambiguous characters in the pairing code, like 0 and O, that\n# are hard to distinguish on a device display.\nALLOWED_CHARACTERS = \"ACEFHJKLMNPRTUVWXY3479\"\nONE_DAY = 86400\n\n_log = get_selene_logger(__name__)\n\n\nclass DeviceCodeEndpoint(PublicEndpoint):\n    \"\"\"Endpoint to generate a pairing code and send it back to the device.\"\"\"\n\n    def get(self):\n        \"\"\"Return a pairing code to the requesting device.\n\n        The pairing process happens in two steps.  First step generates\n        pairing code.  Second step uses the pairing code to activate the device.\n        The state parameter is used to make sure that the device that is\n        \"\"\"\n        response_data = self._build_response()\n        return response_data, HTTPStatus.OK\n\n    def _build_response(self):\n        \"\"\"\n        Build the response data to return to the device.\n\n        The pairing code generated may already exist for another device. So,\n        continue to generate pairing codes until one that does not already\n        exist is created.\n        \"\"\"\n        response_data = dict(\n            state=self.request.args[\"state\"],\n            token=self._generate_token(),\n            expiration=ONE_DAY,\n        )\n        added_to_cache = False\n        while not added_to_cache:\n            pairing_code = self._generate_pairing_code()\n            response_data.update(code=pairing_code)\n            added_to_cache = self._add_pairing_code_to_cache(response_data)\n\n        return response_data\n\n    @staticmethod\n    def _generate_token():\n        \"\"\"Generate the token used by this API to identify pairing session\"\"\"\n        sha512 = hashlib.sha512()\n        sha512.update(bytes(str(uuid.uuid4()), \"utf-8\"))\n\n        return sha512.hexdigest()\n\n    @staticmethod\n    def _generate_pairing_code():\n        \"\"\"Generate the pairing code that will be spoken by the device.\"\"\"\n        pairing_code = \"\".join(random.choice(ALLOWED_CHARACTERS) for _ in range(6))\n        _log.info(\"Generated pairing code {}\".format(pairing_code))\n\n        return pairing_code\n\n    def _add_pairing_code_to_cache(self, response_data):\n        \"\"\"Add data necessary to activate the device to cache for retrieval.\"\"\"\n        cache_key = DEVICE_PAIRING_CODE_KEY.format(pairing_code=response_data[\"code\"])\n        cache_value = dict(**response_data)\n        core_packaging_type = self.request.args.get(\"packaging\")\n        if core_packaging_type is not None:\n            cache_value.update(packaging_type=core_packaging_type)\n        added_to_cache = self.cache.set_if_not_exists_with_expiration(\n            cache_key, value=json.dumps(cache_value), expiration=ONE_DAY\n        )\n        if not added_to_cache:\n            log_msg = \"Pairing code {pairing_code} exists, generating new code\"\n            _log.debug(log_msg.format(pairing_code=response_data[\"pairing_code\"]))\n\n        return added_to_cache\n"
  },
  {
    "path": "api/public/public_api/endpoints/device_email.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Device API endpoint to send an email as specified by the device.\"\"\"\nfrom http import HTTPStatus\n\nfrom schematics import Model\nfrom schematics.types import StringType\n\nfrom selene.api import PublicEndpoint\nfrom selene.data.account import AccountRepository\nfrom selene.util.email import EmailMessage, SeleneMailer\n\n\nclass SendEmail(Model):\n    \"\"\"Data model of the incoming PUT request.\"\"\"\n\n    title = StringType(required=True)\n    sender = StringType(required=True)\n    body = StringType(required=True)\n\n\nclass DeviceEmailEndpoint(PublicEndpoint):\n    \"\"\"Endpoint to send an email to the account associated to a device\"\"\"\n\n    def put(self, device_id):\n        \"\"\"Handle an HTTP PUT request.\"\"\"\n        self._authenticate(device_id)\n        self._validate_request()\n        account = AccountRepository(self.db).get_account_by_device_id(device_id)\n        self._send_message(account)\n\n        return \"\", HTTPStatus.OK\n\n    def _validate_request(self):\n        \"\"\"Validate that the request is well-formed.\"\"\"\n        send_email = SendEmail(self.request.json)\n        send_email.validate()\n\n    def _send_message(self, account):\n        \"\"\"Send an email to the account that owns the device that requested it.\"\"\"\n        message = EmailMessage(\n            recipient=account.email_address,\n            sender=\"support@mycroft.ai\",\n            subject=self.request.json[\"title\"],\n            body=self.request.json[\"body\"],\n        )\n        mailer = SeleneMailer(message)\n        mailer.send()\n"
  },
  {
    "path": "api/public/public_api/endpoints/device_location.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom http import HTTPStatus\n\nfrom selene.api import PublicEndpoint\nfrom selene.api.etag import device_location_etag_key\nfrom selene.data.device import GeographyRepository\n\n\nclass DeviceLocationEndpoint(PublicEndpoint):\n    def __init__(self):\n        super(DeviceLocationEndpoint, self).__init__()\n\n    def get(self, device_id):\n        self._authenticate(device_id)\n        self._validate_etag(device_location_etag_key(device_id))\n        location = GeographyRepository(self.db, None).get_location_by_device_id(\n            device_id\n        )\n        if location:\n            response = (location, HTTPStatus.OK)\n            self._add_etag(device_location_etag_key(device_id))\n        else:\n            response = (\"\", HTTPStatus.NOT_FOUND)\n        return response\n"
  },
  {
    "path": "api/public/public_api/endpoints/device_metrics.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\nfrom http import HTTPStatus\n\nfrom selene.api import PublicEndpoint, track_account_activity\nfrom selene.data.metric import CoreMetric, CoreMetricRepository\n\n\nclass DeviceMetricsEndpoint(PublicEndpoint):\n    \"\"\"Endpoint to communicate with the metric service\"\"\"\n\n    def post(self, device_id, metric):\n        self._authenticate(device_id)\n        self._add_core_metric(metric)\n        track_account_activity(self.db, self.device_id)\n\n        return \"\", HTTPStatus.NO_CONTENT\n\n    def _add_core_metric(self, metric: str):\n        core_metric = CoreMetric(\n            device_id=self.device_id, metric_type=metric, metric_value=self.request.json\n        )\n        # Writing metrics from devices is being deactivated to enable\n        # core_metrics_repo = CoreMetricRepository(self.db)\n        # core_metrics_repo.add(core_metric)\n"
  },
  {
    "path": "api/public/public_api/endpoints/device_oauth.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nimport os\n\nimport requests\n\nfrom selene.api import PublicEndpoint\nfrom selene.data.account import AccountRepository\n\n\nclass OauthServiceEndpoint(PublicEndpoint):\n    def __init__(self):\n        super(OauthServiceEndpoint, self).__init__()\n        self.oauth_service_host = os.environ[\"OAUTH_BASE_URL\"]\n\n    def get(self, device_id, credentials, oauth_path):\n        account = AccountRepository(self.db).get_account_by_device_id(device_id)\n        uuid = account.id\n        url = \"{host}/auth/{credentials}/{oauth_path}\".format(\n            host=self.oauth_service_host, credentials=credentials, oauth_path=oauth_path\n        )\n        params = dict(uuid=uuid)\n        response = requests.get(url, params=params)\n        return response.text, response.status_code\n"
  },
  {
    "path": "api/public/public_api/endpoints/device_pantacor.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2022 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\n\"\"\"Endpoint to determine if a device has registered with Pantacor.\n\nDevice pairing with Selene is considered complete after the device/activate endpoint\nis successful, but there is one more step in the pairing process of a device that\nuses Pantacor for continuous deployment.  This endpoint calls the Pantacor Fleet API to\ndetermine if the device's registration is complete and reports back to the device.\n\"\"\"\nfrom http import HTTPStatus\n\nfrom schematics import Model\nfrom schematics.types import StringType\n\nfrom selene.api import PublicEndpoint\nfrom selene.api.pantacor import get_pantacor_device, PantacorError\nfrom selene.data.device import DeviceRepository\nfrom selene.util.log import get_selene_logger\n\n_log = get_selene_logger(__name__)\n\n\nclass PantacorSyncRequest(Model):\n    \"\"\"Data model of the fields in the request.\"\"\"\n\n    mycroft_device_id = StringType(required=True)\n    pantacor_device_id = StringType(required=True)\n\n\nclass DevicePantacorEndpoint(PublicEndpoint):\n    \"\"\"API endpoint for devices that use Pantacor for deployments.\n\n    Retrieves Pantacor configuration values, such as \"auto update\" from the\n    Pantacor Fleet API and adds the config to the device.pantacor table in the\n    database.  The data on this table allows users to view and edit the config\n    values in the Selene UI.  For this endpoint to be successful, the Pantacor Device\n    ID must be recognized by Pantacor and the device must be \"claimed\" by Pantacor.\n    \"\"\"\n\n    def post(self):\n        \"\"\"Process a HTTP POST request.\"\"\"\n        self._validate_request()\n        pantacor_config = self._get_config_from_pantacor()\n        if pantacor_config is None:\n            response = \"Pantacor Device ID not found\", HTTPStatus.NOT_FOUND\n        elif not pantacor_config.claimed:\n            response = (\n                \"Device not yet claimed by Pantacor\",\n                HTTPStatus.PRECONDITION_REQUIRED,\n            )\n        else:\n            self._add_pantacor_config_to_db(pantacor_config)\n            response = \"\", HTTPStatus.OK\n\n        return response\n\n    def _validate_request(self):\n        \"\"\"Validate the contents of the API request against the data model.\"\"\"\n        # TODO: remove this hack when mycroft-core mark-2 branch is merged into dev\n        activation_request = PantacorSyncRequest(self.request.json)\n        activation_request.validate()\n\n    def _get_config_from_pantacor(self):\n        \"\"\"Attempts to get the Pantacor config values from their Fleet API.\"\"\"\n        pantacor_config = None\n        try:\n            pantacor_config = get_pantacor_device(\n                self.request.json[\"pantacor_device_id\"]\n            )\n        except PantacorError:\n            _log.exception(\"Pantacor device ID not found on PantaHub\")\n\n        return pantacor_config\n\n    def _add_pantacor_config_to_db(self, pantacor_config):\n        \"\"\"Adds the software update configs to the database.\"\"\"\n        device_repository = DeviceRepository(self.db)\n        device_repository.upsert_pantacor_config(\n            self.request.json[\"mycroft_device_id\"], pantacor_config\n        )\n"
  },
  {
    "path": "api/public/public_api/endpoints/device_refresh_token.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nimport hashlib\nimport json\nfrom http import HTTPStatus\n\nfrom selene.api import PublicEndpoint, generate_device_login\nfrom selene.util.auth import AuthenticationError\n\n\nclass DeviceRefreshTokenEndpoint(PublicEndpoint):\n\n    ONE_DAY = 86400\n\n    def __init__(self):\n        super(DeviceRefreshTokenEndpoint, self).__init__()\n        self.sha512 = hashlib.sha512()\n\n    def get(self):\n        headers = self.request.headers\n        if \"Authorization\" not in headers:\n            raise AuthenticationError(\"Oauth token not found\")\n        token_header = self.request.headers[\"Authorization\"]\n        if token_header.startswith(\"Bearer \"):\n            refresh = token_header[len(\"Bearer \") :]\n            session = self._refresh_session_token(refresh)\n            # Trying to fetch a session using the refresh token\n            if session:\n                response = session, HTTPStatus.OK\n            else:\n                device = self.request.headers.get(\"Device\")\n                if device:\n                    # trying to fetch a session using the device uuid\n                    session = self._refresh_session_token_device(device)\n                    if session:\n                        response = session, HTTPStatus.OK\n                    else:\n                        response = \"\", HTTPStatus.UNAUTHORIZED\n                else:\n                    response = \"\", HTTPStatus.UNAUTHORIZED\n        else:\n            response = \"\", HTTPStatus.UNAUTHORIZED\n        return response\n\n    def _refresh_session_token(self, refresh: str):\n        refresh_key = \"device.token.refresh:{}\".format(refresh)\n        session = self.cache.get(refresh_key)\n        if session:\n            old_login = json.loads(session)\n            device_id = old_login[\"uuid\"]\n            self.cache.delete(refresh_key)\n            return generate_device_login(device_id, self.cache)\n\n    def _refresh_session_token_device(self, device: str):\n        refresh_key = \"device.session:{}\".format(device)\n        session = self.cache.get(refresh_key)\n        if session:\n            old_login = json.loads(session)\n            device_id = old_login[\"uuid\"]\n            self.cache.delete(refresh_key)\n            return generate_device_login(device_id, self.cache)\n"
  },
  {
    "path": "api/public/public_api/endpoints/device_setting.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom http import HTTPStatus\n\nfrom selene.api import PublicEndpoint, device_setting_etag_key\nfrom selene.data.device import SettingRepository\n\n\nclass DeviceSettingEndpoint(PublicEndpoint):\n    \"\"\"Return the device's settings for the API v1 model\"\"\"\n\n    def __init__(self):\n        super(DeviceSettingEndpoint, self).__init__()\n\n    def get(self, device_id):\n        self._authenticate(device_id)\n        self._validate_etag(device_setting_etag_key(device_id))\n        setting = SettingRepository(self.db).get_device_settings(device_id)\n        if setting is not None:\n            response = (setting, HTTPStatus.OK)\n            self._add_etag(device_setting_etag_key(device_id))\n        else:\n            response = (\"\", HTTPStatus.NO_CONTENT)\n        return response\n"
  },
  {
    "path": "api/public/public_api/endpoints/device_skill.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\n\"\"\"Applies a skill settings definition to the database.\n\nWhenever a change is made to a skill's settings definition on a device, this\nendpoint is called to update the same on the database.  If a skill has\nsettings, the device's settings are also updated.\n\nThis endpoint assumes that the skill manifest is sent when the a device is\npaired or a skill is installed. A skill that is not installed on a device\ncannot send it's settings... right?  The skill and its relationship to the\ndevice should already be known when this endpoint is called.\n\"\"\"\nfrom http import HTTPStatus\n\nfrom schematics import Model\nfrom schematics.types import BooleanType, ListType, ModelType, StringType\nfrom schematics.exceptions import DataError\n\nfrom selene.api import PublicEndpoint\nfrom selene.data.account import AccountRepository\nfrom selene.data.device import DeviceSkillRepository\nfrom selene.data.skill import (\n    extract_family_from_global_id,\n    SettingsDisplay,\n    SettingsDisplayRepository,\n    SkillRepository,\n)\nfrom selene.data.skill import SkillSettingRepository\nfrom selene.util.log import get_selene_logger\n\n_log = get_selene_logger(__name__)\n\n\ndef _normalize_field_value(field):\n    \"\"\"The field values in skillMetadata are all strings, convert to native.\"\"\"\n    normalized_value = field.get(\"value\")\n    if field[\"type\"].lower() == \"checkbox\":\n        if field[\"value\"] in (\"false\", \"False\", \"0\"):\n            normalized_value = False\n        elif field[\"value\"] in (\"true\", \"True\", \"1\"):\n            normalized_value = True\n    elif field[\"type\"].lower() == \"number\" and isinstance(field[\"value\"], str):\n        if field[\"value\"]:\n            normalized_value = float(field[\"value\"])\n            if not normalized_value % 1:\n                normalized_value = int(field[\"value\"])\n            else:\n                normalized_value = 0\n    elif field[\"value\"] == \"[]\":\n        normalized_value = []\n\n    return normalized_value\n\n\nclass RequestSkillField(Model):\n    \"\"\"Representation of skill setting field for use in validation.\"\"\"\n\n    name = StringType()\n    type = StringType()\n    label = StringType()\n    hint = StringType()\n    placeholder = StringType()\n    hide = BooleanType()\n    value = StringType()\n    options = StringType()\n\n\nclass RequestSkillSection(Model):\n    \"\"\"Representation of skill setting section for use in validation.\"\"\"\n\n    name = StringType(required=True)\n    fields = ListType(ModelType(RequestSkillField))\n\n\nclass RequestSkillMetadata(Model):\n    \"\"\"Representation of skill setting metadata for use in validation.\"\"\"\n\n    sections = ListType(ModelType(RequestSkillSection))\n\n\nclass RequestSkillIcon(Model):\n    \"\"\"Representation of skill icon for use in validation.\"\"\"\n\n    color = StringType()\n    icon = StringType()\n\n\nclass RequestDeviceSkill(Model):\n    \"\"\"Representation of the PUT request object for use in validation.\"\"\"\n\n    display_name = StringType(required=True)\n    icon = ModelType(RequestSkillIcon)\n    icon_img = StringType()\n    skill_gid = StringType(required=True)\n    skillMetadata = ModelType(RequestSkillMetadata)\n\n\nclass SkillSettingsMetaEndpoint(PublicEndpoint):\n    \"\"\"Public API endpoint for maintaining skill settings.\"\"\"\n\n    def __init__(self):\n        super().__init__()\n        self.skill = None\n        self.default_settings = None\n        self.skill_has_settings = False\n        self.settings_definition_id = None\n        self._device_skill_repo = None\n\n    @property\n    def device_skill_repo(self) -> DeviceSkillRepository:\n        \"\"\"Lazily instantiates an instance of the DeviceSkillRepository.\"\"\"\n        if self._device_skill_repo is None:\n            self._device_skill_repo = DeviceSkillRepository(self.db)\n\n        return self._device_skill_repo\n\n    def put(self, device_id: str):\n        \"\"\"Handles a HTTP PUT request.\n\n        Args:\n            device_id: Mycroft identifier of a paired device.\n        \"\"\"\n        self._authenticate(device_id)\n        self._validate_request()\n        self._get_skill()\n        self._parse_skill_metadata()\n        self._ensure_settings_definition_exists()\n        self._update_device_skill(device_id)\n\n        return \"\", HTTPStatus.NO_CONTENT\n\n    def _validate_request(self):\n        \"\"\"Ensure the request is well-formed.\"\"\"\n        request_model = RequestDeviceSkill(self.request.json)\n        request_model.validate()\n\n    def _get_skill(self):\n        \"\"\"Retrieve the skill associated with the request.\"\"\"\n        skill_repo = SkillRepository(self.db)\n        self.skill = skill_repo.get_skill_by_global_id(self.request.json[\"skill_gid\"])\n        if self.skill is None:\n            err_msg = \"No skill on database for skill \" + self.request.json[\"skill_gid\"]\n            _log.error(err_msg)\n            raise DataError(dict(skill_gid=[err_msg]))\n\n    def _parse_skill_metadata(self):\n        \"\"\"Inspect the contents of the skill settings definition.\n\n        Skill authors often write settings definition files with strings in\n        fields that should be boolean or numeric.  Ensure all fields are cast\n        to the correct type before interacting with the database.\n        \"\"\"\n        self.skill_has_settings = \"skillMetadata\" in self.request.json\n        if self.skill_has_settings:\n            skill_metadata = self.request.json[\"skillMetadata\"]\n            self.default_settings = {}\n            normalized_sections = []\n            for section in skill_metadata[\"sections\"]:\n                for field in section[\"fields\"]:\n                    if field[\"type\"] != \"label\":\n                        field[\"value\"] = _normalize_field_value(field)\n                        self.default_settings[field[\"name\"]] = field[\"value\"]\n                normalized_sections.append(section)\n            self.request.json[\"skillMetadata\"].update(sections=normalized_sections)\n\n    def _ensure_settings_definition_exists(self):\n        \"\"\"Add a row to skill.settings_display if it doesn't already exist.\"\"\"\n        self.settings_definition_id = None\n        self._check_for_existing_settings_definition()\n        if self.settings_definition_id is None:\n            self._add_settings_definition()\n\n    def _check_for_existing_settings_definition(self):\n        \"\"\"Look for an existing database row matching the request.\"\"\"\n        settings_def_repo = SettingsDisplayRepository(self.db)\n        settings_defs = settings_def_repo.get_settings_definitions_by_gid(\n            self.skill.skill_gid\n        )\n        for settings_def in settings_defs:\n            if settings_def.display_data == self.request.json:\n                self.settings_definition_id = settings_def.id\n                break\n\n    def _add_settings_definition(self):\n        \"\"\"The settings definition does not exist on database so add it.\"\"\"\n        settings_def_repo = SettingsDisplayRepository(self.db)\n        settings_definition = SettingsDisplay(\n            skill_id=self.skill.id, display_data=self.request.json\n        )\n        self.settings_definition_id = settings_def_repo.add(settings_definition)\n\n    def _update_device_skill(self, device_id):\n        \"\"\"Update device.device_skill to match the new settings definition.\n\n        If the skill has settings and the device_skill table does not, either\n        use the default values in the settings definition or copy the settings\n        from another device under the same account.\n        \"\"\"\n        device_skill = self._get_device_skill(device_id)\n        device_skill.settings_display_id = self.settings_definition_id\n        if self.skill_has_settings:\n            if device_skill.settings_values is None:\n                new_settings_values = self._initialize_skill_settings(device_id)\n            else:\n                new_settings_values = self._reconcile_skill_settings(\n                    device_skill.settings_values\n                )\n            device_skill.settings_values = new_settings_values\n        self.device_skill_repo.update_device_skill_settings(device_id, device_skill)\n\n    def _get_device_skill(self, device_id):\n        \"\"\"Retrieve the device's skill entry from the database.\"\"\"\n        device_skill = self.device_skill_repo.get_skill_settings_for_device(\n            device_id, self.skill.id\n        )\n        if device_skill is None:\n            error_msg = (\n                \"Received skill setting definition before manifest for \"\n                \"skill \" + self.skill.skill_gid\n            )\n            _log.error(error_msg)\n            raise DataError(dict(skill_gid=[error_msg]))\n\n        return device_skill\n\n    def _reconcile_skill_settings(self, settings_values):\n        \"\"\"Fix any new or removed settings.\"\"\"\n        new_settings_values = {}\n        for name in self.default_settings:\n            if name in settings_values:\n                new_settings_values[name] = settings_values[name]\n            else:\n                new_settings_values[name] = self.default_settings[name]\n        for name in settings_values:\n            if name in self.default_settings:\n                new_settings_values[name] = settings_values[name]\n\n        return new_settings_values\n\n    def _initialize_skill_settings(self, device_id):\n        \"\"\"Use default settings or copy from another device in same account.\"\"\"\n        _log.info(f\"Initializing settings for skill {self.skill.skill_gid}\")\n        account_repo = AccountRepository(self.db)\n        account = account_repo.get_account_by_device_id(device_id)\n        skill_settings_repo = SkillSettingRepository(self.db)\n        skill_family = extract_family_from_global_id(self.skill.skill_gid)\n        family_settings = skill_settings_repo.get_family_settings(\n            account.id, skill_family\n        )\n        new_settings_values = self.default_settings\n        if family_settings is not None:\n            for settings in family_settings:\n                if settings.settings_values is None:\n                    continue\n                if settings.settings_values != self.default_settings:\n                    field_names = settings.settings_values.keys()\n                    if field_names == self.default_settings.keys():\n                        _log.info(\n                            \"Copying settings from another device for skill\"\n                            f\"{self.skill.skill_gid}\"\n                        )\n                        new_settings_values = settings.settings_values\n                        break\n        else:\n            _log.info(f\"Using default skill settings for skill {self.skill.skill_gid}\")\n\n        return new_settings_values\n"
  },
  {
    "path": "api/public/public_api/endpoints/device_skill_manifest.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom datetime import datetime\nfrom http import HTTPStatus\nfrom logging import getLogger\n\nfrom schematics import Model\nfrom schematics.types import (\n    StringType,\n    ModelType,\n    ListType,\n    IntType,\n    BooleanType,\n    TimestampType,\n)\n\nfrom selene.api import PublicEndpoint\nfrom selene.data.device import ManifestSkill, DeviceSkillRepository\nfrom selene.data.skill import SkillRepository\n\n\nclass SkillManifestReconciler(object):\n    def __init__(self, db, device_manifest, db_manifest):\n        self.db = db\n        self.skill_manifest_repo = DeviceSkillRepository(db)\n        self.skill_repo = SkillRepository(self.db)\n        self.device_manifest = {sm.skill_gid: sm for sm in device_manifest}\n        self.db_manifest = {ds.skill_gid: ds for ds in db_manifest}\n        self.device_manifest_global_ids = {gid for gid in self.device_manifest.keys()}\n        self.db_manifest_global_ids = {gid for gid in self.db_manifest}\n\n    def reconcile(self):\n        \"\"\"Compare the manifest sent by the device to that on the database.\"\"\"\n        self._update_skills()\n        self._remove_skills()\n        self._add_skills()\n\n    def _update_skills(self):\n        common_global_ids = self.device_manifest_global_ids.intersection(\n            self.db_manifest_global_ids\n        )\n        for gid in common_global_ids:\n            if self.device_manifest[gid] == self.db_manifest[gid]:\n                self.skill_manifest_repo.update_manifest_skill(\n                    self.device_manifest[gid]\n                )\n\n    def _remove_skills(self):\n        skills_to_remove = self.db_manifest_global_ids.difference(\n            self.device_manifest_global_ids\n        )\n        for gid in skills_to_remove:\n            manifest_skill = self.db_manifest[gid]\n            self.skill_manifest_repo.remove_manifest_skill(manifest_skill)\n            if manifest_skill.device_id in gid:\n                self.skill_repo.remove_by_gid(gid)\n\n    def _add_skills(self):\n        skills_to_add = self.device_manifest_global_ids.difference(\n            self.db_manifest_global_ids\n        )\n\n        for gid in skills_to_add:\n            skill_id = self.skill_repo.ensure_skill_exists(gid)\n            self.device_manifest[gid].skill_id = skill_id\n            self.skill_manifest_repo.add_manifest_skill(self.device_manifest[gid])\n\n\nclass RequestManifestSkill(Model):\n    name = StringType(required=True)\n    origin = StringType(required=True)\n    installation = StringType(required=True)\n    failure_message = StringType(default=\"\")\n    status = StringType(required=True)\n    beta = BooleanType(required=True)\n    installed = TimestampType(required=True)\n    updated = TimestampType(required=True)\n    skill_gid = StringType(required=True)\n\n\nclass SkillManifestRequest(Model):\n    blacklist = ListType(StringType)\n    version = IntType()\n    skills = ListType(ModelType(RequestManifestSkill, required=True))\n\n\n_log = getLogger(__package__)\n\n\nclass DeviceSkillManifestEndpoint(PublicEndpoint):\n    _device_skill_repo = None\n\n    def __init__(self):\n        super(DeviceSkillManifestEndpoint, self).__init__()\n\n    @property\n    def device_skill_repo(self):\n        if self._device_skill_repo is None:\n            self._device_skill_repo = DeviceSkillRepository(self.db)\n\n        return self._device_skill_repo\n\n    def put(self, device_id):\n        self._authenticate(device_id)\n        self._validate_put_request()\n        self._update_skill_manifest(device_id)\n\n        return \"\", HTTPStatus.OK\n\n    def _validate_put_request(self):\n        request_data = SkillManifestRequest(self.request.json)\n        request_data.validate()\n\n    def _update_skill_manifest(self, device_id):\n        db_skill_manifest = self.device_skill_repo.get_skill_manifest_for_device(\n            device_id\n        )\n        device_skill_manifest = []\n        for manifest_skill in self.request.json[\"skills\"]:\n            self._convert_manifest_timestamps(manifest_skill)\n            device_skill_manifest.append(\n                ManifestSkill(\n                    device_id=device_id,\n                    install_method=manifest_skill[\"origin\"],\n                    install_status=manifest_skill[\"installation\"],\n                    install_failure_reason=manifest_skill.get(\"failure_message\"),\n                    install_ts=manifest_skill[\"installed\"],\n                    skill_gid=manifest_skill[\"skill_gid\"],\n                    update_ts=manifest_skill[\"updated\"],\n                )\n            )\n        reconciler = SkillManifestReconciler(\n            self.db, device_skill_manifest, db_skill_manifest\n        )\n        reconciler.reconcile()\n\n    @staticmethod\n    def _convert_manifest_timestamps(manifest_skill):\n        for key in (\"installed\", \"updated\"):\n            value = manifest_skill[key]\n            if value:\n                manifest_skill[key] = datetime.fromtimestamp(value)\n            else:\n                manifest_skill[key] = None\n"
  },
  {
    "path": "api/public/public_api/endpoints/device_skill_settings.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nimport json\nfrom http import HTTPStatus\n\nfrom flask import Response\nfrom schematics import Model\nfrom schematics.exceptions import ValidationError\nfrom schematics.types import StringType, BooleanType, ListType, ModelType\n\nfrom selene.api import PublicEndpoint\nfrom selene.data.account import AccountRepository\nfrom selene.data.device import DeviceSkillRepository\nfrom selene.data.skill import (\n    SettingsDisplay,\n    SettingsDisplayRepository,\n    Skill,\n    SkillRepository,\n    SkillSettingRepository,\n)\nfrom selene.util.cache import DEVICE_SKILL_ETAG_KEY\n\n# matches <submodule_name>|<branch>\nGLOBAL_ID_PATTERN = \"^([^\\|@]+)\\|([^\\|]+$)\"\n# matches @<device_id>|<submodule_name>|<branch>\nGLOBAL_ID_DIRTY_PATTERN = \"^@(.*)\\|(.*)\\|(.*)$\"\n# matches @<device_id>|<folder_name>\nGLOBAL_ID_NON_MSM_PATTERN = \"^@([^\\|]+)\\|([^\\|]+$)\"\nGLOBAL_ID_ANY_PATTERN = \"(?:{})|(?:{})|(?:{})\".format(\n    GLOBAL_ID_PATTERN, GLOBAL_ID_DIRTY_PATTERN, GLOBAL_ID_NON_MSM_PATTERN\n)\n\n\ndef _normalize_field_value(field):\n    \"\"\"The field values in skillMetadata are all strings, convert to native.\"\"\"\n    normalized_value = field.get(\"value\")\n    if field[\"type\"].lower() == \"checkbox\":\n        if field[\"value\"] in (\"false\", \"False\", \"0\"):\n            normalized_value = False\n        elif field[\"value\"] in (\"true\", \"True\", \"1\"):\n            normalized_value = True\n    elif field[\"type\"].lower() == \"number\" and isinstance(field[\"value\"], str):\n        if field[\"value\"]:\n            normalized_value = float(field[\"value\"])\n            if not normalized_value % 1:\n                normalized_value = int(field[\"value\"])\n            else:\n                normalized_value = 0\n    elif field[\"value\"] == \"[]\":\n        normalized_value = []\n\n    return normalized_value\n\n\nclass SkillSettingUpdater(object):\n    \"\"\"Update the settings data for all devices with a skill\n\n    Skills and their settings are global across devices.  While the PUT\n    request specifies a single device to update, all devices with\n    the same skill must be updated as well.\n    \"\"\"\n\n    _device_skill_repo = None\n    _settings_display_repo = None\n\n    def __init__(self, db, device_id, display_data: dict):\n        self.db = db\n        self.device_id = device_id\n        self.display_data = display_data\n        self.settings_values = None\n        self.skill = None\n\n    @property\n    def device_skill_repo(self):\n        if self._device_skill_repo is None:\n            self._device_skill_repo = DeviceSkillRepository(self.db)\n\n        return self._device_skill_repo\n\n    @property\n    def settings_display_repo(self):\n        if self._settings_display_repo is None:\n            self._settings_display_repo = SettingsDisplayRepository(self.db)\n\n        return self._settings_display_repo\n\n    def update(self):\n        self._extract_settings_values()\n        self._get_skill_id()\n        self._ensure_settings_display_exists()\n        self._upsert_device_skill()\n\n    def _extract_settings_values(self):\n        \"\"\"Extract the settings values from the skillMetadata\n\n        The device applies the settings values in settings.json to the\n        settings_meta.json file before sending the result to this API.  The\n        settings values are stored separately from the metadata in the database.\n        \"\"\"\n        settings_definition = self.display_data.get(\"skillMetadata\")\n        if settings_definition is not None:\n            self.settings_values = dict()\n            sections_without_values = []\n            for section in settings_definition[\"sections\"]:\n                section_without_values = dict(**section)\n                for field in section_without_values[\"fields\"]:\n                    field_name = field.get(\"name\")\n                    field_value = field.get(\"value\")\n                    if field_name is not None:\n                        if field_value is not None:\n                            field_value = _normalize_field_value(field)\n                            del field[\"value\"]\n                        self.settings_values[field_name] = field_value\n                sections_without_values.append(section_without_values)\n            settings_definition[\"sections\"] = sections_without_values\n\n    def _get_skill_id(self):\n        \"\"\"Get the id of the skill in the request\"\"\"\n        skill_global_id = self.display_data.get(\"skill_gid\") or self.display_data.get(\n            \"identifier\"\n        )\n        skill_repo = SkillRepository(self.db)\n        skill_id = skill_repo.ensure_skill_exists(skill_global_id)\n        self.skill = Skill(skill_global_id, skill_id)\n\n    def _ensure_settings_display_exists(self) -> bool:\n        \"\"\"If the settings display changed, a new row needs to be added.\"\"\"\n        new_settings_display = False\n        self.settings_display = SettingsDisplay(self.skill.id, self.display_data)\n        self.settings_display.id = self.settings_display_repo.get_settings_display_id(\n            self.settings_display\n        )\n        if self.settings_display.id is None:\n            self.settings_display.id = self.settings_display_repo.add(\n                self.settings_display\n            )\n            new_settings_display = True\n\n        return new_settings_display\n\n    def _upsert_device_skill(self):\n        \"\"\"Update the account's devices with the skill to have new settings\"\"\"\n        skill_settings = self._get_account_skill_settings()\n        device_skill_found = self._update_skill_settings(skill_settings)\n        if not device_skill_found:\n            self._add_skill_to_device()\n\n    def _get_account_skill_settings(self):\n        \"\"\"Get all the permutations of settings for a skill\"\"\"\n        account_repo = AccountRepository(self.db)\n        account = account_repo.get_account_by_device_id(self.device_id)\n        skill_settings = self.device_skill_repo.get_skill_settings_for_account(\n            account.id, self.skill.id\n        )\n\n        return skill_settings\n\n    def _update_skill_settings(self, skill_settings):\n        device_skill_found = False\n        for skill_setting in skill_settings:\n            if self.device_id in skill_setting.device_ids:\n                device_skill_found = True\n                if skill_setting.install_method in (\"voice\", \"cli\"):\n                    devices_to_update = [self.device_id]\n                else:\n                    devices_to_update = skill_setting.device_ids\n                self.device_skill_repo.upsert_device_skill_settings(\n                    devices_to_update,\n                    self.settings_display,\n                    self._merge_settings_values(skill_setting.settings_values),\n                )\n                break\n\n        return device_skill_found\n\n    def _merge_settings_values(self, settings_values=None):\n        \"\"\"Merge existing and new settings values into a single place.\n\n        When a settings field is changed or removed, unchanged settings should\n        retain the value they had prior to the change.\n        \"\"\"\n        merged_values = {}\n        for field_name, field_value in self.settings_values.items():\n            if field_value is not None:\n                # Filter out settings with no value, such as labels\n                merged_values[field_name] = field_value\n            elif settings_values is not None and field_name in settings_values:\n                # if updating existing settings, include the unchanged values\n                merged_values[field_name] = settings_values[field_name]\n\n        return merged_values\n\n    def _add_skill_to_device(self):\n        \"\"\"Add a device_skill row for this skill.\n\n        In theory, the skill manifest endpoint handles adding skills to a\n        device but testing shows that this endpoint gets called before the\n        manifest endpoint in some cases.\n        \"\"\"\n        self.device_skill_repo.upsert_device_skill_settings(\n            [self.device_id], self.settings_display, self._merge_settings_values()\n        )\n\n\nclass RequestSkillField(Model):\n    name = StringType()\n    type = StringType()\n    label = StringType()\n    hint = StringType()\n    placeholder = StringType()\n    hide = BooleanType()\n    value = StringType()\n    options = StringType()\n\n\nclass RequestSkillSection(Model):\n    name = StringType(required=True)\n    fields = ListType(ModelType(RequestSkillField))\n\n\nclass RequestSkillMetadata(Model):\n    sections = ListType(ModelType(RequestSkillSection))\n\n\nclass RequestSkillIcon(Model):\n    color = StringType()\n    icon = StringType()\n\n\nclass RequestSkill(Model):\n    name = StringType()\n    skill_gid = StringType(regex=GLOBAL_ID_ANY_PATTERN)\n    skillMetadata = ModelType(RequestSkillMetadata)\n    icon_img = StringType()\n    icon = ModelType(RequestSkillIcon)\n    display_name = StringType()\n    color = StringType()\n    identifier = StringType()\n\n    def validate_skill_gid(self, data, value):\n        if data[\"skill_gid\"] is None and data[\"identifier\"] is None:\n            raise ValidationError(\n                \"skill should have either skill_gid or identifier defined\"\n            )\n        return value\n\n\nclass DeviceSkillSettingsEndpoint(PublicEndpoint):\n    \"\"\"Fetch all skills associated with a device using the API v1 format\"\"\"\n\n    _device_skill_repo = None\n    _skill_repo = None\n    _skill_setting_repo = None\n    _settings_display_repo = None\n\n    @property\n    def device_skill_repo(self):\n        if self._device_skill_repo is None:\n            self._device_skill_repo = DeviceSkillRepository(self.db)\n\n        return self._device_skill_repo\n\n    @property\n    def settings_display_repo(self):\n        if self._settings_display_repo is None:\n            self._settings_display_repo = SettingsDisplayRepository(self.db)\n\n        return self._settings_display_repo\n\n    @property\n    def skill_repo(self):\n        if self._skill_repo is None:\n            self._skill_repo = SkillRepository(self.db)\n\n        return self._skill_repo\n\n    @property\n    def skill_setting_repo(self):\n        if self._skill_setting_repo is None:\n            self._skill_setting_repo = SkillSettingRepository(self.db)\n\n        return self._skill_setting_repo\n\n    def get(self, device_id):\n        \"\"\"\n        Retrieve skills installed on device from the database.\n\n        :raises NotModifiedException: when etag in request matches cache\n        \"\"\"\n        self._authenticate(device_id)\n        self._validate_etag(DEVICE_SKILL_ETAG_KEY.format(device_id=device_id))\n        device_skills = self.skill_setting_repo.get_skill_settings_for_device(device_id)\n\n        if device_skills:\n            response_data = self._build_response_data(device_skills)\n            response = Response(\n                json.dumps(response_data),\n                status=HTTPStatus.OK,\n                content_type=\"application/json\",\n            )\n            self._add_etag(DEVICE_SKILL_ETAG_KEY.format(device_id=device_id))\n        else:\n            response = Response(\n                \"\", status=HTTPStatus.NO_CONTENT, content_type=\"application/json\"\n            )\n        return response\n\n    def _build_response_data(self, device_skills):\n        response_data = []\n        for skill in device_skills:\n            response_skill = dict(uuid=skill.skill_id)\n            settings_definition = skill.settings_display.get(\"skillMetadata\")\n            if settings_definition:\n                settings_sections = self._apply_settings_values(\n                    settings_definition, skill.settings_values\n                )\n                if settings_sections:\n                    response_skill.update(\n                        skillMetadata=dict(sections=settings_sections)\n                    )\n            skill_gid = skill.settings_display.get(\"skill_gid\")\n            if skill_gid is not None:\n                response_skill.update(skill_gid=skill_gid)\n            identifier = skill.settings_display.get(\"identifier\")\n            if identifier is None:\n                response_skill.update(identifier=skill_gid)\n            else:\n                response_skill.update(identifier=identifier)\n            response_data.append(response_skill)\n\n        return response_data\n\n    @staticmethod\n    def _apply_settings_values(settings_definition, settings_values):\n        \"\"\"Build a copy of the settings sections populated with values.\"\"\"\n        sections_with_values = []\n        for section in settings_definition[\"sections\"]:\n            section_with_values = dict(**section)\n            for field in section_with_values[\"fields\"]:\n                field_name = field.get(\"name\")\n                if field_name is not None and field_name in settings_values:\n                    field.update(value=str(settings_values[field_name]))\n            sections_with_values.append(section_with_values)\n\n        return sections_with_values\n\n    def put(self, device_id):\n        self._authenticate(device_id)\n        self._validate_put_request()\n        skill_id = self._update_skill_settings(device_id)\n        self.etag_manager.expire(DEVICE_SKILL_ETAG_KEY.format(device_id=device_id))\n\n        return dict(uuid=skill_id), HTTPStatus.OK\n\n    def _validate_put_request(self):\n        skill = RequestSkill(self.request.json)\n        skill.validate()\n\n    def _update_skill_settings(self, device_id):\n        skill_setting_updater = SkillSettingUpdater(\n            self.db, device_id, self.request.json\n        )\n        skill_setting_updater.update()\n        self._delete_orphaned_settings_display(\n            skill_setting_updater.settings_display.id\n        )\n\n        return skill_setting_updater.skill.id\n\n    def _delete_orphaned_settings_display(self, settings_display_id):\n        skill_count = self.device_skill_repo.get_settings_display_usage(\n            settings_display_id\n        )\n        if not skill_count:\n            self.settings_display_repo.remove(settings_display_id)\n\n\nclass DeviceSkillSettingsEndpointV2(PublicEndpoint):\n    \"\"\"Replacement that decouples settings definition from values.\n\n    The older version of this class needs to be kept around for compatibility\n    with pre 19.08 versions of mycroft-core.  Once those versions are no\n    longer supported, the older class can be deprecated.\n    \"\"\"\n\n    def get(self, device_id):\n        \"\"\"\n        Retrieve skills installed on device from the database.\n\n        :raises NotModifiedException: when etag in request matches cache\n        \"\"\"\n        self._authenticate(device_id)\n        self._validate_etag(DEVICE_SKILL_ETAG_KEY.format(device_id=device_id))\n        response_data = self._build_response_data(device_id)\n        response = self._build_response(device_id, response_data)\n\n        return response\n\n    def _build_response_data(self, device_id):\n        device_skill_repo = DeviceSkillRepository(self.db)\n        device_skills = device_skill_repo.get_skill_settings_for_device(device_id)\n        if device_skills is not None:\n            response_data = {}\n            for skill in device_skills:\n                response_data[skill.skill_gid] = skill.settings_values\n\n            return response_data\n\n    def _build_response(self, device_id, response_data):\n        if response_data is None:\n            response = Response(\n                \"\", status=HTTPStatus.NO_CONTENT, content_type=\"application/json\"\n            )\n        else:\n            response = Response(\n                json.dumps(response_data),\n                status=HTTPStatus.OK,\n                content_type=\"application/json\",\n            )\n            self._add_etag(DEVICE_SKILL_ETAG_KEY.format(device_id=device_id))\n\n        return response\n"
  },
  {
    "path": "api/public/public_api/endpoints/device_subscription.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom http import HTTPStatus\n\nfrom selene.api import PublicEndpoint\nfrom selene.data.account import AccountRepository\n\n\nclass DeviceSubscriptionEndpoint(PublicEndpoint):\n    def __init__(self):\n        super(DeviceSubscriptionEndpoint, self).__init__()\n\n    def get(self, device_id):\n        self._authenticate(device_id)\n        account = AccountRepository(self.db).get_account_by_device_id(device_id)\n        if account:\n            membership = account.membership\n            response = (\n                {\"@type\": membership.type if membership is not None else \"free\"},\n                HTTPStatus.OK,\n            )\n        else:\n            response = \"\", HTTPStatus.NO_CONTENT\n\n        return response\n"
  },
  {
    "path": "api/public/public_api/endpoints/geolocation.py",
    "content": "\"\"\"Call this endpoint to retrieve the timezone for a given location\"\"\"\nfrom http import HTTPStatus\n\nfrom selene.api import PublicEndpoint\nfrom selene.data.geography import CityRepository\nfrom selene.util.log import get_selene_logger\n\nONE_HUNDRED_MILES = 100\n\n_log = get_selene_logger(__name__)\n\n\nclass GeolocationEndpoint(PublicEndpoint):\n    \"\"\"Selene endpoint that will search for a geography give a city name.\"\"\"\n\n    def __init__(self):\n        super().__init__()\n        self.device_id = None\n        self.request_geolocation = None\n        self.cities = None\n        self._city_repo = None\n\n    @property\n    def city_repo(self):\n        \"\"\"Lazy load the CityRepository.\"\"\"\n        if self._city_repo is None:\n            self._city_repo = CityRepository(self.db)\n\n        return self._city_repo\n\n    def get(self):\n        \"\"\"Handle a HTTP GET request.\"\"\"\n        self.request_geolocation = self.request.args[\"location\"].lower()\n        response_geolocation = self._get_geolocation()\n\n        return dict(data=response_geolocation), HTTPStatus.OK\n\n    def _get_geolocation(self):\n        \"\"\"Try our best to find a geolocation matching the request.\"\"\"\n        self._get_cities()\n        if self.cities:\n            selected_geolocation = self._select_geolocation_from_cities()\n        else:\n            selected_geolocation = self.city_repo.get_biggest_city_in_region(\n                self.request_geolocation\n            )\n\n        if selected_geolocation is None:\n            selected_geolocation = self.city_repo.get_biggest_city_in_country(\n                self.request_geolocation\n            )\n\n        if selected_geolocation is not None:\n            selected_geolocation.latitude = float(selected_geolocation.latitude)\n            selected_geolocation.longitude = float(selected_geolocation.longitude)\n\n        return selected_geolocation\n\n    def _get_cities(self):\n        \"\"\"Retrieve a list of cities matching the requested location.\n\n        City names can be a single word (e.g. Seattle) or multiple words\n        (e.g. Kansas City).  Query the database for all permutations of words\n        in the location passed in the request. For example, a request for\n        \"Kansas City Missouri\" will pass \"Kansas\" and \"Kansas City\" and\n        \"Kansas City Missouri\"\n\n        This logic assumes that it will not find a match when a city and\n        region/country are included in the request. For example, a request for\n        \"Kansas City Missouri\" should only find a match for \"Kansas City\".\n        \"\"\"\n        possible_city_names = []\n        geolocation_words = self.request_geolocation.split()\n        for index, _ in enumerate(geolocation_words):\n            possible_city_name = \" \".join(geolocation_words[: index + 1])\n            possible_city_names.append(possible_city_name)\n\n        self.cities = self.city_repo.get_geographic_location_by_city(\n            possible_city_names\n        )\n\n    def _select_geolocation_from_cities(self):\n        \"\"\"Select one of the cities returned by the database.\n\n        If a single match is found, select it.  If multiple matches are found,\n        return the city with the biggest population.  If multiple matches are\n        found and a region or country is included in the requested location,\n        attempt to match based on the extra criteria.\n        \"\"\"\n        selected_geolocation = None\n        if len(self.cities) == 1:\n            selected_geolocation = self.cities[0]\n        elif len(self.cities) > 1:\n            biggest_city = self.cities[0]\n            if biggest_city.city.lower() == self.request_geolocation:\n                selected_geolocation = biggest_city\n            else:\n                city_in_region = self._get_city_for_requested_region()\n                city_in_country = self._get_city_for_requested_country()\n                selected_geolocation = city_in_region or city_in_country\n\n        return selected_geolocation\n\n    def _get_city_for_requested_region(self):\n        \"\"\"If a region is in the request, get the city in that region.\n\n        Example:\n            A request for \"Kansas City Missouri\" should return the city of\n            Kansas City in the state of Missouri\n        \"\"\"\n        city_in_requested_region = None\n        for city in self.cities:\n            location_without_city = self.request_geolocation[len(city.city) :]\n            if city.region.lower() in location_without_city.strip():\n                city_in_requested_region = city\n                break\n\n        return city_in_requested_region\n\n    def _get_city_for_requested_country(self):\n        \"\"\"If a country is in the request, get the city in that country.\n\n        Examples:\n            A request for \"Sydney Australia\" should return the city of Syndey\n            in the country of Australia.\n        \"\"\"\n        selected_city = None\n        for city in self.cities:\n            location_without_city = self.request_geolocation[len(city.city) :]\n            if city.country.lower() in location_without_city.strip():\n                selected_city = city\n                break\n\n        return selected_city\n"
  },
  {
    "path": "api/public/public_api/endpoints/google_stt.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Public API endpoint for transcribing audio using Google's STT API\n\nDEPRECATION WARNING:\n    This endpoint is being replaced with the audio_transcription endpoint.  It will\n    remain in the V1 API for backwards compatibility.\n\"\"\"\nfrom datetime import datetime\nfrom decimal import Decimal\nfrom http import HTTPStatus\nfrom io import BytesIO\n\nimport librosa\nfrom speech_recognition import (\n    AudioData,\n    AudioFile,\n    Recognizer,\n    RequestError,\n    UnknownValueError,\n)\n\nfrom selene.api import PublicEndpoint, track_account_activity\nfrom selene.data.account import AccountRepository, OPEN_DATASET\nfrom selene.data.metric import SttTranscriptionMetric, TranscriptionMetricRepository\nfrom selene.util.log import get_selene_logger\n\n_log = get_selene_logger(__name__)\n\nSAMPLE_RATE = 16000\nSELENE_DATA_DIR = \"/opt/selene/data\"\n\n\nclass GoogleSTTEndpoint(PublicEndpoint):\n    \"\"\"Endpoint to send a flac audio file with voice and get back a utterance.\"\"\"\n\n    def __init__(self):\n        super().__init__()\n        self.recognizer = Recognizer()\n        self.account = None\n        self.account_shares_data = False\n        self.transcription_success = False\n        self.audio_duration = 0\n        self.transcription_duration = 0\n\n    def post(self):\n        \"\"\"Processes an HTTP Post request.\"\"\"\n        _log.info(f\"{self.request_id}: Google STT transcription requested\")\n        self._authenticate()\n        self._get_account()\n        self._check_for_open_dataset_agreement()\n        request_audio_data = self._extract_audio_from_request()\n        transcription = self._call_google_stt(request_audio_data)\n        self._add_transcription_metric()\n        if transcription is not None:\n            track_account_activity(self.db, self.device_id)\n\n        return [transcription], HTTPStatus.OK\n\n    def _get_account(self):\n        \"\"\"Retrieves the account associated with the device from the database.\"\"\"\n        account_repo = AccountRepository(self.db)\n        self.account = account_repo.get_account_by_device_id(self.device_id)\n\n    def _check_for_open_dataset_agreement(self):\n        \"\"\"Determines if the account is opted into the Open Dataset Agreement.\"\"\"\n        if self.account is not None:\n            for agreement in self.account.agreements:\n                if agreement.type == OPEN_DATASET:\n                    self.account_shares_data = True\n                    break\n\n    def _extract_audio_from_request(self) -> AudioData:\n        \"\"\"Extracts the audio data from the request for use in Google STT API.\n\n        We need to replicate the first 16 bytes in the audio due a bug with\n        the Google speech recognition library that removes the first 16 bytes\n        from the flac file we are sending.\n\n        Returns:\n            Object representing the audio data in a format that can be used to call\n            Google's STT API\n        \"\"\"\n        _log.info(f\"{self.request_id}: Extracting audio data from request\")\n        request_audio = self.request.data[:16] + self.request.data\n        with AudioFile(BytesIO(request_audio)) as source:\n            audio_data = self.recognizer.record(source)\n\n        with BytesIO(self.request.data) as request_audio:\n            audio, _ = librosa.load(request_audio, sr=SAMPLE_RATE, mono=True)\n            self.audio_duration = librosa.get_duration(y=audio, sr=SAMPLE_RATE)\n\n        return audio_data\n\n    def _call_google_stt(self, audio: AudioData) -> str:\n        \"\"\"Uses the audio data from the request to call the Google STT API\n\n        Args:\n            audio: audio data representing the words spoken by the user\n\n        Returns:\n            text transcription of the audio data\n        \"\"\"\n        _log.info(f\"{self.request_id}: Transcribing audio with Google STT\")\n        lang = self.request.args[\"lang\"]\n        transcription = None\n        start_time = datetime.now()\n        try:\n            transcription = self.recognizer.recognize_google(\n                audio, key=self.config[\"GOOGLE_STT_KEY\"], language=lang\n            )\n        except RequestError:\n            _log.exception(\"Request to Google TTS failed\")\n        except UnknownValueError:\n            _log.exception(\"TTS transcription deemed unintelligible by Google\")\n        else:\n            log_message = \"Google STT request successful\"\n            if self.account_shares_data:\n                log_message += f\": {transcription}\"\n            _log.info(log_message)\n            self.transcription_success = True\n        end_time = datetime.now()\n        self.transcription_duration = (end_time - start_time).total_seconds()\n\n        return transcription\n\n    def _add_transcription_metric(self):\n        \"\"\"Adds metrics for this STT transcription to the database.\"\"\"\n        account_repo = AccountRepository(self.db)\n        account = account_repo.get_account_by_device_id(self.device_id)\n        transcription_metric = SttTranscriptionMetric(\n            account_id=account.id,\n            engine=\"Google\",\n            success=self.transcription_success,\n            audio_duration=Decimal(str(self.audio_duration)),\n            transcription_duration=Decimal(str(self.transcription_duration)),\n        )\n        transcription_metric_repo = TranscriptionMetricRepository(self.db)\n        transcription_metric_repo.add(transcription_metric)\n"
  },
  {
    "path": "api/public/public_api/endpoints/oauth_callback.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nimport os\n\nimport requests\n\nfrom selene.api import PublicEndpoint\n\n\nclass OauthCallbackEndpoint(PublicEndpoint):\n    def __init__(self):\n        super(OauthCallbackEndpoint, self).__init__()\n        self.oauth_service_host = os.environ[\"OAUTH_BASE_URL\"]\n\n    def get(self):\n        params = dict(self.request.args)\n        url = self.oauth_service_host + \"/auth/callback\"\n        response = requests.get(url, params=params)\n        return response.text, response.status_code\n"
  },
  {
    "path": "api/public/public_api/endpoints/open_weather_map.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nimport json\nimport os\n\nimport requests\n\nfrom selene.api import PublicEndpoint, track_account_activity\n\n\nclass OpenWeatherMapEndpoint(PublicEndpoint):\n    \"\"\"Proxy to the Open Weather Map API\"\"\"\n\n    def __init__(self):\n        super(OpenWeatherMapEndpoint, self).__init__()\n        self.owm_key = os.environ[\"OWM_KEY\"]\n        self.owm_url = os.environ[\"OWM_URL\"]\n\n    def get(self, path):\n        self._authenticate()\n        track_account_activity(self.db, self.device_id)\n        return self._get_weather(path)\n\n    def _get_weather(self, path):\n        params = dict(self.request.args)\n        params[\"APPID\"] = self.owm_key\n        response = requests.get(self.owm_url + \"/\" + path, params=params)\n\n        return json.loads(response.content.decode(\"utf-8\"))\n"
  },
  {
    "path": "api/public/public_api/endpoints/premium_voice.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nimport os\nfrom http import HTTPStatus\n\nfrom selene.api import PublicEndpoint\nfrom selene.data.account import AccountRepository\n\n\nclass PremiumVoiceEndpoint(PublicEndpoint):\n    def __init__(self):\n        super(PremiumVoiceEndpoint, self).__init__()\n\n    def get(self, device_id):\n        self._authenticate(device_id)\n        arch = self.request.args.get(\"arch\")\n        account = AccountRepository(self.db).get_account_by_device_id(device_id)\n        if account and account.membership:\n            link = self._get_premium_voice_link(arch)\n            response = {\"link\": link}, HTTPStatus.OK\n        else:\n            response = \"\", HTTPStatus.NO_CONTENT\n        return response\n\n    def _get_premium_voice_link(self, arch):\n        if arch == \"arm\":\n            response = os.environ[\"URL_VOICE_ARM\"]\n        elif arch == \"x86_64\":\n            response = os.environ[\"URL_VOICE_X86_64\"]\n        else:\n            response = \"\"\n        return response\n"
  },
  {
    "path": "api/public/public_api/endpoints/stripe_webhook.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nimport json\nfrom http import HTTPStatus\n\nfrom selene.api import PublicEndpoint\nfrom selene.data.account import AccountRepository\n\n\nclass StripeWebHookEndpoint(PublicEndpoint):\n    def __init__(self):\n        super(StripeWebHookEndpoint, self).__init__()\n\n    def post(self):\n        event = json.loads(self.request.data)\n        type = event.get(\"type\")\n        if type == \"customer.subscription.deleted\":\n            customer = event[\"data\"][\"object\"][\"customer\"]\n            AccountRepository(self.db).end_active_membership(customer)\n        return \"\", HTTPStatus.OK\n"
  },
  {
    "path": "api/public/public_api/endpoints/wake_word_file.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2020 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\n\"\"\"Public Device API endpoint for uploading a sample wake word for tagging.\"\"\"\nimport json\nfrom datetime import datetime\nfrom http import HTTPStatus\nfrom os import environ\nfrom pathlib import Path\n\nfrom flask import jsonify\nfrom schematics import Model\nfrom schematics.types import StringType\nfrom schematics.exceptions import DataError\n\nfrom selene.api import PublicEndpoint\nfrom selene.data.account import Account, AccountRepository\nfrom selene.data.tagging import (\n    build_tagging_file_name,\n    TaggingFileLocationRepository,\n    UPLOADED_STATUS,\n    WakeWordFile,\n    WakeWordFileRepository,\n)\nfrom selene.data.wake_word import WakeWordRepository\nfrom selene.util.log import get_selene_logger\n\nLOCAL_IP = \"127.0.0.1\"\n\n_log = get_selene_logger(__name__)\n\n\nclass UploadRequest(Model):\n    \"\"\"Data class for validating the content of the POST request.\"\"\"\n\n    wake_word = StringType(required=True)\n    engine = StringType(required=True)\n    timestamp = StringType(required=True)\n    model = StringType(required=True)\n\n\nclass WakeWordFileUpload(PublicEndpoint):\n    \"\"\"Endpoint for submitting and retrieving wake word sample files.\n\n    Samples will be saved to a temporary location on the API host until a daily batch\n    job moves them to a permanent one.  Each file will be logged on the sample table\n    for their location and classification data.\n    \"\"\"\n\n    _file_location = None\n    _wake_word_repository = None\n    _wake_word = None\n\n    def __init__(self):\n        super().__init__()\n        self.request_data = None\n\n    @property\n    def wake_word_repository(self):\n        \"\"\"Lazy instantiation of wake word repository object.\"\"\"\n        if self._wake_word_repository is None:\n            self._wake_word_repository = WakeWordRepository(self.db)\n\n        return self._wake_word_repository\n\n    @property\n    def wake_word(self):\n        \"\"\"Build and return a WakeWord object.\"\"\"\n        if self._wake_word is None:\n            self._wake_word = self.wake_word_repository.ensure_wake_word_exists(\n                name=self.request_data[\"wake_word\"].strip().replace(\"-\", \" \"),\n                engine=self.request_data[\"engine\"],\n            )\n\n        return self._wake_word\n\n    @property\n    def file_location(self):\n        \"\"\"Build and return a TaggingFileLocation object.\"\"\"\n        if self._file_location is None:\n            data_dir = Path(environ[\"SELENE_DATA_DIR\"])\n            wake_word = self.request_data[\"wake_word\"].replace(\" \", \"-\")\n            wake_word_dir = data_dir.joinpath(\"wake-word\").joinpath(wake_word)\n            wake_word_dir.mkdir(parents=True, exist_ok=True)\n            file_location_repository = TaggingFileLocationRepository(self.db)\n            self._file_location = file_location_repository.ensure_location_exists(\n                server=LOCAL_IP, directory=str(wake_word_dir)\n            )\n\n        return self._file_location\n\n    def post(self, device_id):\n        \"\"\"\n        Process a HTTP POST request submitting a wake word sample from a device.\n\n        :param device_id: UUID of the device that originated the request.\n        :return:  HTTP response indicating status of the request.\n        \"\"\"\n        self._authenticate(device_id)\n        self._validate_post_request()\n        account = self._get_account(device_id)\n        file_contents = self.request.files[\"audio\"].read()\n        hashed_file_name = build_tagging_file_name(file_contents)\n        new_file_name = self._add_wake_word_file(account, hashed_file_name)\n        if new_file_name is not None:\n            hashed_file_name = new_file_name\n        self._save_audio_file(hashed_file_name, file_contents)\n\n        return jsonify(\"Wake word sample uploaded successfully\"), HTTPStatus.OK\n\n    def _validate_post_request(self):\n        \"\"\"Load the post request into the validation class and perform validations.\"\"\"\n        if \"audio\" not in self.request.files:\n            raise DataError(dict(audio=\"No audio file included in request\"))\n        if \"metadata\" not in self.request.files:\n            raise DataError(dict(metadata=\"No metadata file included in request\"))\n        metadata = json.loads(self.request.files[\"metadata\"].read().decode())\n        upload_request = UploadRequest(\n            dict(\n                wake_word=metadata.get(\"wake_word\"),\n                engine=metadata.get(\"engine\"),\n                timestamp=metadata.get(\"timestamp\"),\n                model=metadata.get(\"model\"),\n            )\n        )\n        upload_request.validate()\n        self.request_data = upload_request.to_native()\n\n    def _get_account(self, device_id: str):\n        \"\"\"Use the device ID to find the account.\n\n        :param device_id: The database ID for the device that made this API call\n        \"\"\"\n        account_repository = AccountRepository(self.db)\n        return account_repository.get_account_by_device_id(device_id)\n\n    def _save_audio_file(self, hashed_file_name: str, file_contents: bytes):\n        \"\"\"Build the file path for the audio file.\"\"\"\n        file_path = Path(self.file_location.directory).joinpath(hashed_file_name)\n        with open(file_path, \"wb\") as audio_file:\n            audio_file.write(file_contents)\n\n    def _add_wake_word_file(self, account: Account, hashed_file_name: str):\n        \"\"\"Add the sample to the database for reference and classification.\n\n        :param account: the account from which sample originated\n        :param hashed_file_name: name of the audio file saved to file system\n        \"\"\"\n        sample = WakeWordFile(\n            account_id=account.id,\n            location=self.file_location,\n            name=hashed_file_name,\n            origin=\"mycroft\",\n            submission_date=datetime.utcnow().date(),\n            wake_word=self.wake_word,\n            status=UPLOADED_STATUS,\n        )\n        file_repository = WakeWordFileRepository(self.db)\n        new_file_name = file_repository.add(sample)\n\n        return new_file_name\n"
  },
  {
    "path": "api/public/public_api/endpoints/wolfram_alpha.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nimport os\nfrom http import HTTPStatus\n\nimport requests\nfrom flask import Response\n\nfrom selene.api import PublicEndpoint, track_account_activity\n\n\nclass WolframAlphaEndpoint(PublicEndpoint):\n    \"\"\"Proxy to the Wolfram Alpha API.\n\n    WARNING: This Endpoint is deprecated in favor of WolframAlphaV2Endpoint.\n\n    The new endpoint allows for the usage of additional query params beyond\n    the 'input' such as output format to return JSON or XML.\n    \"\"\"\n\n    def __init__(self):\n        super(WolframAlphaEndpoint, self).__init__()\n        self.wolfram_alpha_key = os.environ[\"WOLFRAM_ALPHA_KEY\"]\n        self.wolfram_alpha_url = os.environ[\"WOLFRAM_ALPHA_URL\"]\n\n    def get(self):\n        self._authenticate()\n        track_account_activity(self.db, self.device_id)\n        return self._query_wolfram_alpha()\n\n    def _query_wolfram_alpha(self):\n        query = self.request.args.get(\"input\")\n        if query:\n            params = dict(appid=self.wolfram_alpha_key, input=query)\n            response = requests.get(self.wolfram_alpha_url + \"/v2/query\", params=params)\n            if response.status_code == HTTPStatus.OK:\n                return Response(response.content, mimetype=\"text/xml\")\n"
  },
  {
    "path": "api/public/public_api/endpoints/wolfram_alpha_simple.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2021 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nimport os\nfrom http import HTTPStatus\n\nimport requests\n\nfrom selene.api import PublicEndpoint\n\n\nclass WolframAlphaSimpleEndpoint(PublicEndpoint):\n    \"\"\"Endpoint to communicate with the Wolfram Alpha Simple API.\n\n    The Simple API returns a universally viewable image format.\n    https://products.wolframalpha.com/simple-api/\n    \"\"\"\n\n    def __init__(self):\n        super(WolframAlphaSimpleEndpoint, self).__init__()\n        self.wolfram_alpha_key = os.environ[\"WOLFRAM_ALPHA_KEY\"]\n        self.wolfram_alpha_url = os.environ[\"WOLFRAM_ALPHA_URL\"]\n\n    def get(self):\n        self._authenticate()\n        params = dict(self.request.args)\n        params[\"appid\"] = self.wolfram_alpha_key\n        response = requests.get(self.wolfram_alpha_url + \"/v1/simple\", params=params)\n        code = response.status_code\n        response = (response.text, code) if code == HTTPStatus.OK else (\"\", code)\n        return response\n"
  },
  {
    "path": "api/public/public_api/endpoints/wolfram_alpha_spoken.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nimport os\nfrom http import HTTPStatus\n\nimport requests\n\nfrom selene.api import PublicEndpoint\n\n\nclass WolframAlphaSpokenEndpoint(PublicEndpoint):\n    \"\"\"Endpoint to communicate with the Wolfram Alpha Spoken API\"\"\"\n\n    def __init__(self):\n        super(WolframAlphaSpokenEndpoint, self).__init__()\n        self.wolfram_alpha_key = os.environ[\"WOLFRAM_ALPHA_KEY\"]\n        self.wolfram_alpha_url = os.environ[\"WOLFRAM_ALPHA_URL\"]\n\n    def get(self):\n        self._authenticate()\n        params = dict(self.request.args)\n        params[\"appid\"] = self.wolfram_alpha_key\n        response = requests.get(self.wolfram_alpha_url + \"/v1/spoken\", params=params)\n        code = response.status_code\n        response = (response.text, code) if code == HTTPStatus.OK else (\"\", code)\n        return response\n"
  },
  {
    "path": "api/public/public_api/endpoints/wolfram_alpha_v2.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nimport os\nfrom http import HTTPStatus\n\nimport requests\n\nfrom selene.api import PublicEndpoint, track_account_activity\n\n\nclass WolframAlphaV2Endpoint(PublicEndpoint):\n    \"\"\"Proxy to the Wolfram Alpha Full Results v2 API with JSON output.\n\n    https://products.wolframalpha.com/api/documentation/\n    \"\"\"\n\n    def __init__(self):\n        super(WolframAlphaV2Endpoint, self).__init__()\n        self.wolfram_alpha_key = os.environ[\"WOLFRAM_ALPHA_KEY\"]\n        self.wolfram_alpha_url = os.environ[\"WOLFRAM_ALPHA_URL\"]\n\n    def get(self):\n        self._authenticate()\n        track_account_activity(self.db, self.device_id)\n        params = dict(self.request.args)\n        params[\"appid\"] = self.wolfram_alpha_key\n        params[\"output\"] = \"json\"\n        response = requests.get(self.wolfram_alpha_url + \"/v2/query\", params=params)\n        return response.json(), response.status_code\n"
  },
  {
    "path": "api/public/pyproject.toml",
    "content": "[tool.poetry]\nname = \"public\"\nversion = \"0.1.0\"\ndescription = \"API for interactions between Selene and Mycroft devices\"\nauthors = [\"Chris Veilleux <veilleux.chris@gmail.com>\"]\nlicense = \"GNU AGPL 3.0\"\n\n[tool.poetry.dependencies]\npython = \"^3.9\"\n# Version 1.0 of flask required because later versions do not allow lists to be passed as API repsonses.  The Google\n# STT endpoint passes a list of transcriptions to the device.  Changing this to return a dictionary would break the\n# API's V1 contract with Mycroft Core.\n#\n# To make flask 1.0 work, older versions of itsdangerous, jinja2, markupsafe and werkszeug are required.\nflask = \"<1.1\"\ngoogle-cloud-speech = \"^2.15.1\"\nitsdangerous = \"<=2.0.1\"\njinja2 = \"<=2.10.1\"\nmarkupsafe = \"<=2.0.1\"\nrequests = \"*\"\nselene = {path = \"./../../shared\", develop = true}\nSpeechRecognition = \"*\"\nstripe = \"*\"\nuwsgi = \"*\"\nwerkzeug = \"<=2.0.3\"\nlibrosa = \"^0.9.2\"\nnumpy = \"<=1.22\"\n\n\n[tool.poetry.dev-dependencies]\nallure-behave = \"*\"\nblack = \"*\"\npyhamcrest = \"*\"\npylint = \"*\"\nbehave = \"^1.2.6\"\n\n[build-system]\nrequires = [\"poetry-core>=1.0.0\"]\nbuild-backend = \"poetry.core.masonry.api\"\n"
  },
  {
    "path": "api/public/tests/features/device_email.feature",
    "content": "Feature: Device API -- Send email to the account holder\n  Some skills have the ability to send email upon request.  One example of\n  this is the support skill, which emails device diagnostics.\n\n  Scenario: Email sent to account holder\n    When a user interaction with a device causes an email to be sent\n    Then the request will be successful\n    And an email should be sent to the account that owns the device\n    And the device's last contact time is updated\n\n  Scenario: Email request sent by unauthorized device\n    When an unpaired or unauthenticated device attempts to send an email\n    Then the request will fail with an unauthorized error\n"
  },
  {
    "path": "api/public/tests/features/device_location.feature",
    "content": "Feature: Device API -- Request device location\n\n  Scenario: Location is successfully retrieved from a device\n    When a api call to get the location is done\n    Then the location should be retrieved\n    And the device's last contact time is updated\n\n  Scenario: Try to get a location using an expired etag\n    Given an expired etag from a location entity\n    When try to get the location using the expired etag\n    Then the location should be retrieved\n    And an etag associated with the location should be created\n    And the device's last contact time is updated\n\n  Scenario: Try to get a location using a valid etag\n    Given a valid etag from a location entity\n    When try to get the location using a valid etag\n    Then the location endpoint should return 304\n    And the device's last contact time is updated\n"
  },
  {
    "path": "api/public/tests/features/device_metrics.feature",
    "content": "Feature: Device API -- Save device activity metrics\n\n  Scenario: User opted into the open dataset uses their device\n    Given a device registered to a user opted into the open dataset\n    When someone issues a voice command to the device\n    Then usage metrics are saved to the database\n    And the device's last contact time is updated\n    And the account's last activity time is updated\n    And the account activity metrics will be updated\n\n  Scenario: Metric endpoint fails for unauthorized device\n    Given a non-existent device\n    When someone issues a voice command to the device\n    Then the request will fail with an unauthorized error\n    And the device's last contact time is updated\n"
  },
  {
    "path": "api/public/tests/features/device_pairing.feature",
    "content": "Feature: Device API -- Pair a device\n  Test the device pairing workflow\n\n  Scenario: Pairing code generation\n    When a device requests a pairing code\n    Then the request will be successful\n    And the pairing data is stored in Redis\n    And the pairing data is sent to the device\n\n  Scenario: Device activation\n    Given the user completes the pairing process on the web application\n    When the device requests to be activated\n    Then the request will be successful\n    And the activation data is sent to the device\n    And the device attributes are stored in the database\n\n  Scenario: Pantacor device configuration sync\n    Given an authorized device\n    When Pantacor has claimed the device\n    And a device requests to sync with Pantacor\n    Then the request will be successful\n    And the Pantacor device configuration is stored in the database\n\n  Scenario: Pantacor device not claimed\n    Given an authorized device\n    When Pantacor has not yet claimed the device\n    And a device requests to sync with Pantacor\n    Then the request will fail with a precondition required error\n"
  },
  {
    "path": "api/public/tests/features/device_refresh_token.feature",
    "content": "#Feature: Refresh device token\n#  Test the endpoint used to refresh the device session login\n#\n#  Scenario: A valid login session is returned after the refreshing token is performed\n#    Given a device pairing code\n#    When a device is added to an account using the pairing code\n#    And device is activated\n#    And the session token is refreshed\n#    Then a valid new session entity should be returned\n#\n#  Scenario: An error status code is returned after trying to refresh an invalid token\n#    When try to refresh an invalid refresh token\n#    Then 401 status code should be returned\n"
  },
  {
    "path": "api/public/tests/features/device_skill_manifest.feature",
    "content": "Feature: Device API -- Upload and fetch skills manifest\n\n  Scenario: Device uploads an unchanged manifest\n    Given an authorized device\n    When a device uploads a skill manifest without changes\n    Then the request will be successful\n    And the skill manifest on the database is unchanged\n    And the device's last contact time is updated\n\n  Scenario: Device uploads a manifest with an updated skill\n    Given an authorized device\n    When a device uploads a skill manifest with an updated skill\n    Then the request will be successful\n    And the skill manifest on the database is unchanged\n    And the device's last contact time is updated\n\n  Scenario: Device uploads a manifest with a deleted skill\n    Given an authorized device\n    When a device uploads a skill manifest with a deleted skill\n    Then the request will be successful\n    And the skill is removed from the manifest on the database\n    And the device's last contact time is updated\n\n  Scenario: Device uploads a manifest with a deleted device-specific skill\n    Given an authorized device\n    And a device-specific skill installed on the device\n    When a device uploads a skill manifest with a deleted device-specific skill\n    Then the request will be successful\n    And the device-specific skill is removed from the manifest on the database\n    And the device-specific skill is removed from the database\n    And the device's last contact time is updated\n\n  @new_skill\n  Scenario: Device uploads a manifest with a new skill\n    Given an authorized device\n    When a device uploads a skill manifest with a new skill\n    Then the request will be successful\n    And the skill is added to the database\n    And the skill is added to the manifest on the database\n    And the device's last contact time is updated\n\n  Scenario: Unauthorized device attempts manifest upload\n    Given an unauthorized device\n    When a device uploads a skill manifest without changes\n    Then the request will fail with an unauthorized error\n\n  Scenario: Device sends a malformed request for manifest\n    Given an authorized device\n    When a device uploads a malformed skill manifest\n    Then the request will fail with a bad request error\n"
  },
  {
    "path": "api/public/tests/features/device_skill_settings.feature",
    "content": "Feature: Device API -- Upload and fetch skills and their settings\n  Test all endpoints related to upload and fetch skill settings\n\n  Scenario: A device requests the settings for its skills\n    Given an authorized device\n    When a device requests the settings for its skills\n    Then the request will be successful\n    And the settings are returned\n    And an E-tag is generated for these settings\n    And the device's last contact time is updated\n\n  # This scenario uses the ETag generated by the first scenario\n  Scenario: Device requests skill settings that have not changed since last they were requested\n    Given an authorized device\n    And a valid device skill E-tag\n    When a device requests the settings for its skills\n    Then the request will succeed with a \"not modified\" return code\n    And the device's last contact time is updated\n\n  # This scenario uses the ETag generated by the first scenario\n  Scenario: Device requests skill settings that have changed since last they were requested\n    Given an authorized device\n    And an expired device skill E-tag\n    When a device requests the settings for its skills\n    Then the request will be successful\n    And an E-tag is generated for these settings\n    And the device's last contact time is updated\n\n  Scenario: A device uploads a change to a skill setting value\n    Given an authorized device\n    And a valid device skill E-tag\n    And skill settings with a new value\n    When the device sends a request to update the bar skill settings\n    Then the request will be successful\n    And the skill settings are updated with the new value\n    And the device skill E-tag is expired\n    And the device's last contact time is updated\n\n  Scenario: A device uploads a change to a skill not assigned to it\n    Given an authorized device\n    And a valid device skill E-tag\n    And settings for a skill not assigned to the device\n    When the device sends a request to update the foobar skill settings\n    Then the request will be successful\n    And the skill is assigned to the device with the settings populated\n    And the device skill E-tag is expired\n    And the device's last contact time is updated\n\n  Scenario: A device uploads skill settings with a field deleted from the settings\n    Given an authorized device\n    And a valid device skill E-tag\n    And skill settings with a deleted field\n    When the device sends a request to update the bar skill settings\n    Then the request will be successful\n    And the field is no longer in the skill settings\n    And the device skill E-tag is expired\n    And the device's last contact time is updated\n"
  },
  {
    "path": "api/public/tests/features/device_subscription.feature",
    "content": "Feature: Device API -- Request account subscription type\n  Test the endpoint used to fetch the subscription type of a device\n\n  Scenario: User has a free subscription\n    When the subscription endpoint is called\n    Then free type should be returned\n    And the device's last contact time is updated\n\n  Scenario: User has a monthly subscription\n    When the subscription endpoint is called for a monthly account\n    Then monthly type should be returned\n    And the device's last contact time is updated\n\n  Scenario: The endpoint is called to a nonexistent device\n    When try to get the subscription for a nonexistent device\n    Then 401 status code should be returned for the subscription endpoint\n    And the device's last contact time is updated\n"
  },
  {
    "path": "api/public/tests/features/environment.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Environmental controls for the public API behavioral tests\"\"\"\nimport os\nfrom datetime import date\nfrom pathlib import Path\nfrom tempfile import mkdtemp\n\nfrom behave import fixture, use_fixture\n\nfrom public_api.api import public\nfrom selene.api import generate_device_login\nfrom selene.api.etag import ETagManager\nfrom selene.data.metric import TranscriptionMetricRepository\nfrom selene.testing.account import add_account, remove_account\nfrom selene.testing.account_activity import remove_account_activity\nfrom selene.testing.agreement import add_agreements, remove_agreements\nfrom selene.testing.account_geography import add_account_geography\nfrom selene.testing.account_preference import add_account_preference\nfrom selene.testing.device import add_device\nfrom selene.testing.device_skill import (\n    add_device_skill,\n    add_device_skill_settings,\n    remove_device_skill,\n)\nfrom selene.testing.skill import (\n    add_skill,\n    build_checkbox_field,\n    build_label_field,\n    build_text_field,\n    remove_skill,\n)\nfrom selene.testing.tagging import remove_wake_word_files\nfrom selene.testing.text_to_speech import add_text_to_speech, remove_text_to_speech\nfrom selene.testing.wake_word import add_wake_word, remove_wake_word\nfrom selene.util.cache import SeleneCache\nfrom selene.util.db import connect_to_db\nfrom selene.util.log import get_selene_logger\n\n_log = get_selene_logger(\"public_api.behave_tests\")\n\n\n@fixture\ndef public_api_client(context):\n    \"\"\"Start the public API for use in the tests.\"\"\"\n    public.testing = True\n    context.client_config = public.config\n    context.client = public.test_client()\n    yield context.client\n\n\ndef before_all(context):\n    \"\"\"Setup static test data before any tests run.\n\n    This is data that does not change from test to test so it only needs to be setup\n    and torn down once.\n    \"\"\"\n    _log.info(\"setting up test suite...\")\n    use_fixture(public_api_client, context)\n    context.cache = SeleneCache()\n    context.db = connect_to_db(context.client_config[\"DB_CONNECTION_CONFIG\"])\n    add_agreements(context)\n    context.wake_words = {\"hey selene\": add_wake_word(context.db)}\n    data_dir = mkdtemp()\n    context.wake_word_dir = Path(data_dir).joinpath(\"wake-word\")\n    os.environ[\"SELENE_DATA_DIR\"] = data_dir\n\n\ndef after_all(context):\n    \"\"\"Clean up static test data after all tests have run.\n\n    This is data that does not change from test to test so it only needs to be setup\n    and torn down once.\n    \"\"\"\n    _log.info(\"cleaning up test suite\")\n    try:\n        for wake_word in context.wake_words.values():\n            _remove_wake_word_files(context, wake_word)\n            remove_wake_word(context.db, wake_word)\n        remove_agreements(\n            context.db,\n            [context.privacy_policy, context.terms_of_use, context.open_dataset],\n        )\n        os.removedirs(context.wake_word_dir)\n    except Exception:\n        _log.exception(\"failure in test suite cleanup\")\n        raise\n\n\ndef _remove_wake_word_files(context, wake_word):\n    \"\"\"Delete the .wav files from the file system and their references on the database\n\n    Assumes that there are no subdirectories in the provided temp directory.\n    \"\"\"\n    file_dir = context.wake_word_dir.joinpath(wake_word.name.replace(\" \", \"-\"))\n    for file_name in os.listdir(file_dir):\n        os.remove(file_dir.joinpath(file_name))\n    os.rmdir(file_dir)\n\n\ndef before_scenario(context, _):\n    \"\"\"Setup data that could change during a scenario so each test starts clean.\"\"\"\n    _log.info(\"setting up scenario...\")\n    context.etag_manager = ETagManager(context.cache, context.client_config)\n    _add_account(context)\n    _add_skills(context)\n    _add_device(context)\n    _add_device_skills(context)\n    context.wake_word_files = []\n    context.duplicate_hash = False\n\n\ndef after_scenario(context, _):\n    \"\"\"Cleanup data that could change during a scenario so next scenario starts fresh.\n\n    The database is setup with cascading deletes that take care of cleaning up\n    referential integrity for us.  All we have to do here is to delete the account\n    and all rows on all tables related to that account will also be deleted.\n    \"\"\"\n    _log.info(\"cleaning up after scenario...\")\n    remove_account(context.db, context.account)\n    remove_account_activity(context.db)\n    remove_text_to_speech(context.db, context.voice)\n    for skill in context.skills.values():\n        remove_skill(context.db, skill[0])\n    for wake_word_file in context.wake_word_files:\n        remove_wake_word_files(context.db, wake_word_file)\n\n\ndef _add_account(context):\n    \"\"\"Add an account object to the context for use in step code.\"\"\"\n    context.account = add_account(context.db)\n    add_account_preference(context.db, context.account.id)\n    context.geography_id = add_account_geography(context.db, context.account)\n\n\ndef _add_device(context):\n    \"\"\"Add a device object to the context for use in step code.\"\"\"\n    context.voice = add_text_to_speech(context.db)\n    device_id = add_device(context.db, context.account.id, context.geography_id)\n    context.device_id = device_id\n    context.device_name = \"Selene Test Device\"\n    context.device_login = generate_device_login(device_id, context.cache)\n    context.access_token = context.device_login[\"accessToken\"]\n\n\ndef _add_skills(context):\n    \"\"\"Add skill objects to the context for use in step code.\"\"\"\n    foo_skill, foo_settings_display = add_skill(\n        context.db,\n        skill_global_id=\"foo-skill|19.02\",\n    )\n    bar_skill, bar_settings_display = add_skill(\n        context.db,\n        skill_global_id=\"bar-skill|19.02\",\n        settings_fields=[\n            build_label_field(),\n            build_text_field(),\n            build_checkbox_field(),\n        ],\n    )\n    context.skills = dict(\n        foo=(foo_skill, foo_settings_display), bar=(bar_skill, bar_settings_display)\n    )\n\n\ndef _add_device_skills(context):\n    \"\"\"Link skills to devices for use in step code.\"\"\"\n    for value in context.skills.values():\n        skill, settings_display = value\n        context.manifest_skill = add_device_skill(context.db, context.device_id, skill)\n        settings_values = None\n        if skill.skill_gid.startswith(\"bar\"):\n            settings_values = dict(textfield=\"Device text value\", checkboxfield=\"false\")\n        add_device_skill_settings(\n            context.db,\n            context.device_id,\n            settings_display,\n            settings_values=settings_values,\n        )\n\n\ndef after_tag(context, tag):\n    \"\"\"Delete data that was added as a result of running a test with a specified tag.\"\"\"\n    if tag == \"new_skill\":\n        _delete_new_skill(context)\n    elif tag == \"stt\":\n        _delete_stt_tagging_files()\n\n\ndef _delete_new_skill(context):\n    \"\"\"Delete a skill that was added during a test.\"\"\"\n    remove_device_skill(context.db, context.new_manifest_skill)\n    remove_skill(context.db, context.new_skill)\n\n\ndef _delete_stt_tagging_files():\n    \"\"\"Delete speech to text transcriptions that were added during a test.\"\"\"\n    data_dir = \"/opt/selene/data\"\n    for file_name in os.listdir(data_dir):\n        os.remove(os.path.join(data_dir, file_name))\n\n\ndef _delete_stt_transcription_metrics(context):\n    stt_transcription_repo = TranscriptionMetricRepository(context.db)\n    stt_transcription_repo.delete_by_date(date.today())\n"
  },
  {
    "path": "api/public/tests/features/get_device.feature",
    "content": "Feature: Device API - Request device's information\n  Test the endpoint to get a device\n\n  Scenario: A valid device entity is returned\n    When device is retrieved\n    Then a valid device should be returned\n    And the device's last contact time is updated\n\n  Scenario: Try to fetch a device not allowed by the access token\n    When try to fetch a not allowed device\n    Then a 401 status code should be returned\n    And the device's last contact time is updated\n\n  Scenario: Try to get a device without passing the access token\n    When try to fetch a device without the authorization header\n    Then a 401 status code should be returned\n\n  Scenario: Update device information\n    When the device is updated\n    And device is retrieved\n    Then the information should be updated\n    And the device's last contact time is updated\n\n  Scenario: Get a not modified device using etag\n    Given a device with a valid etag\n    When try to fetch a device using a valid etag\n    Then 304 status code should be returned by the device endpoint\n    And the device's last contact time is updated\n\n  Scenario: Get a device using an expired etag\n    Given a device's etag expired by the web ui\n    When try to fetch a device using an expired etag\n    Then should return status 200\n    And a new etag\n    And the device's last contact time is updated\n"
  },
  {
    "path": "api/public/tests/features/get_device_settings.feature",
    "content": "Feature: Device API -- Request device settings\n  Test the endpoint used to fetch the settings from a device\n\n  Scenario: Device's setting is returned\n    When try to fetch device's setting\n    Then a valid setting should be returned\n    And the device's last contact time is updated\n\n  Scenario: Try to get the settings from a not allowed device\n    When the settings endpoint is a called to a not allowed device\n    Then a 401 status code should be returned for the setting\n    And the device's last contact time is updated\n\n  Scenario: Try to get the device's settings using a valid etag\n    Given a device's setting with a valid etag\n    When try to fetch the device's settings using a valid etag\n    Then 304 status code should be returned by the device's settings endpoint\n    And the device's last contact time is updated\n\n  Scenario: Try to get a device's settings using a expired etag\n    Given a device's setting etag expired by the web ui at device level\n    When try to fetch the device's settings using an expired etag\n    Then 200 status code should be returned by the device's setting endpoint and a new etag\n    And the device's last contact time is updated\n"
  },
  {
    "path": "api/public/tests/features/steps/common.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom http import HTTPStatus\nfrom datetime import datetime\n\nfrom behave import given, then\nfrom hamcrest import assert_that, equal_to, is_in, not_none\n\nfrom selene.data.account import AccountRepository\nfrom selene.data.metric import AccountActivityRepository\nfrom selene.util.cache import DEVICE_LAST_CONTACT_KEY\n\n\n@then(\"the device's last contact time is updated\")\ndef check_device_last_contact(context):\n    key = DEVICE_LAST_CONTACT_KEY.format(device_id=context.device_id)\n    value = context.cache.get(key).decode()\n    assert_that(value, not_none())\n\n    last_contact_ts = datetime.strptime(value, \"%Y-%m-%d %H:%M:%S.%f\")\n    assert_that(last_contact_ts.date(), equal_to(datetime.utcnow().date()))\n\n\n@then(\"the request will be successful\")\ndef check_request_success(context):\n    assert_that(\n        context.response.status_code, is_in([HTTPStatus.OK, HTTPStatus.NO_CONTENT])\n    )\n\n\n@then('the request will succeed with a \"not modified\" return code')\ndef check_request_success(context):\n    assert_that(context.response.status_code, equal_to(HTTPStatus.NOT_MODIFIED))\n\n\n@then(\"the request will fail with {error_type} error\")\ndef check_for_bad_request(context, error_type):\n    if error_type == \"a bad request\":\n        assert_that(context.response.status_code, equal_to(HTTPStatus.BAD_REQUEST))\n    elif error_type == \"an unauthorized\":\n        assert_that(context.response.status_code, equal_to(HTTPStatus.UNAUTHORIZED))\n    elif error_type == \"a precondition required\":\n        assert_that(\n            context.response.status_code, equal_to(HTTPStatus.PRECONDITION_REQUIRED)\n        )\n    else:\n        raise ValueError(\"unsupported error_type\")\n\n\n@given(\"an authorized device\")\ndef build_request_header(context):\n    context.request_header = dict(\n        Authorization=\"Bearer {token}\".format(token=context.access_token)\n    )\n\n\n@given(\"an unauthorized device\")\ndef build_unauthorized_request_header(context):\n    context.request_header = dict(\n        Authorization=\"Bearer {token}\".format(token=\"bogus_token\")\n    )\n\n\n@then(\"the account's last activity time is updated\")\ndef validate_account_last_activity(context):\n    account_repo = AccountRepository(context.db)\n    account = account_repo.get_account_by_device_id(context.device_login[\"uuid\"])\n    assert_that(account.last_activity, not_none())\n    assert_that(account.last_activity.date(), equal_to(datetime.utcnow().date()))\n\n\n@then(\"the account activity metrics will be updated\")\ndef validate_account_activity_metrics(context):\n    account_activity_repo = AccountActivityRepository(context.db)\n    account_activity = account_activity_repo.get_activity_by_date(\n        datetime.utcnow().date()\n    )\n    assert_that(account_activity.accounts_active, equal_to(1))\n    assert_that(account_activity.members_active, equal_to(0))\n    assert_that(account_activity.open_dataset_active, equal_to(1))\n"
  },
  {
    "path": "api/public/tests/features/steps/device_email.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Step functions for the device API call to send an email.\"\"\"\nimport json\nimport uuid\nfrom unittest.mock import patch, MagicMock\n\nfrom behave import when, then  # pylint: disable=no-name-in-module\n\n\n@when(\"a user interaction with a device causes an email to be sent\")\ndef send_email(context):\n    \"\"\"Call the email endpoint to send an email as specified by a device.\n\n    The SendGrid call to send email is mocked out because we trust that the library\n    is coded to correctly send email via sendgrid.  That, and testing sent emails is\n    a real pain in the arse.\n    \"\"\"\n    with patch(\"selene.util.email.email.Mail\") as message_patch:\n        context.message_patch = message_patch\n        with patch(\"selene.util.email.email.SendGridAPIClient\") as sendgrid_patch:\n            sendgrid_patch.return_value, post_mock = _define_sendgrid_mock()\n            context.sendgrid_patch = sendgrid_patch\n            context.post_mock = post_mock\n            _call_email_endpoint(context)\n\n\n@when(\"an unpaired or unauthenticated device attempts to send an email\")\ndef send_email_invalid_device(context):\n    \"\"\"Call the email endpoint with an invalid device ID to test authentication.\"\"\"\n    _call_email_endpoint(context, device_id=str(uuid.uuid4()))\n\n\ndef _define_sendgrid_mock():\n    \"\"\"Go through some insane hoops to setup the correct Mock for the Sendgrid code.\"\"\"\n    post_return_value = MagicMock(name=\"post_return\")\n    post_return_value.status_code = 200\n    post_mock = MagicMock(name=\"post_mock\")\n    post_mock.return_value = post_return_value\n    send_mock = MagicMock(name=\"send_mock\")\n    send_mock.post = post_mock\n    mail_mock = MagicMock(name=\"mail_mock\")\n    mail_mock.send = send_mock\n    client_mock = MagicMock(name=\"client_mock\")\n    client_mock.mail = mail_mock\n    sendgrid_mock = MagicMock(name=\"sendgrid_mock\")\n    sendgrid_mock.client = client_mock\n\n    return sendgrid_mock, post_mock\n\n\ndef _call_email_endpoint(context, device_id=None):\n    \"\"\"Build the request to the email endpoint and issue a call.\"\"\"\n    if device_id is None:\n        login = context.device_login\n        request_device_id = login[\"uuid\"]\n        request_headers = dict(Authorization=f\"Bearer {login['accessToken']}\")\n    else:\n        request_device_id = device_id\n        request_headers = dict(Authorization=f\"Bearer thisisadummybearertoken\")\n    request_data = dict(\n        title=\"this is a test\", sender=\"test@test.com\", body=\"body message\"\n    )\n    context.response = context.client.put(\n        f\"/v1/device/{request_device_id}/message\",\n        data=json.dumps(request_data),\n        content_type=\"application/json\",\n        headers=request_headers,\n    )\n\n\n@then(\"an email should be sent to the account that owns the device\")\ndef validate_response(context):\n    \"\"\"Validate that the SendGrid API was called as expected.\"\"\"\n    sendgrid_patch = context.sendgrid_patch\n    sendgrid_patch.assert_called_with(api_key=\"test_sendgrid_key\")\n    post_mock = context.post_mock\n    post_mock.assert_called_with(request_body=context.message_patch().get())\n"
  },
  {
    "path": "api/public/tests/features/steps/device_location.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nimport json\nfrom http import HTTPStatus\n\nfrom behave import when, then, given\nfrom hamcrest import assert_that, equal_to, has_key, not_none, is_not\n\nfrom selene.api.etag import ETagManager, device_location_etag_key\n\n\n@when(\"a api call to get the location is done\")\ndef get_device_location(context):\n    login = context.device_login\n    device_id = login[\"uuid\"]\n    access_token = login[\"accessToken\"]\n    headers = dict(Authorization=\"Bearer {token}\".format(token=access_token))\n    context.get_location_response = context.client.get(\n        \"/v1/device/{uuid}/location\".format(uuid=device_id), headers=headers\n    )\n\n\n@then(\"the location should be retrieved\")\ndef validate_location(context):\n    response = context.get_location_response\n    assert_that(response.status_code, equal_to(HTTPStatus.OK))\n    location = json.loads(response.data)\n    assert_that(location, has_key(\"coordinate\"))\n    assert_that(location, has_key(\"timezone\"))\n    assert_that(location, has_key(\"city\"))\n\n    coordinate = location[\"coordinate\"]\n    assert_that(coordinate, has_key(\"latitude\"))\n    assert_that(coordinate, has_key(\"longitude\"))\n\n    timezone = location[\"timezone\"]\n    assert_that(timezone, has_key(\"name\"))\n    assert_that(timezone, has_key(\"code\"))\n    assert_that(timezone, has_key(\"offset\"))\n    assert_that(timezone, has_key(\"dstOffset\"))\n\n    city = location[\"city\"]\n    assert_that(city, has_key(\"name\"))\n    assert_that(city, has_key(\"state\"))\n\n    state = city[\"state\"]\n    assert_that(state, has_key(\"name\"))\n    assert_that(state, has_key(\"country\"))\n    assert_that(state, has_key(\"code\"))\n\n    country = state[\"country\"]\n    assert_that(country, has_key(\"name\"))\n    assert_that(country, has_key(\"code\"))\n\n\n@given(\"an expired etag from a location entity\")\ndef expire_location_etag(context):\n    etag_manager: ETagManager = context.etag_manager\n    device_id = context.device_login[\"uuid\"]\n    context.expired_location_etag = etag_manager.get(\n        device_location_etag_key(device_id)\n    )\n    etag_manager.expire_device_location_etag_by_device_id(device_id)\n\n\n@when(\"try to get the location using the expired etag\")\ndef get_using_expired_etag(context):\n    login = context.device_login\n    device_id = login[\"uuid\"]\n    access_token = login[\"accessToken\"]\n    headers = {\n        \"Authorization\": \"Bearer {token}\".format(token=access_token),\n        \"If-None-Match\": context.expired_location_etag,\n    }\n    context.get_location_response = context.client.get(\n        \"/v1/device/{uuid}/location\".format(uuid=device_id), headers=headers\n    )\n\n\n@then(\"an etag associated with the location should be created\")\ndef validate_etag(context):\n    response = context.get_location_response\n    new_location_etag = response.headers.get(\"ETag\")\n    assert_that(new_location_etag, not_none())\n    assert_that(new_location_etag, is_not(context.expired_location_etag))\n\n\n@given(\"a valid etag from a location entity\")\ndef valid_etag(context):\n    etag_manager = context.etag_manager\n    device_id = context.device_login[\"uuid\"]\n    context.valid_location_etag = etag_manager.get(device_location_etag_key(device_id))\n\n\n@when(\"try to get the location using a valid etag\")\ndef get_using_valid_etag(context):\n    login = context.device_login\n    device_id = login[\"uuid\"]\n    access_token = login[\"accessToken\"]\n    headers = {\n        \"Authorization\": \"Bearer {token}\".format(token=access_token),\n        \"If-None-Match\": context.valid_location_etag,\n    }\n    context.get_location_response = context.client.get(\n        \"/v1/device/{uuid}/location\".format(uuid=device_id), headers=headers\n    )\n\n\n@then(\"the location endpoint should return 304\")\ndef validate_response_valid_etag(context):\n    response = context.get_location_response\n    assert_that(response.status_code, equal_to(HTTPStatus.NOT_MODIFIED))\n"
  },
  {
    "path": "api/public/tests/features/steps/device_metrics.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nimport json\nimport uuid\nfrom datetime import datetime\n\nfrom behave import given, then, when\nfrom hamcrest import assert_that, equal_to, greater_than, not_none\n\nfrom selene.data.account import AccountRepository\nfrom selene.data.metric import AccountActivityRepository, CoreMetricRepository\n\nMETRIC_TYPE_TIMING = \"timing\"\nmetric_value = dict(type=\"timing\", start=\"123\")\n\n\n@given(\"a device registered to a user opted {in_or_out} the open dataset\")\ndef define_authorized_device(context, in_or_out):\n    context.metric_device_id = context.device_login[\"uuid\"]\n\n\n@given(\"a non-existent device\")\ndef define_unauthorized_device(context):\n    context.metric_device_id = str(uuid.uuid4())\n\n\n@when(\"someone issues a voice command to the device\")\ndef call_metrics_endpoint(context):\n    headers = dict(\n        Authorization=\"Bearer {token}\".format(token=context.device_login[\"accessToken\"])\n    )\n    url = \"/v1/device/{device_id}/metric/{metric}\".format(\n        device_id=context.metric_device_id, metric=\"timing\"\n    )\n    context.client.content_type = \"application/json\"\n    context.response = context.client.post(\n        url,\n        data=json.dumps(metric_value),\n        content_type=\"application/json\",\n        headers=headers,\n    )\n\n\n@then(\"usage metrics are saved to the database\")\ndef validate_metric_in_db(context):\n    core_metric_repo = CoreMetricRepository(context.db)\n    device_metrics = core_metric_repo.get_metrics_by_device(\n        context.device_login[\"uuid\"]\n    )\n    device_metric = device_metrics[0]\n    assert_that(device_metric.metric_type, equal_to(METRIC_TYPE_TIMING))\n    assert_that(device_metric.metric_value, equal_to(metric_value))\n"
  },
  {
    "path": "api/public/tests/features/steps/device_pairing.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Python code to support the device pairing feature.\"\"\"\nimport json\nimport uuid\n\nfrom unittest.mock import patch, MagicMock\n\nfrom behave import given, then, when  # pylint: disable=no-name-in-module\nfrom hamcrest import assert_that, equal_to, has_key, none, not_none\n\nfrom selene.data.device import DeviceRepository\nfrom selene.util.cache import DEVICE_PAIRING_CODE_KEY, DEVICE_PAIRING_TOKEN_KEY\n\nONE_MINUTE = 60\nONE_DAY = 86400\n\n\n@given(\"the user completes the pairing process on the web application\")\ndef add_device(context):\n    \"\"\"Imitate the logic in the account API to pair a device\"\"\"\n    context.pairing_token = \"pairing_token\"\n    context.pairing_state = \"pairing_state\"\n    pairing_data = dict(\n        code=\"ABC123\",\n        uuid=context.device_id,\n        state=context.pairing_state,\n        token=context.pairing_token,\n        expiration=ONE_DAY,\n    )\n    context.cache.set_with_expiration(\n        key=DEVICE_PAIRING_TOKEN_KEY.format(pairing_token=context.pairing_token),\n        value=json.dumps(pairing_data),\n        expiration=ONE_MINUTE,\n    )\n\n\n@when(\"a device requests a pairing code\")\ndef get_device_pairing_code(context):\n    \"\"\"Call the endpoint that generates the pairing data.\"\"\"\n    context.state = str(uuid.uuid4())\n    response = context.client.get(\n        \"/v1/device/code?state={state}&packaging=pantacor\".format(state=context.state)\n    )\n    context.response = response\n\n\n@when(\"the device requests to be activated\")\ndef activate_device(context):\n    \"\"\"Call the endpoint that completes the device registration process.\n\n    This call is for devices that are not managed by Pantacor.\n    \"\"\"\n    activation_request = dict(\n        token=context.pairing_token,\n        state=context.pairing_state,\n        platform=\"test_platform\",\n        core_version=\"test_core_version\",\n        enclosure_version=\"test_enclosure_version\",\n    )\n    response = context.client.post(\n        \"/v1/device/activate\",\n        data=json.dumps(activation_request),\n        content_type=\"application/json\",\n    )\n    context.response = response\n\n\n@when(\"Pantacor has not yet claimed the device\")\ndef set_pantacor_not_claimed(context):\n    context.pantacor_claimed = False\n\n\n@when(\"Pantacor has claimed the device\")\ndef set_pantacor_not_claimed(context):\n    context.pantacor_claimed = True\n\n\n@when(\"a device requests to sync with Pantacor\")\ndef activate_pantacor_device(context):\n    \"\"\"Call the endpoint that completes the device registration process.\n\n    This call is for devices that are managed by Pantacor.\n    \"\"\"\n    login = context.device_login\n    device_id = login[\"uuid\"]\n    pantacor_request = dict(\n        mycroft_device_id=device_id, pantacor_device_id=\"test_pantacor_id\"\n    )\n    with patch(\"requests.request\") as request_patch:\n        get_channel_response = _mock_get_channel_response()\n        get_device_response = _mock_get_device_response(context)\n        request_patch.side_effect = [get_channel_response, get_device_response]\n        response = context.client.post(\n            \"/v1/device/pantacor\",\n            data=json.dumps(pantacor_request),\n            content_type=\"application/json\",\n            headers=context.request_header,\n        )\n        context.response = response\n\n\ndef _mock_get_channel_response() -> MagicMock:\n    \"\"\"Mock the response that would be generated by the Pantacor API channel endpoint.\n\n    Ideally, there would be a test device setup so we could test without mocking.\n    Until then, here we are.\n    \"\"\"\n    get_channel_content = dict(\n        items=[dict(id=\"test_channel_id\", name=\"test_channel_name\")]\n    )\n    get_channel_response = MagicMock(spec=[\"ok\", \"content\"])\n    get_channel_response.ok = True\n    get_channel_response.content = json.dumps(get_channel_content).encode()\n\n    return get_channel_response\n\n\ndef _mock_get_device_response(context) -> MagicMock:\n    \"\"\"Mock the response that would be generated by the Pantacor API device endpoint.\n\n    Ideally, there would be a test device setup so we could test without mocking.\n    Until then, here we are.\n    \"\"\"\n    labels = [\n        \"device-meta/interfaces.wlan0.ipv4.0=192.168.1.2\",\n        f\"device-meta/pantahub.claimed={1 if context.pantacor_claimed else 0}\",\n    ]\n    get_device_content = dict(\n        id=\"test_device_id\",\n        channel_id=\"test_channel_id\",\n        update_policy=\"auto\",\n        labels=labels,\n    )\n    get_device_response = MagicMock(spec=[\"ok\", \"content\"])\n    get_device_response.ok = True\n    get_device_response.content = json.dumps(get_device_content).encode()\n\n    return get_device_response\n\n\n@then(\"the pairing data is stored in Redis\")\ndef check_cached_pairing_data(context):\n    \"\"\"Confirm that the pairing data stored in Redis is as expected.\"\"\"\n    pairing_code_key = DEVICE_PAIRING_CODE_KEY.format(\n        pairing_code=context.response.json[\"code\"]\n    )\n    pairing_data = context.cache.get(pairing_code_key)\n    pairing_data = json.loads(pairing_data)\n    context.cache.delete(pairing_code_key)\n    assert_that(pairing_data, has_key(\"token\"))\n    assert_that(pairing_data[\"code\"], equal_to(context.response.json[\"code\"]))\n    assert_that(pairing_data[\"expiration\"], equal_to(ONE_DAY))\n    assert_that(pairing_data[\"state\"], equal_to(context.state))\n    assert_that(pairing_data[\"packaging_type\"], equal_to(\"pantacor\"))\n\n\n@then(\"the pairing data is sent to the device\")\ndef validate_pairing_code_response(context):\n    \"\"\"Check that the endpoint returns the expected pairing data to the device\"\"\"\n    response = context.response\n    assert_that(response.json, has_key(\"code\"))\n    assert_that(response.json, has_key(\"token\"))\n    assert_that(response.json[\"expiration\"], equal_to(ONE_DAY))\n    assert_that(response.json[\"state\"], equal_to(context.state))\n\n\n@then(\"the activation data is sent to the device\")\ndef validate_activation_response(context):\n    \"\"\"Check that the endpoint returns the expected activation data to the device.\"\"\"\n    response = context.response\n    assert_that(response.json[\"uuid\"], equal_to(context.device_id))\n    assert_that(response.json, has_key(\"accessToken\"))\n    assert_that(response.json, has_key(\"refreshToken\"))\n    assert_that(response.json[\"expiration\"], equal_to(ONE_DAY))\n\n\n@then(\"the device attributes are stored in the database\")\ndef validate_device_update(context):\n    \"\"\"Validate that the non-Pantacor device attributes are updated correctly.\"\"\"\n    device_repo = DeviceRepository(context.db)\n    device = device_repo.get_device_by_id(context.device_id)\n    assert_that(device.core_version, equal_to(\"test_core_version\"))\n    assert_that(device.platform, equal_to(\"test_platform\"))\n    assert_that(device.enclosure_version, equal_to(\"test_enclosure_version\"))\n\n\n@then(\"the Pantacor device configuration is stored in the database\")\ndef validate_pantacor_update(context):\n    \"\"\"Validate that the Pantacor config of the device is stored in the database.\"\"\"\n    device_repo = DeviceRepository(context.db)\n    device = device_repo.get_device_by_id(context.device_id)\n    assert_that(device.pantacor_config, not_none())\n    assert_that(device.pantacor_config.pantacor_id, equal_to(\"test_pantacor_id\"))\n    assert_that(device.pantacor_config.release_channel, equal_to(\"test_channel_name\"))\n    assert_that(device.pantacor_config.auto_update, equal_to(True))\n    assert_that(device.pantacor_config.ip_address, equal_to(\"192.168.1.2\"))\n    assert_that(device.pantacor_config.ssh_public_key, none())\n"
  },
  {
    "path": "api/public/tests/features/steps/device_refresh_token.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nimport json\nfrom http import HTTPStatus\n\nfrom behave import when, then\nfrom hamcrest import assert_that, equal_to, has_key, is_not\n\n\n@when(\"the session token is refreshed\")\ndef refresh_token(context):\n    login = json.loads(context.activate_device_response.data)\n    refresh = login[\"refreshToken\"]\n    context.refresh_token_response = context.client.get(\n        \"/v1/auth/token\",\n        headers={\"Authorization\": \"Bearer {token}\".format(token=refresh)},\n    )\n\n\n@then(\"a valid new session entity should be returned\")\ndef validate_refresh_token(context):\n    response = context.refresh_token_response\n    assert_that(response.status_code, equal_to(HTTPStatus.OK))\n\n    new_login = json.loads(response.data)\n    assert_that(new_login, has_key(equal_to(\"uuid\")))\n    assert_that(new_login, has_key(equal_to(\"accessToken\")))\n    assert_that(new_login, has_key(equal_to(\"refreshToken\")))\n    assert_that(new_login, has_key(equal_to(\"expiration\")))\n\n    old_login = json.loads(context.activate_device_response.data)\n    assert_that(new_login[\"uuid\"]), equal_to(old_login[\"uuid\"])\n    assert_that(new_login[\"accessToken\"], is_not(equal_to(old_login[\"accessToken\"])))\n    assert_that(new_login[\"refreshToken\"], is_not(equal_to(old_login[\"refreshToken\"])))\n\n\n@when(\"try to refresh an invalid refresh token\")\ndef refresh_invalid_token(context):\n    context.refresh_invalid_token_response = context.client.get(\n        \"/v1/auth/token\",\n        headers={\"Authorization\": \"Bearer {token}\".format(token=\"123\")},\n    )\n\n\n@then(\"401 status code should be returned\")\ndef validate_refresh_invalid_token(context):\n    response = context.refresh_invalid_token_response\n    assert_that(response.status_code, equal_to(HTTPStatus.UNAUTHORIZED))\n"
  },
  {
    "path": "api/public/tests/features/steps/device_skill_manifest.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Step functions for skill manifest management via the public device API.\"\"\"\nimport json\nfrom datetime import datetime\n\nfrom behave import given, then, when  # pylint: disable=no-name-in-module\nfrom hamcrest import assert_that, equal_to, is_in, is_, none, not_none, not_\n\nfrom selene.data.device import DeviceSkillRepository, ManifestSkill\nfrom selene.data.skill import SkillRepository, Skill\nfrom selene.testing.device_skill import add_device_skill\nfrom selene.testing.skill import add_skill\n\n\ndef _build_manifest_upload(manifest_skills):\n    \"\"\"Build a skill manifest to be used in tests.\"\"\"\n    upload_skills = []\n    for skill in manifest_skills:\n        upload_skills.append(\n            dict(\n                name=\"test-skill-name\",\n                origin=skill.install_method,\n                beta=False,\n                status=\"active\",\n                installed=skill.install_ts.timestamp(),\n                updated=skill.update_ts.timestamp(),\n                installation=skill.install_status,\n                skill_gid=skill.skill_gid,\n            )\n        )\n    return {\"blacklist\": [], \"version\": 1, \"skills\": upload_skills}\n\n\n@given(\"a device-specific skill installed on the device\")\ndef _add_device_specific_skill(context):\n    \"\"\"Add a skill with a device specific skill GID.\"\"\"\n    dirty_skill, dirty_skill_settings = add_skill(\n        context.db,\n        skill_global_id=\"@{device_id}|device-specific-skill|19.02\".format(\n            device_id=context.device_id\n        ),\n    )\n    context.skills.update(dirty=(dirty_skill, dirty_skill_settings))\n    context.device_specific_skill = add_device_skill(\n        context.db, context.device_id, dirty_skill\n    )\n\n\n@when(\"a device uploads a skill manifest without changes\")\ndef upload_unchanged_skill_manifest(context):\n    \"\"\"Call the device API to upload an unchanged skill manifest.\"\"\"\n    skill_manifest = _build_manifest_upload([context.manifest_skill])\n    _upload_skill_manifest(context, skill_manifest)\n\n\n@when(\"a device uploads a skill manifest with an updated skill\")\ndef upload_changed_skill_manifest(context):\n    \"\"\"Call the device API to upload a skill manifest with a updated skill.\"\"\"\n    skill_manifest = _build_manifest_upload([context.manifest_skill])\n    context.update_ts = datetime.utcnow().timestamp()\n    skill_manifest[\"skills\"][0][\"updated\"] = context.update_ts\n    _upload_skill_manifest(context, skill_manifest)\n\n\n@when(\"a device uploads a skill manifest with a deleted skill\")\ndef upload_skill_manifest_with_deleted_skill(context):\n    \"\"\"Call the device API to upload a skill manifest with a deleted skill.\"\"\"\n    skill_manifest = _build_manifest_upload([])\n    _upload_skill_manifest(context, skill_manifest)\n\n\n@when(\"a device uploads a skill manifest with a deleted device-specific skill\")\ndef upload_skill_manifest_no_device_specific(context):\n    \"\"\"Call the device API to upload a skill manifest with a deleted device-specific.\"\"\"\n    skill_manifest = _build_manifest_upload([context.manifest_skill])\n    _upload_skill_manifest(context, skill_manifest)\n\n\n@when(\"a device uploads a skill manifest with a new skill\")\ndef upload_skill_manifest_with_new_skill(context):\n    \"\"\"Call the device API to upload a skill manifest with a new skill\"\"\"\n    context.new_skill = Skill(skill_gid=\"new-test-skill|19.02\")\n    context.new_manifest_skill = ManifestSkill(\n        device_id=context.device_id,\n        install_method=\"test_install_method\",\n        install_status=\"test_install_status\",\n        skill_gid=context.new_skill.skill_gid,\n        install_ts=datetime.utcnow(),\n        update_ts=datetime.utcnow(),\n    )\n\n    skill_manifest = _build_manifest_upload(\n        [context.manifest_skill, context.new_manifest_skill]\n    )\n    _upload_skill_manifest(context, skill_manifest)\n\n\n@when(\"a device uploads a malformed skill manifest\")\ndef upload_malformed_skill_manifest(context):\n    \"\"\"Call the device API to upload a malformed skill manifest\"\"\"\n    skill_manifest = _build_manifest_upload([context.manifest_skill])\n    del skill_manifest[\"skills\"][0][\"name\"]\n    _upload_skill_manifest(context, skill_manifest)\n\n\ndef _upload_skill_manifest(context, skill_manifest):\n    \"\"\"Helper method to call the skill manifest endpoint of the public API\"\"\"\n    context.response = context.client.put(\n        \"/v1/device/{device_id}/skillJson\".format(device_id=context.device_id),\n        data=json.dumps(skill_manifest),\n        content_type=\"application/json\",\n        headers=context.request_header,\n    )\n\n\n@then(\"the skill manifest on the database is unchanged\")\ndef get_unchanged_skill_manifest(context):\n    \"\"\"Check that the skill manifest on the database did not change.\"\"\"\n    device_skill_repo = DeviceSkillRepository(context.db)\n    skill_manifest = device_skill_repo.get_skill_manifest_for_device(context.device_id)\n    assert_that(len(skill_manifest), equal_to(1))\n    manifest_skill = skill_manifest[0]\n    assert_that(manifest_skill, equal_to(context.manifest_skill))\n\n\n@then(\"the skill manifest on the database is updated\")\ndef get_updated_skill_manifest(context):\n    \"\"\"Check that the skill manifest on the database changed.\"\"\"\n    device_skill_repo = DeviceSkillRepository(context.db)\n    skill_manifest = device_skill_repo.get_skill_manifest_for_device(context.device_id)\n    assert_that(len(skill_manifest), equal_to(1))\n    manifest_skill = skill_manifest[0]\n    assert_that(manifest_skill, not_(equal_to(context.manifest_skill)))\n    manifest_skill.update_ts = context.update_ts\n    assert_that(manifest_skill, (equal_to(context.manifest_skill)))\n\n\n@then(\"the skill is removed from the manifest on the database\")\ndef get_empty_skill_manifest(context):\n    \"\"\"Check for an empty skill manifest on the database.\"\"\"\n    device_skill_repo = DeviceSkillRepository(context.db)\n    skill_manifest = device_skill_repo.get_skill_manifest_for_device(context.device_id)\n    assert_that(len(skill_manifest), equal_to(0))\n\n\n@then(\"the device-specific skill is removed from the manifest on the database\")\ndef get_skill_manifest_no_device_specific(context):\n    \"\"\"Check that there are no device-specific skills on the skill manifest.\"\"\"\n    device_skill_repo = DeviceSkillRepository(context.db)\n    skill_manifest = device_skill_repo.get_skill_manifest_for_device(context.device_id)\n    assert_that(len(skill_manifest), equal_to(1))\n    remaining_skill = skill_manifest[0]\n    assert_that(\n        remaining_skill.skill_gid,\n        not_(equal_to(context.device_specific_skill.skill_gid)),\n    )\n\n\n@then(\"the device-specific skill is removed from the database\")\ndef ensure_device_specific_skill_removed(context):\n    \"\"\"Check that the device-specific skill is no longer on the skill table.\"\"\"\n    skill_repo = SkillRepository(context.db)\n    skill = skill_repo.get_skill_by_global_id(context.device_specific_skill.skill_gid)\n    assert_that(skill, is_(none()))\n\n\n@then(\"the skill is added to the manifest on the database\")\ndef get_skill_manifest_new_skill(context):\n    \"\"\"Check that a new skill is added to the manifest.\"\"\"\n    device_skill_repo = DeviceSkillRepository(context.db)\n    skill_manifest = device_skill_repo.get_skill_manifest_for_device(context.device_id)\n    assert_that(len(skill_manifest), equal_to(2))\n    assert_that(context.manifest_skill, is_in(skill_manifest))\n\n    # the device_skill id is not part of the request data so clear it out\n    for manifest_skill in skill_manifest:\n        if manifest_skill.skill_gid == context.new_skill.skill_gid:\n            manifest_skill.id = None\n            manifest_skill.skill_id = None\n    assert_that(context.new_manifest_skill, is_in(skill_manifest))\n\n\n@then(\"the skill is added to the database\")\ndef get_new_skill(context):\n    \"\"\"Check that a skill was added to the database.\"\"\"\n    skill_repo = SkillRepository(context.db)\n    skill = skill_repo.get_skill_by_global_id(context.new_skill.skill_gid)\n    assert_that(skill, is_(not_none()))\n"
  },
  {
    "path": "api/public/tests/features/steps/device_skill_settings.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nimport json\n\nfrom behave import when, then, given\nfrom hamcrest import assert_that, equal_to, is_not, is_in\n\nfrom selene.api.etag import ETAG_REQUEST_HEADER_KEY\nfrom selene.data.device import DeviceSkillRepository\nfrom selene.data.skill import SkillSettingRepository\nfrom selene.testing.skill import add_skill, build_label_field, build_text_field\nfrom selene.util.cache import DEVICE_SKILL_ETAG_KEY\n\n\n@given(\"skill settings with a new value\")\ndef change_skill_setting_value(context):\n    _, bar_settings_display = context.skills[\"bar\"]\n    section = bar_settings_display.display_data[\"skillMetadata\"][\"sections\"][0]\n    field_with_value = section[\"fields\"][1]\n    field_with_value[\"value\"] = \"New device text value\"\n\n\n@given(\"skill settings with a deleted field\")\ndef delete_field_from_settings(context):\n    _, bar_settings_display = context.skills[\"bar\"]\n    section = bar_settings_display.display_data[\"skillMetadata\"][\"sections\"][0]\n    context.removed_field = section[\"fields\"].pop(1)\n    context.remaining_field = section[\"fields\"][1]\n\n\n@given(\"a valid device skill E-tag\")\ndef set_skill_setting_etag(context):\n    context.device_skill_etag = context.etag_manager.get(\n        DEVICE_SKILL_ETAG_KEY.format(device_id=context.device_id)\n    )\n\n\n@given(\"an expired device skill E-tag\")\ndef expire_skill_setting_etag(context):\n    valid_device_skill_etag = context.etag_manager.get(\n        DEVICE_SKILL_ETAG_KEY.format(device_id=context.device_id)\n    )\n    context.device_skill_etag = context.etag_manager.expire(valid_device_skill_etag)\n\n\n@given(\"settings for a skill not assigned to the device\")\ndef add_skill_not_assigned_to_device(context):\n    foobar_skill, foobar_settings_display = add_skill(\n        context.db,\n        skill_global_id=\"foobar-skill|19.02\",\n        settings_fields=[build_label_field(), build_text_field()],\n    )\n    section = foobar_settings_display.display_data[\"skillMetadata\"][\"sections\"][0]\n    field_with_value = section[\"fields\"][1]\n    field_with_value[\"value\"] = \"New skill text value\"\n    context.skills.update(foobar=(foobar_skill, foobar_settings_display))\n\n\n@when(\"a device requests the settings for its skills\")\ndef get_device_skill_settings(context):\n    if hasattr(context, \"device_skill_etag\"):\n        context.request_header[ETAG_REQUEST_HEADER_KEY] = context.device_skill_etag\n    context.response = context.client.get(\n        \"/v1/device/{device_id}/skill\".format(device_id=context.device_id),\n        content_type=\"application/json\",\n        headers=context.request_header,\n    )\n\n\n@when(\"the device sends a request to update the {skill} skill settings\")\ndef update_skill_settings(context, skill):\n    _, settings_display = context.skills[skill]\n    context.response = context.client.put(\n        \"/v1/device/{device_id}/skill\".format(device_id=context.device_id),\n        data=json.dumps(settings_display.display_data),\n        content_type=\"application/json\",\n        headers=context.request_header,\n    )\n\n\n@when(\"the device requests a skill to be deleted\")\ndef delete_skill(context):\n    foo_skill, _ = context.skills[\"foo\"]\n    context.response = context.client.delete(\n        \"/v1/device/{device_id}/skill/{skill_gid}\".format(\n            device_id=context.device_id, skill_gid=foo_skill.skill_gid\n        ),\n        headers=context.request_header,\n    )\n\n\n@then(\"the settings are returned\")\ndef validate_response(context):\n    response = context.response.json\n    assert_that(len(response), equal_to(2))\n    foo_skill, foo_settings_display = context.skills[\"foo\"]\n    foo_skill_expected_result = dict(\n        uuid=foo_skill.id,\n        skill_gid=foo_skill.skill_gid,\n        identifier=foo_settings_display.display_data[\"identifier\"],\n    )\n    assert_that(foo_skill_expected_result, is_in(response))\n\n    bar_skill, bar_settings_display = context.skills[\"bar\"]\n    section = bar_settings_display.display_data[\"skillMetadata\"][\"sections\"][0]\n    text_field = section[\"fields\"][1]\n    text_field[\"value\"] = \"Device text value\"\n    checkbox_field = section[\"fields\"][2]\n    checkbox_field[\"value\"] = \"false\"\n    bar_skill_expected_result = dict(\n        uuid=bar_skill.id,\n        skill_gid=bar_skill.skill_gid,\n        identifier=bar_settings_display.display_data[\"identifier\"],\n        skillMetadata=bar_settings_display.display_data[\"skillMetadata\"],\n    )\n    assert_that(bar_skill_expected_result, is_in(response))\n\n\n@then(\"the device skill E-tag is expired\")\ndef check_for_expired_etag(context):\n    \"\"\"An E-tag is expired by changing its value.\"\"\"\n    expired_device_skill_etag = context.etag_manager.get(\n        DEVICE_SKILL_ETAG_KEY.format(device_id=context.device_id)\n    )\n    assert_that(\n        expired_device_skill_etag.decode(), is_not(equal_to(context.device_skill_etag))\n    )\n\n\ndef _get_device_skill_settings(context):\n    \"\"\"Minimize DB hits and code duplication by getting these values once.\"\"\"\n    if not hasattr(context, \"device_skill_settings\"):\n        settings_repo = SkillSettingRepository(context.db)\n        context.device_skill_settings = settings_repo.get_skill_settings_for_device(\n            context.device_id\n        )\n        context.device_settings_values = [\n            dss.settings_values for dss in context.device_skill_settings\n        ]\n\n\n@then(\"the skill settings are updated with the new value\")\ndef validate_updated_skill_setting_value(context):\n    _get_device_skill_settings(context)\n    assert_that(len(context.device_skill_settings), equal_to(2))\n    expected_settings_values = dict(\n        textfield=\"New device text value\", checkboxfield=\"false\"\n    )\n    assert_that(expected_settings_values, is_in(context.device_settings_values))\n\n\n@then(\"the skill is assigned to the device with the settings populated\")\ndef validate_updated_skill_setting_value(context):\n    _get_device_skill_settings(context)\n    assert_that(len(context.device_skill_settings), equal_to(3))\n    expected_settings_values = dict(textfield=\"New skill text value\")\n    assert_that(expected_settings_values, is_in(context.device_settings_values))\n\n\n@then(\"an E-tag is generated for these settings\")\ndef get_skills_etag(context):\n    response_headers = context.response.headers\n    response_etag = response_headers[\"ETag\"]\n    skill_etag = context.etag_manager.get(\n        DEVICE_SKILL_ETAG_KEY.format(device_id=context.device_id)\n    )\n    assert_that(skill_etag.decode(), equal_to(response_etag))\n\n\n@then(\"the field is no longer in the skill settings\")\ndef validate_skill_setting_field_removed(context):\n    _get_device_skill_settings(context)\n    assert_that(len(context.device_skill_settings), equal_to(2))\n    # The removed field should no longer be in the settings values but the\n    # value of the field that was not deleted should remain\n    assert_that(dict(checkboxfield=\"false\"), is_in(context.device_settings_values))\n\n    new_section = dict(fields=None)\n    for device_skill_setting in context.device_skill_settings:\n        skill_gid = device_skill_setting.settings_display[\"skill_gid\"]\n        if skill_gid.startswith(\"bar\"):\n            new_settings_display = device_skill_setting.settings_display\n            new_skill_definition = new_settings_display[\"skillMetadata\"]\n            new_section = new_skill_definition[\"sections\"][0]\n    # The removed field should no longer be in the settings values but the\n    # value of the field that was not deleted should remain\n    assert_that(context.removed_field, not is_in(new_section[\"fields\"]))\n    assert_that(context.remaining_field, is_in(new_section[\"fields\"]))\n"
  },
  {
    "path": "api/public/tests/features/steps/get_device.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nimport json\nimport uuid\nfrom http import HTTPStatus\n\nfrom behave import when, then, given\nfrom hamcrest import assert_that, equal_to, has_key, not_none, is_not\n\nfrom selene.api.etag import ETagManager, device_etag_key\n\nnew_fields = dict(\n    platform=\"mycroft_mark_1\", coreVersion=\"19.2.0\", enclosureVersion=\"1.4.0\"\n)\n\n\n@when(\"device is retrieved\")\ndef get_device(context):\n    access_token = context.device_login[\"accessToken\"]\n    headers = dict(Authorization=\"Bearer {token}\".format(token=access_token))\n    device_id = context.device_login[\"uuid\"]\n    context.get_device_response = context.client.get(\n        \"/v1/device/{uuid}\".format(uuid=device_id), headers=headers\n    )\n    context.device_etag = context.get_device_response.headers.get(\"ETag\")\n\n\n@then(\"a valid device should be returned\")\ndef validate_response(context):\n    response = context.get_device_response\n    assert_that(response.status_code, equal_to(HTTPStatus.OK))\n    device = json.loads(response.data)\n    assert_that(device, has_key(\"uuid\"))\n    assert_that(device, has_key(\"name\"))\n    assert_that(device, has_key(\"description\"))\n    assert_that(device, has_key(\"coreVersion\"))\n    assert_that(device, has_key(\"enclosureVersion\"))\n    assert_that(device, has_key(\"platform\"))\n    assert_that(device, has_key(\"user\"))\n    assert_that(device[\"user\"], has_key(\"uuid\"))\n    assert_that(device[\"user\"][\"uuid\"], equal_to(context.account.id))\n\n\n@when(\"try to fetch a device without the authorization header\")\ndef get_invalid_device(context):\n    context.get_invalid_device_response = context.client.get(\n        \"/v1/device/{uuid}\".format(uuid=str(uuid.uuid4()))\n    )\n\n\n@when(\"try to fetch a not allowed device\")\ndef get_not_allowed_device(context):\n    access_token = context.device_login[\"accessToken\"]\n    headers = dict(Authorization=\"Bearer {token}\".format(token=access_token))\n    context.get_invalid_device_response = context.client.get(\n        \"/v1/device/{uuid}\".format(uuid=str(uuid.uuid4())), headers=headers\n    )\n\n\n@then(\"a 401 status code should be returned\")\ndef validate_invalid_response(context):\n    response = context.get_invalid_device_response\n    assert_that(response.status_code, equal_to(HTTPStatus.UNAUTHORIZED))\n\n\n@when(\"the device is updated\")\ndef update_device(context):\n    login = context.device_login\n    access_token = login[\"accessToken\"]\n    device_id = login[\"uuid\"]\n    headers = dict(Authorization=\"Bearer {token}\".format(token=access_token))\n\n    context.update_device_response = context.client.patch(\n        \"/v1/device/{uuid}\".format(uuid=device_id),\n        data=json.dumps(new_fields),\n        content_type=\"application_json\",\n        headers=headers,\n    )\n\n\n@then(\"the information should be updated\")\ndef validate_update(context):\n    response = context.update_device_response\n    assert_that(response.status_code, equal_to(HTTPStatus.OK))\n\n    response = context.get_device_response\n    assert_that(response.status_code, equal_to(HTTPStatus.OK))\n    device = json.loads(response.data)\n    assert_that(device, has_key(\"name\"))\n    assert_that(device[\"coreVersion\"], equal_to(new_fields[\"coreVersion\"]))\n    assert_that(device[\"enclosureVersion\"], equal_to(new_fields[\"enclosureVersion\"]))\n    assert_that(device[\"platform\"], equal_to(new_fields[\"platform\"]))\n\n\n@given(\"a device with a valid etag\")\ndef get_device_etag(context):\n    etag_manager: ETagManager = context.etag_manager\n    device_id = context.device_login[\"uuid\"]\n    context.device_etag = etag_manager.get(device_etag_key(device_id))\n\n\n@when(\"try to fetch a device using a valid etag\")\ndef get_device_using_etag(context):\n    etag = context.device_etag\n    assert_that(etag, not_none())\n    access_token = context.device_login[\"accessToken\"]\n    device_uuid = context.device_login[\"uuid\"]\n    headers = {\n        \"Authorization\": \"Bearer {token}\".format(token=access_token),\n        \"If-None-Match\": etag,\n    }\n    context.response_using_etag = context.client.get(\n        \"/v1/device/{uuid}\".format(uuid=device_uuid), headers=headers\n    )\n\n\n@then(\"304 status code should be returned by the device endpoint\")\ndef validate_etag(context):\n    response = context.response_using_etag\n    assert_that(response.status_code, equal_to(HTTPStatus.NOT_MODIFIED))\n\n\n@given(\"a device's etag expired by the web ui\")\ndef expire_etag(context):\n    etag_manager: ETagManager = context.etag_manager\n    device_id = context.device_login[\"uuid\"]\n    context.device_etag = etag_manager.get(device_etag_key(device_id))\n    etag_manager.expire_device_etag_by_device_id(device_id)\n\n\n@when(\"try to fetch a device using an expired etag\")\ndef fetch_device_expired_etag(context):\n    etag = context.device_etag\n    assert_that(etag, not_none())\n    access_token = context.device_login[\"accessToken\"]\n    device_uuid = context.device_login[\"uuid\"]\n    headers = {\n        \"Authorization\": \"Bearer {token}\".format(token=access_token),\n        \"If-None-Match\": etag,\n    }\n    context.response_using_invalid_etag = context.client.get(\n        \"/v1/device/{uuid}\".format(uuid=device_uuid), headers=headers\n    )\n\n\n@then(\"should return status 200\")\ndef validate_status_code(context):\n    response = context.response_using_invalid_etag\n    assert_that(response.status_code, equal_to(HTTPStatus.OK))\n\n\n@then(\"a new etag\")\ndef validate_new_etag(context):\n    etag = context.device_etag\n    response = context.response_using_invalid_etag\n    etag_from_response = response.headers.get(\"ETag\")\n    assert_that(etag, is_not(etag_from_response))\n"
  },
  {
    "path": "api/public/tests/features/steps/get_device_settings.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nimport json\nimport uuid\nfrom http import HTTPStatus\n\nfrom behave import when, then, given\nfrom hamcrest import assert_that, equal_to, has_key, is_not\n\nfrom selene.api.etag import ETagManager, device_setting_etag_key\n\n\n@when(\"try to fetch device's setting\")\ndef get_device_settings(context):\n    login = context.device_login\n    device_id = login[\"uuid\"]\n    access_token = login[\"accessToken\"]\n    headers = dict(Authorization=\"Bearer {token}\".format(token=access_token))\n    context.response_setting = context.client.get(\n        \"/v1/device/{uuid}/setting\".format(uuid=device_id), headers=headers\n    )\n\n\n@then(\"a valid setting should be returned\")\ndef validate_response_setting(context):\n    response = context.response_setting\n    assert_that(response.status_code, equal_to(HTTPStatus.OK))\n    setting = json.loads(response.data)\n    assert_that(response.status_code, equal_to(HTTPStatus.OK))\n    assert_that(setting, has_key(\"uuid\"))\n    assert_that(setting, has_key(\"systemUnit\"))\n    assert_that(setting[\"systemUnit\"], equal_to(\"imperial\"))\n    assert_that(setting, has_key(\"timeFormat\"))\n    assert_that(setting, has_key(\"dateFormat\"))\n    assert_that(setting, has_key(\"optIn\"))\n    assert_that(setting[\"optIn\"], equal_to(True))\n    assert_that(setting, has_key(\"ttsSettings\"))\n    tts = setting[\"ttsSettings\"]\n    assert_that(tts, has_key(\"module\"))\n\n\n@when(\"the settings endpoint is a called to a not allowed device\")\ndef get_device_settings(context):\n    access_token = context.device_login[\"accessToken\"]\n    headers = dict(Authorization=\"Bearer {token}\".format(token=access_token))\n    context.get_invalid_setting_response = context.client.get(\n        \"/v1/device/{uuid}/setting\".format(uuid=str(uuid.uuid4())), headers=headers\n    )\n\n\n@then(\"a 401 status code should be returned for the setting\")\ndef validate_response(context):\n    response = context.get_invalid_setting_response\n    assert_that(response.status_code, equal_to(HTTPStatus.UNAUTHORIZED))\n\n\n@given(\"a device's setting with a valid etag\")\ndef get_device_setting_etag(context):\n    device_id = context.device_login[\"uuid\"]\n    etag_manager: ETagManager = context.etag_manager\n    context.device_etag = etag_manager.get(device_setting_etag_key(device_id))\n\n\n@when(\"try to fetch the device's settings using a valid etag\")\ndef get_device_settings_using_etag(context):\n    etag = context.device_etag\n    access_token = context.device_login[\"accessToken\"]\n    device_id = context.device_login[\"uuid\"]\n    headers = {\n        \"Authorization\": \"Bearer {token}\".format(token=access_token),\n        \"If-None-Match\": etag,\n    }\n    context.get_setting_etag_response = context.client.get(\n        \"/v1/device/{uuid}/setting\".format(uuid=str(device_id)), headers=headers\n    )\n\n\n@then(\"304 status code should be returned by the device's settings endpoint\")\ndef validate_etag_response(context):\n    response = context.get_setting_etag_response\n    assert_that(response.status_code, equal_to(HTTPStatus.NOT_MODIFIED))\n\n\n@given(\"a device's setting etag expired by the web ui at device level\")\ndef expire_etag_device_level(context):\n    device_id = context.device_login[\"uuid\"]\n    etag_manager: ETagManager = context.etag_manager\n    context.device_etag = etag_manager.get(device_setting_etag_key(device_id))\n    etag_manager.expire_device_setting_etag_by_device_id(device_id)\n\n\n@given(\"a device's setting etag expired by the web ui at account level\")\ndef expire_etag_account_level(context):\n    account_id = context.account.id\n    device_id = context.device_login[\"uuid\"]\n    etag_manager: ETagManager = context.etag_manager\n    context.device_etag = etag_manager.get(device_setting_etag_key(device_id))\n    etag_manager.expire_device_setting_etag_by_account_id(account_id)\n\n\n@when(\"try to fetch the device's settings using an expired etag\")\ndef get_device_settings_using_etag(context):\n    etag = context.device_etag\n    access_token = context.device_login[\"accessToken\"]\n    device_id = context.device_login[\"uuid\"]\n    headers = {\n        \"Authorization\": \"Bearer {token}\".format(token=access_token),\n        \"If-None-Match\": etag,\n    }\n    context.get_setting_invalid_etag_response = context.client.get(\n        \"/v1/device/{uuid}/setting\".format(uuid=str(device_id)), headers=headers\n    )\n\n\n@then(\n    \"200 status code should be returned by the device's setting endpoint and a new etag\"\n)\ndef validate_new_etag(context):\n    etag = context.device_etag\n    response = context.get_setting_invalid_etag_response\n    etag_from_response = response.headers.get(\"ETag\")\n    assert_that(etag, is_not(etag_from_response))\n"
  },
  {
    "path": "api/public/tests/features/steps/get_device_subscription.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nimport json\nimport uuid\nfrom datetime import date\nfrom http import HTTPStatus\n\nfrom behave import when, then\nfrom hamcrest import assert_that, has_entry, equal_to\n\nfrom selene.data.account import AccountRepository, AccountMembership\nfrom selene.util.db import connect_to_db\n\n\n@when(\"the subscription endpoint is called\")\ndef get_device_subscription(context):\n    login = context.device_login\n    device_id = login[\"uuid\"]\n    access_token = login[\"accessToken\"]\n    headers = dict(Authorization=\"Bearer {token}\".format(token=access_token))\n    context.subscription_response = context.client.get(\n        \"/v1/device/{uuid}/subscription\".format(uuid=device_id), headers=headers\n    )\n\n\n@then(\"free type should be returned\")\ndef validate_response(context):\n    response = context.subscription_response\n    assert_that(response.status_code, HTTPStatus.OK)\n    subscription = json.loads(response.data)\n    assert_that(subscription, has_entry(\"@type\", \"free\"))\n\n\n@when(\"the subscription endpoint is called for a monthly account\")\ndef get_device_subscription(context):\n    membership = AccountMembership(\n        start_date=date.today(),\n        type=\"Monthly Membership\",\n        payment_method=\"Stripe\",\n        payment_account_id=\"test_monthly\",\n        payment_id=\"stripe_id\",\n    )\n    login = context.device_login\n    device_id = login[\"uuid\"]\n    access_token = login[\"accessToken\"]\n    headers = dict(Authorization=\"Bearer {token}\".format(token=access_token))\n    db = connect_to_db(context.client_config[\"DB_CONNECTION_CONFIG\"])\n    AccountRepository(db).add_membership(context.account.id, membership)\n    context.subscription_response = context.client.get(\n        \"/v1/device/{uuid}/subscription\".format(uuid=device_id), headers=headers\n    )\n\n\n@then(\"monthly type should be returned\")\ndef validate_response_monthly(context):\n    response = context.subscription_response\n    assert_that(response.status_code, HTTPStatus.OK)\n    subscription = json.loads(response.data)\n    assert_that(subscription, has_entry(\"@type\", \"Monthly Membership\"))\n\n\n@when(\"try to get the subscription for a nonexistent device\")\ndef get_subscription_nonexistent_device(context):\n    access_token = context.device_login[\"accessToken\"]\n    headers = dict(Authorization=\"Bearer {token}\".format(token=access_token))\n    context.invalid_subscription_response = context.client.get(\n        \"/v1/device/{uuid}/subscription\".format(uuid=str(uuid.uuid4())), headers=headers\n    )\n\n\n@then(\"401 status code should be returned for the subscription endpoint\")\ndef validate_nonexistent_device(context):\n    response = context.invalid_subscription_response\n    assert_that(response.status_code, equal_to(HTTPStatus.UNAUTHORIZED))\n"
  },
  {
    "path": "api/public/tests/features/steps/transcribe_audio.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Step functions for the audio transcription endpoints of the Device API.\"\"\"\nimport json\nfrom decimal import Decimal\nfrom io import BytesIO\nfrom pathlib import Path\n\nfrom behave import when, then  # pylint: disable=no-name-in-module\nfrom hamcrest import assert_that, equal_to\n\nfrom selene.data.metric import TranscriptionMetricRepository\n\n\n@when('Utterance \"{utterance}\" is transcribed using Google\\'s STT API')\ndef call_google_stt_endpoint(context, utterance):\n    \"\"\"Call the endpoint with an audio file known to contain a certain phrase.\"\"\"\n    context.engine = \"Google\"\n    context.utterance = utterance\n    audio_data = _build_audio_data()\n    request_headers = _build_request_header(context)\n    context.response = context.client.post(\n        \"/v1/stt?lang=en-US&limit=1\", data=audio_data, headers=request_headers\n    )\n\n\n@when('Utterance \"{utterance}\" is transcribed using Mycroft\\'s transcription service')\ndef call_audio_transcription_endpoint(context, utterance):\n    \"\"\"Call the endpoint with an audio file known to contain a certain phrase.\"\"\"\n    context.engine = \"Google Cloud\"\n    context.utterance = utterance\n    audio_data = _build_audio_data()\n    request_headers = _build_request_header(context)\n    context.response = context.client.post(\n        \"/v1/transcribe?language=en-US\", data=audio_data, headers=request_headers\n    )\n\n\ndef _build_audio_data() -> BytesIO:\n    \"\"\"Converts a .flac file into a byte stream to pass to the endpoint.\"\"\"\n    resources_dir = Path(__file__).parent.joinpath(\"resources\")\n    with open(resources_dir.joinpath(\"test_stt.flac\"), \"rb\") as flac_file:\n        audio_data = BytesIO(flac_file.read())\n\n    return audio_data\n\n\ndef _build_request_header(context):\n    \"\"\"Builds the authentication header for calling the Public API endpoint.\"\"\"\n    access_token = context.device_login[\"accessToken\"]\n    headers = dict(Authorization=f\"Bearer {access_token}\")\n\n    return headers\n\n\n@then(\"Google's transcription will be correct\")\ndef validate_google_response(context):\n    \"\"\"Check that the right phrase was returned by Google STT.\"\"\"\n    response_data = json.loads(context.response.data)\n    expected_response = [context.utterance]\n    assert_that(response_data, equal_to(expected_response))\n\n\n@then(\"the transcription will be returned to the device\")\ndef validate_transcription_response(context):\n    \"\"\"Validates that the transcription endpoint returns the correct result.\"\"\"\n    assert_that(context.response.json[\"transcription\"], equal_to(context.utterance))\n\n\n@then(\"the transcription metrics for will be added to the database\")\ndef validate_transcription_metrics(context):\n    \"\"\"Checks values are present in the metrics.stt_transcription table.\"\"\"\n    transcription_metric_repo = TranscriptionMetricRepository(context.db)\n    metrics = transcription_metric_repo.get_by_account(context.account.id)\n    assert_that(len(metrics), equal_to(1))\n    metric = metrics[0]\n    assert_that(metric.engine, equal_to(context.engine))\n    assert_that(metric.account_id, equal_to(context.account.id))\n    expected_audio_duration = metric.audio_duration.quantize((Decimal(\"0.001\")))\n    assert_that(expected_audio_duration, equal_to(Decimal(\"2.100\")))\n"
  },
  {
    "path": "api/public/tests/features/steps/wake_word_file.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Step functions for the wake word sample upload feature.\"\"\"\nimport json\nimport os\nfrom datetime import datetime\nfrom io import BytesIO\nfrom pathlib import Path\n\nfrom behave import then, when  # pylint: disable=no-name-in-module\nfrom hamcrest import assert_that, equal_to, is_in\n\nfrom selene.data.tagging import UPLOADED_STATUS, WakeWordFile, WakeWordFileRepository\nfrom selene.data.wake_word import WakeWord\nfrom selene.testing.tagging import add_wake_word_file\n\nAUDIO_FILE_NAME = \"c46e74fa09732bb8d5e3aa4d8f46072bae9b732d.wav\"\nDUPLICATE_FILE_NAME = \"c46e74fa09732bb8d5e3aa4d8f46072bae9b732d.0.wav\"\n\n\n@when(\"the device uploads a wake word file\")\ndef upload_known_wake_word_file(context):\n    \"\"\"Upload a wake word sample file using the public API\"\"\"\n    _build_expected_wake_word_file(context, wake_word=\"hey selene\")\n    wake_word_request = dict(\n        wake_word=\"hey selene\",\n        engine=\"precise\",\n        timestamp=\"12345\",\n        model=\"selene_test_model\",\n    )\n    _call_upload_endpoint(context, wake_word_request)\n\n\n@when(\"the hash value of the file being uploaded matches a previous upload\")\ndef add_collision_file(context):\n    \"\"\"Add a tagging.file row representing a file that resolves to same hash.\"\"\"\n    add_wake_word_file(context, AUDIO_FILE_NAME)\n    context.duplicate_hash = True\n\n\n@when(\"the device uploads a wake word file for an unknown wake word\")\ndef upload_unknown_wake_word_file(context):\n    \"\"\"Upload a wake word sample file using the public API\"\"\"\n    context.wake_words[\"computer\"] = WakeWord(name=\"computer\", engine=\"test\")\n    _build_expected_wake_word_file(context, \"computer\")\n    wake_word_request = dict(\n        wake_word=\"computer\",\n        engine=\"test\",\n        timestamp=\"12345\",\n        model=\"selene_test_model\",\n    )\n    _call_upload_endpoint(context, wake_word_request)\n\n\ndef _build_expected_wake_word_file(context, wake_word):\n    \"\"\"Helper function to build a WakeWordFile object and store it in context.\"\"\"\n    wake_word_file = WakeWordFile(\n        wake_word=context.wake_words[wake_word],\n        name=AUDIO_FILE_NAME,\n        origin=\"mycroft\",\n        submission_date=datetime.utcnow().date(),\n        location=None,\n        status=UPLOADED_STATUS,\n        account_id=context.account.id,\n    )\n    context.expected_wake_word_file = wake_word_file\n\n\ndef _call_upload_endpoint(context, metadata):\n    \"\"\"Upload a wake word sample file using the public API\"\"\"\n    resources_dir = Path(os.path.dirname(__file__)).joinpath(\"resources\")\n    audio_file_path = str(resources_dir.joinpath(\"wake_word_test.wav\"))\n    access_token = context.device_login[\"accessToken\"]\n    with open(audio_file_path, \"rb\") as audio_file:\n        request = dict(\n            audio=(audio_file, \"wake_word.wav\"),\n            metadata=(BytesIO(json.dumps(metadata).encode()), \"metadata.json\"),\n        )\n        response = context.client.post(\n            f\"/v1/device/{context.device_id}/wake-word-file\",\n            headers=dict(Authorization=\"Bearer {token}\".format(token=access_token)),\n            data=request,\n            content_type=\"multipart/form-data\",\n        )\n    context.response = response\n\n\n@then(\"the audio file is saved to a temporary directory\")\ndef check_file_save(context):\n    \"\"\"The audio file containing the wake word sample is saved to the right location.\"\"\"\n\n    file_dir = context.wake_word_dir.joinpath(\n        context.expected_wake_word_file.wake_word.name.replace(\" \", \"-\")\n    )\n    if context.duplicate_hash:\n        expected_file_name = DUPLICATE_FILE_NAME\n    else:\n        expected_file_name = AUDIO_FILE_NAME\n    file_path = file_dir.joinpath(expected_file_name)\n    assert file_path.exists()\n\n\n@then(\"a reference to the sample is stored in the database\")\ndef check_wake_word_file_table(context):\n    \"\"\"The data representing the audio file is stored correctly on the database.\"\"\"\n    file_repository = WakeWordFileRepository(context.db)\n    context.wake_word_files = file_repository.get_by_wake_word(\n        context.expected_wake_word_file.wake_word\n    )\n    if context.duplicate_hash:\n        expected_file_names = [AUDIO_FILE_NAME, DUPLICATE_FILE_NAME]\n        file_count = 2\n    else:\n        expected_file_names = [AUDIO_FILE_NAME]\n        file_count = 1\n    assert_that(len(context.wake_word_files), equal_to(file_count))\n    for wake_word_file in context.wake_word_files:\n        assert_that(wake_word_file.name, is_in(expected_file_names))\n        assert_that(\n            wake_word_file.location.directory,\n            context.wake_word_dir.joinpath(\n                context.expected_wake_word_file.wake_word.name.replace(\" \", \"-\")\n            ),\n        )\n        assert_that(wake_word_file.location.server, equal_to(\"127.0.0.1\"))\n"
  },
  {
    "path": "api/public/tests/features/steps/wolfram_alpha.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom http import HTTPStatus\n\nfrom behave import when, then\nfrom hamcrest import assert_that\n\n\n@when(\"a question is sent to the Wolfram Alpha full results endpoint\")\ndef send_question(context):\n    login = context.device_login\n    access_token = login[\"accessToken\"]\n    context.wolfram_response = context.client.get(\n        \"/v1/wolframAlphaFull?input=what+is+the+capital+of+Brazil\",\n        headers=dict(Authorization=\"Bearer {token}\".format(token=access_token)),\n    )\n\n\n@when(\"a question is sent to the wolfram alpha simple endpoint\")\ndef send_question(context):\n    login = context.device_login\n    access_token = login[\"accessToken\"]\n    context.wolfram_response = context.client.get(\n        \"/v1/wolframAlphaSimple?i=What+airplanes+are+flying+overhead%3F&background=F5F5F5&foreground=white&fontsize=16&width=400&units=Metric\",\n        headers=dict(Authorization=\"Bearer {token}\".format(token=access_token)),\n    )\n\n\n@when(\"a question is sent to the wolfram alpha spoken endpoint\")\ndef send_question(context):\n    login = context.device_login\n    access_token = login[\"accessToken\"]\n    context.wolfram_response = context.client.get(\n        \"/v1/wolframAlphaSpoken?i=how+tall+was+abraham+lincoln&geolocation=51.50853%2C-0.12574&units=Metric\",\n        headers=dict(Authorization=\"Bearer {token}\".format(token=access_token)),\n    )\n\n\n@then(\"the answer provided by Wolfram Alpha is returned\")\ndef validate_response(context):\n    response = context.wolfram_response\n    assert_that(response.status_code, HTTPStatus.OK)\n"
  },
  {
    "path": "api/public/tests/features/transcribe_audio.feature",
    "content": "Feature: Transcribe audio data\n  Test the integration with audio transcription service providers\n\n  @stt\n  Scenario: Transcribe audio using old Google endpoint\n    When Utterance \"what time is it\" is transcribed using Google's STT API\n    Then the request will be successful\n    And Google's transcription will be correct\n    And the device's last contact time is updated\n    And the transcription metrics for will be added to the database\n\n  @stt\n  Scenario: Transcribe audio using new Google Cloud endpoint\n    When Utterance \"what time is it\" is transcribed using Mycroft's transcription service\n    Then the request will be successful\n    And the transcription will be returned to the device\n    And the device's last contact time is updated\n    And the transcription metrics for will be added to the database\n"
  },
  {
    "path": "api/public/tests/features/wake_word_file_upload.feature",
    "content": "Feature: Device API -- Upload wake word samples\n  Users that opted in to the Open Dataset Agreement will have files containing the audio that\n  activated the wake word recognizer uploaded to Mycroft servers for classification and tracking.\n\n  Scenario: Device sends wake word audio file\n    When the device uploads a wake word file\n    Then the request will be successful\n    And the audio file is saved to a temporary directory\n    And a reference to the sample is stored in the database\n\n   Scenario: Device sends wake word file for an unknown wake word\n    When the device uploads a wake word file for an unknown wake word\n    Then the request will be successful\n    And the audio file is saved to a temporary directory\n    And a reference to the sample is stored in the database\n\n  Scenario: Device sends wake word file with non-unique hash value\n    When the hash value of the file being uploaded matches a previous upload\n    And the device uploads a wake word file\n    Then the request will be successful\n    And the audio file is saved to a temporary directory\n    And a reference to the sample is stored in the database\n"
  },
  {
    "path": "api/public/tests/features/wolfram_alpha.feature",
    "content": "Feature: Device API -- Integration with Wolfram Alpha API\n  Mycroft Core uses Wolfram Alpha as a \"fallback\".  When a user makes a request\n  that cannot be satisfied by an installed skill, the fallback system is used to\n  attempt to answer the query.  The device API is used as a proxy to anonymize user\n  requests.\n\n  Scenario: Mycroft Core fallback system sends query to the Wolfram Alpha\n    When a question is sent to the Wolfram Alpha full results endpoint\n    Then the answer provided by Wolfram Alpha is returned\n    And the device's last contact time is updated\n    And the account's last activity time is updated\n    And the account activity metrics will be updated\n\n  Scenario: Question sent to the wolfram alpha simple endpoint\n    When a question is sent to the wolfram alpha simple endpoint\n    Then the answer provided by Wolfram Alpha is returned\n    And the device's last contact time is updated\n\n  Scenario: Question sent to the wolfram alpha spoken endpoint\n    When a question is sent to the wolfram alpha spoken endpoint\n    Then the answer provided by Wolfram Alpha is returned\n    And the device's last contact time is updated\n"
  },
  {
    "path": "api/public/uwsgi.ini",
    "content": "[uwsgi]\nmaster = true\nmodule = public_api.api:public\nprocesses = 10\nsocket = :5000\ndie-on-term = true\nlazy = true\nlazy-apps = true\n"
  },
  {
    "path": "api/sso/Dockerfile",
    "content": "# Docker config for the Selene skill service\n\n# The selene-shared parent image contains all the common Docker configs for\n# all Selene apps and services see the \"shared\" directory in this repository.\nFROM docker.mycroft.ai/selene-shared:2018.4\nLABEL description=\"Run the API for the Mycroft login screen\"\n\n# Use pipenv to install the package's dependencies in the container\nCOPY Pipfile Pipfile\nCOPY Pipfile.lock Pipfile.lock\nRUN pipenv install --system\n\n# Now that pipenv has installed all the packages required by selene-util\n# the Pipfile can be removed from the container.\nRUN rm Pipfile\nRUN rm Pipfile.lock\n\n# Load the skill service application to the image\nCOPY sso_api /opt/selene/sso_api\nWORKDIR /opt/selene/\n\nEXPOSE 7102\n\n# Use uwsgi to serve the API\nCOPY uwsgi.ini uwsgi.ini\nENTRYPOINT [\"uwsgi\", \"--ini\", \"uwsgi.ini\"]"
  },
  {
    "path": "api/sso/pyproject.toml",
    "content": "[tool.poetry]\nname = \"sso\"\nversion = \"0.1.0\"\ndescription = \"Single Sign on for all Selene applications\"\nauthors = [\"Chris Veilleux <veilleux.chris@gmail.com>\"]\nlicense = \"GNU AGPL 3.0\"\n\n[tool.poetry.dependencies]\npython = \"^3.9\"\ncertifi = \"*\"\nflask = \"*\"\nselene = {path = \"./../../shared\", develop = true}\nuwsgi = \"*\"\n\n[tool.poetry.dev-dependencies]\nallure-behave = \"*\"\nbehave = \"*\"\nblack = \"*\"\npyhamcrest = \"*\"\npylint = \"*\"\n\n[build-system]\nrequires = [\"poetry-core>=1.0.0\"]\nbuild-backend = \"poetry.core.masonry.api\"\n"
  },
  {
    "path": "api/sso/sso_api/__init__.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n"
  },
  {
    "path": "api/sso/sso_api/api.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\n\"\"\"Define the API that will support Mycroft single sign on (SSO).\"\"\"\nimport os\n\nfrom flask import Flask, request\n\nfrom selene.api import get_base_config, selene_api, SeleneResponse\nfrom selene.api.endpoints import (\n    AccountEndpoint,\n    AgreementsEndpoint,\n    ValidateEmailEndpoint,\n)\nfrom selene.util.log import configure_selene_logger\nfrom .endpoints import (\n    AuthenticateInternalEndpoint,\n    GithubTokenEndpoint,\n    LogoutEndpoint,\n    PasswordChangeEndpoint,\n    PasswordResetEndpoint,\n    ValidateFederatedEndpoint,\n    ValidateTokenEndpoint,\n)\n\nconfigure_selene_logger(\"sso_api\")\n\n# Define the Flask application\nsso = Flask(__name__)\nsso.config.from_object(get_base_config())\nsso.config.update(RESET_SECRET=os.environ[\"JWT_RESET_SECRET\"])\nsso.config.update(GITHUB_CLIENT_ID=os.environ[\"GITHUB_CLIENT_ID\"])\nsso.config.update(GITHUB_CLIENT_SECRET=os.environ[\"GITHUB_CLIENT_SECRET\"])\nsso.response_class = SeleneResponse\nsso.register_blueprint(selene_api)\n\n# Define the endpoints\nsso.add_url_rule(\n    \"/api/account\",\n    view_func=AccountEndpoint.as_view(\"account_endpoint\"),\n    methods=[\"POST\"],\n)\n\nsso.add_url_rule(\n    \"/api/agreement/<string:agreement_type>\",\n    view_func=AgreementsEndpoint.as_view(\"agreements_endpoint\"),\n    methods=[\"GET\"],\n)\n\nsso.add_url_rule(\n    \"/api/internal-login\",\n    view_func=AuthenticateInternalEndpoint.as_view(\"internal_login\"),\n    methods=[\"GET\"],\n)\n\nsso.add_url_rule(\n    \"/api/github-token\",\n    view_func=GithubTokenEndpoint.as_view(\"github_token_endpoint\"),\n    methods=[\"GET\"],\n)\n\nsso.add_url_rule(\n    \"/api/logout\", view_func=LogoutEndpoint.as_view(\"logout\"), methods=[\"GET\"]\n)\n\nsso.add_url_rule(\n    \"/api/password-change\",\n    view_func=PasswordChangeEndpoint.as_view(\"password_change_endpoint\"),\n    methods=[\"PUT\"],\n)\n\nsso.add_url_rule(\n    \"/api/password-reset\",\n    view_func=PasswordResetEndpoint.as_view(\"password_reset\"),\n    methods=[\"POST\"],\n)\n\nsso.add_url_rule(\n    \"/api/validate-email\",\n    view_func=ValidateEmailEndpoint.as_view(\"validate_email\"),\n    methods=[\"GET\"],\n)\n\nsso.add_url_rule(\n    \"/api/validate-federated\",\n    view_func=ValidateFederatedEndpoint.as_view(\"federated_login\"),\n    methods=[\"POST\"],\n)\n\nsso.add_url_rule(\n    \"/api/validate-token\",\n    view_func=ValidateTokenEndpoint.as_view(\"validate_token\"),\n    methods=[\"POST\"],\n)\n\n\ndef add_cors_headers(response):\n    \"\"\"Allow any application to logout\"\"\"\n    # if 'logout' in request.url:\n    response.headers[\"Access-Control-Allow-Origin\"] = \"*\"\n    if request.method == \"OPTIONS\":\n        response.headers[\"Access-Control-Allow-Methods\"] = \"DELETE, GET, POST, PUT\"\n        headers = request.headers.get(\"Access-Control-Request-Headers\")\n        if headers:\n            response.headers[\"Access-Control-Allow-Headers\"] = headers\n    return response\n\n\nsso.after_request(add_cors_headers)\n"
  },
  {
    "path": "api/sso/sso_api/endpoints/__init__.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Public API into the Single Sign On API endpoint package.\"\"\"\n\nfrom .authenticate_internal import AuthenticateInternalEndpoint\nfrom .github_token import GithubTokenEndpoint\nfrom .logout import LogoutEndpoint\nfrom .password_change import PasswordChangeEndpoint\nfrom .password_reset import PasswordResetEndpoint\nfrom .validate_federated import ValidateFederatedEndpoint\nfrom .validate_token import ValidateTokenEndpoint\n"
  },
  {
    "path": "api/sso/sso_api/endpoints/authenticate_internal.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\n\"\"\"Authenticate a user logging in with a email address and password\n\nThis type of login is considered \"internal\" because we are storing the email\naddress and password on our servers.  This is as opposed to \"external\"\nauthentication, which uses a 3rd party authentication, like Google.\n\"\"\"\n\nfrom binascii import a2b_base64\nfrom http import HTTPStatus\n\nfrom selene.data.account import Account, AccountRepository\nfrom selene.api import SeleneEndpoint\nfrom selene.util.auth import AuthenticationError\n\n\nclass AuthenticateInternalEndpoint(SeleneEndpoint):\n    \"\"\"Sign in a user with an email address and password.\"\"\"\n\n    def __init__(self):\n        super(AuthenticateInternalEndpoint, self).__init__()\n        self.account: Account = None\n\n    def get(self):\n        \"\"\"Process HTTP GET request.\"\"\"\n        self._authenticate_credentials()\n        self._generate_tokens()\n        self._set_token_cookies()\n\n        return \"\", HTTPStatus.NO_CONTENT\n\n    def _authenticate_credentials(self):\n        \"\"\"Compare credentials in request to credentials in database.\n\n        :raises AuthenticationError when no match found on database\n        \"\"\"\n        basic_credentials = self.request.headers[\"authorization\"]\n        binary_credentials = a2b_base64(basic_credentials[6:])\n        email_address, password = binary_credentials.decode().split(\":||:\")\n        acct_repository = AccountRepository(self.db)\n        self.account = acct_repository.get_account_from_credentials(\n            email_address, password\n        )\n        if self.account is None:\n            raise AuthenticationError(\"provided credentials not found\")\n        self.access_token.account_id = self.account.id\n        self.refresh_token.account_id = self.account.id\n"
  },
  {
    "path": "api/sso/sso_api/endpoints/github_token.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom http import HTTPStatus\n\nfrom selene.api import SeleneEndpoint\nfrom selene.util.auth import get_github_authentication_token\n\n\nclass GithubTokenEndpoint(SeleneEndpoint):\n    def get(self):\n        token = get_github_authentication_token(\n            self.request.args[\"code\"], self.request.args[\"state\"]\n        )\n\n        return dict(token=token), HTTPStatus.OK\n"
  },
  {
    "path": "api/sso/sso_api/endpoints/logout.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\n\"\"\"Log a user out of Mycroft web sites\"\"\"\n\nfrom http import HTTPStatus\n\nfrom selene.api import SeleneEndpoint\nfrom selene.util.log import get_selene_logger\n\n_log = get_selene_logger(__name__)\n\n\nclass LogoutEndpoint(SeleneEndpoint):\n    \"\"\"Single Sign On endpoint to log a user out of the web application.\"\"\"\n\n    def get(self):\n        self._authenticate()\n        self._logout()\n\n        return self.response\n\n    def _logout(self):\n        \"\"\"Delete tokens from database and expire the token cookies.\n\n        An absence of tokens will force the user to re-authenticate next time\n        they visit the site.\n        \"\"\"\n        self._generate_tokens()\n        self._set_token_cookies(expire=True)\n\n        self.response = (\"\", HTTPStatus.NO_CONTENT)\n"
  },
  {
    "path": "api/sso/sso_api/endpoints/password_change.py",
    "content": "#  Mycroft Server - Backend\n#  Copyright (c) 2022 Mycroft AI Inc\n#  SPDX-License-Identifier: \tAGPL-3.0-or-later\n#  #\n#  This file is part of the Mycroft Server.\n#  #\n#  The Mycroft Server is free software: you can redistribute it and/or\n#  modify it under the terms of the GNU Affero General Public License as\n#  published by the Free Software Foundation, either version 3 of the\n#  License, or (at your option) any later version.\n#  #\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n#  GNU Affero General Public License for more details.\n#  #\n#  You should have received a copy of the GNU Affero General Public License\n#  along with this program. If not, see <https://www.gnu.org/licenses/>.\n#\n\"\"\"Defines the password change endpoint for the account API.\"\"\"\n\nfrom selene.api.endpoints import PasswordChangeEndpoint as CommonPasswordChangeEndpoint\n\n\nclass PasswordChangeEndpoint(CommonPasswordChangeEndpoint):\n    \"\"\"Adds authentication to the common password changing endpoint.\"\"\"\n\n    @property\n    def account_id(self):\n        \"\"\"Returns the account passed to the endpoint in the PUT request\"\"\"\n        return self.request.json[\"accountId\"]\n\n    def _authenticate(self):\n        \"\"\"Skips authentication because user is not logged in when this is called.\"\"\"\n"
  },
  {
    "path": "api/sso/sso_api/endpoints/password_reset.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Single Sign On API endpoint to reset a user's password.\"\"\"\nfrom http import HTTPStatus\nimport os\n\nfrom selene.api import SeleneEndpoint\nfrom selene.data.account import AccountRepository\nfrom selene.util.auth import AuthenticationToken\nfrom selene.util.email import EmailMessage, SeleneMailer\n\nONE_HOUR = 3600\n\n\nclass PasswordResetEndpoint(SeleneEndpoint):\n    \"\"\"Defines an endpoint that will be called when a user resets their password.\"\"\"\n\n    def post(self):\n        \"\"\"Handles an HTTP POST request.\"\"\"\n        self._get_account_from_email()\n        if self.account is None:\n            self._send_account_not_found_email()\n        else:\n            reset_token = self._generate_reset_token()\n            self._send_reset_email(reset_token)\n\n        return \"\", HTTPStatus.OK\n\n    def _get_account_from_email(self):\n        \"\"\"Retrieves the account from the database using the email address on the db.\"\"\"\n        acct_repository = AccountRepository(self.db)\n        self.account = acct_repository.get_account_by_email(\n            self.request.json[\"emailAddress\"]\n        )\n\n    def _generate_reset_token(self) -> str:\n        \"\"\"Returns a reset token that will be included in the password reset email.\"\"\"\n        reset_token = AuthenticationToken(self.config[\"RESET_SECRET\"], ONE_HOUR)\n        reset_token.generate(self.account.id)\n\n        return reset_token.jwt\n\n    def _send_reset_email(self, reset_token: str):\n        \"\"\"Sends a password reset message to the email address provided by the user.\n\n        :param reset_token: JWT to authenticate the password reset\n        \"\"\"\n        url = f\"{os.environ['SSO_BASE_URL']}/change-password?token={reset_token}\"\n        email = EmailMessage(\n            recipient=self.request.json[\"emailAddress\"],\n            sender=\"Mycroft AI<no-reply@mycroft.ai>\",\n            subject=\"Password Reset Request\",\n            template_file_name=\"reset_password.html\",\n            template_variables=dict(reset_password_url=url),\n        )\n        mailer = SeleneMailer(email)\n        mailer.send(using_jinja=True)\n\n    def _send_account_not_found_email(self):\n        \"\"\"Sends an email indicating no account found for supplied email address.\"\"\"\n        email = EmailMessage(\n            recipient=self.request.json[\"emailAddress\"],\n            sender=\"Mycroft AI<no-reply@mycroft.ai>\",\n            subject=\"Password Reset Request\",\n            template_file_name=\"account_not_found.html\",\n        )\n        mailer = SeleneMailer(email)\n        mailer.send()\n"
  },
  {
    "path": "api/sso/sso_api/endpoints/validate_federated.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\n\"\"\"Validate user who logged in using a 3rd party authentication mechanism\n\nAuthenticating with Google, Faceboook, etc. is known as \"federated\" login.\nUsers that choose this option have been authenticated by the selected platform\nso all we need to to to complete login is validate that the email address exists\non our database and build JWTs for access and refresh.\n\"\"\"\nfrom http import HTTPStatus\n\nfrom schematics import Model\nfrom schematics.types import StringType\n\nfrom selene.api import SeleneEndpoint\nfrom selene.data.account import AccountRepository\nfrom selene.util.auth import (\n    AuthenticationError,\n    get_facebook_account_email,\n    get_google_account_email,\n    get_github_account_email,\n)\nfrom selene.util.log import get_selene_logger\n\nFEDERATED_PLATFORMS = (\"Facebook\", \"Google\", \"GitHub\")\n\n_log = get_selene_logger(__name__)\n\n\nclass ValidateFederatedRequest(Model):\n    \"\"\"Defines the request arguments for this endpoint; used for validation.\"\"\"\n\n    platform = StringType(required=True, choices=FEDERATED_PLATFORMS)\n    token = StringType(required=True)\n\n\nclass ValidateFederatedEndpoint(SeleneEndpoint):\n    \"\"\"Single Sign On endpoint to validate a login via third party, such as Google.\"\"\"\n\n    def __init__(self):\n        super().__init__()\n        self.email_address = None\n\n    def post(self):\n        \"\"\"Process a HTTP POST request.\"\"\"\n        self._validate_request()\n        self._get_email_address()\n        self._get_account_by_email()\n        self._generate_tokens()\n        self._set_token_cookies()\n\n        return \"\", HTTPStatus.NO_CONTENT\n\n    def _validate_request(self):\n        validator = ValidateFederatedRequest(self.request.json)\n        validator.validate()\n\n    def _get_email_address(self):\n        if self.request.json[\"platform\"] == \"Google\":\n            self.email_address = get_google_account_email(self.request.json[\"token\"])\n        elif self.request.json[\"platform\"] == \"Facebook\":\n            self.email_address = get_facebook_account_email(self.request.json[\"token\"])\n        elif self.request.json[\"platform\"] == \"GitHub\":\n            self.email_address = get_github_account_email(self.request.json[\"token\"])\n\n    def _get_account_by_email(self):\n        \"\"\"Use email returned by the authentication platform for validation\"\"\"\n        if self.email_address is None:\n            raise AuthenticationError(\"could not retrieve email from provider\")\n\n        acct_repository = AccountRepository(self.db)\n        self.account = acct_repository.get_account_by_email(self.email_address)\n\n        if self.account is None:\n            raise AuthenticationError(\"no account found for provided email\")\n"
  },
  {
    "path": "api/sso/sso_api/endpoints/validate_token.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom http import HTTPStatus\nfrom selene.api import SeleneEndpoint\nfrom selene.util.auth import AuthenticationToken\n\n\nclass ValidateTokenEndpoint(SeleneEndpoint):\n    def post(self):\n        response_data = self._validate_token()\n        return response_data, HTTPStatus.OK\n\n    def _validate_token(self):\n        auth_token = AuthenticationToken(self.config[\"RESET_SECRET\"], duration=0)\n        auth_token.jwt = self.request.json[\"token\"]\n        auth_token.validate()\n\n        return dict(\n            account_id=auth_token.account_id,\n            token_expired=auth_token.is_expired,\n            token_invalid=not auth_token.is_valid,\n        )\n"
  },
  {
    "path": "api/sso/tests/features/add_account.feature",
    "content": "Feature: Single Sign On API -- Add a new account\n  Test the API call to add an account to the database.\n\n  Scenario: Successful account addition\n    Given a user completes new account setup\n    When the new account request is submitted\n    Then the request will be successful\n    And the account will be added to the system\n    And the new account will be reflected in the account activity metrics\n\n  Scenario: User enters email address belonging to an existing account\n    When a user enters an email address already in use\n    Then a duplicate email address error is returned\n\n  Scenario Outline: Request missing a required field\n    Given a user completes new account setup\n    And user does not include <required field>\n    When the new account request is submitted\n    Then the request will fail with a bad request error\n    And the response will contain a error message\n\n  Examples:\n    | required field             |\n    | an email address           |\n    | a password                 |\n    | an accepted Terms of Use   |\n    | an accepted Privacy Policy |\n\n  Scenario Outline: Required agreement not accepted\n    Given a user completes new account setup\n    And user does not agree to the <agreement>\n    When the new account request is submitted\n    Then the request will fail with a bad request error\n    And the response will contain a error message\n\n  Examples:\n    | agreement      |\n    | Terms of Use   |\n    | Privacy Policy |\n"
  },
  {
    "path": "api/sso/tests/features/agreements.feature",
    "content": "Feature: Single Sign On API -- Get the active agreements\n  We need to be able to retrieve an agreement and display it on the web app.\n\n  Scenario: Retrieve Privacy Policy\n     When API request for Privacy Policy is made\n     Then the request will be successful\n     And the current version of the Privacy Policy agreement is returned\n\n\n  Scenario: Retrieve Terms of Use\n     When API request for Terms of Use is made\n     Then the request will be successful\n     And the current version of the Terms of Use agreement is returned\n"
  },
  {
    "path": "api/sso/tests/features/environment.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Environmental controls for the single sign on API tests.\"\"\"\nimport os\nfrom datetime import datetime\n\nfrom behave import fixture, use_fixture\n\nfrom sso_api.api import sso\nfrom selene.data.metric import AccountActivityRepository\nfrom selene.testing.account import add_account, remove_account\nfrom selene.testing.agreement import add_agreements, remove_agreements\nfrom selene.util.db import connect_to_db\n\n\n@fixture\ndef sso_client(context):\n    \"\"\"Setup a test fixture for the single-sign-on api.\"\"\"\n    sso.testing = True\n    context.db_pool = sso.config[\"DB_CONNECTION_POOL\"]\n    context.client_config = sso.config\n    context.client = sso.test_client()\n\n    yield context.client\n\n\ndef before_all(context):\n    \"\"\"Global setup to run before any tests.\"\"\"\n    use_fixture(sso_client, context)\n    os.environ[\"SALT\"] = \"testsalt\"\n    context.db = connect_to_db(context.client_config[\"DB_CONNECTION_CONFIG\"])\n    add_agreements(context)\n\n\ndef before_scenario(context, _):\n    \"\"\"Scenario-level setup.\"\"\"\n    account = add_account(context.db, password=\"foo\")\n    context.accounts = dict(foobar=account)\n    acct_activity_repository = AccountActivityRepository(context.db)\n    context.account_activity = acct_activity_repository.get_activity_by_date(\n        datetime.utcnow().date()\n    )\n\n\ndef after_scenario(context, _):\n    \"\"\"Scenario-level cleanup.\n\n    The database is setup with cascading deletes that take care of cleaning up[\n    referential integrity for us.  All we have to do here is delete the account\n    and all rows on all tables related to that account will also be deleted.\n    \"\"\"\n    for account in context.accounts.values():\n        remove_account(context.db, account)\n\n\ndef after_all(context):\n    \"\"\"Global cleanup steps run after all tests complete.\"\"\"\n    remove_agreements(\n        context.db, [context.privacy_policy, context.terms_of_use, context.open_dataset]\n    )\n"
  },
  {
    "path": "api/sso/tests/features/federated_login.feature",
    "content": "Feature: Single Sign On API -- Federated login\n  User signs into a selene web app after authenticating with a 3rd party.\n\n  Scenario: User with existing account signs in via Facebook\n    Given user \"foo@mycroft.ai\" authenticates through Facebook\n     When single sign on validates the account\n     Then the request will be successful\n      And response contains authentication tokens\n\n  Scenario: User without account signs in via Facebook\n    Given user \"bar@mycroft.ai\" authenticates through Facebook\n     When single sign on validates the account\n     Then the request will fail with an unauthorized error\n      And the response will contain a \"no account found for provided email\" error message\n\n  Scenario: User with existing account signs in via Google\n    Given user \"foo@mycroft.ai\" authenticates through Google\n     When single sign on validates the account\n     Then the request will be successful\n      And response contains authentication tokens\n\n  Scenario: User with existing account signs in via GitHub\n    Given user \"foo@mycroft.ai\" authenticates through GitHub\n     When single sign on validates the account\n     Then the request will be successful\n      And response contains authentication tokens\n"
  },
  {
    "path": "api/sso/tests/features/internal_login.feature",
    "content": "Feature: Single Sign On API -- Internal login\n  User signs into a selene web app with an email address and password (rather\n  than signing in with a third party authenticator, like Google).\n\n  Scenario: User signs in with valid email/password combination\n    Given user enters email address \"foo@mycroft.ai\" and password \"foo\"\n     When user attempts to login\n     Then the request will be successful\n      And response contains authentication tokens\n\n  Scenario: User signs in with invalid email/password combination\n    Given user enters email address \"foo@mycroft.ai\" and password \"bar\"\n     When user attempts to login\n     Then the request will fail with an unauthorized error\n      And the response will contain a \"provided credentials not found\" error message\n"
  },
  {
    "path": "api/sso/tests/features/logout.feature",
    "content": "Feature: Single Sign On API -- Logout\n  Regardless of how a user logs in, logging out consists of expiring the\n  tokens we use to identify logged-in users.\n\n  Scenario: Logged in user requests logout\n    Given an authenticated account\n     When user attempts to logout\n     Then the request will be successful\n      And response contains expired token cookies\n"
  },
  {
    "path": "api/sso/tests/features/password_change.feature",
    "content": "Feature: Single Sign On API -- Password reset\n  The only way a user can change their password in the single sign on application is\n  through the password reset feature.  The user clicks a link in an email sent when a\n  password reset is requested.  The link takes the user to a page where they reset\n  their password and the change password endpoint is called.\n\n  Scenario: User changes password via the password reset email\n    Given a user who authenticates with a password\n     When the user changes their password\n      And user attempts to login\n     Then the request will be successful\n"
  },
  {
    "path": "api/sso/tests/features/steps/add_account.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Step functions for adding an account via the single sign on API.\"\"\"\nfrom binascii import b2a_base64\nfrom datetime import datetime\n\nfrom behave import given, then, when  # pylint: disable=no-name-in-module\nfrom flask import json\nfrom hamcrest import assert_that, equal_to, is_in, not_none\n\nfrom selene.data.account import AccountRepository, PRIVACY_POLICY, TERMS_OF_USE\nfrom selene.data.metric import AccountActivityRepository\n\n\n@given(\"a user completes new account setup\")\ndef build_new_account_request(context):\n    \"\"\"Build base request data for the new account tests.\"\"\"\n    context.new_account_request = dict(\n        termsOfUse=True,\n        privacyPolicy=True,\n        login=dict(\n            federatedPlatform=None,\n            federatedToken=None,\n            email=b2a_base64(b\"bar@mycroft.ai\").decode(),\n            password=b2a_base64(b\"bar\").decode(),\n        ),\n    )\n\n\n@given(\"user does not include {required_field}\")\ndef remove_required_field(context, required_field):\n    \"\"\"Remove the specified field from the new account request.\"\"\"\n    if required_field == \"an email address\":\n        del context.new_account_request[\"login\"][\"email\"]\n    elif required_field == \"a password\":\n        del context.new_account_request[\"login\"][\"password\"]\n    elif required_field == \"an accepted Terms of Use\":\n        del context.new_account_request[\"termsOfUse\"]\n    elif required_field == \"an accepted Privacy Policy\":\n        del context.new_account_request[\"privacyPolicy\"]\n\n\n@given(\"user does not agree to the {agreement}\")\ndef reject_agreement(context, agreement):\n    \"\"\"Set the status of the specified agreement to rejected.\"\"\"\n    if agreement == \"Terms of Use\":\n        context.new_account_request[\"termsOfUse\"] = False\n    elif agreement == \"Privacy Policy\":\n        context.new_account_request[\"privacyPolicy\"] = False\n\n\n@when(\"the new account request is submitted\")\ndef call_add_account_endpoint(context):\n    \"\"\"Call the single sign on API endpoint to create a new account\"\"\"\n    context.client.content_type = \"application/json\"\n    response = context.client.post(\n        \"/api/account\",\n        data=json.dumps(context.new_account_request),\n        content_type=\"application/json\",\n    )\n    context.response = response\n\n\n@when(\"a user enters an email address already in use\")\ndef call_validate_email_endpoint(context):\n    \"\"\"Call the single sign on API endpoint to validate an email address.\"\"\"\n    existing_account = context.accounts[\"foobar\"]\n    email_address = existing_account.email_address.encode()\n    token = b2a_base64(email_address).decode()\n\n    context.client.content_type = \"application/json\"\n    response = context.client.get(\n        f\"/api/validate-email?platform=Internal&token={token}\"\n    )\n    context.response = response\n\n\n@then(\"the account will be added to the system\")\ndef check_db_for_account(context):\n    \"\"\"Check that the account was created as expected.\"\"\"\n    acct_repository = AccountRepository(context.db)\n    account = acct_repository.get_account_by_email(\"bar@mycroft.ai\")\n    # add account to context so it will deleted by cleanup step\n    context.accounts[\"bar\"] = account\n    assert_that(account, not_none())\n    assert_that(account.email_address, equal_to(\"bar@mycroft.ai\"))\n\n    assert_that(len(account.agreements), equal_to(2))\n    utc_date = datetime.utcnow().date()\n    for agreement in account.agreements:\n        assert_that(agreement.type, is_in((PRIVACY_POLICY, TERMS_OF_USE)))\n        assert_that(agreement.accept_date, equal_to(str(utc_date)))\n\n\n@then(\"the new account will be reflected in the account activity metrics\")\ndef check_db_for_account_metrics(context):\n    \"\"\"Ensure the new account is accurately reflected in the metrics.\"\"\"\n    acct_activity_repository = AccountActivityRepository(context.db)\n    account_activity = acct_activity_repository.get_activity_by_date(\n        datetime.utcnow().date()\n    )\n    if context.account_activity is None:\n        assert_that(account_activity.accounts_added, equal_to(1))\n    else:\n        assert_that(\n            account_activity.accounts_added,\n            equal_to(context.account_activity.accounts_added + 1),\n        )\n\n\n@then(\"a duplicate email address error is returned\")\ndef check_for_duplicate_account_error(context):\n    \"\"\"Check the API response for an \"account exists\" error.\"\"\"\n    response = context.response\n    assert_that(response.json[\"accountExists\"], equal_to(True))\n"
  },
  {
    "path": "api/sso/tests/features/steps/agreements.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2020 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Step functions for single sign on agreement tests.\"\"\"\nfrom behave import then, when  # pylint: disable=no-name-in-module\n\nfrom selene.testing.agreement import (\n    get_agreements_from_api,\n    validate_agreement_response,\n)\n\n\n@when(\"API request for {agreement} is made\")\ndef call_agreement_endpoint(context, agreement):\n    \"\"\"Issue call to SSO API to get an agreement\"\"\"\n    get_agreements_from_api(context, agreement)\n\n\n@then(\"the current version of the {agreement} agreement is returned\")\ndef validate_response(context, agreement):\n    \"\"\"Validate that the agreement is returned by the API\"\"\"\n    validate_agreement_response(context, agreement)\n"
  },
  {
    "path": "api/sso/tests/features/steps/common.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Common step functions for multiple single sign on feature files\"\"\"\nfrom behave import then  # pylint: disable=no-name-in-module\nfrom hamcrest import assert_that, equal_to, not_none\n\nfrom selene.testing.api import check_http_success, check_http_error\n\n\n@then(\"the request will be successful\")\ndef check_request_success(context):\n    \"\"\"Check for successful HTTP return codes.\"\"\"\n    check_http_success(context)\n\n\n@then(\"the request will fail with {error_type} error\")\ndef check_for_bad_request(context, error_type):\n    \"\"\"Check for unsuccessful HTTP return codes.\"\"\"\n    check_http_error(context, error_type)\n\n\n@then(\"the response will contain a error message\")\ndef check_error_message_exists(context):\n    \"\"\"Check that an error message was returned.\"\"\"\n    assert_that(context.response.data, not_none())\n\n\n@then('the response will contain a \"{error_msg}\" error message')\ndef check_error_message(context, error_msg):\n    \"\"\"Check that a specific error message was returned.\"\"\"\n    assert_that(context.response.json[\"error\"], equal_to(error_msg))\n"
  },
  {
    "path": "api/sso/tests/features/steps/login.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Step functions for the login feature.\"\"\"\nfrom binascii import b2a_base64\nfrom http import HTTPStatus\nimport json\nfrom unittest.mock import patch\n\nfrom behave import given, then, when  # pylint: disable=no-name-in-module\nfrom hamcrest import assert_that, equal_to\n\nfrom selene.testing.api import validate_token_cookies\n\nVALIDATE_FEDERATED = \"sso_api.endpoints.validate_federated.\"\n\n\n@given('user enters email address \"{email}\" and password \"{password}\"')\ndef save_credentials(context, email, password):\n    \"\"\"Save the email and password for use in later steps.\"\"\"\n    context.email = email\n    context.password = password\n\n\n@given('user \"{email}\" authenticates through {platform}')\ndef save_email(context, email, platform):\n    \"\"\"Save email and federated login platform for use in later steps\"\"\"\n    context.email = email\n    context.platform = platform\n\n\n@when(\"single sign on validates the account\")\ndef call_validate_federated_endpoint(context):\n    \"\"\"Call the single sign on endpoint to validate a token.\"\"\"\n    func_to_patch = VALIDATE_FEDERATED + \"get_{}_account_email\".format(\n        context.platform.lower()\n    )\n    with patch(func_to_patch, return_value=context.email):\n        context.response = context.client.post(\n            \"/api/validate-federated\",\n            data=json.dumps(dict(platform=context.platform, token=\"federated_token\")),\n            content_type=\"application/json\",\n        )\n\n\n@when(\"user attempts to login\")\ndef call_internal_login_endpoint(context):\n    \"\"\"Call the single sign on API endpoint to login with email & password.\"\"\"\n    credentials = \"{}:||:{}\".format(context.email, context.password).encode()\n    credentials = b2a_base64(credentials, newline=False).decode()\n    context.response = context.client.get(\n        \"/api/internal-login\", headers=dict(Authorization=\"Basic \" + credentials)\n    )\n\n\n@then(\"response contains authentication tokens\")\ndef check_token_cookies(context):\n    \"\"\"Ensure proper JWTs for authentication are included in the cookies.\"\"\"\n    validate_token_cookies(context)\n"
  },
  {
    "path": "api/sso/tests/features/steps/logout.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Behave step functions for single sign on API logout functionality.\"\"\"\nfrom http import HTTPStatus\nfrom behave import given, then, when  # pylint: disable=no-name-in-module\nfrom hamcrest import assert_that, equal_to\n\nfrom selene.testing.api import (\n    generate_access_token,\n    generate_refresh_token,\n    set_access_token_cookie,\n    set_refresh_token_cookie,\n    validate_token_cookies,\n)\n\n\n@given(\"an authenticated account\")\ndef use_account_with_valid_access_token(context):\n    \"\"\"Setup test context with an authenticated account for future steps.\"\"\"\n    context.username = \"foobar\"\n    context.access_token = generate_access_token(context)\n    set_access_token_cookie(context)\n    context.refresh_token = generate_refresh_token(context)\n    set_refresh_token_cookie(context)\n\n\n@when(\"user attempts to logout\")\ndef call_logout_endpoint(context):\n    \"\"\"Call the single sign on endpoint to logout a user.\"\"\"\n    generate_access_token(context)\n    generate_refresh_token(context)\n    context.response = context.client.get(\"/api/logout\")\n\n\n@then(\"response contains expired token cookies\")\ndef check_response_cookies(context):\n    validate_token_cookies(context, expired=True)\n"
  },
  {
    "path": "api/sso/tests/features/steps/password_change.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2020 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Behave step functions for single sign on API password change functionality.\"\"\"\nimport json\nfrom binascii import b2a_base64\nfrom behave import given, when  # pylint: disable=no-name-in-module\n\n\n@given(\"a user who authenticates with a password\")\ndef setup_user(context):\n    \"\"\"Set user context for use in other steps.\"\"\"\n    acct = context.accounts[\"foobar\"]\n    context.email = acct.email_address\n    context.password = \"bar\"\n    context.change_password_request = dict(\n        accountId=acct.id, password=b2a_base64(b\"bar\").decode()\n    )\n\n\n@when(\"the user changes their password\")\ndef call_password_change_endpoint(context):\n    \"\"\"Call the password change endpoint for the single sign on API.\"\"\"\n    context.client.content_type = \"application/json\"\n    response = context.client.put(\n        \"/api/password-change\",\n        data=json.dumps(context.change_password_request),\n        content_type=\"application/json\",\n    )\n    context.response = response\n"
  },
  {
    "path": "api/sso/uwsgi.ini",
    "content": "[uwsgi]\nmaster = true\nmodule = sso_api.api:sso\nprocesses = 4\nsocket = :5000\ndie-on-term = true\nlazy = true\nlazy-apps = true\n"
  },
  {
    "path": "batch/job_scheduler/__init__.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n"
  },
  {
    "path": "batch/job_scheduler/jobs.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\n\"\"\"Define the commands to run Selene batch jobs and the execution schedule.\n\nThis module is run as a daemon on the Selene batch host.  It defines the\ncommands needed to run each job using the subprocess module.  The jobs are\nscheduled using the \"schedule\" library.\n\"\"\"\nimport os\nimport subprocess\nimport time\nfrom datetime import date, timedelta\n\nimport schedule\n\nfrom selene.util.log import configure_selene_logger\n\n_log = configure_selene_logger(\"job_scheduler\")\n\n\nclass JobRunner(object):\n    \"\"\"Build the command to run a batch job and run it via subprocess.\"\"\"\n\n    def __init__(self, script_name: str):\n        self.script_name = script_name\n        self.job_args: str = None\n        self.job_date: date = None\n\n    def run_job(self):\n        if self.job_date is not None:\n            self._add_date_to_args()\n        command = self._build_command()\n        self._execute_command(command)\n\n    def _add_date_to_args(self):\n        \"\"\"Adds a date argument to the argument string.\n\n        The SeleneScript base class defaults the run date to current date so the\n        date argument only needs to be specified when it is not current date.\n        \"\"\"\n        if self.job_args is None:\n            self.job_args = \"\"\n        date_arg = \" --date \" + str(self.job_date)\n        self.job_args += date_arg\n\n    def _build_command(self):\n        \"\"\"Build the command to run the script.\"\"\"\n        command = [\"pipenv\", \"run\", \"python\"]\n        script_path = os.path.join(os.environ[\"SELENE_SCRIPT_DIR\"], self.script_name)\n        command.append(script_path)\n        if self.job_args is not None:\n            command.extend(self.job_args.split())\n        _log.info(command)\n\n        return command\n\n    def _execute_command(self, command):\n        \"\"\"Run the script using the subprocess module.\"\"\"\n        result = subprocess.run(command, capture_output=True)\n        if result.returncode:\n            _log.error(\n                \"Job {job_name} failed\\n\"\n                \"\\tSTDOUT - {stdout}\"\n                \"\\tSTDERR - {stderr}\".format(\n                    job_name=self.script_name[:-3],\n                    stdout=result.stdout.decode(),\n                    stderr=result.stderr.decode(),\n                )\n            )\n        else:\n            log_msg = \"Job {job_name} completed successfully\"\n            _log.info(log_msg.format(job_name=self.script_name[:-3]))\n\n\ndef test_scheduler():\n    \"\"\"Run in non-production environments to test scheduler functionality.\"\"\"\n    job_runner = JobRunner(\"test_scheduler.py\")\n    job_runner.job_date = date.today() - timedelta(days=1)\n    job_runner.job_args = \"--arg-with-value test --arg-no-value\"\n    job_runner.run_job()\n\n\ndef load_skills(version):\n    \"\"\"Load the json file from the mycroft-skills-data repository to the DB\"\"\"\n    job_runner = JobRunner(\"load_skill_display_data.py\")\n    job_runner.job_args = \"--core-version {}\".format(version)\n    job_runner.job_date = date.today() - timedelta(days=1)\n    job_runner.run_job()\n\n\ndef parse_core_metrics():\n    \"\"\"Copy rows from metric.core to de-normalized metric.core_interaction\n\n    Build a de-normalized table that will make latency research easier.\n    \"\"\"\n    job_runner = JobRunner(\"parse_core_metrics.py\")\n    job_runner.job_date = date.today() - timedelta(days=1)\n    job_runner.run_job()\n\n\ndef partition_api_metrics():\n    \"\"\"Copy rows from metric.api table to partitioned metric.api_history table\n\n    Build a partition on the metric.api_history table for yesterday's date.\n    Copy yesterday's metric.api table rows to the partition.\n    \"\"\"\n    job_runner = JobRunner(\"partition_api_metrics.py\")\n    job_runner.job_date = date.today() - timedelta(days=1)\n    job_runner.run_job()\n\n\ndef update_device_last_contact():\n    \"\"\"Update the last time a device was seen.\n\n    Each time a device calls the public API, the Redis database is updated with\n    to associate the time of the call with the device.  Dump the contents of\n    the Redis data to the device.device table on the Postgres database.\n    \"\"\"\n    job_runner = JobRunner(\"update_device_last_contact.py\")\n    job_runner.run_job()\n\n\n# Define the schedule\nif os.environ[\"SELENE_ENVIRONMENT\"] != \"prod\":\n    schedule.every(5).minutes.do(test_scheduler)\n\nschedule.every().day.at(\"00:00\").do(partition_api_metrics)\nschedule.every().day.at(\"00:05\").do(update_device_last_contact)\nschedule.every().day.at(\"00:10\").do(parse_core_metrics)\nschedule.every().day.at(\"00:15\").do(load_skills, version=\"19.02\")\nschedule.every().day.at(\"00:20\").do(load_skills, version=\"19.08\")\nschedule.every().day.at(\"00:25\").do(load_skills, version=\"20.02\")\nschedule.every().day.at(\"00:25\").do(load_skills, version=\"20.08\")\nschedule.every().day.at(\"00:25\").do(load_skills, version=\"21.02\")\n\n# Run the schedule\nwhile True:\n    schedule.run_pending()\n    time.sleep(1)\n"
  },
  {
    "path": "batch/pyproject.toml",
    "content": "[tool.poetry]\nname = \"batch\"\nversion = \"0.1.0\"\ndescription = \"Selene batch scripts and scheduler\"\nauthors = [\"Chris Veilleux <veilleux.chris@gmail.com>\"]\nlicense = \"GNU AGPL 3.0\"\n\n[tool.poetry.dependencies]\npython = \"^3.9\"\nselene = {path = \"./../shared\", develop = true}\n\n[tool.poetry.dev-dependencies]\nblack = \"*\"\npylint = \"*\"\n\n[build-system]\nrequires = [\"poetry-core>=1.0.0\"]\nbuild-backend = \"poetry.core.masonry.api\"\n"
  },
  {
    "path": "batch/script/__init__.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n"
  },
  {
    "path": "batch/script/daily_report.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nimport os\nfrom datetime import datetime\nfrom os import environ\n\nimport schedule\nimport time\n\nfrom selene.batch import SeleneScript\nfrom selene.data.account import AccountRepository\nfrom selene.util.db import DatabaseConnectionConfig\nfrom selene.util.email import EmailMessage, SeleneMailer\n\nmycroft_db = DatabaseConnectionConfig(\n    host=environ[\"DB_HOST\"],\n    db_name=environ[\"DB_NAME\"],\n    user=environ[\"DB_USER\"],\n    password=environ[\"DB_PASSWORD\"],\n    port=int(environ[\"DB_PORT\"]),\n    sslmode=environ[\"DB_SSL_MODE\"],\n)\n\n\nclass DailyReport(SeleneScript):\n    def __init__(self):\n        super(DailyReport, self).__init__(__file__)\n        self._arg_parser.add_argument(\n            \"--run-mode\",\n            help=\"If the script should run as a job or just once\",\n            choices=[\"job\", \"once\"],\n            type=str,\n            default=\"job\",\n        )\n\n    def _run(self):\n        if self.args.run_mode == \"job\":\n            schedule.every().day.at(\"00:00\").do(self._build_report)\n            while True:\n                schedule.run_pending()\n                time.sleep(1)\n        else:\n            self._build_report(self.args.date)\n\n    def _build_report(self, date: datetime = None):\n        if date is None:\n            date = datetime.now()\n        user_metrics = AccountRepository(self.db).daily_report(date)\n\n        email = EmailMessage(\n            sender=\"reports@mycroft.ai\",\n            recipient=os.environ[\"REPORT_RECIPIENT\"],\n            subject=\"Mycroft Daily Report - {}\".format(date.strftime(\"%Y-%m-%d\")),\n            template_file_name=\"metrics.html\",\n            template_variables=dict(user_metrics=user_metrics),\n        )\n\n        mailer = SeleneMailer(email)\n        mailer.send(True)\n\n\nDailyReport().run()\n"
  },
  {
    "path": "batch/script/delete_wake_word_files.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2020 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\n\"\"\"Delete wake word files for accounts that have been deleted.\"\"\"\nimport shlex\nfrom os import environ, listdir, remove, rmdir\nfrom pathlib import Path\nfrom subprocess import run, CalledProcessError\n\nfrom selene.batch import SeleneScript\nfrom selene.data.tagging import (\n    DELETED_STATUS,\n    WakeWordFileRepository,\n)\n\n\nclass WakeWordFileRemover(SeleneScript):\n    precise_server = environ[\"PRECISE_SERVER\"]\n    precise_ssh_port = environ.get(\"PRECISE_SSH_PORT\")\n    _file_repository = None\n\n    def __init__(self):\n        super(WakeWordFileRemover, self).__init__(__file__)\n\n    @property\n    def file_repository(self):\n        \"\"\"Lazily instantiate the wake word file repository.\"\"\"\n        if self._file_repository is None:\n            self._file_repository = WakeWordFileRepository(self.db)\n\n        return self._file_repository\n\n    def _run(self):\n        \"\"\"Make it so.\"\"\"\n        wake_word_files = self.file_repository.get_pending_delete()\n        if not wake_word_files:\n            self.log.info('No wake word files in \"pending delete\" status found.')\n        for account_id, files_to_delete in wake_word_files.items():\n            self._delete_files_for_account(account_id, files_to_delete)\n\n    def _delete_files_for_account(self, account_id, files_to_delete):\n        file_counter = 0\n        for file_to_delete in files_to_delete:\n            success = self._remove_from_file_system(file_to_delete)\n            if success:\n                self.file_repository.change_file_status(file_to_delete, DELETED_STATUS)\n                empty = self._check_for_empty_directory(file_to_delete.location)\n                if empty:\n                    self._delete_empty_directory(file_to_delete.location)\n                file_counter += 1\n        self.log.info(\n            f\"Deleted {file_counter} wake word files for account {account_id}\"\n        )\n\n    def _remove_from_file_system(self, file_to_delete):\n        file_dir = Path(file_to_delete.location.directory)\n        file_path = file_dir.joinpath(file_to_delete.name)\n        if file_to_delete.location.remote_server == self.precise_server:\n            success = self._remove_from_precise_file_system(file_path)\n        else:\n            success = self._remove_from_local_file_system(file_path)\n        return success\n\n    def _remove_from_precise_file_system(self, file_path):\n        delete_command = f'\"rm {file_path}\"'\n        success, stdout, stderr = self._run_on_precise_server(delete_command)\n        if not success:\n            self.log.error(\n                f\"Failed to delete file {file_path} from file system on \"\n                f\"{self.precise_server}\\n\\tstdout: {stdout}\\n\\tstderr: {stderr}\"\n            )\n\n        return success\n\n    def _remove_from_local_file_system(self, file_path):\n        try:\n            remove(file_path)\n            success = True\n        except FileNotFoundError:\n            success = False\n            self.log.error(\n                f\"Failed to delete file {file_path} from local file system - \"\n                f\"file not found\"\n            )\n\n        return success\n\n    def _check_for_empty_directory(self, file_location):\n        directory_empty = False\n        if file_location.remote_server == self.precise_server:\n            list_command = f'\"ls {file_location.directory}\"'\n            success, stdout, stderr = self._run_on_precise_server(list_command)\n            if success:\n                if not stdout:\n                    directory_empty = True\n            else:\n                self.log.error(\n                    f\"Failed to list contents of {file_location.directory} from \"\n                    f\"file system on {file_location.remote_server}\\n\\tstdout: \"\n                    f\"{stdout}\\n\\tstderr: {stderr}\"\n                )\n        else:\n            if not listdir(file_location.directory):\n                directory_empty = True\n\n        return directory_empty\n\n    def _delete_empty_directory(self, file_location):\n        if file_location.remote_server == self.precise_server:\n            delete_command = f'\"rmdir {file_location.directory}\"'\n            success, stdout, stderr = self._run_on_precise_server(delete_command)\n            if success:\n                self.log.info(\n                    f\"Deleted Directory {file_location.directory} \"\n                    f\"on {self.precise_server}\"\n                )\n            else:\n                self.log.error(\n                    f\"Failed to delete directory {file_location.directory} from file \"\n                    f\"system on {file_location.remote_server}\\n\\tstdout: {stdout}\\n\\t\"\n                    f\"stderr: {stderr}\"\n                )\n        else:\n            rmdir(file_location.directory)\n\n    def _run_on_precise_server(self, command):\n        ssh_cmd = f\"ssh -p {self.precise_ssh_port} precise@{self.precise_server} \"\n        ssh_cmd += command\n        try:\n            result = run(shlex.split(ssh_cmd), check=True, capture_output=True)\n            stdout = result.stdout\n            stderr = result.stderr\n            success = True\n        except CalledProcessError as cpe:\n            stdout = cpe.stdout\n            stderr = cpe.stderr\n            success = False\n\n        return success, stdout.decode(), stderr.decode()\n\n\nif __name__ == \"__main__\":\n    WakeWordFileRemover().run()\n"
  },
  {
    "path": "batch/script/designate_wake_word_files.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2020 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\n\"\"\"Convert tags to designations if the criteria for becoming a designation is met.\"\"\"\nfrom typing import List\n\nfrom selene.batch import SeleneScript\nfrom selene.data.tagging import (\n    FileDesignation,\n    FileDesignationRepository,\n    FileTag,\n    FileTagRepository,\n    TagRepository,\n)\n\n\nclass WakeWordFileDesignator(SeleneScript):\n    \"\"\"Batch script to convert tags to designations.\"\"\"\n\n    _tag_names = None\n\n    def __init__(self):\n        super().__init__(__file__)\n        self.prev_file_tag_id = None\n        self.designation_stats = dict()\n        self.value_counts = dict()\n        self.file_tags = 0\n        self.wake_word_tags = 0\n\n    @property\n    def tag_names(self):\n        \"\"\"Get the names related to the tag IDs and their values for logging.\"\"\"\n        if self._tag_names is None:\n            self._tag_names = dict()\n            tag_repo = TagRepository(self.db)\n            tags = tag_repo.get_all()\n            for tag in tags:\n                tag_values = dict()\n                for tag_value in tag.values:\n                    tag_values[tag_value.id] = tag_value.value\n                self._tag_names[tag.id] = (tag.name, tag_values)\n\n        return self._tag_names\n\n    def _run(self):\n        \"\"\"Make it so.\"\"\"\n        designation_candidates = self._get_designation_candidates()\n        for wake_word, file_tags in designation_candidates.items():\n            self.wake_word_tags = len(file_tags)\n            self._assign_designations(file_tags)\n            self._log_designation_stats(wake_word)\n\n    def _get_designation_candidates(self) -> dict:\n        \"\"\"Get file tags that have not yet been converted to designations\n\n        :return: dictionary keyed by wake word with list of candidates as values.\n        \"\"\"\n        file_tag_repo = FileTagRepository(self.db)\n        return file_tag_repo.get_designation_candidates()\n\n    def _assign_designations(self, file_tags: List[FileTag]):\n        \"\"\"Evaluate tagging activity to determine if any designations can be derived.\n\n        :param file_tags: List of tagging events for a wake word.\n        \"\"\"\n        self._init_tag(file_tags[0])\n        for file_tag in file_tags:\n            file_tag_id = file_tag.file_id, file_tag.tag_id\n            if file_tag_id != self.prev_file_tag_id:\n                self._convert_tags_to_designations()\n                self._init_tag(file_tag)\n            self._increment_value_counts(file_tag.tag_value_id)\n\n    def _init_tag(self, file_tag: FileTag):\n        \"\"\"Initialize file tag level attributes at the beginning of each new tag.\n\n        :param file_tag: The file tag being processed\n        \"\"\"\n        self.prev_file_tag_id = file_tag.file_id, file_tag.tag_id\n        self.value_counts = dict()\n        self.file_tags = 0\n\n    def _increment_value_counts(self, tag_value_id: str):\n        \"\"\"Track how many times each of a tag's values have been assigned to a file.\n\n        :param tag_value_id: the ID of the value assigned in one tag event\n        \"\"\"\n        value_count = self.value_counts.get(tag_value_id, 0)\n        self.value_counts[tag_value_id] = value_count + 1\n        self.file_tags += 1\n\n    def _convert_tags_to_designations(self):\n        \"\"\"Build designations for files with tags that meet the criteria.\"\"\"\n        designation_value = self._apply_designation_criteria()\n        if designation_value is not None:\n            self._add_designation(designation_value)\n            self._increment_designation_stats(designation_value)\n\n    def _apply_designation_criteria(self) -> str:\n        \"\"\"Determine if a file has enough tags to meet the designation criteria.\n\n        :return None if the criteria is not met or the designation if criteria is met\n        \"\"\"\n        designation_value = None\n        for tag_value, count in self.value_counts.items():\n            tag_percent = (count / self.file_tags) * 100\n            if count >= 2 and tag_percent > 75:\n                designation_value = tag_value\n                break\n\n        return designation_value\n\n    def _add_designation(self, designation_value):\n        \"\"\"Add a new file designation to the database\n\n        :param designation_value: The tag value to convert into a designation\n        \"\"\"\n        file_id, tag_id = self.prev_file_tag_id\n        designation = FileDesignation(\n            file_id=file_id, tag_id=tag_id, tag_value_id=designation_value\n        )\n        file_designation_repo = FileDesignationRepository(self.db)\n        file_designation_repo.add(designation)\n\n    def _increment_designation_stats(self, designation_value):\n        \"\"\"Keep track of the designations generated by this script for logging.\n\n        :param designation_value: The tag value to convert into a designation\n        \"\"\"\n        _, tag_id = self.prev_file_tag_id\n        tag = self.designation_stats.get(tag_id)\n        if tag is None:\n            self.designation_stats[tag_id] = {designation_value: 1}\n        else:\n            value_cnt = tag.get(designation_value, 0)\n            self.designation_stats[tag_id][designation_value] = value_cnt + 1\n\n    def _log_designation_stats(self, wake_word):\n        \"\"\"Write out log messages regarding the activity for a wake word\n\n        :param wake_word: The wake word for the statistics\n        \"\"\"\n        self.log.info(\n            \"{} tags processed for wake word {}\".format(\n                str(self.wake_word_tags), wake_word\n            )\n        )\n        log_msg = \"Designations applied for wake word: \" + wake_word\n        for tag_id, tag_values in self.designation_stats.items():\n            tag_name, tag_value_names = self.tag_names[tag_id]\n            log_msg += \"\\n\\tTag: \" + tag_name\n            for tag_value_id, tag_value_count in tag_values.items():\n                tag_value_name = tag_value_names[tag_value_id]\n                log_msg += \"\\n\\t\\t{}: {}\".format(tag_value_name, tag_value_count)\n        if \"Tag: \" not in log_msg:\n            log_msg = log_msg.replace(\"Designations\", \"No designations\")\n        self.log.info(log_msg)\n\n\nif __name__ == \"__main__\":\n    WakeWordFileDesignator().run()\n"
  },
  {
    "path": "batch/script/load_skill_display_data.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\n\"\"\"Update skill.display table with skill information from repositories.\n\nDownload skill-metadata.json from the mycroft-skills-data GitHub repository.\nUse the contents of the file to update the skill.display table which is\nprimarily for displaying skills in the marketplace.\n\nThe mycroft-skills-data repository has a branch for each major release of\nMycroft core containing the skills available in that release.\n\"\"\"\nimport json\nfrom os import environ\n\nfrom selene.batch import SeleneScript\nfrom selene.data.skill import SkillDisplay, SkillDisplayRepository, SkillRepository\nfrom selene.util.github import download_repository_file, log_into_github\n\nGITHUB_USER = environ[\"GITHUB_USER\"]\nGITHUB_PASSWORD = environ[\"GITHUB_PASSWORD\"]\nSKILL_DATA_GITHUB_REPO = \"mycroft-skills-data\"\nSKILL_DATA_FILE_NAME = \"skill-metadata.json\"\n\n\nclass SkillDisplayUpdater(SeleneScript):\n    def __init__(self):\n        super(SkillDisplayUpdater, self).__init__(__file__)\n        self.skill_display_data = None\n\n    def _define_args(self):\n        super(SkillDisplayUpdater, self)._define_args()\n        self._arg_parser.add_argument(\n            \"--core-version\",\n            help=\"Version of Mycroft Core related to skill display data\",\n            required=True,\n            type=str,\n        )\n\n    def _run(self):\n        \"\"\"Make it so.\"\"\"\n        self.log.info(\n            \"Updating skill display data for core version \" + self.args.core_version\n        )\n        self._get_skill_display_data()\n        self._update_skill_display_table()\n\n    def _get_skill_display_data(self):\n        \"\"\"Use the GitHub API to retrieve the JSON file.\"\"\"\n        github_api = log_into_github(GITHUB_USER, GITHUB_PASSWORD)\n        file_contents = download_repository_file(\n            github_api,\n            SKILL_DATA_GITHUB_REPO,\n            self.args.core_version,\n            SKILL_DATA_FILE_NAME,\n        )\n        self.skill_display_data = json.loads(file_contents)\n\n    def _update_skill_display_table(self):\n        skill_count = 0\n        skill_repository = SkillRepository(self.db)\n        display_repository = SkillDisplayRepository(self.db)\n        for skill_name, skill_metadata in self.skill_display_data.items():\n            skill_count += 1\n            skill_id = skill_repository.ensure_skill_exists(skill_metadata[\"skill_gid\"])\n\n            # add the skill display row\n            display_data = SkillDisplay(\n                skill_id=skill_id,\n                core_version=self.args.core_version,\n                display_data=json.dumps(skill_metadata),\n            )\n            display_repository.upsert(display_data)\n\n        self.log.info(\"updated {} skills\".format(skill_count))\n\n\nif __name__ == \"__main__\":\n    SkillDisplayUpdater().run()\n"
  },
  {
    "path": "batch/script/move_wake_word_files.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2020 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\n\"\"\"Move wake word samples from public API host to long-term storage.\"\"\"\nimport shlex\nfrom os import environ, remove\nfrom pathlib import Path\nfrom subprocess import run\n\nfrom selene.batch import SeleneScript\nfrom selene.data.tagging import (\n    TaggingFileLocationRepository,\n    WakeWordFileRepository,\n)\n\n\nclass WakeWordSampleMover(SeleneScript):\n    dest_base_dir = Path(environ[\"PRECISE_WAKE_WORD_DIR\"])\n    precise_server = environ[\"PRECISE_SERVER\"]\n    precise_ssh_port = environ.get(\"PRECISE_SSH_PORT\")\n    _file_repository = None\n\n    def __init__(self):\n        super(WakeWordSampleMover, self).__init__(__file__)\n        self.new_directories = dict()\n\n    def _run(self):\n        wake_word_files = self._get_wake_word_file_info()\n        self._move_files(wake_word_files)\n\n    @property\n    def file_repository(self):\n        if self._file_repository is None:\n            self._file_repository = WakeWordFileRepository(self.db)\n\n        return self._file_repository\n\n    def _get_wake_word_file_info(self):\n        wake_word_files = self.file_repository.get_by_submission_date(self.args.date)\n        self.log.info(f\"{len(wake_word_files)} wake word samples will be moved\")\n\n        return wake_word_files\n\n    def _move_files(self, wake_word_file_info):\n        self.log.info(f\"moving wake word sample files to {self.precise_server}\")\n        for file_info in wake_word_file_info:\n            destination_dir = self._ensure_remote_directory_exists(file_info)\n            self._copy_file(file_info, destination_dir)\n            self.file_repository.change_file_location(\n                file_info.id, self.new_directories[destination_dir]\n            )\n            remove(str(Path(file_info.location.directory).joinpath(file_info.name)))\n\n    def _ensure_remote_directory_exists(self, file_info) -> Path:\n        remote_directory = self.dest_base_dir.joinpath(\n            file_info.wake_word.name.replace(\" \", \"-\"), str(file_info.submission_date)\n        )\n        self._ensure_directory_exists_on_server(remote_directory)\n        self._ensure_directory_exists_on_db(remote_directory)\n\n        return remote_directory\n\n    def _ensure_directory_exists_on_server(self, directory):\n        cmd = f\"ssh precise@{self.precise_server} \"\n        if self.precise_ssh_port:\n            cmd += f\"-p {self.precise_ssh_port} \"\n        cmd += f'\"mkdir -p {directory}\"'\n        run(shlex.split(cmd), check=True)\n\n    def _ensure_directory_exists_on_db(self, new_directory):\n        file_location_repository = TaggingFileLocationRepository(self.db)\n        if new_directory not in self.new_directories:\n            new_location = file_location_repository.ensure_location_exists(\n                server=self.precise_server, directory=new_directory\n            )\n            self.new_directories[new_directory] = new_location.id\n\n    def _copy_file(self, file_info, destination_dir):\n        source_path = Path(file_info.location.directory).joinpath(file_info.name)\n        destination_path = destination_dir.joinpath(file_info.name)\n        cmd = f\"scp \"\n        if self.precise_ssh_port:\n            cmd += f\"-P {self.precise_ssh_port} \"\n        cmd += f\"{source_path} precise@{self.precise_server}:{destination_path}\"\n        run(shlex.split(cmd), check=True)\n\n\nif __name__ == \"__main__\":\n    WakeWordSampleMover().run()\n"
  },
  {
    "path": "batch/script/parse_core_metrics.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\n\"\"\"Convert the raw timing metrics from core into a format for latency profiling.\n\nThe raw core metric data is stored in a JSON object in the metric.core table.\nThere is a row on the metric.core table for each step in each user interaction.\nThis format is not ideal for querying timing statistics for latency profiling.\n\nCombine the pertinent data from all steps in an interaction into a single row\non the metric.core_interaction table.\n\"\"\"\nfrom datetime import datetime\nfrom decimal import Decimal\n\nfrom selene.batch import SeleneScript\nfrom selene.data.metric import CoreMetricRepository, CoreInteraction\n\nSKILL_HANDLERS_TO_SKIP = (\"reset\", \"notify\", \"prime\", \"stop_laugh\")\n\n\nclass CoreMetricsParser(SeleneScript):\n    def __init__(self):\n        super(CoreMetricsParser, self).__init__(__file__)\n        self.core_metric_repo = CoreMetricRepository(self.db)\n        self.interaction_cnt = 0\n        self.interaction: CoreInteraction = None\n        self.stt_start_ts = None\n        self.playback_start_ts = None\n\n    def _run(self):\n        last_interaction_id = None\n        for metric in self.core_metric_repo.get_metrics_by_date(self.args.date):\n            if metric.metric_value[\"id\"] != last_interaction_id:\n                self._add_interaction_to_db()\n                self._start_new_interaction(metric)\n                last_interaction_id = self.interaction.core_id\n            self._add_metric_to_interaction(metric.metric_value)\n        self._add_interaction_to_db()\n\n    def _start_new_interaction(self, metric):\n        \"\"\"Initialize the interaction object\"\"\"\n        self.interaction = CoreInteraction(\n            core_id=metric.metric_value[\"id\"],\n            device_id=metric.device_id,\n            start_ts=datetime.utcfromtimestamp(metric.metric_value[\"start_time\"]),\n        )\n        self.stt_start_ts = None\n        self.playback_start_ts = None\n\n    def _add_metric_to_interaction(self, metric_value):\n        \"\"\"Combine all the steps of an interaction into a single record\"\"\"\n        duration = Decimal(str(metric_value[\"time\"]))\n        duration = duration.quantize(Decimal(\"0.000001\"))\n        if metric_value[\"system\"] == \"stt\":\n            self.interaction.stt_engine = metric_value[\"stt\"]\n            self.interaction.stt_transcription = metric_value[\"transcription\"]\n            self.interaction.stt_duration = duration\n            self.stt_start_ts = metric_value[\"start_time\"]\n        elif metric_value[\"system\"] == \"intent_service\":\n            self.interaction.intent_type = metric_value[\"intent_type\"]\n            self.interaction.intent_duration = duration\n        elif metric_value[\"system\"] == \"fallback_handler\":\n            self.interaction.fallback_handler_duration = duration\n        elif metric_value[\"system\"] == \"skill_handler\":\n            if metric_value[\"handler\"] not in SKILL_HANDLERS_TO_SKIP:\n                self.interaction.skill_handler = metric_value[\"handler\"]\n                self.interaction.skill_duration = duration\n        elif metric_value[\"system\"] == \"speech\":\n            self.interaction.tts_engine = metric_value[\"tts\"]\n            self.interaction.tts_utterance = metric_value[\"utterance\"]\n            self.interaction.tts_duration = duration\n        elif metric_value[\"system\"] == \"speech_playback\":\n            self.interaction.speech_playback_duration = duration\n            self.playback_start_ts = metric_value[\"start_time\"]\n\n        # The user-experienced latency is the time between when the user\n        # finishes speaking their intent and when the device provides a voice\n        # response.\n        if self.stt_start_ts is not None and self.playback_start_ts is not None:\n            self.interaction.user_latency = self.playback_start_ts - self.stt_start_ts\n\n    def _add_interaction_to_db(self):\n        if self.interaction is not None:\n            if self.interaction.stt_transcription is not None:\n                self.interaction_cnt += 1\n                self.core_metric_repo.add_interaction(self.interaction)\n\n\nif __name__ == \"__main__\":\n    CoreMetricsParser().run()\n"
  },
  {
    "path": "batch/script/partition_api_metrics.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\n\"\"\"Copy api metric from a transient table to a partitioned table.\n\nMillions of rows per day are added to the metric.api table.  To help with\nquery performance, copy the rows from this table to a partitioned table on a\ndaily basis.\n\"\"\"\nfrom selene.batch.base import SeleneScript\nfrom selene.data.metric import ApiMetricsRepository\nfrom selene.util.db import use_transaction\n\n\nclass PartitionApiMetrics(SeleneScript):\n    def __init__(self):\n        super(PartitionApiMetrics, self).__init__(__file__)\n\n    @use_transaction\n    def _run(self):\n        api_metrics_repo = ApiMetricsRepository(self.db)\n        api_metrics_repo.create_partition(self.args.date)\n        api_metrics_repo.copy_to_partition(self.args.date)\n        api_metrics_repo.remove_by_date(self.args.date)\n\n\nif __name__ == \"__main__\":\n    PartitionApiMetrics().run()\n"
  },
  {
    "path": "batch/script/test_scheduler.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\n\"\"\"Job to test the scheduler functionality.\n\nThis job is run through the batch job scheduler.  It contains assertion\nstatements that test the functionality of the code in the\njob_scheduler.job module.\n\"\"\"\nfrom datetime import date, timedelta\n\nfrom selene.batch.base import SeleneScript\n\n\nclass TestScheduler(SeleneScript):\n    def __init__(self):\n        super(TestScheduler, self).__init__(__file__)\n\n    def _define_args(self):\n        \"\"\"Pass an arg with value and arg without value to the script\n\n        The scheduler needs to be able to handle arguments that take a value\n        and those that do not.  Define one of each and specify them in the\n        scheduler.\n        \"\"\"\n        super(TestScheduler, self)._define_args()\n        self._arg_parser.add_argument(\n            \"--arg-with-value\",\n            help=\"Argument to test passing a value with an argument\",\n            required=True,\n            type=str,\n        )\n        self._arg_parser.add_argument(\n            \"--arg-no-value\",\n            help=\"Argument to test passing a value with an argument\",\n            action=\"store_true\",\n        )\n\n    def _run(self):\n        self.log.info(\"Running the scheduler test job\")\n        assert self.args.arg_no_value\n        assert self.args.arg_with_value == \"test\"\n\n        # Tests the logic that overrides the default date in the scheduler.\n        assert self.args.date == date.today() - timedelta(days=1)\n\n\nif __name__ == \"__main__\":\n    TestScheduler().run()\n"
  },
  {
    "path": "batch/script/update_device_last_contact.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\n\"\"\"Update last contact timestamp of devices that had activity\n\nAs devices make API calls throughout the day, they store a timestamp of the\nAPI call in Redis.  This is done to take some load off of the Postgres database\nthroughout the day.\n\nThis script should run on a daily basis to update the Postgres database with\nthe values on the Redis database.\n\"\"\"\nfrom datetime import datetime\n\nfrom selene.batch import SeleneScript\nfrom selene.data.device import DeviceRepository\nfrom selene.util.cache import SeleneCache, DEVICE_LAST_CONTACT_KEY\n\n\nclass UpdateDeviceLastContact(SeleneScript):\n    def __init__(self):\n        super(UpdateDeviceLastContact, self).__init__(__file__)\n        self.cache = SeleneCache()\n\n    def _run(self):\n        device_repo = DeviceRepository(self.db)\n        devices_updated = 0\n        for device in device_repo.get_all_device_ids():\n            last_contact_ts = self._get_ts_from_cache(device.id)\n            if last_contact_ts is not None:\n                devices_updated += 1\n                device_repo.update_last_contact_ts(device.id, last_contact_ts)\n\n        self.log.info(str(devices_updated) + \" devices were active today\")\n\n    def _get_ts_from_cache(self, device_id):\n        last_contact_ts = None\n        cache_key = DEVICE_LAST_CONTACT_KEY.format(device_id=device_id)\n        value = self.cache.get(cache_key)\n        if value is not None:\n            last_contact_ts = datetime.strptime(value.decode(), \"%Y-%m-%d %H:%M:%S.%f\")\n            self.cache.delete(cache_key)\n\n        return last_contact_ts\n\n\nif __name__ == \"__main__\":\n    UpdateDeviceLastContact().run()\n"
  },
  {
    "path": "db/mycroft/account_schema/create_schema.sql",
    "content": "-- create the schema that will be used to store user data\n-- took out the \"e\" in \"user\" because \"user\" is a Postgres keyword\nCREATE SCHEMA account;\n"
  },
  {
    "path": "db/mycroft/account_schema/data/membership.sql",
    "content": "INSERT INTO\n    account.membership (type, rate, rate_period, stripe_plan)\nVALUES\n    ('Monthly Membership', 1.99, 'month', 'monthly_premium'),\n    ('Yearly Membership', 19.99, 'year', 'mycroft_ai_premium_annual_1999')\n;\n"
  },
  {
    "path": "db/mycroft/account_schema/grants.sql",
    "content": "GRANT USAGE ON SCHEMA account TO selene;\nGRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA account TO selene;\n"
  },
  {
    "path": "db/mycroft/account_schema/tables/account.sql",
    "content": "CREATE TABLE account.account (\n    id                      uuid        PRIMARY KEY\n            DEFAULT gen_random_uuid(),\n    email_address           text        NOT NULL\n            UNIQUE,\n    username                text\n            UNIQUE,\n    password                text,\n    insert_ts               TIMESTAMP   NOT NULL\n            DEFAULT CURRENT_TIMESTAMP,\n    last_activity_ts        TIMESTAMP\n);\n"
  },
  {
    "path": "db/mycroft/account_schema/tables/account_agreement.sql",
    "content": "CREATE TABLE account.account_agreement (\n    id                  uuid        PRIMARY KEY DEFAULT gen_random_uuid(),\n    account_id          uuid        NOT NULL REFERENCES account.account ON DELETE CASCADE,\n    agreement_id        uuid        NOT NULL REFERENCES account.agreement,\n    accept_date         DATE        NOT NULL,\n    insert_ts           TIMESTAMP   NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    UNIQUE (account_id, agreement_id, accept_date)\n);\n"
  },
  {
    "path": "db/mycroft/account_schema/tables/account_membership.sql",
    "content": "CREATE TABLE account.account_membership (\n    id                      uuid                    PRIMARY KEY\n            DEFAULT gen_random_uuid(),\n    account_id              uuid                    NOT NULL\n            REFERENCES account.account ON DELETE CASCADE,\n    membership_id           uuid                    NOT NULL\n            REFERENCES account.membership,\n    membership_ts_range     tsrange                 NOT NULL,\n    payment_method          payment_method_enum     NOT NULL,\n    payment_account_id      text                    NOT NULL,\n    payment_id              text                    NOT NULL,\n    insert_ts               TIMESTAMP               NOT NULL\n            DEFAULT CURRENT_TIMESTAMP,\n    EXCLUDE USING gist (account_id WITH =, membership_ts_range with &&),\n    UNIQUE (account_id, membership_id, membership_ts_range)\n)\n"
  },
  {
    "path": "db/mycroft/account_schema/tables/agreement.sql",
    "content": "CREATE TABLE account.agreement (\n    id              uuid            PRIMARY KEY DEFAULT gen_random_uuid(),\n    agreement       agreement_enum  NOT NULL,\n    version         text            NOT NULL,\n    effective       daterange       NOT NULL,\n    content_id      oid,\n    insert_ts       TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    EXCLUDE USING gist (agreement WITH =, effective WITH &&),\n    UNIQUE (agreement, version)\n);\n"
  },
  {
    "path": "db/mycroft/account_schema/tables/membership.sql",
    "content": "CREATE TABLE account.membership (\n    id              uuid                    PRIMARY KEY\n            DEFAULT gen_random_uuid(),\n    type            membership_type_enum    NOT NULL\n            UNIQUE,\n    rate            NUMERIC                 NOT NULL,\n    rate_period     text                    NOT NULL,\n    stripe_plan     text                    NOT NULL,\n    insert_ts       TIMESTAMP               NOT NULL\n        DEFAULT CURRENT_TIMESTAMP\n);\n"
  },
  {
    "path": "db/mycroft/create_extensions.sql",
    "content": "CREATE EXTENSION pgcrypto;\nCREATE EXTENSION btree_gist;"
  },
  {
    "path": "db/mycroft/create_mycroft_db.sql",
    "content": "CREATE DATABASE mycroft WITH TEMPLATE mycroft_template OWNER selene;\n"
  },
  {
    "path": "db/mycroft/create_roles.sql",
    "content": "-- create the roles that will be used by selene applications\nCREATE ROLE selene WITH SUPERUSER LOGIN ENCRYPTED PASSWORD 'adam';\n"
  },
  {
    "path": "db/mycroft/create_template_db.sql",
    "content": "CREATE DATABASE mycroft_template WITH OWNER = selene;\n"
  },
  {
    "path": "db/mycroft/device_schema/create_schema.sql",
    "content": "-- create the schema that will be used to store user data\n-- took out the \"e\" in \"user\" because \"user\" is a Postgres keyword\nCREATE SCHEMA device;\n"
  },
  {
    "path": "db/mycroft/device_schema/data/text_to_speech.sql",
    "content": "INSERT INTO\n    device.text_to_speech (setting_name, display_name,  engine)\nVALUES\n    ('ap', 'British Male', 'mimic'),\n    ('amy', 'American Female', 'mimic'),\n    ('kusal', 'American Male', 'mimic'),\n    ('google', 'Google Free Voice', 'google')\n"
  },
  {
    "path": "db/mycroft/device_schema/get_device_defaults_for_city.sql",
    "content": "SELECT\n    id,\n    city_id\nFROM\n    device.account_defaults\nWHERE\n    city_id IN %(city_ids)s\n"
  },
  {
    "path": "db/mycroft/device_schema/get_device_geographies_for_city.sql",
    "content": "SELECT\n    id,\n    city_id\nFROM\n    device.geography\nWHERE\n    city_id IN %(city_ids)s\n"
  },
  {
    "path": "db/mycroft/device_schema/grants.sql",
    "content": "GRANT USAGE ON SCHEMA device TO selene;\nGRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA device TO selene;\n"
  },
  {
    "path": "db/mycroft/device_schema/tables/account_defaults.sql",
    "content": "-- Account level preferences that pertain to device function.\nCREATE TABLE device.account_defaults (\n    id                  uuid                    PRIMARY KEY\n            DEFAULT gen_random_uuid(),\n    account_id          uuid                    NOT NULL\n            UNIQUE\n            REFERENCES account.account ON DELETE CASCADE,\n    wake_word_id        uuid\n            REFERENCES wake_word.wake_word,\n    text_to_speech_id   uuid\n            REFERENCES device.text_to_speech,\n    country_id          uuid\n            REFERENCES geography.country,\n    region_id           uuid\n            REFERENCES geography.region,\n    city_id             uuid\n            REFERENCES geography.city,\n    timezone_id         uuid\n            REFERENCES geography.timezone,\n    insert_ts           TIMESTAMP               NOT NULL\n            DEFAULT CURRENT_TIMESTAMP\n\n);\n"
  },
  {
    "path": "db/mycroft/device_schema/tables/account_preferences.sql",
    "content": "-- Account level preferences that pertain to device function.\nCREATE TABLE device.account_preferences (\n    id                  uuid                    PRIMARY KEY\n            DEFAULT gen_random_uuid(),\n    account_id          uuid                    NOT NULL\n            UNIQUE\n            REFERENCES account.account ON DELETE CASCADE,\n    date_format         date_format_enum        NOT NULL\n            DEFAULT 'MM/DD/YYYY',\n    time_format         time_format_enum        NOT NULL\n            DEFAULT '12 Hour',\n    measurement_system  measurement_system_enum NOT NULL\n            DEFAULT 'Imperial',\n    insert_ts           TIMESTAMP               NOT NULL\n            DEFAULT CURRENT_TIMESTAMP\n);\n"
  },
  {
    "path": "db/mycroft/device_schema/tables/category.sql",
    "content": "CREATE TABLE device.category (\n    id          uuid        PRIMARY KEY DEFAULT gen_random_uuid(),\n    account_id  uuid        NOT NULL REFERENCES account.account ON DELETE CASCADE,\n    category    text,\n    insert_ts   TIMESTAMP   NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    UNIQUE (account_id, category)\n);\n"
  },
  {
    "path": "db/mycroft/device_schema/tables/device.sql",
    "content": "CREATE TABLE device.device (\n    id                  uuid            PRIMARY KEY\n            DEFAULT gen_random_uuid(),\n    account_id          uuid            NOT NULL\n            REFERENCES account.account ON DELETE CASCADE ,\n    name                text            NOT NULL,\n    platform            text            NOT NULL\n            DEFAULT 'unknown',\n    enclosure_version   text            NOT NULL\n            DEFAULT 'unknown',\n    core_version        text            NOT NULL\n            DEFAULT 'unknown',\n    wake_word_id        uuid            NOT NULL\n            REFERENCES wake_word.wake_word,\n    text_to_speech_id   uuid            NOT NULL\n            REFERENCES device.text_to_speech,\n    category_id         uuid\n            REFERENCES device.category,\n    geography_id        uuid            NOT NULL\n            REFERENCES device.geography,\n    placement           text,\n    last_contact_ts     timestamp,\n    insert_ts           TIMESTAMP       NOT NULL\n            DEFAULT CURRENT_TIMESTAMP,\n    UNIQUE (account_id, name)\n);\n"
  },
  {
    "path": "db/mycroft/device_schema/tables/device_skill.sql",
    "content": "CREATE TABLE device.device_skill (\n    id                      uuid        PRIMARY KEY\n        DEFAULT gen_random_uuid(),\n    device_id               uuid        NOT NULL\n        REFERENCES device.device\n        ON DELETE CASCADE,\n    skill_id                uuid        NOT NULL\n        REFERENCES skill.skill,\n    install_method          text\t    NOT NULL\n        DEFAULT 'msm',\n    install_status          text\t    NOT NULL\n        DEFAULT 'installed',\n    install_failure_reason  text,\n    install_ts              TIMESTAMP,\n    update_ts               TIMESTAMP,\n    skill_settings_display_id   uuid\n        REFERENCES skill.settings_display,\n    settings                json,\n    insert_ts               TIMESTAMP   NOT NULL\n        DEFAULT CURRENT_TIMESTAMP,\n    UNIQUE (device_id, skill_id)\n);\n"
  },
  {
    "path": "db/mycroft/device_schema/tables/geography.sql",
    "content": "CREATE TABLE device.geography (\n    id              uuid        PRIMARY KEY DEFAULT gen_random_uuid(),\n    account_id      uuid        NOT NULL\n            REFERENCES account.account ON DELETE CASCADE,\n    country_id      uuid        NOT NULL\n            REFERENCES geography.country,\n    region_id       uuid        NOT NULL\n            REFERENCES geography.region,\n    city_id         uuid        NOT NULL\n            REFERENCES geography.city,\n    timezone_id     uuid        NOT NULL\n            REFERENCES geography.timezone,\n    insert_ts       TIMESTAMP   NOT NULL\n            DEFAULT CURRENT_TIMESTAMP,\n    UNIQUE (account_id, country_id, region_id, city_id, timezone_id)\n);\n"
  },
  {
    "path": "db/mycroft/device_schema/tables/pantacor_config.sql",
    "content": "CREATE TABLE device.pantacor_config (\n    id              uuid        PRIMARY KEY DEFAULT gen_random_uuid(),\n    device_id       uuid        UNIQUE REFERENCES device.device ON DELETE CASCADE,\n    pantacor_id     text        UNIQUE NOT NULL,\n    ip_address      inet        NOT NULL,\n    ssh_public_key  text,\n    auto_update     bool        NOT NULL,\n    release_channel text        NOT NULL,\n    insert_ts   TIMESTAMP   NOT NULL DEFAULT CURRENT_TIMESTAMP\n);\n"
  },
  {
    "path": "db/mycroft/device_schema/tables/skill_setting.sql",
    "content": "CREATE TABLE device.skill_setting (\n    id              uuid        PRIMARY KEY DEFAULT gen_random_uuid(),\n    device_skill_id uuid        NOT NULL REFERENCES device.device_skill ON DELETE CASCADE,\n    setting_id      uuid        NOT NULL REFERENCES skill.setting,\n    value           text        NOT NULL,\n    insert_ts       TIMESTAMP   NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    UNIQUE (device_skill_id, setting_id)\n);\n"
  },
  {
    "path": "db/mycroft/device_schema/tables/text_to_speech.sql",
    "content": "CREATE TABLE device.text_to_speech (\n    id              uuid            PRIMARY KEY DEFAULT gen_random_uuid(),\n    setting_name    text            NOT NULL UNIQUE,\n    display_name    text            NOT NULL,\n    engine          tts_engine_enum NOT NULL,\n    insert_ts       TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP\n);\n"
  },
  {
    "path": "db/mycroft/device_schema/tables/wake_word.sql",
    "content": "CREATE TABLE device.wake_word (\n    id              uuid        PRIMARY KEY DEFAULT gen_random_uuid(),\n    setting_name    text        NOT NULL,\n    display_name    text        NOT NULL,\n    account_id      uuid        REFERENCES account.account ON DELETE CASCADE,\n    engine          text        NOT NULL,\n    insert_ts   TIMESTAMP   NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    UNIQUE (account_id, setting_name)\n);\n"
  },
  {
    "path": "db/mycroft/device_schema/tables/wake_word_settings.sql",
    "content": "-- Settings for wake words using the Pocketsphinx engine\nCREATE TABLE device.wake_word_settings (\n    id                      uuid        PRIMARY KEY DEFAULT gen_random_uuid(),\n    wake_word_id            uuid        UNIQUE REFERENCES device.wake_word ON DELETE CASCADE,\n    sample_rate             INTEGER,\n    channels                INTEGER,\n    pronunciation           text,\n    threshold               text,\n    threshold_multiplier    NUMERIC,\n    dynamic_energy_ratio    NUMERIC,\n    insert_ts               TIMESTAMP   NOT NULL DEFAULT CURRENT_TIMESTAMP\n\n);\n"
  },
  {
    "path": "db/mycroft/drop_extensions.sql",
    "content": "DROP EXTENSION IF EXISTS pgcrypto;\nDROP EXTENSION IF EXISTS btree_gist;"
  },
  {
    "path": "db/mycroft/drop_mycroft_db.sql",
    "content": "DROP DATABASE IF EXISTS mycroft;\n"
  },
  {
    "path": "db/mycroft/drop_roles.sql",
    "content": "DROP ROLE IF EXISTS selene;\n"
  },
  {
    "path": "db/mycroft/drop_template_db.sql",
    "content": "DROP DATABASE IF EXISTS mycroft_template;\n"
  },
  {
    "path": "db/mycroft/geography_schema/create_schema.sql",
    "content": "-- create the schema that will be used to geographical reference data\nCREATE SCHEMA geography;\n"
  },
  {
    "path": "db/mycroft/geography_schema/delete_duplicate_cities.sql",
    "content": "DELETE FROM\n    geography.city\nWHERE\n    id IN %(city_ids)s\n    and (population is null or population != %(max_population)s)\n"
  },
  {
    "path": "db/mycroft/geography_schema/get_duplicated_cities.sql",
    "content": "-- Query to get a list of duplicated cities for use in a process to remove them.\nWITH duplicated_cities AS (\n    SELECT\n        c.name AS country_name,\n        r.name AS region_name,\n        y.name AS city_name,\n        max(population) as max_population,\n        array_agg(y.id::text) as city_ids\n    FROM\n        geography.city y\n        INNER JOIN geography.region r on r.id = y.region_id\n        INNER JOIN geography.country c on c.id = r.country_id\n    GROUP BY\n        1, 2, 3\n)\nSELECT\n    *\nFROM\n    duplicated_cities\nWHERE\n    cardinality(city_ids) > 1\n;\n"
  },
  {
    "path": "db/mycroft/geography_schema/grants.sql",
    "content": "GRANT USAGE ON SCHEMA geography TO selene;\nGRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA geography TO selene;\n"
  },
  {
    "path": "db/mycroft/geography_schema/tables/city.sql",
    "content": "CREATE TABLE geography.city (\n    id          uuid        PRIMARY KEY\n            DEFAULT gen_random_uuid(),\n    region_id   uuid        NOT NULL\n            REFERENCES geography.region,\n    timezone_id uuid        NOT NULL\n            REFERENCES geography.timezone,\n    name        text        NOT NULL,\n    latitude    NUMERIC     NOT NULL,\n    longitude   NUMERIC     NOT NULL,\n    insert_ts   TIMESTAMP   NOT NULL\n            DEFAULT CURRENT_TIMESTAMP,\n    population  INTEGER\n)\n"
  },
  {
    "path": "db/mycroft/geography_schema/tables/country.sql",
    "content": "CREATE TABLE geography.country (\n    id          uuid        PRIMARY KEY\n            DEFAULT gen_random_uuid(),\n    iso_code    CHAR(2)     NOT NULL\n            UNIQUE,\n    name        text        NOT NULL,\n    insert_ts   TIMESTAMP   NOT NULL\n            DEFAULT CURRENT_TIMESTAMP\n)\n"
  },
  {
    "path": "db/mycroft/geography_schema/tables/region.sql",
    "content": "CREATE TABLE geography.region (\n    id          uuid        PRIMARY KEY\n        DEFAULT gen_random_uuid(),\n    country_id  uuid        NOT NULL\n        REFERENCES geography.country,\n    region_code VARCHAR(20) NOT NULL UNIQUE,\n    name        text        NOT NULL,\n    insert_ts   TIMESTAMP   NOT NULL\n            DEFAULT CURRENT_TIMESTAMP\n)\n"
  },
  {
    "path": "db/mycroft/geography_schema/tables/timezone.sql",
    "content": "CREATE TABLE geography.timezone (\n    id          uuid        PRIMARY KEY\n            DEFAULT gen_random_uuid(),\n    country_id  uuid        NOT NULL\n            REFERENCES geography.country,\n    name        text        NOT NULL UNIQUE,\n    gmt_offset  NUMERIC     NOT NULL,\n    dst_offset  NUMERIC,\n    insert_ts   TIMESTAMP   NOT NULL\n            DEFAULT CURRENT_TIMESTAMP\n)\n"
  },
  {
    "path": "db/mycroft/metric_schema/create_schema.sql",
    "content": "-- create the schema that will be used to store system metrics data\nCREATE SCHEMA metric;\n"
  },
  {
    "path": "db/mycroft/metric_schema/grants.sql",
    "content": "GRANT USAGE ON SCHEMA metric TO selene;\nGRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA metric TO selene;\n"
  },
  {
    "path": "db/mycroft/metric_schema/tables/account_activity.sql",
    "content": "CREATE TABLE metric.account_activity (\n    id                      uuid        PRIMARY KEY DEFAULT gen_random_uuid(),\n    activity_dt             date        NOT NULL DEFAULT current_date,\n    accounts                INTEGER     NOT NULL,\n    accounts_added          INTEGER     NOT NULL DEFAULT 0,\n    accounts_deleted        INTEGER     NOT NULL DEFAULT 0,\n    accounts_active         INTEGER     NOT NULL DEFAULT 0,\n    members                 INTEGER     NOT NULL,\n    members_added           INTEGER     NOT NULL DEFAULT 0,\n    members_expired         INTEGER     NOT NULL DEFAULT 0,\n    members_active          INTEGER     NOT NULL DEFAULT 0,\n    open_dataset            INTEGER     NOT NULL,\n    open_dataset_added      INTEGER     NOT NULL DEFAULT 0,\n    open_dataset_deleted    INTEGER     NOT NULL DEFAULT 0,\n    open_dataset_active     INTEGER     NOT NULL DEFAULT 0,\n    insert_ts               timestamp   NOT NULL DEFAULT now()\n);\n\nCREATE INDEX IF NOT EXISTS\n    account_activity_dt_idx\nON\n    metric.account_activity (activity_dt)\n;\n"
  },
  {
    "path": "db/mycroft/metric_schema/tables/api.sql",
    "content": "CREATE TABLE metric.api (\n    id          uuid        PRIMARY KEY\n            DEFAULT gen_random_uuid(),\n    http_method text        NOT NULL,\n    http_status CHAR(3)     NOT NULL,\n    duration    NUMERIC     NOT NULL,\n    access_ts   timestamp   NOT NULL\n            DEFAULT now(),\n    api         text        NOT NULL,\n    url         text        NOT NULL,\n    account_id  uuid,\n    device_id   uuid\n);\n\nCREATE INDEX IF NOT EXISTS\n    api_access_ts_idx\nON\n    metric.api (access_ts)\n;\n"
  },
  {
    "path": "db/mycroft/metric_schema/tables/api_history.sql",
    "content": "CREATE TABLE metric.api_history (\n    id          uuid        NOT NULL,\n    http_method text        NOT NULL,\n    http_status CHAR(3)     NOT NULL,\n    duration    NUMERIC     NOT NULL,\n    access_ts   timestamp   NOT NULL,\n    api         text        NOT NULL,\n    url         text        NOT NULL,\n    account_id  uuid,\n    device_id   uuid\n)\nPARTITION BY RANGE\n    (access_ts)\n"
  },
  {
    "path": "db/mycroft/metric_schema/tables/core.sql",
    "content": "CREATE TABLE metric.core (\n    id              uuid        PRIMARY KEY\n        DEFAULT gen_random_uuid(),\n    device_id       uuid        NOT NULL\n        REFERENCES device.device\n        ON DELETE CASCADE,\n    metric_type     text        NOT NULL,\n    insert_ts       TIMESTAMP   NOT NULL\n        DEFAULT now(),\n    metric_value    json        NOT NULL,\n    UNIQUE (device_id, insert_ts)\n)\n"
  },
  {
    "path": "db/mycroft/metric_schema/tables/core_interaction.sql",
    "content": "CREATE TABLE metric.core_interaction (\n    id                          uuid        PRIMARY KEY\n            DEFAULT gen_random_uuid(),\n    device_id                   uuid        NOT NULL\n            REFERENCES device.device,\n    core_id                     text        NOT NULL,\n    start_ts                    TIMESTAMP   NOT NULL,\n    stt_engine                  text,\n    stt_transcription           text,\n    stt_duration                NUMERIC,\n    intent_type                 text,\n    intent_duration             NUMERIC,\n    fallback_handler_duration   NUMERIC,\n    skill_handler               text,\n    skill_duration              NUMERIC,\n    tts_engine                  text,\n    tts_utterance               text,\n    tts_duration                NUMERIC,\n    speech_playback_duration    NUMERIC,\n    -- user_latency is the time between stt start and speech playback start\n    user_latency                NUMERIC,\n    insert_ts                   TIMESTAMP   NOT NULL\n            DEFAULT current_timestamp,\n    UNIQUE (device_id, core_id)\n)\n"
  },
  {
    "path": "db/mycroft/metric_schema/tables/job.sql",
    "content": "CREATE TABLE metric.job (\n    id          uuid        PRIMARY KEY\n            DEFAULT gen_random_uuid(),\n    job_name    text        NOT NULL,\n    batch_date  date        NOT NULL,\n    start_ts    TIMESTAMP   NOT NULL,\n    end_ts      TIMESTAMP   NOT NULL,\n    command     text        NOT NULL,\n    success     BOOLEAN     NOT NULL,\n    UNIQUE (job_name, start_ts)\n)\n"
  },
  {
    "path": "db/mycroft/metric_schema/tables/stt_engine.sql",
    "content": "CREATE TABLE metric.stt_engine (\n    id                      uuid        PRIMARY KEY DEFAULT gen_random_uuid(),\n    activity_dt             DATE        NOT NULL DEFAULT current_date,\n    engine                  TEXT        NOT NULL,\n    requests                INTEGER     NOT NULL DEFAULT 0,\n    success_rate            NUMERIC     NOT NULL,\n    transcription_speed     NUMERIC     NOT NULL,\n    audio_duration          NUMERIC     NOT NULL DEFAULT 0,\n    transcription_duration  NUMERIC     NOT NULL DEFAULT 0,\n    insert_ts               TIMESTAMP   NOT NULL DEFAULT now()\n);\n\nCREATE UNIQUE INDEX IF NOT EXISTS\n    stt_transcription_engine_activity_idx\nON\n    metric.stt_engine (activity_dt, engine)\n;\n"
  },
  {
    "path": "db/mycroft/metric_schema/tables/stt_transcription.sql",
    "content": "-- The metrics stored on this table provide the ability to reconcile STT transcription activity with a\n-- bill from a service provider.  It can also be used to derive performance metrics by service provider.\n-- To keep our promise of privacy, this data will only be available at the account level for 24 hours, at\n-- which point it will be aggregated and wiped.\nCREATE TABLE metric.stt_transcription (\n    id                      uuid        PRIMARY KEY DEFAULT gen_random_uuid(),\n    account_id              uuid        NOT NULL,\n    engine                  TEXT        NOT NULL,\n    success                 BOOLEAN     NOT NULL,\n    audio_duration          NUMERIC     NOT NULL DEFAULT 0,\n    transcription_duration  NUMERIC     NOT NULL DEFAULT 0,\n    insert_ts               TIMESTAMP   NOT NULL DEFAULT now()\n);\n\nCREATE UNIQUE INDEX IF NOT EXISTS\n    stt_transcription_account_activity_idx\nON\n    metric.stt_transcription (account_id, insert_ts)\n;\n"
  },
  {
    "path": "db/mycroft/skill_schema/create_schema.sql",
    "content": "-- create the schema that will be used to store user data\n-- took out the \"e\" in \"user\" because \"user\" is a Postgres keyword\nCREATE SCHEMA skill;\n"
  },
  {
    "path": "db/mycroft/skill_schema/grants.sql",
    "content": "GRANT USAGE ON SCHEMA skill TO selene;\nGRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA skill TO selene;\n"
  },
  {
    "path": "db/mycroft/skill_schema/tables/display.sql",
    "content": "CREATE TABLE skill.display (\n    id              uuid                PRIMARY KEY\n            DEFAULT gen_random_uuid(),\n    skill_id        uuid                NOT NULL\n            REFERENCES skill.skill,\n    core_version    core_version_enum   NOT NULL,\n    display_data    json                NOT NULL,\n    insert_ts       TIMESTAMP           NOT NULL\n            DEFAULT CURRENT_TIMESTAMP,\n    UNIQUE (skill_id, core_version)\n);\n"
  },
  {
    "path": "db/mycroft/skill_schema/tables/oauth_credential.sql",
    "content": "CREATE TABLE skill.oauth_credential (\n    id                  uuid        PRIMARY KEY DEFAULT gen_random_uuid(),\n    developer_id        uuid        NOT NULL REFERENCES account.account,\n    application_name    text        NOT NULL,\n    oauth_client_id     text        NOT NULL,\n    oauth_client_secret text        NOT NULL,\n    oauth_scope         text        NOT NULL,\n    token_uri           text        NOT NULL,\n    auth_uri            text        NOT NULL,\n    revoke_uri          text        NOT NULL,\n    insert_ts           TIMESTAMP   NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    UNIQUE (developer_id, application_name)\n)\n"
  },
  {
    "path": "db/mycroft/skill_schema/tables/oauth_token.sql",
    "content": "CREATE TABLE skill.oauth_token (\n    id                  uuid        PRIMARY KEY DEFAULT gen_random_uuid(),\n    oauth_credential_id uuid        NOT NULL REFERENCES skill.oauth_credential,\n    account_id          uuid        NOT NULL REFERENCES account.account,\n    token               json        NOT NULL,\n    insert_ts           TIMESTAMP   NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    UNIQUE (oauth_credential_id, account_id)\n)\n"
  },
  {
    "path": "db/mycroft/skill_schema/tables/settings_display.sql",
    "content": "CREATE TABLE skill.settings_display (\n    id                  uuid        PRIMARY KEY\n            DEFAULT gen_random_uuid(),\n    skill_id            uuid        NOT NULL\n            REFERENCES skill.skill ON DELETE CASCADE,\n    settings_display    jsonb       NOT NULL,\n    insert_ts           TIMESTAMP   NOT NULL\n        DEFAULT CURRENT_TIMESTAMP,\n    UNIQUE (skill_id, settings_display)\n);\n"
  },
  {
    "path": "db/mycroft/skill_schema/tables/skill.sql",
    "content": "CREATE TABLE skill.skill (\n    id          uuid        PRIMARY KEY DEFAULT gen_random_uuid(),\n    skill_gid   text        NOT NULL UNIQUE,\n    family_name text        NOT NULL,\n    insert_ts   TIMESTAMP   NOT NULL DEFAULT CURRENT_TIMESTAMP\n);\n"
  },
  {
    "path": "db/mycroft/tagging_schema/create_schema.sql",
    "content": "-- create the schema that will be used to store user data\n-- took out the \"e\" in \"user\" because \"user\" is a Postgres keyword\nCREATE SCHEMA tagging;\n"
  },
  {
    "path": "db/mycroft/tagging_schema/grants.sql",
    "content": "GRANT USAGE ON SCHEMA tagging TO selene;\nGRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA tagging TO selene;\n"
  },
  {
    "path": "db/mycroft/tagging_schema/tables/file_location.sql",
    "content": "-- Servers and directories of files used for training machine learning models.\nCREATE TABLE tagging.file_location (\n    id              uuid        PRIMARY KEY DEFAULT gen_random_uuid(),\n    server          inet        NOT NULL,\n    directory       text        NOT NULL,\n    insert_ts       TIMESTAMP   NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    UNIQUE (server, directory)\n);\n"
  },
  {
    "path": "db/mycroft/tagging_schema/tables/session.sql",
    "content": "-- A period of time a tagger spent tagging files.\nCREATE TABLE tagging.session (\n    id                  uuid        PRIMARY KEY DEFAULT gen_random_uuid(),\n    tagger_id           text        NOT NULL,\n    session_ts_range    tsrange     NOT NULL,\n    note                text,\n    insert_ts           timestamp   NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    EXCLUDE USING gist (tagger_id WITH =, session_ts_range with &&),\n    UNIQUE (tagger_id, session_ts_range)\n)\n"
  },
  {
    "path": "db/mycroft/tagging_schema/tables/tag.sql",
    "content": "-- Represents all the ways data can be classified (i.e. tagged).\nCREATE TABLE tagging.tag (\n    id              uuid        PRIMARY KEY DEFAULT gen_random_uuid(),\n    name            text        NOT NULL UNIQUE,\n    title           text        NOT NULL,\n    instructions    text        NOT NULL,\n    priority        INTEGER     NOT NULL,\n    insert_ts       TIMESTAMP   NOT NULL DEFAULT CURRENT_TIMESTAMP\n);\n"
  },
  {
    "path": "db/mycroft/tagging_schema/tables/tag_value.sql",
    "content": "-- Represents all the ways data can be classified (i.e. tagged).\nCREATE TABLE tagging.tag_value (\n    id              uuid        PRIMARY KEY DEFAULT gen_random_uuid(),\n    tag_id          uuid        NOT NULL REFERENCES tagging.tag,\n    value           text        NOT NULL,\n    display         text        NOT NULL,\n    insert_ts       TIMESTAMP   NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    UNIQUE (tag_id, value)\n);\n"
  },
  {
    "path": "db/mycroft/tagging_schema/tables/tagger.sql",
    "content": "-- Entities that apply tags to files.\nCREATE TABLE tagging.tagger (\n    id              uuid                PRIMARY KEY DEFAULT gen_random_uuid(),\n    entity_type     tagger_type_enum    NOT NULL,\n    entity_id       text                NOT NULL,\n    insert_ts       TIMESTAMP           NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    UNIQUE (entity_type, entity_id)\n);\n"
  },
  {
    "path": "db/mycroft/tagging_schema/tables/wake_word_file.sql",
    "content": "-- Files containing audio data used to train wake word machine learning models.\nCREATE TABLE tagging.wake_word_file (\n    id                  uuid                        PRIMARY KEY DEFAULT gen_random_uuid(),\n    name                text                        NOT NULL UNIQUE,\n    wake_word_id        uuid                        NOT NULL REFERENCES wake_word.wake_word,\n    origin              tagging_file_origin_enum    NOT NULL,\n    submission_date     date                        NOT NULL DEFAULT CURRENT_DATE,\n    file_location_id    uuid                        NOT NULL REFERENCES tagging.file_location,\n    account_id          uuid,\n    status              tagging_file_status_enum    NOT NULL DEFAULT 'uploaded'::tagging_file_status_enum,\n    insert_ts           TIMESTAMP                   NOT NULL DEFAULT CURRENT_TIMESTAMP\n);\n"
  },
  {
    "path": "db/mycroft/tagging_schema/tables/wake_word_file_designation.sql",
    "content": "-- The final agreed upon value of a tag for a file.\nCREATE TABLE tagging.wake_word_file_designation (\n    id                  uuid        PRIMARY KEY DEFAULT gen_random_uuid(),\n    wake_word_file_id   uuid        NOT NULL REFERENCES tagging.wake_word_file,\n    tag_id              uuid        NOT NULL REFERENCES tagging.tag,\n    tag_value_id        uuid        NOT NULL REFERENCES tagging.tag_value,\n    insert_ts           TIMESTAMP   NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    UNIQUE (wake_word_file_id, tag_id)\n);\n"
  },
  {
    "path": "db/mycroft/tagging_schema/tables/wake_word_file_tag.sql",
    "content": "-- Represents all the ways data can be classified (i.e. tagged).\nCREATE TABLE tagging.wake_word_file_tag (\n    id              uuid        PRIMARY KEY DEFAULT gen_random_uuid(),\n    wake_word_file_id   uuid    NOT NULL REFERENCES tagging.wake_word_file,\n    session_id      uuid        NOT NULL REFERENCES tagging.session,\n    tag_id          uuid        NOT NULL REFERENCES tagging.tag,\n    tag_value_id    uuid        NOT NULL REFERENCES tagging.tag_value,\n    insert_ts       TIMESTAMP   NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    UNIQUE (wake_word_file_id, session_id, tag_id)\n);\n"
  },
  {
    "path": "db/mycroft/types/agreement_enum.sql",
    "content": "CREATE TYPE agreement_enum AS ENUM (\n    'Privacy Policy',\n    'Terms of Use',\n    'Open Dataset'\n);\n"
  },
  {
    "path": "db/mycroft/types/cateogory_enum.sql",
    "content": "CREATE TYPE category_enum AS ENUM (\n    'Daily',\n    'Configuration',\n    'Entertainment',\n    'Information',\n    'IoT',\n    'Music and Audio',\n    'Media',\n    'Productivity',\n    'Transport'\n);"
  },
  {
    "path": "db/mycroft/types/core_version_enum.sql",
    "content": "CREATE TYPE core_version_enum AS ENUM ('18.08', '19.02', '19.08',  '20.02', '20.08', '21.02', '21.08');\n"
  },
  {
    "path": "db/mycroft/types/date_format_enum.sql",
    "content": "CREATE TYPE date_format_enum AS ENUM ('DD/MM/YYYY', 'MM/DD/YYYY');"
  },
  {
    "path": "db/mycroft/types/measurement_system_enum.sql",
    "content": "CREATE TYPE measurement_system_enum AS ENUM ('Imperial', 'Metric');"
  },
  {
    "path": "db/mycroft/types/membership_type_enum.sql",
    "content": "CREATE TYPE membership_type_enum AS ENUM ('Monthly Membership', 'Yearly Membership');\n"
  },
  {
    "path": "db/mycroft/types/payment_method_enum.sql",
    "content": "CREATE TYPE payment_method_enum AS ENUM ('Stripe', 'PayPal');\n"
  },
  {
    "path": "db/mycroft/types/tagger_type_enum.sql",
    "content": "-- How a tagging file is originated.  Used as column type on the tagging.file table\nCREATE TYPE tagger_type_enum AS ENUM ('account', 'automated');\n"
  },
  {
    "path": "db/mycroft/types/tagging_file_origin_enum.sql",
    "content": "-- How a tagging file is originated.  Used as column type on the tagging.file table\nCREATE TYPE tagging_file_origin_enum AS ENUM ('mycroft', 'selene', 'manual');\n"
  },
  {
    "path": "db/mycroft/types/tagging_file_status_enum.sql",
    "content": "CREATE TYPE tagging_file_status_enum AS ENUM (\n    'uploaded',\n    'stored',\n    'pending delete',\n    'deleted'\n);\n"
  },
  {
    "path": "db/mycroft/types/time_format_enum.sql",
    "content": "CREATE TYPE time_format_enum AS ENUM ('12 Hour', '24 Hour');"
  },
  {
    "path": "db/mycroft/types/tts_engine_enum.sql",
    "content": "CREATE TYPE tts_engine_enum AS ENUM ('google', 'mimic');"
  },
  {
    "path": "db/mycroft/versions/2020.9.1.sql",
    "content": "-- This set of schema changes is for the wake word collection project.\n--\n-- First, Create the wake word schema and the tables it will contain.  The device.wake_word\n-- and device.wake_word_settings tables are being moved to this schema.  A new table to\n-- track Precise models is also included.\n--\n-- The new wake word table is now a domain table of all possible wake words.  The account ID\n-- is moved from the wake_word level to the pocketsphinx settings level to accommodate this.\nCREATE SCHEMA wake_word;\nCREATE TABLE wake_word.wake_word (\n    id              uuid        PRIMARY KEY DEFAULT gen_random_uuid(),\n    name            text        NOT NULL,\n    engine          text        NOT NULL,\n    insert_ts       TIMESTAMP   NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    UNIQUE (name, engine)\n);\nCREATE TABLE wake_word.pocketsphinx_settings (\n    id                      uuid        PRIMARY KEY DEFAULT gen_random_uuid(),\n    wake_word_id            uuid        NOT NULL REFERENCES wake_word.wake_word ON DELETE CASCADE,\n    account_id              uuid        REFERENCES account.account ON DELETE CASCADE,\n    sample_rate             INTEGER,\n    channels                INTEGER,\n    pronunciation           text,\n    threshold               text,\n    threshold_multiplier    NUMERIC,\n    dynamic_energy_ratio    NUMERIC,\n    insert_ts               TIMESTAMP   NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    UNIQUE (wake_word_id, account_id)\n);\nGRANT USAGE ON SCHEMA wake_word TO selene;\nGRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA wake_word TO selene;\n\n-- Populate the new wake_word.wake_word table from the existing version.\n-- Some wake words on the existing table are preceded by a space.  The TRIM\n-- function will remove that space before adding it to the new table.\nINSERT INTO\n    wake_word.wake_word (name, engine)\nVALUES\n    ('hey mycroft', 'precise')\n;\nINSERT INTO\n    wake_word.wake_word (name, engine)\n    (\n        SELECT\n            DISTINCT TRIM(LEADING FROM setting_name),\n            'pocketsphinx'\n        FROM\n            device.wake_word\n    )\n;\n\n-- Populate the new wake_word.pocketsphinx_settings table from the device.wake_word_settings\n-- table.  Account ID is populated from the wake_word.wake_word table because it is\n-- now a domain table.\nINSERT INTO\n    wake_word.pocketsphinx_settings (\n        wake_word_id,\n        account_id,\n        sample_rate,\n        channels,\n        pronunciation,\n        threshold,\n        threshold_multiplier,\n        dynamic_energy_ratio)\n    (\n        SELECT\n            ww.id,\n            dww.account_id,\n            wws.sample_rate,\n            wws.channels,\n            wws.pronunciation,\n            wws.threshold,\n            wws.threshold_multiplier,\n            wws.dynamic_energy_ratio\n        FROM\n            device.wake_word dww\n            INNER JOIN device.wake_word_settings wws ON dww.id = wws.wake_word_id\n            INNER JOIN wake_word.wake_word as ww ON TRIM(LEADING FROM dww.setting_name) = ww.name\n    )\n;\n\n-- Update the device.device table to reference the new wake word IDs.\n--      * Drop the existing constraint\n--      * Update the IDs\n--      * Add new constraint to wake_word.wake_word table.\nALTER TABLE device.device DROP CONSTRAINT device_wake_word_id_fkey;\nUPDATE\n    device.device\nSET\n    wake_word_id = subquery.wake_word_id\nFROM\n    (\n        SELECT\n            d.id as device_id,\n            ww.id as wake_word_id\n        FROM\n            device.device d\n            INNER JOIN device.wake_word as dww ON d.wake_word_id = dww.id\n            INNER JOIN wake_word.wake_word as ww ON TRIM(LEADING FROM dww.setting_name) = ww.name\n    ) AS subquery\nWHERE\n    device.device.id = subquery.device_id\n;\nALTER TABLE device.device ADD CONSTRAINT device_wake_word_id_fkey\n    FOREIGN KEY (wake_word_id) REFERENCES wake_word.wake_word (id);\n\n-- Update the device.device table to reference the new wake word IDs.\n--      * Drop the existing constraint\n--      * Update the IDs\n--      * Add new constraint to wake_word.wake_word table.ALTER TABLE device.device DROP CONSTRAINT device_wake_word_id_fkey;\nALTER TABLE device.account_defaults DROP CONSTRAINT account_defaults_wake_word_id_fkey;\nUPDATE\n    device.account_defaults\nSET\n    wake_word_id = subquery.wake_word_id\nFROM\n    (\n        SELECT\n            ad.id as account_default_id,\n            ww.id as wake_word_id\n        FROM\n            device.account_defaults ad\n            INNER JOIN device.wake_word as dww ON ad.wake_word_id = dww.id\n            INNER JOIN wake_word.wake_word as ww ON TRIM(LEADING FROM dww.setting_name) = ww.name\n    ) AS subquery\nWHERE\n    device.account_defaults.id = subquery.account_default_id\n;\nALTER TABLE device.account_defaults ADD CONSTRAINT account_defaults_wake_word_id_fkey\n    FOREIGN KEY (wake_word_id) REFERENCES wake_word.wake_word (id);\n\n-- With all the wake word data moved to the new schema, drop the existing tables.\nDROP TABLE device.wake_word_settings;\nDROP TABLE device.wake_word;\n\n-- Create the tagging schema\nCREATE SCHEMA tagging;\nCREATE TYPE tagging_file_origin_enum AS ENUM ('mycroft', 'selene', 'manual');\nCREATE TYPE tagging_file_status_enum AS ENUM (\n    'uploaded',\n    'stored',\n    'pending delete',\n    'deleted'\n);\nCREATE TABLE tagging.file_location (\n    id              uuid        PRIMARY KEY DEFAULT gen_random_uuid(),\n    server          inet        NOT NULL,\n    directory       text        NOT NULL,\n    insert_ts       TIMESTAMP   NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    UNIQUE (server, directory)\n);\nCREATE TABLE tagging.wake_word_file (\n    id                  uuid                        PRIMARY KEY DEFAULT gen_random_uuid(),\n    name                text                        NOT NULL UNIQUE,\n    wake_word_id        uuid                        NOT NULL REFERENCES wake_word.wake_word,\n    origin              tagging_file_origin_enum    NOT NULL,\n    submission_date     date                        NOT NULL DEFAULT CURRENT_DATE,\n    file_location_id    uuid                        NOT NULL REFERENCES tagging.file_location,\n    account_id          uuid,\n    status              tagging_file_status_enum    NOT NULL DEFAULT 'uploaded'::tagging_file_status_enum,\n    insert_ts           TIMESTAMP                   NOT NULL DEFAULT CURRENT_TIMESTAMP\n);\nCREATE TABLE tagging.tagger (\n    id              uuid                PRIMARY KEY DEFAULT gen_random_uuid(),\n    entity_type     tagger_type_enum    NOT NULL,\n    entity_id       text                NOT NULL,\n    insert_ts       TIMESTAMP           NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    UNIQUE (entity_type, entity_id)\n);\nCREATE TABLE tagging.session (\n    id                  uuid        PRIMARY KEY DEFAULT gen_random_uuid(),\n    tagger_id           text        NOT NULL,\n    session_ts_range    tsrange     NOT NULL,\n    note                text,\n    insert_ts           timestamp   NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    EXCLUDE USING gist (tagger_id WITH =, session_ts_range with &&),\n    UNIQUE (tagger_id, session_ts_range)\n);\nCREATE TABLE tagging.tag (\n    id              uuid        PRIMARY KEY DEFAULT gen_random_uuid(),\n    name            text        NOT NULL UNIQUE,\n    title           text        NOT NULL,\n    instructions    text        NOT NULL,\n    insert_ts       TIMESTAMP   NOT NULL DEFAULT CURRENT_TIMESTAMP\n);\nCREATE TABLE tagging.tag_value (\n    id              uuid        PRIMARY KEY DEFAULT gen_random_uuid(),\n    tag_id          uuid        NOT NULL REFERENCES tagging.tag,\n    value           text        NOT NULL,\n    display         text        NOT NULL,\n    insert_ts       TIMESTAMP   NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    UNIQUE (tag_id, value)\n);\nCREATE TABLE tagging.wake_word_file_tag (\n    id              uuid        PRIMARY KEY DEFAULT gen_random_uuid(),\n    wake_word_file_id   uuid    NOT NULL REFERENCES tagging.wake_word_file,\n    session_id      uuid        NOT NULL REFERENCES tagging.session,\n    tag_id          uuid        NOT NULL REFERENCES tagging.tag,\n    tag_value_id    uuid        NOT NULL REFERENCES tagging.tag_value,\n    insert_ts       TIMESTAMP   NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    UNIQUE (wake_word_file_id, session_id, tag_id)\n);\nCREATE TABLE tagging.wake_word_file_designation (\n    id                  uuid        PRIMARY KEY DEFAULT gen_random_uuid(),\n    wake_word_file_id   uuid        NOT NULL REFERENCES tagging.wake_word_file,\n    tag_id              uuid        NOT NULL REFERENCES tagging.tag,\n    tag_value_id        uuid        NOT NULL REFERENCES tagging.tag_value,\n    insert_ts           TIMESTAMP   NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    UNIQUE (wake_word_file_id, tag_id)\n);\n\n-- Give the selene user access to the schema and its tables\nGRANT USAGE ON SCHEMA tagging TO selene;\nGRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA tagging TO selene;\n\n--  Populate the static data in tagging.tag and tagging.tag_values\nINSERT INTO\n    tagging.tag (name, title, instructions, priority)\nVALUES\n    (\n        'speaking',\n        'Do you hear someone',\n        'Indicate whether or not you hear someone talking in this sample or if all you hear is noise '\n        || '(static, machinery, etc.) or silence.',\n        1\n    ),\n    (\n        'wake word',\n        'Do you hear the wake word',\n        'Indicate if you did not hear the wake word (no); heard a partial wake word or something '\n         || 'that sounds similar to the wake word (no, but similar); heard multiple occurrences of '\n         || 'the wake word (yes, multiple); or heard a single occurrence of the full wake word (yes, '\n         || 'once).  Using the \"Hey Mycroft\" wake word as an example, answer \"no, but similar\" if you '\n         || 'hear something like \"Mycroft\", \"Microsoft\", \"Hey Minecraft\" or \"Hey Mike Ross\".',\n        2\n    ),\n    (\n        'gender',\n        'What is the perceived',\n        'For the speaker in this audio clip choose which category best describes the pitch and timbre.',\n        3\n    ),\n    (\n        'age',\n        'What is the perceived',\n        'For the speaker in this audio clip choose which category best describes the their age.',\n        4\n    ),\n    (\n        'background noise',\n        'Do you hear any',\n        'Besides the voice of the speaker, can you hear any background noise (e.g. fan, appliance, '\n        || 'vehicle, television or radio)?',\n        5\n    )\n;\nINSERT INTO\n    tagging.tag_value (tag_id, value, display)\nVALUES\n    ((SELECT id FROM tagging.tag WHERE tag.name = 'speaking'), 'no', 'NO, JUST NOISE'),\n    ((SELECT id FROM tagging.tag WHERE tag.name = 'speaking'), 'yes', 'YES, SPEAKING'),\n    ((SELECT id FROM tagging.tag WHERE tag.name = 'wake_word'), 'no', 'NO'),\n    ((SELECT id FROM tagging.tag WHERE tag.name = 'wake_word'), 'similar', 'NO, BUT SIMILAR'),\n    ((SELECT id FROM tagging.tag WHERE tag.name = 'wake_word'), 'multiple', 'YES, MULTIPLE'),\n    ((SELECT id FROM tagging.tag WHERE tag.name = 'wake_word'), 'yes', 'YES, SINGLE'),\n    ((SELECT id FROM tagging.tag WHERE name = 'gender'), 'male', 'MASCULINE'),\n    ((SELECT id FROM tagging.tag WHERE name = 'gender'), 'neutral', 'NEUTRAL'),\n    ((SELECT id FROM tagging.tag WHERE name = 'gender'), 'female', 'FEMININE'),\n    ((SELECT id FROM tagging.tag WHERE name = 'age'), 'child', 'CHILD'),\n    ((SELECT id FROM tagging.tag WHERE name = 'age'), 'unsure', 'UNSURE'),\n    ((SELECT id FROM tagging.tag WHERE name = 'age'), 'adult', 'ADULT'),\n    ((SELECT id FROM tagging.tag WHERE name = 'background noise'), 'no', 'NO NOISE'),\n    ((SELECT id FROM tagging.tag WHERE name = 'background noise'), 'some', 'SOME NOISE'),\n    ((SELECT id FROM tagging.tag WHERE name = 'background noise'), 'yes', 'ADULT')\n;\n"
  },
  {
    "path": "db/mycroft/wake_word_schema/create_schema.sql",
    "content": "-- create the schema that will be used to store user data\n-- took out the \"e\" in \"user\" because \"user\" is a Postgres keyword\nCREATE SCHEMA wake_word;\n"
  },
  {
    "path": "db/mycroft/wake_word_schema/grants.sql",
    "content": "GRANT USAGE ON SCHEMA wake_word TO selene;\nGRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA wake_word TO selene;\n"
  },
  {
    "path": "db/mycroft/wake_word_schema/tables/pocketsphinx_settings.sql",
    "content": "-- The Pocketsphinx wake word settings in use by each account.\nCREATE TABLE wake_word.pocketsphinx_settings (\n    id                      uuid        PRIMARY KEY DEFAULT gen_random_uuid(),\n    wake_word_id            uuid        REFERENCES wake_word.wake_word ON DELETE CASCADE,\n    account_id              uuid        REFERENCES account.account ON DELETE CASCADE,\n    sample_rate             INTEGER,\n    channels                INTEGER,\n    pronunciation           text,\n    threshold               text,\n    threshold_multiplier    NUMERIC,\n    dynamic_energy_ratio    NUMERIC,\n    insert_ts               TIMESTAMP   NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    UNIQUE (wake_word_id, account_id)\n);\n"
  },
  {
    "path": "db/mycroft/wake_word_schema/tables/wake_word.sql",
    "content": "-- Domain table for all known wake words currently in use or previously used.\nCREATE TABLE wake_word.wake_word (\n    id              uuid        PRIMARY KEY DEFAULT gen_random_uuid(),\n    name            text        NOT NULL,\n    engine          text        NOT NULL,\n    insert_ts       TIMESTAMP   NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    UNIQUE (name, engine)\n);\n"
  },
  {
    "path": "db/pyproject.toml",
    "content": "[tool.poetry]\nname = \"db\"\nversion = \"0.1.0\"\ndescription = \"Database definition and related scripts\"\nauthors = [\"Chris Veilleux <veilleux.chris@gmail.com>\"]\nlicense = \"GNU AGPL 3.0\"\n\n[tool.poetry.dependencies]\npython = \"^3.9\"\npsycopg2-binary = \"*\"\nMarkdown = \"^3.4.1\"\n\n[tool.poetry.dev-dependencies]\nipython = \"==5\"\npylint = \"*\"\n\n[build-system]\nrequires = [\"poetry-core>=1.0.0\"]\nbuild-backend = \"poetry.core.masonry.api\"\n"
  },
  {
    "path": "db/scripts/__init__.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\n"
  },
  {
    "path": "db/scripts/bootstrap_mycroft_db.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\nimport os\nfrom argparse import ArgumentParser\nfrom datetime import date\nfrom glob import glob\nfrom io import BytesIO\nfrom os import environ, path, remove\nfrom urllib import request\nfrom zipfile import ZipFile\n\nfrom markdown import markdown\nfrom psycopg2 import connect\nfrom psycopg2.extras import DateRange\n\nMYCROFT_DB_DIR = environ.get(\"DB_DIR\", \"/opt/selene/selene-backend/db/mycroft\")\nMYCROFT_DB_NAME = environ.get(\"DB_NAME\", \"mycroft\")\nSCHEMAS = (\"account\", \"skill\", \"device\", \"geography\", \"metric\", \"tagging\", \"wake_word\")\nDB_DESTROY_FILES = (\"drop_mycroft_db.sql\", \"drop_template_db.sql\", \"drop_roles.sql\")\nDB_CREATE_FILES = (\n    \"create_roles.sql\",\n    \"create_template_db.sql\",\n)\nACCOUNT_TABLE_ORDER = (\n    \"account\",\n    \"agreement\",\n    \"account_agreement\",\n    \"membership\",\n    \"account_membership\",\n)\nSKILL_TABLE_ORDER = (\n    \"skill\",\n    \"settings_display\",\n    \"display\",\n    \"oauth_credential\",\n    \"oauth_token\",\n)\nDEVICE_TABLE_ORDER = (\n    \"category\",\n    \"geography\",\n    \"text_to_speech\",\n    \"account_preferences\",\n    \"account_defaults\",\n    \"device\",\n    \"device_skill\",\n    \"pantacor_config\",\n)\nGEOGRAPHY_TABLE_ORDER = (\"country\", \"timezone\", \"region\", \"city\")\nMETRIC_TABLE_ORDER = (\n    \"api\",\n    \"api_history\",\n    \"job\",\n    \"core\",\n    \"account_activity\",\n    \"stt_transcription\",\n    \"stt_engine\",\n)\nTAGGING_TABLE_ORDER = (\n    \"file_location\",\n    \"wake_word_file\",\n    \"tag\",\n    \"tag_value\",\n    \"tagger\",\n    \"session\",\n    \"wake_word_file_tag\",\n    \"wake_word_file_designation\",\n)\nWAKE_WORD_TABLE_ORDER = (\"wake_word\", \"pocketsphinx_settings\")\n\nschema_directory = \"{}_schema\"\n\n\ndef get_sql_from_file(file_path: str) -> str:\n    with open(path.join(MYCROFT_DB_DIR, file_path)) as sql_file:\n        sql = sql_file.read()\n\n    return sql\n\n\nclass PostgresDB(object):\n    def __init__(self, db_name, user=None):\n        db_host = environ.get(\"DB_HOST\", \"127.0.0.1\")\n        db_port = environ.get(\"DB_PORT\", 5432)\n        db_ssl_mode = environ.get(\"DB_SSLMODE\")\n        if db_name in (\"postgres\", \"defaultdb\", \"mycroft_template\"):\n            db_user = environ.get(\"POSTGRES_USER\", \"postgres\")\n            db_password = environ.get(\"POSTGRES_PASSWORD\")\n        else:\n            db_user = environ.get(\"DB_USER\", \"selene\")\n            db_password = environ[\"DB_PASSWORD\"]\n\n        if user is not None:\n            db_user = user\n\n        self.db = connect(\n            dbname=db_name,\n            user=db_user,\n            password=db_password,\n            host=db_host,\n            port=db_port,\n            sslmode=db_ssl_mode,\n        )\n        self.db.autocommit = True\n        self.db.set_client_encoding(\"UTF8\")\n\n    def close_db(self):\n        self.db.close()\n\n    def execute_sql(self, sql: str, args=None):\n        _cursor = self.db.cursor()\n        _cursor.execute(sql, args)\n        return _cursor\n\n\ndef destroy_existing(db):\n    print(\"Destroying any objects we will be creating later.\")\n    for db_destroy_file in DB_DESTROY_FILES:\n        db.execute_sql(get_sql_from_file(db_destroy_file))\n\n\ndef create_anew(db):\n    print(\"Creating the mycroft database\")\n    for db_setup_file in DB_CREATE_FILES:\n        db.execute_sql(get_sql_from_file(db_setup_file))\n\n\ndef _init_db():\n    postgres_db = PostgresDB(db_name=\"postgres\")\n    destroy_existing(postgres_db)\n    create_anew(postgres_db)\n    postgres_db.close_db()\n\n\ndef _setup_template_db(db):\n    print(\"Creating the extensions\")\n    db.execute_sql(get_sql_from_file(path.join(\"create_extensions.sql\")))\n    print(\"Creating user-defined data types\")\n    type_directory = path.join(MYCROFT_DB_DIR, \"types\")\n    for type_file in glob(type_directory + \"/*.sql\"):\n        db.execute_sql(get_sql_from_file(path.join(type_directory, type_file)))\n    print(\"Create the schemas and grant access\")\n    for schema in SCHEMAS:\n        db.execute_sql(get_sql_from_file(schema + \"_schema/create_schema.sql\"))\n\n\ndef _build_schema_tables(db, schema, tables):\n    print(f\"Creating the {schema} schema tables\")\n    for table in tables:\n        create_table_file = path.join(schema + \"_schema\", \"tables\", table + \".sql\")\n        db.execute_sql(get_sql_from_file(create_table_file))\n\n\ndef _grant_access(db):\n    print(\"Granting access to schemas and tables\")\n    for schema in SCHEMAS:\n        db.execute_sql(get_sql_from_file(schema + \"_schema/grants.sql\"))\n\n\ndef _build_template_db():\n    template_db = PostgresDB(db_name=\"mycroft_template\")\n    _setup_template_db(template_db)\n    _build_schema_tables(template_db, \"account\", ACCOUNT_TABLE_ORDER)\n    _build_schema_tables(template_db, \"skill\", SKILL_TABLE_ORDER)\n    _build_schema_tables(template_db, \"geography\", GEOGRAPHY_TABLE_ORDER)\n    _build_schema_tables(template_db, \"wake_word\", WAKE_WORD_TABLE_ORDER)\n    _build_schema_tables(template_db, \"device\", DEVICE_TABLE_ORDER)\n    _build_schema_tables(template_db, \"tagging\", TAGGING_TABLE_ORDER)\n    _build_schema_tables(template_db, \"metric\", METRIC_TABLE_ORDER)\n    _grant_access(template_db)\n    template_db.close_db()\n\n\ndef _create_mycroft_db_from_template():\n    print(\"Copying template to new database.\")\n    db = PostgresDB(db_name=\"postgres\")\n    db.execute_sql(get_sql_from_file(\"create_mycroft_db.sql\"))\n    db.close_db()\n\n\ndef _apply_insert_file(db, schema_dir, file_name):\n    insert_file_path = path.join(schema_dir, \"data\", file_name)\n    try:\n        db.execute_sql(get_sql_from_file(insert_file_path))\n    except FileNotFoundError:\n        pass\n\n\ndef _populate_agreement_table(db):\n    print(\"Populating account.agreement table\")\n    db.db.autocommit = False\n    insert_sql = \"insert into account.agreement VALUES (default, %s, '1', %s, %s)\"\n    privacy_policy_path = path.join(\n        environ.get(\"MYCROFT_DOC_DIR\", \"/opt/mycroft/devops/agreements\"),\n        \"privacy_policy.md\",\n    )\n    terms_of_use_path = path.join(\n        environ.get(\"MYCROFT_DOC_DIR\", \"/opt/mycroft/devops/agreements\"),\n        \"terms_of_use.md\",\n    )\n    docs = {\"Privacy Policy\": privacy_policy_path, \"Terms of Use\": terms_of_use_path}\n    agreement_date_range = DateRange(lower=date(2000, 1, 1), bounds=\"[]\")\n    for agreement_type, doc_path in docs.items():\n        try:\n            lobj = db.db.lobject(0, \"b\")\n            with open(doc_path) as doc:\n                doc_html = markdown(doc.read(), output_format=\"html5\")\n                lobj.write(doc_html)\n            db.execute_sql(\n                insert_sql, args=(agreement_type, agreement_date_range, lobj.oid)\n            )\n            db.execute_sql(f\"grant select on large object {lobj.oid} to selene\")\n        except FileNotFoundError:\n            print(\n                f\"WARNING: File {doc_path} was not found. \"\n                f\"The {agreement_type} agreement was not added.\"\n            )\n            db.db.rollback()\n        except:\n            db.db.rollback()\n            raise\n        else:\n            db.db.commit()\n\n    db.db.autocommit = True\n    db.execute_sql(insert_sql, args=(\"Open Dataset\", agreement_date_range, None))\n\n\ndef _populate_country_table(db):\n    print(\"Populating geography.country table\")\n    country_insert = \"\"\"\n    INSERT INTO\n        geography.country (iso_code, name)\n    VALUES\n        ('{iso_code}', '{country_name}')\n    \"\"\"\n    country_url = \"http://download.geonames.org/export/dump/countryInfo.txt\"\n    with request.urlopen(country_url) as country_file:\n        for rec in country_file.readlines():\n            if rec.startswith(b\"#\"):\n                continue\n            country_fields = rec.decode().split(\"\\t\")\n            insert_args = dict(\n                iso_code=country_fields[0], country_name=country_fields[4]\n            )\n            db.execute_sql(country_insert.format(**insert_args))\n\n\ndef _populate_region_table(db):\n    print(\"Populating geography.region table\")\n    region_insert = \"\"\"\n    INSERT INTO\n        geography.region (country_id, region_code, name)\n    VALUES\n        (\n            (SELECT id FROM geography.country WHERE iso_code = %(iso_code)s),\n            %(region_code)s,\n            %(region_name)s\n        )\n    \"\"\"\n    region_url = \"http://download.geonames.org/export/dump/admin1CodesASCII.txt\"\n    with request.urlopen(region_url) as region_file:\n        for region in region_file.readlines():\n            region_fields = region.decode().split(\"\\t\")\n            country_iso_code = region_fields[0][:2]\n            insert_args = dict(\n                iso_code=country_iso_code,\n                region_code=region_fields[0],\n                region_name=region_fields[1],\n            )\n            db.execute_sql(region_insert, insert_args)\n\n\ndef _populate_timezone_table(db):\n    print(\"Populating geography.timezone table\")\n    timezone_insert = \"\"\"\n    INSERT INTO\n        geography.timezone (country_id, name, gmt_offset, dst_offset)\n    VALUES\n        (\n            (SELECT id FROM geography.country WHERE iso_code = %(iso_code)s),\n            %(timezone_name)s,\n            %(gmt_offset)s,\n            %(dst_offset)s\n        )\n    \"\"\"\n    timezone_url = \"http://download.geonames.org/export/dump/timeZones.txt\"\n    with request.urlopen(timezone_url) as timezone_file:\n        timezone_file.readline()\n        for timezone in timezone_file.readlines():\n            timezone_fields = timezone.decode().split(\"\\t\")\n            insert_args = dict(\n                iso_code=timezone_fields[0],\n                timezone_name=timezone_fields[1],\n                gmt_offset=timezone_fields[2],\n                dst_offset=timezone_fields[3],\n            )\n            db.execute_sql(timezone_insert, insert_args)\n\n\ndef _populate_city_table(db, continuous_integration):\n    print(\"Populating geography.city table\")\n    region_query = \"SELECT id, region_code FROM geography.region\"\n    query_result = db.execute_sql(region_query)\n    region_lookup = dict()\n    for row in query_result.fetchall():\n        region_lookup[row[1]] = row[0]\n\n    timezone_query = \"SELECT id, name FROM geography.timezone\"\n    query_result = db.execute_sql(timezone_query)\n    timezone_lookup = dict()\n    for row in query_result.fetchall():\n        timezone_lookup[row[1]] = row[0]\n    cities_download = request.urlopen(\n        \"http://download.geonames.org/export/dump/cities500.zip\"\n    )\n    city_dump_path = path.join(\n        environ.get(\"MYCROFT_DATA_DIR\", \"/tmp/selene\"), \"city.dump\"\n    )\n    with ZipFile(BytesIO(cities_download.read())) as cities_zip:\n        with cities_zip.open(\"cities500.txt\") as cities:\n            with open(city_dump_path, \"w\") as dump_file:\n                for city in cities.readlines():\n                    city_fields = city.decode().split(\"\\t\")\n                    city_region = city_fields[8] + \".\" + city_fields[10]\n                    region_id = region_lookup.get(city_region)\n                    timezone_id = timezone_lookup.get(city_fields[17])\n                    if region_id is not None and timezone_id is not None:\n                        dump_file.write(\n                            \"\\t\".join(\n                                [\n                                    region_id,\n                                    timezone_id,\n                                    city_fields[1],\n                                    city_fields[4],\n                                    city_fields[5],\n                                    city_fields[14],\n                                ]\n                            )\n                            + \"\\n\"\n                        )\n    if continuous_integration:\n        os.chmod(city_dump_path, 0o666)\n        os.chown(city_dump_path, 999, 996)\n    city_copy = (\n        f\"COPY geography.city (region_id, timezone_id, name, latitude, longitude, \"\n        f\"population) FROM '{city_dump_path}'\"\n    )\n    db.execute_sql(city_copy)\n    remove(city_dump_path)\n\n\ndef _populate_db(continuous_integration):\n    mycroft_db = PostgresDB(db_name=MYCROFT_DB_NAME)\n    _apply_insert_file(\n        mycroft_db, schema_dir=\"account_schema\", file_name=\"membership.sql\"\n    )\n    _apply_insert_file(\n        mycroft_db, schema_dir=\"device_schema\", file_name=\"text_to_speech.sql\"\n    )\n    _populate_agreement_table(mycroft_db)\n    _populate_country_table(mycroft_db)\n    _populate_region_table(mycroft_db)\n    _populate_timezone_table(mycroft_db)\n    _populate_city_table(mycroft_db, continuous_integration)\n    mycroft_db.close_db()\n\n\ndef _define_args():\n    argument_parser = ArgumentParser()\n    argument_parser.add_argument(\n        \"--ci\",\n        help=\"Run in a continuous integration environment\",\n        action=\"store_true\",\n        default=False,\n    )\n    script_args = argument_parser.parse_args()\n\n    return script_args\n\n\nif __name__ == \"__main__\":\n    args = _define_args()\n    _init_db()\n    _build_template_db()\n    _create_mycroft_db_from_template()\n    _populate_db(args.ci)\n"
  },
  {
    "path": "db/scripts/neo4j-postgres.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nimport csv\nimport datetime\nimport json\nimport uuid\nfrom collections import defaultdict\n\nimport time\nfrom geopy.distance import distance\nfrom psycopg2 import connect\nfrom psycopg2.extras import execute_batch\n\nusers = {}\nuser_settings = {}\nsubscription = {}\ndevices = {}\nuser_devices = {}\nskills = {}\nskill_sections = {}\nskill_fields = {}\nskill_field_values = {}\ndevice_to_skill = {}\nskill_to_section = {}\nsection_to_field = {}\ndevice_to_field = {}\nlocations = {}\ntimezones = {}\ncities = {}\nregions = {}\ncountries = {}\ncoordinates = {}\ndevice_location = {}\nhey_mycroft = str(uuid.uuid4())\nchristopher = str(uuid.uuid4())\nezra = str(uuid.uuid4())\njarvis = str(uuid.uuid4())\n\ndefault_wake_words = {\n    \"hey mycroft\": hey_mycroft,\n    \"christopher\": christopher,\n    \"hey ezra\": ezra,\n    \"hey jarvis\": jarvis,\n}\n\n\ndef load_csv():\n    with open(\"users.csv\") as user_csv:\n        user_reader = csv.reader(user_csv)\n        next(user_reader, None)\n        for row in user_reader:\n            # email, password\n            users[row[0]] = {}\n            users[row[0]][\"email\"] = row[1]\n            users[row[0]][\"password\"] = row[2]\n            users[row[0]][\"terms\"] = row[3]\n            users[row[0]][\"privacy\"] = row[4]\n\n    with open(\"user_settings.csv\") as user_setting_csv:\n        user_setting_reader = csv.reader(user_setting_csv)\n        next(user_setting_reader, None)\n        for row in user_setting_reader:\n            user_settings[row[0]] = {}\n            user_settings[row[0]][\"date_format\"] = row[1]\n            user_settings[row[0]][\"time_format\"] = row[2]\n            user_settings[row[0]][\"measurement_system\"] = row[3]\n            user_settings[row[0]][\"tts_type\"] = row[4]\n            user_settings[row[0]][\"tts_voice\"] = row[5]\n            user_settings[row[0]][\"wake_word\"] = row[6]\n            user_settings[row[0]][\"sample_rate\"] = row[7]\n            user_settings[row[0]][\"channels\"] = row[8]\n            user_settings[row[0]][\"pronunciation\"] = row[9]\n            user_settings[row[0]][\"threshold\"] = row[10]\n            user_settings[row[0]][\"threshold_multiplier\"] = row[11]\n            user_settings[row[0]][\"dynamic_energy_ratio\"] = row[12]\n\n    with open(\"subscription.csv\") as subscription_csv:\n        subscription_reader = csv.reader(subscription_csv)\n        next(subscription_reader, None)\n        for row in subscription_reader:\n            subscription[row[0]] = {}\n            subscription[row[0]][\"stripe_customer_id\"] = row[1]\n            subscription[row[0]][\"last_payment_ts\"] = row[2]\n            subscription[row[0]][\"type\"] = row[3]\n\n    with open(\"devices.csv\") as devices_csv:\n        devices_reader = csv.reader(devices_csv)\n        next(devices_reader, None)\n        for row in devices_reader:\n            devices[row[0]] = {}\n            user_uuid = row[1]\n            devices[row[0]][\"user_uuid\"] = row[1]\n            devices[row[0]][\"name\"] = row[2]\n            devices[row[0]][\"description\"] = (row[3],)\n            devices[row[0]][\"platform\"] = (row[4],)\n            devices[row[0]][\"enclosure_version\"] = row[5]\n            devices[row[0]][\"core_version\"] = row[6]\n\n            if user_uuid in user_devices:\n                user_devices[user_uuid].append((row[0], row[2]))\n            else:\n                user_devices[user_uuid] = [(row[0], row[2])]\n\n    with open(\"skill.csv\") as skill_csv:\n        skill_reader = csv.reader(skill_csv)\n        next(skill_reader, None)\n        for row in skill_reader:\n            skill = row[0]\n            skills[skill] = {}\n            dev_uuid = row[1]\n            skills[skill][\"device_uuid\"] = dev_uuid\n            skills[skill][\"name\"] = row[2]\n            skills[skill][\"description\"] = row[3]\n            skills[skill][\"identifier\"] = row[4]\n            if dev_uuid in device_to_skill:\n                device_to_skill[dev_uuid].add(skill)\n            else:\n                device_to_skill[dev_uuid] = {skill}\n\n    with open(\"skill_section.csv\") as skill_section_csv:\n        skill_section_reader = csv.reader(skill_section_csv)\n        next(skill_section_reader, None)\n        for row in skill_section_reader:\n            section_uuid = row[0]\n            skill_sections[section_uuid] = {}\n            skill_uuid = row[1]\n            skill_sections[section_uuid][\"skill_uuid\"] = skill_uuid\n            skill_sections[section_uuid][\"section\"] = row[2]\n            skill_sections[section_uuid][\"display_order\"] = row[3]\n            if skill_uuid in skill_to_section:\n                skill_to_section[skill_uuid].add(section_uuid)\n            else:\n                skill_to_section[skill_uuid] = {section_uuid}\n\n    with open(\"skill_fields.csv\") as skill_fields_csv:\n        skill_fields_reader = csv.reader(skill_fields_csv)\n        next(skill_fields_reader, None)\n        for row in skill_fields_reader:\n            field_uuid = row[0]\n            skill_fields[field_uuid] = {}\n            section_uuid = row[1]\n            # skill_fields[field_uuid]['section_uuid'] = section_uuid\n            skill_fields[field_uuid][\"name\"] = row[2]\n            skill_fields[field_uuid][\"type\"] = row[3]\n            skill_fields[field_uuid][\"label\"] = row[4]\n            skill_fields[field_uuid][\"hint\"] = row[5]\n            skill_fields[field_uuid][\"placeholder\"] = row[6]\n            skill_fields[field_uuid][\"hide\"] = row[7]\n            skill_fields[field_uuid][\"options\"] = row[8]\n            # skill_fields[field_uuid]['order'] = row[9]\n            if section_uuid in section_to_field:\n                section_to_field[section_uuid].add(field_uuid)\n            else:\n                section_to_field[section_uuid] = {field_uuid}\n\n    with open(\"skill_fields_values.csv\") as skill_field_values_csv:\n        skill_field_values_reader = csv.reader(skill_field_values_csv)\n        next(skill_field_values_reader, None)\n        for row in skill_field_values_reader:\n            field_uuid = row[0]\n            skill_field_values[field_uuid] = {}\n            skill_field_values[field_uuid][\"skill_uuid\"] = row[1]\n            device_uuid = row[2]\n            skill_field_values[field_uuid][\"device_uuid\"] = device_uuid\n            skill_field_values[field_uuid][\"field_value\"] = row[3]\n            if device_uuid in device_to_field:\n                device_to_field[device_uuid].add(field_uuid)\n            else:\n                device_to_field[device_uuid] = {field_uuid}\n\n    with open(\"location.csv\") as location_csv:\n        location_reader = csv.reader(location_csv)\n        next(location_reader, None)\n        for row in location_reader:\n            location_uuid = row[0]\n            locations[location_uuid] = {}\n            locations[location_uuid][\"timezone\"] = row[1]\n            locations[location_uuid][\"city\"] = row[2]\n            locations[location_uuid][\"coordinate\"] = row[3]\n\n    with open(\"timezone.csv\") as timezone_csv:\n        timezone_reader = csv.reader(timezone_csv)\n        next(timezone_reader, None)\n        for row in timezone_reader:\n            timezone_uuid = row[0]\n            timezones[timezone_uuid] = {}\n            timezones[timezone_uuid][\"code\"] = row[1]\n            timezones[timezone_uuid][\"name\"] = row[2]\n\n    with open(\"city.csv\") as city_csv:\n        city_reader = csv.reader(city_csv)\n        next(city_reader, None)\n        for row in city_reader:\n            city_uuid = row[0]\n            cities[city_uuid] = {}\n            cities[city_uuid][\"region\"] = row[1]\n            cities[city_uuid][\"name\"] = row[2]\n\n    with open(\"region.csv\") as region_csv:\n        region_reader = csv.reader(region_csv)\n        next(region_reader, None)\n        for row in region_reader:\n            region_uuid = row[0]\n            regions[region_uuid] = {}\n            regions[region_uuid][\"country\"] = row[1]\n            regions[region_uuid][\"name\"] = row[2]\n            regions[region_uuid][\"code\"] = row[3]\n\n    with open(\"country.csv\") as country_csv:\n        country_reader = csv.reader(country_csv)\n        next(country_reader, None)\n        for row in country_reader:\n            country_uuid = row[0]\n            countries[country_uuid] = {}\n            countries[country_uuid][\"name\"] = row[1]\n            countries[country_uuid][\"code\"] = row[2]\n\n    with open(\"coordinate.csv\") as coordinate_csv:\n        coordinate_reader = csv.reader(coordinate_csv)\n        next(coordinate_reader, None)\n        for row in coordinate_reader:\n            coordinate_uuid = row[0]\n            coordinates[coordinate_uuid] = {}\n            coordinates[coordinate_uuid][\"latitude\"] = row[1]\n            coordinates[coordinate_uuid][\"longitude\"] = row[2]\n\n    with open(\"device_location.csv\") as device_location_csv:\n        device_location_reader = csv.reader(device_location_csv, None)\n        next(device_location_reader, None)\n        for row in device_location_reader:\n            device_uuid = row[0]\n            if device_uuid in devices:\n                devices[device_uuid][\"location\"] = row[1]\n\n\ndef format_date(value):\n    value = int(value)\n    value = datetime.datetime.fromtimestamp(value // 1000)\n    return f\"{value:%Y-%m-%d}\"\n\n\ndef format_timestamp(value):\n    value = int(value)\n    value = datetime.datetime.fromtimestamp(value // 1000)\n    return f\"{value:%Y-%m-%d %H:%M:%S}\"\n\n\ndb = connect(dbname=\"mycroft\", user=\"postgres\", host=\"127.0.0.1\")\n\n\ndb.autocommit = True\n\nsubscription_uuids = {}\n\n\ndef get_subscription_uuid(subs):\n    if subs in subscription_uuids:\n        return subscription_uuids[subs]\n    else:\n        cursor = db.cursor()\n        cursor.execute(\n            f\"select id from account.membership s where s.rate_period = '{subs}'\"\n        )\n        result = cursor.fetchone()\n        subscription_uuids[subs] = result\n        return result\n\n\ntts_uuids = {}\n\n\ndef get_tts_uuid(tts):\n    if tts in tts_uuids:\n        return tts_uuids[tts]\n    else:\n        cursor = db.cursor()\n        cursor.execute(\n            f\"select id from device.text_to_speech s where s.setting_name = '{tts}'\"\n        )\n        result = cursor.fetchone()\n        tts_uuids[tts] = result\n        return result\n\n\ndef fill_account_table():\n    query = (\n        \"insert into account.account(\"\n        \"id, \"\n        \"email_address, \"\n        \"password) \"\n        \"values (%s, %s, %s)\"\n    )\n    with db.cursor() as cur:\n        accounts = (\n            (uuid, account[\"email\"], account[\"password\"])\n            for uuid, account in users.items()\n        )\n        execute_batch(cur, query, accounts, page_size=1000)\n\n\ndef fill_account_agreement_table():\n    query = (\n        \"insert into account.account_agreement(account_id, agreement_id, accept_date)\"\n        \"values (%s, (select id from account.agreement where agreement = %s), %s)\"\n    )\n    with db.cursor() as cur:\n        terms = (\n            (uuid, \"Terms of Use\", format_timestamp(account[\"terms\"]))\n            for uuid, account in users.items()\n            if account[\"terms\"] != \"\"\n        )\n        privacy = (\n            (uuid, \"Privacy Policy\", format_timestamp(account[\"privacy\"]))\n            for uuid, account in users.items()\n            if account[\"privacy\"] != \"\"\n        )\n        execute_batch(cur, query, terms, page_size=1000)\n        execute_batch(cur, query, privacy, page_size=1000)\n\n\ndef fill_default_wake_word():\n    query1 = (\n        \"insert into device.wake_word (\"\n        \"id,\"\n        \"setting_name,\"\n        \"display_name,\"\n        \"engine)\"\n        \"values (%s, %s, %s, %s)\"\n    )\n    query2 = (\n        \"insert into device.wake_word_settings(\"\n        \"wake_word_id,\"\n        \"sample_rate,\"\n        \"channels,\"\n        \"pronunciation,\"\n        \"threshold,\"\n        \"threshold_multiplier,\"\n        \"dynamic_energy_ratio)\"\n        \"values (%s, %s, %s, %s, %s, %s, %s)\"\n    )\n    wake_words = [\n        (hey_mycroft, \"Hey Mycroft\", \"Hey Mycroft\", \"precise\"),\n        (christopher, \"Christopher\", \"Christopher\", \"precise\"),\n        (ezra, \"Hey Ezra\", \"Hey Ezra\", \"precise\"),\n        (jarvis, \"Hey Jarvis\", \"Hey Jarvis\", \"precise\"),\n    ]\n    wake_word_settings = [\n        (hey_mycroft, \"16000\", \"1\", \"HH EY . M AY K R AO F T\", \"1e-90\", \"1\", \"1.5\"),\n        (christopher, \"16000\", \"1\", \"K R IH S T AH F ER .\", \"1e-25\", \"1\", \"1.5\"),\n        (ezra, \"16000\", \"1\", \"HH EY . EH Z R AH\", \"1e-10\", \"1\", \"2.5\"),\n        (jarvis, \"16000\", \"1\", \"HH EY . JH AA R V AH S\", \"1e-25\", \"1\", \"1.5\"),\n    ]\n    with db.cursor() as cur:\n        execute_batch(cur, query1, wake_words)\n        execute_batch(cur, query2, wake_word_settings)\n\n\ndef fill_wake_word_table():\n    query = (\n        \"insert into device.wake_word (\"\n        \"id,\"\n        \"setting_name,\"\n        \"display_name,\"\n        \"engine,\"\n        \"account_id)\"\n        \"values (%s, %s, %s, %s, %s)\"\n    )\n\n    def map_wake_word(user_id):\n        wake_word_id = str(uuid.uuid4())\n        wake_word = (\n            user_settings[user_id][\"wake_word\"].lower()\n            if user_id in user_settings\n            else \"hey mycroft\"\n        )\n        mycroft_wake_word = default_wake_words.get(wake_word)\n        if mycroft_wake_word is not None:\n            wake_word_id = mycroft_wake_word\n        users[user_id][\"wake_word_id\"] = wake_word_id\n        return wake_word_id, wake_word, wake_word, \"precise\", user_id\n\n    with db.cursor() as cur:\n        wake_words = (map_wake_word(account_id) for account_id in users)\n        wake_words = (\n            wk\n            for wk in wake_words\n            if wk[0] not in (hey_mycroft, christopher, ezra, jarvis)\n        )\n        execute_batch(cur, query, wake_words, page_size=1000)\n\n\ndef fill_account_preferences_table():\n    query = (\n        \"insert into device.account_preferences(\"\n        \"account_id, \"\n        \"date_format, \"\n        \"time_format, \"\n        \"measurement_system)\"\n        \"values (%s, %s, %s, %s)\"\n    )\n\n    def map_account_preferences(user_uuid):\n        if user_uuid in user_settings:\n            user_setting = user_settings[user_uuid]\n            date_format = user_setting[\"date_format\"]\n            if date_format == \"DMY\":\n                date_format = \"DD/MM/YYYY\"\n            else:\n                date_format = \"MM/DD/YYYY\"\n            time_format = user_setting[\"time_format\"]\n            if time_format == \"full\":\n                time_format = \"24 Hour\"\n            else:\n                time_format = \"12 Hour\"\n            measurement_system = user_setting[\"measurement_system\"]\n            if measurement_system == \"metric\":\n                measurement_system = \"Metric\"\n            elif measurement_system == \"imperial\":\n                measurement_system = \"Imperial\"\n            tts_type = user_setting[\"tts_type\"]\n            tts_voice = user_setting[\"tts_voice\"]\n            if tts_type == \"MimicSetting\":\n                if tts_voice == \"ap\":\n                    tts = \"ap\"\n                elif tts_voice == \"trinity\":\n                    tts = \"amy\"\n                else:\n                    tts = \"ap\"\n            elif tts_type == \"Mimic2Setting\":\n                tts = \"kusal\"\n            elif tts_type == \"GoogleTTSSetting\":\n                tts = \"google\"\n            else:\n                tts = \"ap\"\n            text_to_speech_id = get_tts_uuid(tts)\n            users[user_uuid][\"text_to_speech_id\"] = text_to_speech_id\n            return user_uuid, date_format, time_format, measurement_system\n        else:\n            text_to_speech_id = get_tts_uuid(\"ap\")\n            users[user_uuid][\"text_to_speech_id\"] = text_to_speech_id\n            return user_uuid, \"MM/DD/YYYY\", \"12 Hour\", \"Imperial\"\n\n    with db.cursor() as cur:\n        account_preferences = (\n            map_account_preferences(user_uuid) for user_uuid in users\n        )\n        execute_batch(cur, query, account_preferences, page_size=1000)\n\n\ndef fill_subscription_table():\n    query = (\n        \"insert into account.account_membership(\"\n        \"account_id, \"\n        \"membership_id, \"\n        \"membership_ts_range, \"\n        \"payment_account_id,\"\n        \"payment_method,\"\n        \"payment_id) \"\n        \"values (%s, %s, %s, %s, %s, %s)\"\n    )\n\n    def map_subscription(user_uuid):\n        subscr = subscription[user_uuid]\n        stripe_customer_id = subscr[\"stripe_customer_id\"]\n        start = format_timestamp(subscr[\"last_payment_ts\"])\n        subscription_ts_range = \"[{},)\".format(start)\n        subscription_type = subscr[\"type\"]\n        if subscription_type == \"MonthlyAccount\":\n            subscription_type = \"month\"\n        elif subscription_type == \"YearlyAccount\":\n            subscription_type = \"year\"\n        subscription_uuid = get_subscription_uuid(subscription_type)\n        return (\n            user_uuid,\n            subscription_uuid,\n            subscription_ts_range,\n            stripe_customer_id,\n            \"Stripe\",\n            \"subscription_id\",\n        )\n\n    with db.cursor() as cur:\n        account_subscriptions = (\n            map_subscription(user_uuid) for user_uuid in subscription\n        )\n        execute_batch(cur, query, account_subscriptions, page_size=1000)\n\n\ndef fill_wake_word_settings_table():\n    query = (\n        \"insert into device.wake_word_settings(\"\n        \"wake_word_id,\"\n        \"sample_rate,\"\n        \"channels,\"\n        \"pronunciation,\"\n        \"threshold,\"\n        \"threshold_multiplier,\"\n        \"dynamic_energy_ratio)\"\n        \"values (%s, %s, %s, %s, %s, %s, %s)\"\n    )\n\n    def map_wake_word_settings(user_uuid):\n        user_setting = user_settings[user_uuid]\n        wake_word_id = users[user_uuid][\"wake_word_id\"]\n        sample_rate = user_setting[\"sample_rate\"]\n        channels = user_setting[\"channels\"]\n        pronunciation = user_setting[\"pronunciation\"]\n        threshold = user_setting[\"threshold\"]\n        threshold_multiplier = user_setting[\"threshold_multiplier\"]\n        dynamic_energy_ratio = user_setting[\"dynamic_energy_ratio\"]\n        return (\n            wake_word_id,\n            sample_rate,\n            channels,\n            pronunciation,\n            threshold,\n            threshold_multiplier,\n            dynamic_energy_ratio,\n        )\n\n    with db.cursor() as cur:\n        account_wake_word_settings = (\n            map_wake_word_settings(user_uuid)\n            for user_uuid in users\n            if user_uuid in user_settings\n        )\n        account_wake_word_settings = (\n            wks\n            for wks in account_wake_word_settings\n            if wks[0] not in (hey_mycroft, christopher, ezra, jarvis)\n        )\n        execute_batch(cur, query, account_wake_word_settings, page_size=1000)\n\n\ndef change_device_name():\n    for user in user_devices:\n        if user in users:\n            device_names = defaultdict(list)\n            for device_uuid, name in user_devices[user]:\n                device_names[name].append(device_uuid)\n            for name in device_names:\n                uuids = device_names[name]\n                if len(uuids) > 1:\n                    count = 1\n                    for uuid in uuids:\n                        devices[uuid][\"name\"] = \"{name}-{uuid}\".format(\n                            name=name, uuid=uuid\n                        )\n                        count += 1\n\n\ndef fill_device_table():\n    query = (\n        \"insert into device.device(\"\n        \"id, \"\n        \"account_id, \"\n        \"name, \"\n        \"placement,\"\n        \"platform,\"\n        \"enclosure_version,\"\n        \"core_version,\"\n        \"wake_word_id,\"\n        \"geography_id,\"\n        \"text_to_speech_id) \"\n        \"values (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)\"\n    )\n    query2 = (\n        \"insert into device.geography(\"\n        \"id,\"\n        \"account_id,\"\n        \"country_id,\"\n        \"region_id,\"\n        \"city_id,\"\n        \"timezone_id) \"\n        \"values (%s, %s, %s, %s, %s, %s)\"\n    )\n\n    with db.cursor() as cur:\n        query_geography = \"\"\"\n        SELECT\n            city.id, region.id, country.id, timezone.id\n        FROM\n            geography.city city\n        INNER JOIN\n            geography.region region ON city.region_id = region.id\n        INNER JOIN\n            geography.country country ON region.country_id = country.id\n        INNER JOIN\n            geography.timezone timezone ON country.id = timezone.country_id\n        WHERE\n            city.name = %s and region.name = %s and timezone.name = %s;\n        \"\"\"\n        cur.execute(query_geography, (\"Lawrence\", \"Kansas\", \"America/Chicago\"))\n        city_default, region_default, country_default, timezone_default = cur.fetchone()\n\n    def map_geography(account_id, device_id):\n        geography_id = str(uuid.uuid4())\n        with db.cursor() as cur:\n            query = \"\"\"\n            SELECT\n                city.id, region.id, country.id, timezone.id\n            FROM\n                geography.city city\n            INNER JOIN\n                geography.region region ON city.region_id = region.id\n            INNER JOIN\n                geography.country country ON region.country_id = country.id\n            INNER JOIN\n                geography.timezone timezone ON country.id = timezone.country_id\n            WHERE\n                city.name = %s and region.name = %s and timezone.name = %s and country.name = %s;\n            \"\"\"\n            location_uuid = devices[device_id].get(\"location\")\n            if location_uuid is not None:\n                location = locations[location_uuid]\n                timezone_entity = timezones[location[\"timezone\"]]\n                timezone = timezone_entity[\"code\"]\n                city_entity = cities[location[\"city\"]]\n                city = city_entity[\"name\"]\n                region_entity = regions[city_entity[\"region\"]]\n                region = region_entity[\"name\"]\n                country_entity = countries[region_entity[\"country\"]]\n                country = country_entity[\"name\"]\n                cur.execute(query, (city, region, timezone, country))\n                result = cur.fetchone()\n                if result is not None:\n                    city, region, country, timezone = result\n                    return geography_id, account_id, country, region, city, timezone\n        return (\n            geography_id,\n            account_id,\n            country_default,\n            region_default,\n            city_default,\n            timezone_default,\n        )\n\n    def map_device(device_id):\n        device = devices[device_id]\n        account_id = device[\"user_uuid\"]\n        name = device[\"name\"]\n        placement = device[\"description\"]\n        platform = device[\"platform\"]\n        enclosure_version = device[\"enclosure_version\"]\n        core_version = device[\"core_version\"]\n        wake_word_id = users[account_id][\"wake_word_id\"]\n        geography_id = device[\"geography_id\"]\n\n        user_setting = user_settings[account_id]\n        tts_type = user_setting[\"tts_type\"]\n        tts_voice = user_setting[\"tts_voice\"]\n        if tts_type == \"MimicSetting\":\n            if tts_voice == \"ap\":\n                tts = \"ap\"\n            elif tts_voice == \"trinity\":\n                tts = \"amy\"\n            else:\n                tts = \"ap\"\n        elif tts_type == \"Mimic2Setting\":\n            tts = \"kusal\"\n        elif tts_type == \"GoogleTTSSetting\":\n            tts = \"google\"\n        else:\n            tts = \"ap\"\n        text_to_speech_id = get_tts_uuid(tts)\n\n        return (\n            device_id,\n            account_id,\n            name,\n            placement,\n            platform,\n            enclosure_version,\n            core_version,\n            wake_word_id,\n            geography_id,\n            text_to_speech_id,\n        )\n\n    with db.cursor() as cur:\n        geography_batch = []\n        for user in user_devices:\n            if user in users and user in user_settings:\n                aux = user_devices[user]\n                device_id, _ = aux[0]\n                geography = map_geography(user, device_id)\n                geography_batch.append(geography)\n                for device_id, name in aux:\n                    devices[device_id][\"geography_id\"] = geography[0]\n        execute_batch(cur, query2, geography_batch, page_size=1000)\n        devices_batch = (\n            map_device(device_id)\n            for user in user_devices\n            if user in users and user in user_settings\n            for device_id, name in user_devices[user]\n        )\n        execute_batch(cur, query, devices_batch, page_size=1000)\n\n\ndef fill_skills_table():\n    skills_batch = []\n    settings_display_batch = []\n    device_skill_batch = []\n    for user in user_devices:\n        if user in users and user in user_settings:\n            for device_uuid, name in user_devices[user]:\n                if device_uuid in device_to_skill:\n                    for skill_uuid in device_to_skill[device_uuid]:\n                        skill = skills[skill_uuid]\n                        skill_name = skill[\"name\"]\n                        identifier = skill[\"identifier\"]\n                        sections = []\n                        settings = {}\n                        if skill_uuid in skill_to_section:\n                            for section_uuid in skill_to_section[skill_uuid]:\n                                section = skill_sections[section_uuid]\n                                section_name = section[\"section\"]\n                                fields = []\n                                if section_uuid in section_to_field:\n                                    for field_uuid in section_to_field[section_uuid]:\n                                        fields.append(skill_fields[field_uuid])\n                                        if field_uuid in skill_field_values:\n                                            settings[\n                                                skill_fields[field_uuid][\"name\"]\n                                            ] = skill_field_values[field_uuid][\n                                                \"field_value\"\n                                            ]\n                                sections.append(\n                                    {\"name\": section_name, \"fields\": fields}\n                                )\n                        skill_setting_display = {\n                            \"name\": skill_name,\n                            \"identifier\": identifier,\n                            \"skillMetadata\": {\"sections\": sections},\n                        }\n                        skills_batch.append((skill_uuid, skill_name))\n                        meta_id = str(uuid.uuid4())\n                        settings_display_batch.append(\n                            (meta_id, skill_uuid, json.dumps(skill_setting_display))\n                        )\n                        device_skill_batch.append(\n                            (device_uuid, skill_uuid, meta_id, json.dumps(settings))\n                        )\n\n    with db.cursor() as curr:\n        query = \"insert into skill.skill(id, name) values (%s, %s)\"\n        execute_batch(curr, query, skills_batch, page_size=1000)\n        query = \"insert into skill.settings_display(id, skill_id, settings_display) values (%s, %s, %s)\"\n        execute_batch(curr, query, settings_display_batch, page_size=1000)\n        query = (\n            \"insert into device.device_skill(device_id, skill_id, skill_settings_display_id, settings) \"\n            \"values (%s, %s, %s, %s)\"\n        )\n        execute_batch(curr, query, device_skill_batch, page_size=1000)\n\n\ndef analyze_locations():\n    matches = 0\n    mismatches = 0\n    g_mismatches = defaultdict(lambda: defaultdict(list))\n    for city in cities.values():\n        region = regions[city[\"region\"]]\n        country = countries[region[\"country\"]]\n        city_name = city[\"name\"]\n        region_name = region[\"name\"]\n        country_name = country[\"name\"]\n        remove = [\n            \"District\",\n            \"Region\",\n            \"Development\",\n            \"Prefecture\",\n            \"Community\",\n            \"County\",\n            \"Province\",\n            \"Division\",\n            \"Voivodeship\",\n            \"State\",\n            \"of\",\n            \"Governorate\",\n        ]\n        with db.cursor() as curr:\n            original_region_name = region_name\n            region_name = \" \".join(i for i in region_name.split() if i not in remove)\n            query = (\n                \"select city.name \"\n                \"from geography.city city \"\n                \"inner join geography.region region on city.region_id = region.id \"\n                \"inner join geography.country country on region.country_id = country.id \"\n                \"where \"\n                \"city.name = '{}' and \"\n                \"(region.name = '{}' or region.name = '{}') and \"\n                \"country.name = '{}'\".format(\n                    city_name.replace(\"'\", \"''\"),\n                    original_region_name.replace(\"'\", \"''\"),\n                    region_name.replace(\"'\", \"''\"),\n                    country_name.replace(\"'\", \"''\"),\n                )\n            )\n            curr.execute(query)\n            result = curr.fetchone()\n            if result is None:\n                mismatches += 1\n                g_mismatches[country_name][region_name].append(city_name)\n            else:\n                matches += 1\n\n    for country2, regions2 in g_mismatches.items():\n        for region2, cities2 in regions2.items():\n            for city2 in cities2:\n                print(\"{} - {} - {}\".format(country2, region2, city2))\n\n    print(\"Number os mismatches: {}\".format(mismatches))\n\n\ndef analyze_location_2():\n    aux = defaultdict(lambda: defaultdict(lambda: defaultdict(str)))\n\n    locations_from_db = defaultdict(list)\n    with db.cursor() as cur:\n        cur.execute(\n            \"select \"\n            \"c1.id, \"\n            \"c1.name, \"\n            \"c1.latitude, \"\n            \"c1.longitude, \"\n            \"r.name, \"\n            \"c2.name, \"\n            \"c2.iso_code \"\n            \"from geography.city c1 \"\n            \"inner join geography.region r on c1.region_id = r.id \"\n            \"inner join geography.country c2 on r.country_id = c2.id\"\n        )\n        for c1_id, c1, latitude, longitude, r, c2_name, c2_code in cur:\n            aux[c2_name][r][c1] = c1_id\n            locations_from_db[c2_code].append((c1, latitude, longitude))\n\n    for location_uuid, location in locations.items():\n        coordinate = coordinates[location_uuid]\n        city = cities[location[\"city\"]]\n        city_name = city[\"name\"]\n        region = regions[city[\"region\"]]\n        region_name = region[\"name\"]\n        country = countries[region[\"country\"]]\n        country_code = country[\"code\"]\n        country_name = country[\"name\"]\n\n        res = aux.get(country_name)\n        if res is not None:\n            res = res.get(region_name)\n            if res is not None:\n                res = res.get(city_name)\n                if res is not None:\n                    print(\"Match: {}\".format(city_name))\n                    continue\n        min_dist = None\n        result_name = None\n        for c1_name, latitude, longitude in locations_from_db[country_code]:\n            point1 = (float(latitude), float(longitude))\n            point2 = (float(coordinate[\"latitude\"]), float(coordinate[\"longitude\"]))\n            dist = distance(point1, point2).km\n            if min_dist is None or dist < min_dist:\n                min_dist = dist\n                result_name = c1_name\n        print(\"Actual: {}, calculated: {}\".format(city_name, result_name))\n\n\nstart = time.time()\nload_csv()\nend = time.time()\n\nprint(\"Time to load CSVs {}\".format(end - start))\n\nstart = time.time()\nprint(\"Importing account table\")\n# fill_account_table()\nprint(\"Importing agreements table\")\n# fill_account_agreement_table()\nprint(\"Importing account preferences table\")\n# fill_account_preferences_table()\nprint(\"Importing subscription table\")\n# fill_subscription_table()\nprint(\"Importing wake word table\")\n# fill_default_wake_word()\n# fill_wake_word_table()\nprint(\"Importing wake word settings table\")\n# fill_wake_word_settings_table()\nprint(\"Importing device table\")\n# change_device_name()\n# fill_device_table()\nprint(\"Importing skills table\")\n# fill_skills_table()\nanalyze_location_2()\nend = time.time()\nprint(\"Time to import: {}\".format(end - start))\n"
  },
  {
    "path": "db/scripts/queries.cypher",
    "content": "users.csv\nmatch (n:User) return n.uuid, n.email, n.password, n.termsOfUseDate, n.privacyPolicyDate\n\nsubscription.csv\nmatch (n) where ((n:MonthlyAccount) or (n:YearlyAccount)) and n.expiratesAt is not null set n.expiresAt = n.expiratesAt\n\nmatch (n:User)-[:ACCOUNT]->(acc)\nwhere not (acc:FreeAccount)\nreturn n.uuid, acc.customerId, acc.lastPayment, labels(acc)[0]\n\nuser_location.csv\nmatch (n:User)-[:LIVES_AT]->()-[:COORDINATE]->(coord) return n.uuid, coord.latitude, coord.longitude\n\nuser_setting.csv\nmatch (n:User)-[:SETTING]->(setting)-[:TTS_SETTING]->(tts:Active), (setting)-[:LISTENER_SETTING]->(listener)\nwith filter(l in labels(tts) where l <> 'Active') as s, n, tts, setting, listener\nreturn n.uuid, setting.dateFormat, setting.timeFormat, setting.systemUnit, s[0], tts.voice, listener.wakeWord, listener.sampleRate, listener.channels, listener.phonemes, listener.threshold, listener.multiplier, listener.energyRatio\n\ndevice.csv\nmatch (user:User)-[:DEVICE]->(n:Device) return n.uuid, user.uuid, n.name, n.description, n.platform, n.enclosureVersion, n.coreVersion\n\ndevices_location.csv\nmatch (n:Device)-[:PLACED_AT]->()-[:COORDINATE]->(coord) return n.uuid, coord.latitude, coord.longitude\n\nskill.csv\nmatch (dev:Device)-[:SKILL_MAPPING]->()-[:SKILL]->(n:Skill) return n.uuid, dev.uuid, n.name, n.description, n.identifier, n.color\n\nskill_section.csv\nmatch (skill:Skill)-[:METADATA]->()-[:SECTION]->(section) return section.uuid, skill.uuid, section.name, section.order order by skill.uuid\n\nskill_fields.csv\nmatch (section:SkillMetadataSection)-[:FIELD]->(field:SkillMetadataField) return field.uuid, section.uuid, field.name, field.type, field.label, field.hint, field.placeholder, field.hide, field.options, field.order\n\nskill_fields_values.csv\nmatch (device:Device)-[:SKILL_MAPPING]->(map:SkillMetadataMapping)-[r:SKILL_FIELD_VALUE]->(field:SkillMetadataField), (map)-[:SKILL]->(skill:Skill) return field.uuid, skill.uuid, device.uuid, r.value\n\nlocation.csv\nmatch (l:Location)-[:TIMEZONE]->(t:Timezone), (l)-[:IN]->(c:City), (l)-[:COORDINATE]->(c2:Coordinate) return l.uuid, t.uuid, c.uuid, c2.uuid\n\ntimezone.csv\nmatch (n:Timezone) return n.uuid, n.code, n.name\n\ncity.csv\nmatch (country)-[:STATE]->(state:State)-[:CITY]->(city:City) return city.uuid, state.uuid, city.name\n\nregion.csv\nmatch (country)-[:STATE]->(state:State)-[:CITY]->(city:City) return state.uuid, country.uuid, state.name\n\ncountry.csv\nmatch (country)-[:STATE]->(state:State)-[:CITY]->(city:City) return country.uuid, country.name\n\ndevice_location.csv\nmatch (d:Device)-[:PLACED_AT]->(l:Location) return d.uuid, l.uuid\n\ncoordinate.csv\ncall apoc.export.csv.query(\"match (l:Location)-[:COORDINATE]->(c:Coordinate) return l.uuid, c.latitude, c.longitude\", \"/dump/selene/coordinate.csv\", {});\n"
  },
  {
    "path": "db/scripts/remove_duplicate_cities.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2020 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Short script to delete duplicate rows from the geography.city table\"\"\"\nfrom os import environ\nfrom pathlib import Path\n\nfrom selene.util.db import (\n    connect_to_db,\n    Cursor,\n    DatabaseConnectionConfig,\n    DatabaseRequest,\n    get_sql_from_file,\n)\n\nMYCROFT_DB_DIR = environ.get(\"DB_DIR\", \"/opt/selene/selene-backend/db/mycroft\")\n\n\ndef get_cursor():\n    \"\"\"Get a cursor object for executing SQL against the DB\"\"\"\n    db_connection_config = DatabaseConnectionConfig(\n        host=environ[\"DB_HOST\"],\n        db_name=environ[\"DB_NAME\"],\n        password=environ[\"DB_PASSWORD\"],\n        port=environ.get(\"DB_PORT\", 5432),\n        user=environ[\"DB_USER\"],\n        sslmode=environ.get(\"DB_SSLMODE\"),\n    )\n    db = connect_to_db(db_connection_config)\n    cursor = Cursor(db)\n\n    return cursor\n\n\ndef get_duplicate_cities(cursor):\n    \"\"\"Get a list of the cities that appear multiple times in the city table.\"\"\"\n    geography_dir = Path(MYCROFT_DB_DIR).joinpath(\"geography_schema\")\n    sql = get_sql_from_file(str(geography_dir.joinpath(\"get_duplicated_cities.sql\")))\n    request = DatabaseRequest(sql)\n    result = cursor.select_all(request)\n    print(\"Removing duplicate cities from the geography.city table\")\n    print(f\"found {len(result)} duplicated cities\")\n\n    return result\n\n\ndef get_device_geographies(cursor, city):\n    \"\"\"Get any device.geography rows that use one of the duplicated cities.\"\"\"\n    device_dir = Path(MYCROFT_DB_DIR).joinpath(\"device_schema\")\n    sql = get_sql_from_file(\n        str(device_dir.joinpath(\"get_device_geographies_for_city.sql\"))\n    )\n    args = dict(city_ids=tuple(city[\"city_ids\"]))\n    request = DatabaseRequest(sql, args)\n    result = cursor.select_all(request)\n    if result:\n        print(\n            f\"found {len(result)} device geographies for city: {city['city_name']}; \"\n            f\"region: {city['region_name']}; country: {city['country_name']}\"\n        )\n\n    return result\n\n\ndef get_account_defaults(cursor, city):\n    \"\"\"Get any device.account_default rows that use one of the duplicated cities.\"\"\"\n    device_dir = Path(MYCROFT_DB_DIR).joinpath(\"device_schema\")\n    sql = get_sql_from_file(\n        str(device_dir.joinpath(\"get_device_defaults_for_city.sql\"))\n    )\n    args = dict(city_ids=tuple(city[\"city_ids\"]))\n    request = DatabaseRequest(sql, args)\n    result = cursor.select_all(request)\n    if result:\n        print(f\"found {len(result)} device defaults for {city['city_name']}\")\n\n    return result\n\n\ndef check_device_geography_for_dup_cities(cursor, duplicate_cities):\n    \"\"\"Checks for duplicated cities on the device.geography table.\n\n    In theory this should not find any matches as the GUI breaks when a city with\n    duplicates is chosen.  No logic to deal with this scenario is in here yet, but will\n    be added if the assumption is proven wrong.\n    \"\"\"\n    device_geographies_found = False\n    for city in duplicate_cities:\n        device_geographies = get_device_geographies(cursor, city)\n        if device_geographies:\n            device_geographies_found = True\n    if not device_geographies_found:\n        print(\"there are no devices assigned to duplicated city\")\n\n    return device_geographies_found\n\n\ndef check_account_defaults_for_dup_cities(cursor, duplicate_cities):\n    \"\"\"Checks for duplicated cities on the device.account_default table.\n\n    In theory this should not find any matches as the GUI breaks when a city with\n    duplicates is chosen.  No logic to deal with this scenario is in here yet, but will\n    be added if the assumption is proven wrong.\n    \"\"\"\n    account_defaults_found = False\n    for city in duplicate_cities:\n        account_defaults = get_account_defaults(cursor, city)\n        if account_defaults:\n            account_defaults_found = True\n    if not account_defaults_found:\n        print(\"there are no account defaults using a duplicated city\")\n\n    return account_defaults_found\n\n\ndef delete_duplicates(cursor, city, used_cities):\n    \"\"\"Once all the checks are done, we can delete the rows from the database.\n\n    Remove the first ID from the list so that one of the rows remains.\n    \"\"\"\n    deleted_rows = 0\n    geography_dir = Path(MYCROFT_DB_DIR).joinpath(\"geography_schema\")\n    sql = get_sql_from_file(str(geography_dir.joinpath(\"delete_duplicate_cities.sql\")))\n    if used_cities:\n        sql += \" and id not in %(used_cities)s\"\n        args = dict(\n            city_ids=tuple(city[\"city_ids\"]),\n            max_population=city[\"max_population\"],\n            used_cities=tuple(used_cities),\n        )\n    else:\n        args = dict(\n            city_ids=tuple(city[\"city_ids\"]), max_population=city[\"max_population\"],\n        )\n    request = DatabaseRequest(sql, args)\n    result = cursor.delete(request)\n    deleted_rows += result\n\n    print(f\"Deleted {deleted_rows} from the geography.city table\")\n\n\ndef main():\n    \"\"\"Make it so.\"\"\"\n    cursor = get_cursor()\n    duplicate_cities = get_duplicate_cities(cursor)\n    account_defaults_found = check_account_defaults_for_dup_cities(\n        cursor, duplicate_cities\n    )\n    if account_defaults_found:\n        print(\"Great, now you need to write more code!\")\n    else:\n        for city in duplicate_cities:\n            device_geographies = get_device_geographies(cursor, city)\n            if not device_geographies:\n                delete_duplicates(cursor, city, [])\n            else:\n                print(device_geographies)\n                used_cities = [geo[\"city_id\"] for geo in device_geographies]\n                delete_duplicates(cursor, city, used_cities)\n                break\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "shared/Dockerfile",
    "content": "# Parent dockerfile for all Selene services and APIs\n#\n# This Dockerfile contains the steps that are common to building all the\n# docker images for the Selene backend to Mycroft\n\nFROM python:3.9\nLABEL maintainer=\"Mycroft AI <devops@mycroft.ai>\"\n\n# Install the software required for this image\nRUN pip install pipenv\n\n# Use pipenv to install the dependencies for selene-util\nCOPY Pipfile Pipfile\nCOPY Pipfile.lock Pipfile.lock\nRUN pipenv install --system\n\n# Now that pipenv has installed all the packages required by selene-util\n# the Pipfile can be removed from the container.  This makes way for the\n# pepenv to use these files to install dependencies for the Selene services\n# or applications that will use this Docker config\nRUN rm Pipfile\nRUN rm Pipfile.lock\n\n# Copy the applicaction code to the image\nCOPY selene_util /opt/selene/selene_util\nWORKDIR /opt/selene/\n"
  },
  {
    "path": "shared/MANIFEST.in",
    "content": "include selene/data/account/repository/sql/*.sql\ninclude selene/data/device/repository/sql/*.sql\ninclude selene/data/skill/repository/sql/*.sql"
  },
  {
    "path": "shared/pyproject.toml",
    "content": "[tool.poetry]\nname = \"selene\"\nversion = \"0.1.0\"\ndescription = \"Selene library code\"\nauthors = [\"Chris Veilleux <veilleux.chris@gmail.com>\"]\nlicense = \"GNU AGPL 3.0\"\n\n[tool.poetry.dependencies]\npython = \"^3.9\"\nfacebook-sdk = \"*\"\n# Version 1.0 of flask required because later versions do not allow lists to be passed as API repsonses.  The Google\n# STT endpoint passes a list of transcriptions to the device.  Changing this to return a dictionary would break the\n# API's V1 contract with Mycroft Core.\n#\n# To make flask 1.0 work, older versions of itsdangerous, jinja2 and markupsafe are required.\nemail-validator = \"*\"\nflask = \"<1.1\"\nitsdangerous = \"<=2.0.1\"\njinja2 = \"<=2.10.1\"\nmarkupsafe = \"<=2.0.1\"\nparamiko = \"*\"\npasslib = \"*\"\npsycopg2-binary = \"*\"\npygithub = \"*\"\npyjwt = \"*\"\nredis = \"*\"\nschedule = \"*\"\nschematics = \"*\"\nsendgrid = \"*\"\nstripe = \"*\"\nwerkzeug = \"<=2.0.3\"\n\n[tool.poetry.dev-dependencies]\nblack = \"*\"\npyhamcrest = \"*\"\npylint = \"*\"\n\n[build-system]\nrequires = [\"poetry-core>=1.0.0\"]\nbuild-backend = \"poetry.core.masonry.api\"\n"
  },
  {
    "path": "shared/selene/__init__.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n"
  },
  {
    "path": "shared/selene/api/__init__.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom .base_config import get_base_config\nfrom .base_endpoint import APIError, SeleneEndpoint\nfrom .blueprint import selene_api\nfrom .etag import device_etag_key, device_setting_etag_key, ETagManager\nfrom .public_endpoint import PublicEndpoint, track_account_activity\nfrom .public_endpoint import generate_device_login\nfrom .response import SeleneResponse, snake_to_camel\n"
  },
  {
    "path": "shared/selene/api/base_config.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\n\"\"\"Define configuration values that apply to all Mycroft Flask APIs\n\nThe below base config classes included in this module represent globally\napplicable config values.  Please do not add to these classes unless the new\nconfig applies to ALL APIs. Define additional configs specific to an\napplication within the application..\n\nExample usage:\n    in api.py:\n        from selene.api import get_environment_config\n        app.config = from_object(get_base_config())\n        app.config.update(<app_specific_configs>)\n        .\n        .\n        .\n        @app.teardown_appcontext\n        def close_db_connections():\n            app.config['DB_CONNECTION_POOL'].close_all()\n\"\"\"\n\nimport os\n\nfrom selene.util.db import allocate_db_connection_pool, DatabaseConnectionConfig\n\n\nclass APIConfigError(Exception):\n    pass\n\n\nclass BaseConfig(object):\n    \"\"\"Base configuration.\"\"\"\n\n    ACCESS_SECRET = os.environ[\"JWT_ACCESS_SECRET\"]\n    DB_CONNECTION_POOL = None\n    DEBUG = False\n    ENV = os.environ[\"SELENE_ENVIRONMENT\"]\n    REFRESH_SECRET = os.environ[\"JWT_REFRESH_SECRET\"]\n    DB_CONNECTION_CONFIG = DatabaseConnectionConfig(\n        host=os.environ[\"DB_HOST\"],\n        db_name=os.environ[\"DB_NAME\"],\n        password=os.environ[\"DB_PASSWORD\"],\n        port=os.environ.get(\"DB_PORT\", 5432),\n        user=os.environ[\"DB_USER\"],\n        sslmode=os.environ.get(\"DB_SSLMODE\"),\n    )\n\n\nclass DevelopmentConfig(BaseConfig):\n    DEBUG = True\n    DOMAIN = \".mycroft.test\"\n\n\nclass TestConfig(BaseConfig):\n    DOMAIN = \".mycroft-test.net\"\n\n\nclass ProdConfig(BaseConfig):\n    DOMAIN = \".mycroft.ai\"\n\n\ndef get_base_config():\n    \"\"\"Determine which config object to pass to the application.\n\n    :return: an object containing the configs for the API.\n    \"\"\"\n    environment_configs = dict(dev=DevelopmentConfig, test=TestConfig, prod=ProdConfig)\n\n    try:\n        environment_name = os.environ[\"SELENE_ENVIRONMENT\"]\n    except KeyError:\n        raise APIConfigError(\"the SELENE_ENVIRONMENT variable is not set\")\n\n    try:\n        app_config = environment_configs[environment_name]\n    except KeyError:\n        error_msg = 'no configuration defined for the \"{}\" environment'\n        raise APIConfigError(error_msg.format(environment_name))\n\n    return app_config\n"
  },
  {
    "path": "shared/selene/api/base_endpoint.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\n\"\"\"Base class for Flask API endpoints\"\"\"\nfrom flask import after_this_request, current_app, request, g as global_context\nfrom flask.views import MethodView\n\nfrom selene.data.account import Account, AccountRepository\nfrom selene.util.auth import AuthenticationError, AuthenticationToken\nfrom selene.util.db import connect_to_db\nfrom selene.util.log import get_selene_logger\n\nACCESS_TOKEN_COOKIE_NAME = \"seleneAccess\"\nFIFTEEN_MINUTES = 900\nONE_MONTH = 2628000\nREFRESH_TOKEN_COOKIE_NAME = \"seleneRefresh\"\n\n_log = get_selene_logger(__name__)\n\n\nclass APIError(Exception):\n    \"\"\"Raise this exception whenever a non-successful response is built\"\"\"\n\n\nclass SeleneEndpoint(MethodView):\n    \"\"\"Abstract base class for Selene Flask Restful API calls.\n\n    Subclasses must do the following:\n        -  override the allowed_methods class attribute to a list of all allowed\n           HTTP methods.  Each list member must be a HTTPMethod enum\n        -  override the _build_response_data method\n    \"\"\"\n\n    def __init__(self):\n        global_context.url = request.url\n        self.config: dict = current_app.config\n        self.request = request\n        self.response: tuple = None\n        self.account: Account = None\n        self.access_token = self._init_access_token()\n        self.refresh_token = self._init_refresh_token()\n\n    @property\n    def db(self):\n        \"\"\"Returns an instance of the Selene database connection.\"\"\"\n        if \"db\" not in global_context:\n            global_context.db = connect_to_db(\n                current_app.config[\"DB_CONNECTION_CONFIG\"]\n            )\n\n        return global_context.db\n\n    def _init_access_token(self):\n        \"\"\"Returns an instantiated authentication token object.\"\"\"\n        return AuthenticationToken(self.config[\"ACCESS_SECRET\"], FIFTEEN_MINUTES)\n\n    def _init_refresh_token(self):\n        return AuthenticationToken(self.config[\"REFRESH_SECRET\"], ONE_MONTH)\n\n    def _authenticate(self):\n        \"\"\"\n        Authenticate the user using tokens passed via cookies.\n\n        :raises: APIError()\n        \"\"\"\n        try:\n            account_id = self._validate_auth_tokens()\n            self._get_account(account_id)\n            self._validate_account(account_id)\n            if self.access_token.is_expired:\n                self._refresh_auth_tokens()\n        except Exception:\n            _log.exception(\"an exception occurred during authentication\")\n            raise\n\n    def _validate_auth_tokens(self):\n        \"\"\"Ensure the tokens are passed in request and are well formed.\"\"\"\n        self._get_auth_tokens()\n        self._decode_access_token()\n        if self.access_token.is_expired:\n            self._decode_refresh_token()\n        account_not_found = (\n            self.access_token.account_id is None\n            and self.refresh_token.account_id is None\n        )\n        if account_not_found:\n            raise AuthenticationError(\n                \"failed to retrieve account ID from authentication tokens\"\n            )\n\n        return self.access_token.account_id or self.refresh_token.account_id\n\n    def _get_auth_tokens(self):\n        \"\"\"Extracts the JWTs used for authentication from the cookies.\"\"\"\n        self.access_token.jwt = self.request.cookies.get(ACCESS_TOKEN_COOKIE_NAME)\n        self.refresh_token.jwt = self.request.cookies.get(REFRESH_TOKEN_COOKIE_NAME)\n        if self.access_token.jwt is None and self.refresh_token.jwt is None:\n            raise AuthenticationError(\"no authentication tokens found\")\n\n    def _decode_access_token(self):\n        \"\"\"Decode the JWT to get the account ID and check for errors.\"\"\"\n        self.access_token.validate()\n\n        if not self.access_token.is_valid:\n            raise AuthenticationError(\"invalid access token\")\n\n    def _decode_refresh_token(self):\n        \"\"\"Decode the JWT to get the account ID and check for errors.\"\"\"\n        self.refresh_token.validate()\n\n        if not self.refresh_token.is_valid:\n            raise AuthenticationError(\"invalid refresh token\")\n\n        if self.refresh_token.is_expired:\n            raise AuthenticationError(\"authentication tokens expired\")\n\n    def _get_account(self, account_id):\n        \"\"\"Use account ID from decoded authentication token to get account.\"\"\"\n        account_repository = AccountRepository(self.db)\n        self.account = account_repository.get_account_by_id(account_id)\n\n    def _validate_account(self, account_id: str):\n        \"\"\"Account must exist and contain have a refresh token matching request.\n\n        :raises: AuthenticationError\n        \"\"\"\n        if self.account is None:\n            _log.error(\"account ID {} not on database\".format(account_id))\n            raise AuthenticationError(\"account not found\")\n        global_context.account_id = self.account.id\n\n    def _refresh_auth_tokens(self):\n        \"\"\"Steps necessary to refresh the tokens used for authentication.\"\"\"\n        self._generate_tokens()\n        self._set_token_cookies()\n\n    def _generate_tokens(self):\n        \"\"\"Generate an access token and refresh token.\"\"\"\n        self.access_token = self._init_access_token()\n        self.refresh_token = self._init_refresh_token()\n        self.access_token.generate(self.account.id)\n        self.refresh_token.generate(self.account.id)\n\n    def _set_token_cookies(self, expire=False):\n        \"\"\"Set the cookies that contain the authentication token.\n\n        This method should be called when a user logs in, logs out, or when\n        their access token expires.\n\n        :param expire: generate tokens that immediately expire, effectively\n            logging a user out of the system.\n        :return:\n        \"\"\"\n        access_token_cookie = dict(\n            key=\"seleneAccess\",\n            value=str(self.access_token.jwt),\n            domain=self.config[\"DOMAIN\"],\n            max_age=FIFTEEN_MINUTES,\n        )\n        refresh_token_cookie = dict(\n            key=\"seleneRefresh\",\n            value=str(self.refresh_token.jwt),\n            domain=self.config[\"DOMAIN\"],\n            max_age=ONE_MONTH,\n        )\n\n        if expire:\n            for cookie in (access_token_cookie, refresh_token_cookie):\n                cookie.update(value=\"\", max_age=0)\n\n        @after_this_request\n        def set_cookies(response):  # pylint: disable=unused-variable\n            \"\"\"Use Flask after request hook to reset token cookies\"\"\"\n            response.set_cookie(**access_token_cookie)\n            response.set_cookie(**refresh_token_cookie)\n\n            return response\n"
  },
  {
    "path": "shared/selene/api/blueprint.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nimport json\nfrom datetime import datetime\nfrom decimal import Decimal\nfrom http import HTTPStatus\n\nfrom flask import current_app, Blueprint, g as global_context, request\nfrom schematics.exceptions import DataError\n\nfrom selene.data.metric import ApiMetric, ApiMetricsRepository\nfrom selene.util.auth import AuthenticationError\nfrom selene.util.cache import DEVICE_LAST_CONTACT_KEY\nfrom selene.util.db import connect_to_db\nfrom selene.util.exceptions import NotModifiedException\n\nselene_api = Blueprint(\"selene_api\", __name__)\n\n\n@selene_api.app_errorhandler(DataError)\ndef handle_data_error(error):\n    return json.dumps(error.to_primitive()), HTTPStatus.BAD_REQUEST\n\n\n@selene_api.app_errorhandler(AuthenticationError)\ndef handle_data_error(error):\n    return dict(error=str(error)), HTTPStatus.UNAUTHORIZED\n\n\n@selene_api.app_errorhandler(NotModifiedException)\ndef handle_not_modified(_):\n    return \"\", HTTPStatus.NOT_MODIFIED\n\n\n@selene_api.before_app_request\ndef setup_request():\n    global_context.start_ts = datetime.utcnow()\n\n\n@selene_api.after_app_request\ndef teardown_request(response):\n    add_api_metric(response.status_code)\n    update_device_last_contact()\n\n    return response\n\n\ndef add_api_metric(http_status):\n    \"\"\"Add a row to the table tracking metric for API calls\"\"\"\n    api = None\n    # We are not logging metric for the public API until after the socket\n    # implementation to avoid putting millions of rows a day on the table\n    for api_name in (\"account\", \"sso\", \"market\", \"public\"):\n        if api_name in current_app.name:\n            api = api_name\n\n    if api is not None and int(http_status) != 304:\n        if \"db\" not in global_context:\n            global_context.db = connect_to_db(\n                current_app.config[\"DB_CONNECTION_CONFIG\"]\n            )\n        if \"account_id\" in global_context:\n            account_id = global_context.account_id\n        else:\n            account_id = None\n\n        if \"device_id\" in global_context:\n            device_id = global_context.device_id\n        else:\n            device_id = None\n\n        duration = datetime.utcnow() - global_context.start_ts\n        api_metric = ApiMetric(\n            access_ts=datetime.utcnow(),\n            account_id=account_id,\n            api=api,\n            device_id=device_id,\n            duration=Decimal(str(duration.total_seconds())),\n            http_method=request.method,\n            http_status=int(http_status),\n            url=global_context.url,\n        )\n        # Writing the API metrics to the database is disabled here to facilitate\n        # reducing the size of the production database.  Uncomment the below to\n        # reactivate.\n        #\n        # metric_repository = ApiMetricsRepository(global_context.db)\n        # metric_repository.add(api_metric)\n\n\ndef update_device_last_contact():\n    \"\"\"Update the timestamp on the device table indicating last contact.\n\n    This should only be done on public API calls because we are tracking\n    device activity only.\n    \"\"\"\n    if \"public\" in current_app.name and \"device_id\" in global_context:\n        key = DEVICE_LAST_CONTACT_KEY.format(device_id=global_context.device_id)\n        global_context.cache.set(key, str(datetime.utcnow()))\n"
  },
  {
    "path": "shared/selene/api/endpoints/__init__.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Public API for the selene.api.endpoints package.\"\"\"\n\nfrom .account import AccountEndpoint\nfrom .agreements import AgreementsEndpoint\nfrom .password_change import PasswordChangeEndpoint\nfrom .validate_email import ValidateEmailEndpoint\n"
  },
  {
    "path": "shared/selene/api/endpoints/account.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\n\"\"\"API endpoint to return the logged-in user's profile\"\"\"\nimport os\nfrom binascii import a2b_base64\nfrom dataclasses import asdict\nfrom datetime import date, datetime, timedelta\nfrom http import HTTPStatus\n\nimport stripe\nfrom schematics import Model\nfrom schematics.exceptions import ValidationError\nfrom schematics.types import BooleanType, EmailType, ModelType, StringType\nfrom flask import json, jsonify\n\nfrom selene.data.account import (\n    Account,\n    AccountAgreement,\n    AccountRepository,\n    AccountMembership,\n    OPEN_DATASET,\n    PRIVACY_POLICY,\n    TERMS_OF_USE,\n    MembershipRepository,\n)\nfrom selene.data.metric import AccountActivityRepository\nfrom selene.data.tagging import WakeWordFileRepository\nfrom selene.util.auth import (\n    get_facebook_account_email,\n    get_github_account_email,\n    get_google_account_email,\n)\nfrom selene.util.cache import SeleneCache\nfrom selene.util.log import get_selene_logger\nfrom selene.util.payment import (\n    cancel_stripe_subscription,\n    create_stripe_account,\n    create_stripe_subscription,\n)\nfrom ..base_endpoint import SeleneEndpoint\nfrom ..etag import ETagManager\n\nMONTHLY_MEMBERSHIP = \"Monthly Membership\"\nYEARLY_MEMBERSHIP = \"Yearly Membership\"\nSTRIPE_PAYMENT = \"Stripe\"\n\n_log = get_selene_logger(__name__)\n\n\ndef agreement_accepted(value):\n    \"\"\"Helper function to for validating an account add request.\"\"\"\n    if not value:\n        raise ValidationError(\"agreement not accepted\")\n\n\nclass Login(Model):\n    \"\"\"Representation of the login information for an add account request.\"\"\"\n\n    federated_platform = StringType(choices=[\"Facebook\", \"Google\", \"GitHub\"])\n    federated_token = StringType()\n    email = EmailType()\n    password = StringType()\n\n    def validate_email(self, data, value):\n        \"\"\"If email address is used to login, it must be specified.\"\"\"\n        if data[\"federated_token\"] is None:\n            if value is None:\n                raise ValidationError(\n                    \"either a federated login or an email address is required\"\n                )\n\n    def validate_password(self, data, value):\n        \"\"\"If email address is used to login, the password must be supplied.\"\"\"\n        if data.get(\"email\") is not None:\n            if value is None:\n                raise ValidationError(\"email address must be accompanied by a password\")\n\n\nclass UpdateMembershipRequest(Model):\n    \"\"\"Representation of a request to update a membership for data validation.\"\"\"\n\n    action = StringType(choices=(\"add\", \"cancel\", \"update\"))\n    membership_type = StringType(choices=(MONTHLY_MEMBERSHIP, YEARLY_MEMBERSHIP))\n    payment_method = StringType(choices=[STRIPE_PAYMENT])\n    payment_token = StringType()\n\n    def validate_membership_type(self, data, value):\n        \"\"\"A new membership must have a membership type.\"\"\"\n        if data[\"action\"] == \"add\" and value is None:\n            raise ValidationError(\"new memberships require a membership type\")\n\n    def validate_payment_method(self, data, value):\n        \"\"\"A new membership must have a payment method.\"\"\"\n        if data[\"action\"] == \"add\" and value is None:\n            raise ValidationError(\"new memberships require a payment method\")\n\n    def validate_payment_token(self, data, value):\n        \"\"\"A new membership must have a payment token.\"\"\"\n        if data[\"action\"] == \"add\" and value is None:\n            raise ValidationError(\"payment token required for new memberships\")\n\n\nclass AddAccountRequest(Model):\n    \"\"\"Representation of a request to add and account for data validation.\"\"\"\n\n    privacy_policy = BooleanType(required=True, validators=[agreement_accepted])\n    terms_of_use = BooleanType(required=True, validators=[agreement_accepted])\n    login = ModelType(Login)\n\n\nclass AccountEndpoint(SeleneEndpoint):\n    \"\"\"Retrieve information about the user based on their UUID\"\"\"\n\n    _account_repository = None\n    _account_activity_repository = None\n\n    def __init__(self):\n        \"\"\"Constructor\"\"\"\n        super().__init__()\n        self.request_data = None\n\n    @property\n    def account_repository(self):\n        \"\"\"Lazily instantiates an instance of the account repository.\"\"\"\n        if self._account_repository is None:\n            self._account_repository = AccountRepository(self.db)\n\n        return self._account_repository\n\n    @property\n    def account_activity_repository(self):\n        \"\"\"Lazily instantiates an instance of the account activity repository.\"\"\"\n        if self._account_activity_repository is None:\n            self._account_activity_repository = AccountActivityRepository(self.db)\n\n        return self._account_activity_repository\n\n    def get(self):\n        \"\"\"Process HTTP GET request for an account.\"\"\"\n        self._authenticate()\n        response_data = self._build_response_data()\n        self.response = response_data, HTTPStatus.OK\n\n        return self.response\n\n    def _build_response_data(self):\n        \"\"\"Build the response to the GET request.\"\"\"\n        response_data = asdict(self.account)\n        for agreement in response_data[\"agreements\"]:\n            agreement_date = self._format_agreement_date(agreement)\n            agreement[\"accept_date\"] = agreement_date\n        if response_data[\"membership\"] is None:\n            response_data[\"membership\"] = None\n        else:\n            membership_duration = self._format_membership_duration(response_data)\n            response_data[\"membership\"][\"duration\"] = membership_duration\n            del response_data[\"membership\"][\"start_date\"]\n\n        return response_data\n\n    @staticmethod\n    def _format_agreement_date(agreement):\n        \"\"\"Helper function to format the agreement date for display in the GUI.\"\"\"\n        agreement_date = datetime.strptime(agreement[\"accept_date\"], \"%Y-%m-%d\")\n        formatted_agreement_date = agreement_date.strftime(\"%B %d, %Y\")\n\n        return formatted_agreement_date\n\n    @staticmethod\n    def _format_membership_duration(response_data):\n        \"\"\"Helper function to format the membership duration for display in the GUI.\"\"\"\n        membership_start = datetime.strptime(\n            response_data[\"membership\"][\"start_date\"], \"%Y-%m-%d\"\n        )\n        one_year = timedelta(days=365)\n        one_month = timedelta(days=30)\n        duration = datetime.utcnow().date() - membership_start.date()\n        years, remaining_duration = divmod(duration, one_year)\n        months, _ = divmod(remaining_duration, one_month)\n        membership_duration = []\n        if years:\n            membership_duration.append(f\"{years} years\")\n        if months:\n            membership_duration.append(f\" {months} months\")\n\n        return \" \".join(membership_duration) if membership_duration else None\n\n    def post(self):\n        \"\"\"Process HTTP POST request for an account.\"\"\"\n        self.request_data = json.loads(self.request.data)\n        self._validate_post_request()\n        email_address, password = self._determine_login_method()\n        self._add_account(email_address, password)\n        return jsonify(\"Account added successfully\"), HTTPStatus.OK\n\n    def _validate_post_request(self):\n        \"\"\"Validate the contents of the request object for a POST request.\"\"\"\n        add_request = AddAccountRequest(\n            dict(\n                privacy_policy=self.request_data.get(\"privacyPolicy\"),\n                terms_of_use=self.request_data.get(\"termsOfUse\"),\n                login=self._build_login_schematic(),\n            )\n        )\n        add_request.validate()\n\n        self.request_data = add_request.to_native()\n\n    def _build_login_schematic(self) -> Login:\n        \"\"\"Build the data representation of the POST request for validation.\"\"\"\n        login = None\n        login_data = self.request.json.get(\"login\")\n        if login_data is not None:\n            email = login_data.get(\"email\")\n            if email is not None:\n                email = a2b_base64(email).decode()\n            password = login_data.get(\"password\")\n            if password is not None:\n                password = a2b_base64(password).decode()\n            login = Login(\n                dict(\n                    federated_platform=login_data.get(\"federatedPlatform\"),\n                    federated_token=login_data.get(\"federatedToken\"),\n                    email=email,\n                    password=password,\n                )\n            )\n\n        return login\n\n    def _determine_login_method(self):\n        \"\"\"Use the data in the request to determine the login method employed.\"\"\"\n        login_data = self.request_data[\"login\"]\n        password = None\n        if login_data[\"federated_platform\"] == \"Facebook\":\n            email_address = get_facebook_account_email(login_data[\"federated_token\"])\n        elif login_data[\"federated_platform\"] == \"Google\":\n            email_address = get_google_account_email(login_data[\"federated_token\"])\n        elif login_data[\"federated_platform\"] == \"GitHub\":\n            email_address = get_github_account_email(login_data.args[\"federated_token\"])\n        else:\n            email_address = login_data[\"email\"]\n            password = login_data[\"password\"]\n\n        return email_address, password\n\n    def _add_account(self, email_address, password):\n        \"\"\"Add a new account to the database.\n\n        :param email_address: the email address of the user\n        :param password: the password used for login\n        \"\"\"\n        account = Account(\n            email_address=email_address,\n            federated_login=password is None,\n            agreements=[\n                AccountAgreement(type=PRIVACY_POLICY, accept_date=date.today()),\n                AccountAgreement(type=TERMS_OF_USE, accept_date=date.today()),\n            ],\n        )\n        self.account_repository.add(account, password=password)\n        self.account_activity_repository.increment_accounts_added()\n\n    def patch(self):\n        \"\"\"Process HTTP PATCH request to update an account.\"\"\"\n        self._authenticate()\n        errors = self._update_account()\n        if errors:\n            response_data = dict(errors=errors)\n            response_status = HTTPStatus.BAD_REQUEST\n        else:\n            self._expire_device_setting_cache()\n            response_data = \"\"\n            response_status = HTTPStatus.NO_CONTENT\n\n        return response_data, response_status\n\n    def _expire_device_setting_cache(self):\n        cache = SeleneCache()\n        etag_manager = ETagManager(cache, self.config)\n        etag_manager.expire_device_setting_etag_by_account_id(self.account.id)\n\n    def _update_account(self):\n        \"\"\"Update the account on the database based on the PATCH request.\"\"\"\n        errors = []\n        for key, value in self.request.json.items():\n            if key == \"membership\":\n                valid_values = self._validate_membership_update_request(value)\n                self._update_membership(valid_values.to_native())\n            elif key == \"username\":\n                self._update_username(value)\n            elif key == \"openDataset\":\n                self._update_open_dataset_agreement(value)\n            else:\n                errors.append(f\"update of {key} not supported\")\n\n        return errors\n\n    @staticmethod\n    def _validate_membership_update_request(value):\n        \"\"\"Validate a request to update membership is well formed.\"\"\"\n        validator = UpdateMembershipRequest()\n        validator.action = value[\"action\"]\n        validator.membership_type = value.get(\"membershipType\")\n        validator.payment_token = value.get(\"paymentToken\")\n        validator.payment_method = value.get(\"paymentMethod\")\n        validator.validate()\n\n        return validator\n\n    def _update_membership(self, membership_change):\n        \"\"\"Update an account's membership status.\"\"\"\n        stripe.api_key = os.environ[\"STRIPE_PRIVATE_KEY\"]\n        active_membership = self._get_active_membership()\n        if membership_change[\"action\"] is None:\n            _log.info(\"No membership option selected\")\n        elif membership_change[\"action\"] == \"cancel\":\n            self._cancel_membership(active_membership)\n            self.account_activity_repository.increment_members_expired()\n        elif membership_change[\"action\"] == \"add\":\n            if active_membership is None:\n                self._add_membership(membership_change, active_membership)\n                self.account_activity_repository.increment_members_added()\n            else:\n                raise ValidationError(\n                    \"new membership requested for account with active membership\"\n                )\n        else:\n            if active_membership is not None:\n                self._cancel_membership(active_membership)\n                self._add_membership(membership_change, active_membership)\n            else:\n                raise ValidationError(\n                    \"membership change requested for account with no \"\n                    \"active membership\"\n                )\n\n    def _get_active_membership(self):\n        \"\"\"Get the currently active membership for the account.\"\"\"\n        acct_repository = AccountRepository(self.db)\n        active_membership = acct_repository.get_active_account_membership(\n            self.account.id\n        )\n\n        return active_membership\n\n    def _add_membership(self, membership_change, active_membership):\n        \"\"\"Add a membership for an account to the database.\"\"\"\n        if active_membership is None:\n            payment_account_id = create_stripe_account(\n                membership_change[\"payment_token\"], self.account.email_address\n            )\n        else:\n            payment_account_id = active_membership.payment_account_id\n        stripe_plan = self._get_stripe_plan(membership_change[\"membership_type\"])\n        payment_id = create_stripe_subscription(payment_account_id, stripe_plan)\n\n        new_membership = AccountMembership(\n            start_date=date.today(),\n            payment_method=STRIPE_PAYMENT,\n            payment_account_id=payment_account_id,\n            payment_id=payment_id,\n            type=membership_change[\"membership_type\"],\n        )\n\n        self.account_repository.add_membership(self.account.id, new_membership)\n\n    def _get_stripe_plan(self, plan):\n        \"\"\"Get the Stripe plan being used for the membership.\"\"\"\n        membership_repository = MembershipRepository(self.db)\n        membership = membership_repository.get_membership_by_type(plan)\n\n        return membership.stripe_plan\n\n    def _cancel_membership(self, active_membership):\n        \"\"\"Cancel the Stripe plan and expire the database row.\"\"\"\n        cancel_stripe_subscription(active_membership.payment_id)\n        active_membership.end_date = datetime.utcnow()\n        account_repository = AccountRepository(self.db)\n        account_repository.end_membership(active_membership)\n\n    def _update_username(self, username):\n        \"\"\"Change the username for an account.\"\"\"\n        self.account_repository.update_username(self.account.id, username)\n\n    def _update_open_dataset_agreement(self, opt_in: bool):\n        \"\"\"Update the status of the open dataset agreement for the account.\"\"\"\n        if opt_in:\n            agreement = AccountAgreement(type=OPEN_DATASET, accept_date=date.today())\n            self.account_repository.add_agreement(self.account.id, agreement)\n            self.account_activity_repository.increment_open_dataset_added()\n        else:\n            self.account_repository.expire_open_dataset_agreement(self.account.id)\n            self.account_activity_repository.increment_open_dataset_deleted()\n\n    def delete(self):\n        \"\"\"Process a HTTP DELETE request for an account.\"\"\"\n        self._authenticate()\n        self.account_repository.remove(self.account)\n        self.account_activity_repository.increment_accounts_deleted()\n        if self.account.membership is not None:\n            cancel_stripe_subscription(self.account.membership.payment_id)\n        self._change_wake_word_file_status()\n\n        return \"\", HTTPStatus.NO_CONTENT\n\n    def _change_wake_word_file_status(self):\n        \"\"\"Set the status of wake word files to \"pending delete\".\n\n        Deleting all the wake word files for an account can be a time consuming\n        process.  Especially if the account has contributed a lot of files.\n        To keep the API call from taking too long to return, the \"pending delete\"\n        status will be used by a nightly batch job to actually delete the files from\n        the file system.\n        \"\"\"\n        agreements = [agreement.type for agreement in self.account.agreements]\n        if OPEN_DATASET in agreements:\n            file_repository = WakeWordFileRepository(self.db)\n            file_repository.change_account_file_status(\n                self.account.id, \"pending delete\"\n            )\n"
  },
  {
    "path": "shared/selene/api/endpoints/agreements.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\n\"\"\"API endpoint to return the contents of an agreement.\"\"\"\nfrom dataclasses import asdict\nfrom http import HTTPStatus\n\nfrom selene.data.account import AgreementRepository\nfrom selene.util.db import connect_to_db\nfrom ..base_endpoint import SeleneEndpoint\n\n\nclass AgreementsEndpoint(SeleneEndpoint):\n    authentication_required = False\n    agreement_types = {\n        \"terms-of-use\": \"Terms of Use\",\n        \"privacy-policy\": \"Privacy Policy\",\n    }\n\n    def get(self, agreement_type):\n        \"\"\"Process HTTP GET request for an agreement.\"\"\"\n        db = connect_to_db(self.config[\"DB_CONNECTION_CONFIG\"])\n        agreement_repository = AgreementRepository(db)\n        agreement = agreement_repository.get_active_for_type(\n            self.agreement_types[agreement_type]\n        )\n        if agreement is not None:\n            agreement = asdict(agreement)\n            del agreement[\"effective_date\"]\n        self.response = agreement, HTTPStatus.OK\n\n        return self.response\n"
  },
  {
    "path": "shared/selene/api/endpoints/password_change.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Common parts of an endpoint to change a user's password.\"\"\"\n\nfrom binascii import a2b_base64\nfrom http import HTTPStatus\n\nfrom selene.api import SeleneEndpoint\nfrom selene.data.account import AccountRepository\n\n\nclass PasswordChangeEndpoint(SeleneEndpoint):\n    \"\"\"Inherit this endpoint for password change endpoints.\"\"\"\n\n    @property\n    def account_id(self):\n        \"\"\"Returns account ID, which can be obtained in different ways.\"\"\"\n        raise NotImplementedError\n\n    def put(self):\n        \"\"\"Executes an HTTP PUT request.\"\"\"\n        self._authenticate()\n        coded_password = self.request.json[\"password\"]\n        binary_password = a2b_base64(coded_password)\n        password = binary_password.decode()\n        acct_repository = AccountRepository(self.db)\n        acct_repository.update_password(self.account_id, password)\n        self._send_email()\n\n        return \"\", HTTPStatus.NO_CONTENT\n\n    def _send_email(self):\n        \"\"\"Override in subclass to send a password changed email.\"\"\"\n"
  },
  {
    "path": "shared/selene/api/endpoints/validate_email.py",
    "content": "#  Mycroft Server - Backend\n#  Copyright (c) 2022 Mycroft AI Inc\n#  SPDX-License-Identifier: \tAGPL-3.0-or-later\n#  #\n#  This file is part of the Mycroft Server.\n#  #\n#  The Mycroft Server is free software: you can redistribute it and/or\n#  modify it under the terms of the GNU Affero General Public License as\n#  published by the Free Software Foundation, either version 3 of the\n#  License, or (at your option) any later version.\n#  #\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n#  GNU Affero General Public License for more details.\n#  #\n#  You should have received a copy of the GNU Affero General Public License\n#  along with this program. If not, see <https://www.gnu.org/licenses/>.\n#\n\"\"\"Defines an API endpoint to validate an email address supplied by the user.\"\"\"\n\nfrom binascii import a2b_base64\nfrom http import HTTPStatus\n\nfrom selene.api import APIError, SeleneEndpoint\nfrom selene.data.account import AccountRepository\nfrom selene.util.auth import (\n    get_facebook_account_email,\n    get_github_account_email,\n    get_google_account_email,\n)\nfrom selene.util.email import validate_email_address\n\n\nclass ValidateEmailEndpoint(SeleneEndpoint):\n    \"\"\"Defines an API endpoint to validate an email address supplied by the user.\"\"\"\n\n    def get(self):\n        \"\"\"Handles a HTTP GET request.\"\"\"\n        return_data = dict(accountExists=False, noFederatedEmail=False)\n        if self.request.args[\"token\"]:\n            email_address = self._get_email_address()\n            if self.request.args[\"platform\"] != \"Internal\" and not email_address:\n                return_data.update(noFederatedEmail=True)\n            account_repository = AccountRepository(self.db)\n            account = account_repository.get_account_by_email(email_address)\n            if account is not None:\n                return_data.update(accountExists=True)\n\n        return return_data, HTTPStatus.OK\n\n    def _get_email_address(self):\n        \"\"\"Retrieves the user's email address from the URL or service provider.\"\"\"\n        if self.request.args[\"platform\"] == \"Google\":\n            email_address = get_google_account_email(self.request.args[\"token\"])\n        elif self.request.args[\"platform\"] == \"Facebook\":\n            email_address = get_facebook_account_email(self.request.args[\"token\"])\n        elif self.request.args[\"platform\"] == \"GitHub\":\n            email_address = get_github_account_email(self.request.args[\"token\"])\n        else:\n            email_address = self._validate_email_address()\n\n        return email_address\n\n    def _validate_email_address(self) -> str:\n        \"\"\"Validates the user's email address to ensure notifications can be sent.\"\"\"\n        coded_email = self.request.args[\"token\"]\n        email_address = a2b_base64(coded_email).decode()\n        normalized_email_address, error = validate_email_address(email_address)\n        if error is not None:\n            raise APIError(error)\n\n        return normalized_email_address\n"
  },
  {
    "path": "shared/selene/api/etag.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nimport random\nimport string\n\nfrom selene.data.device import DeviceRepository\nfrom selene.util.cache import SeleneCache, DEVICE_SKILL_ETAG_KEY\nfrom selene.util.db import connect_to_db\n\nETAG_REQUEST_HEADER_KEY = \"If-None-Match\"\n\n\ndef device_etag_key(device_id: str):\n    return \"device.etag:{uuid}\".format(uuid=device_id)\n\n\ndef device_setting_etag_key(device_id: str):\n    return \"device.setting.etag:{uuid}\".format(uuid=device_id)\n\n\ndef device_location_etag_key(device_id: str):\n    return \"device.location.etag:{uuid}\".format(uuid=device_id)\n\n\nclass ETagManager(object):\n    \"\"\"Class responsible for generate and expire etags\"\"\"\n\n    etag_chars = string.ascii_letters + string.digits\n\n    def __init__(self, cache: SeleneCache, config: dict):\n        self.cache: SeleneCache = cache\n        self.db_connection_config = config[\"DB_CONNECTION_CONFIG\"]\n\n    def get(self, key: str) -> str:\n        \"\"\"Generate a etag with 32 random chars and store it into a given key\n        :param key: key where the etag will be stored\n        :return etag\"\"\"\n        etag = self.cache.get(key)\n        if etag is None:\n            etag = \"\".join(random.choice(self.etag_chars) for _ in range(32))\n            self.cache.set(key, etag)\n        return etag\n\n    def expire(self, key):\n        \"\"\"Expires an existent etag\n        :param key: key where the etag is stored\"\"\"\n        etag = \"\".join(random.choice(self.etag_chars) for _ in range(32))\n        self.cache.set(key, etag)\n\n    def expire_device_etag_by_device_id(self, device_id: str):\n        \"\"\"Expire the etag associated with a device entity\n        :param device_id: device uuid\"\"\"\n        self.expire(device_etag_key(device_id))\n\n    def expire_device_setting_etag_by_device_id(self, device_id: str):\n        \"\"\"Expire the etag associated with a device's settings entity\n        :param device_id: device uuid\"\"\"\n        self.expire(device_setting_etag_key(device_id))\n\n    def expire_device_setting_etag_by_account_id(self, account_id: str):\n        \"\"\"Expire the settings' etags for all devices from a given account. Used when the settings are updated\n        at account level\"\"\"\n        db = connect_to_db(self.db_connection_config)\n        devices = DeviceRepository(db).get_devices_by_account_id(account_id)\n        for device in devices:\n            self.expire_device_setting_etag_by_device_id(device.id)\n\n    def expire_device_location_etag_by_device_id(self, device_id: str):\n        \"\"\"Expire the etag associate with the device's location entity\n        :param device_id: device uuid\"\"\"\n        self.expire(device_location_etag_key(device_id))\n\n    def expire_device_location_etag_by_account_id(self, account_id: str):\n        \"\"\"Expire the locations' etag fpr açç device for a given acccount\n        :param account_id: account uuid\"\"\"\n        db = connect_to_db(self.db_connection_config)\n        devices = DeviceRepository(db).get_devices_by_account_id(account_id)\n        for device in devices:\n            self.expire_device_location_etag_by_device_id(device.id)\n\n    def expire_skill_etag_by_device_id(self, device_id):\n        \"\"\"Expire the locations' etag for a given device\n\n        :param device_id: device uuid\n        \"\"\"\n        self.expire(DEVICE_SKILL_ETAG_KEY.format(device_id=device_id))\n\n    def expire_skill_etag_by_account_id(self, account_id):\n        db = connect_to_db(self.db_connection_config)\n        devices = DeviceRepository(db).get_devices_by_account_id(account_id)\n        for device in devices:\n            self.expire_skill_etag_by_device_id(device.id)\n"
  },
  {
    "path": "shared/selene/api/pantacor.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2021 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Interface with the Pantacor API for devices with software managed by them.\"\"\"\nimport json\nimport logging\nimport requests\nfrom os import environ\n\nfrom selene.data.device import PantacorConfig\n\n_log = logging.getLogger(__name__)\n\n\nclass PantacorError(Exception):\n    \"\"\"Custom exception for unexpected occurrences in a Pantacor APi calls.\"\"\"\n\n\ndef _get_release_channels():\n    \"\"\"Use the API to get the list of available software release channels.\"\"\"\n    release_channels = {}\n    response_data = _call_pantacor_api(\"GET\", endpoint=\"channels\")\n    for channel in response_data[\"items\"]:\n        release_channels[channel[\"id\"]] = channel[\"name\"]\n\n    return release_channels\n\n\ndef get_pantacor_device(pantacor_device_id: str) -> PantacorConfig:\n    \"\"\"Use the API to search for a device based on the pairing code.\n\n    :param pantacor_device_id: six character code used to pair a device to Selene\n    :raises PantacorError: raised when multiple devices match a pairing code\n    \"\"\"\n    _log.info(\n        f\"Requesting device information for pantacor device ID {pantacor_device_id}\"\n    )\n    release_channels = _get_release_channels()\n    response_data = _call_pantacor_api(\"GET\", endpoint=f\"devices/{pantacor_device_id}\")\n    if not response_data:\n        raise PantacorError(\"Pantacor device ID not found.\")\n    ip_address = None\n    claimed = False\n    for label in response_data[\"labels\"]:\n        if label.startswith(\"device-meta\"):\n            label = label.replace(\"device-meta/\", \"\")\n            key, value = label.split(\"=\")\n            if key == \"interfaces.eth0.ipv4.0\":\n                ip_address = value\n            if key == \"interfaces.wlan0.ipv4.0\" and ip_address is None:\n                ip_address = value\n            elif key == \"pantahub.claimed\":\n                claimed = value == \"1\"\n\n    return PantacorConfig(\n        pantacor_id=pantacor_device_id,\n        ip_address=ip_address,\n        release_channel=release_channels[response_data[\"channel_id\"]],\n        auto_update=response_data[\"update_policy\"] == \"auto\",\n        claimed=claimed,\n    )\n\n\ndef get_pantacor_pending_deployment(device_id: str):\n    \"\"\"Use the API to search for a device based on the pairing code.\n\n    :param device_id: Pantacor device ID\n    :raises PantacorError: raised when multiple devices match a pairing code\n    \"\"\"\n    update_id = None\n    params = dict(device_id=device_id, fields=\"-step\")\n    try:\n        response_data = _call_pantacor_api(\n            \"GET\", endpoint=\"deployment-actions/pending\", params=params\n        )\n    except PantacorError:\n        _log.exception(\"Failed to get pending deployment data from Pantacor API\")\n    else:\n        if response_data[\"items\"]:\n            pending_update = response_data[\"items\"][0]\n            update_id = pending_update[\"id\"]\n\n    return update_id\n\n\ndef apply_pantacor_update(deployment_id: str):\n    \"\"\"Use the API to change the update policy of the device.\n\n    :param deployment_id: identifier of a Pantacor deployment to a device.\n    \"\"\"\n    endpoint = f\"deployment-actions/{deployment_id}/play\"\n    _call_pantacor_api(\"PATCH\", endpoint=endpoint)\n\n\ndef _change_pantacor_update_policy(device_id: str, auto_update: bool):\n    \"\"\"Use the API to change the update policy of the device.\n\n    :param device_id: Pantacor device ID\n    :param auto_update: the new value of the attribute\n    \"\"\"\n    update_policy = \"auto\" if auto_update else \"manual\"\n    data = dict(update_policy=update_policy)\n    _call_pantacor_api(\"PATCH\", endpoint=\"devices/\" + device_id, data=data)\n\n\ndef _change_pantacor_release_channel(device_id: str, release_channel: str):\n    \"\"\"Use the API to change the release channel the device is subscribed to.\n\n    We know the names of the release channels, but not their IDs.  Make an extra\n    API call to get the list of valid release channels so we can pass the ID\n    in the PATCH request.\n\n    :param device_id: Pantacor device ID\n    :param release_channel: name of the Pantacor release channel\n    :raises PantacorError: raised if supplied channel name is not in the Pantacor list\n    \"\"\"\n    release_channels = _get_release_channels()\n    new_channel_id = None\n    for key, value in release_channels.items():\n        if release_channel == value:\n            new_channel_id = key\n    if new_channel_id is None:\n        raise PantacorError(\"Could not find release channel \" + release_channel)\n    data = dict(channel_id=new_channel_id)\n    _call_pantacor_api(\"PATCH\", endpoint=\"devices/\" + device_id, data=data)\n\n\ndef _change_pantacor_ssh_key(device_id: str, ssh_key: str):\n    \"\"\"Use the API to change the SSH key used to login to the device remotely.\n\n    :param device_id: Pantacor device ID\n    :param ssh_key: public SSH key of a computer used to log into a device\n    :raises PantacorError: raised if supplied channel name is not in the Pantacor list\n    \"\"\"\n    data = {\"pvr-sdk.authorized_keys\": ssh_key}\n    endpoint = \"devices/\" + device_id + \"/user-meta\"\n    _call_pantacor_api(\"PATCH\", endpoint=endpoint, data=data)\n\n\ndef _call_pantacor_api(method: str, endpoint: str, **kwargs):\n    \"\"\"Issue a request to the Pantacor API.\n\n    :param method: HTTP request method (i.e. GET, PUT, etc.)\n    :param endpoint: portion of URL indicating which endpoint to hit.\n    \"\"\"\n    access_token = environ[\"PANTACOR_API_TOKEN\"]\n    headers = {\n        \"Authorization\": \"Bearer \" + access_token,\n        \"Content-Type\": \"application/json\",\n    }\n    url = environ[\"PANTACOR_API_BASE_URL\"] + endpoint\n    response = requests.request(\n        method,\n        url,\n        params=kwargs.get(\"params\"),\n        headers=headers,\n        data=json.dumps(kwargs.get(\"data\")),\n        timeout=5,\n    )\n\n    if response.ok:\n        response_data = json.loads(response.content.decode())\n    else:\n        raise PantacorError(\n            f\"{method} {url} failed.  Status code: {response.status_code}  \"\n            f\"Content: {response.content.decode()}\"\n        )\n\n    return response_data\n\n\ndef update_pantacor_config(old_config: dict, new_config: dict):\n    \"\"\"Make calls to the Pantacor API to update any values that have changed.\n\n    :param old_config: the config values before the update.\n    :param new_config: the new config values (which may be the same as the old values)\n    \"\"\"\n    for config_name, new_value in new_config.items():\n        old_value = old_config[config_name]\n        if old_value != new_value:\n            if config_name == \"auto_update\":\n                _change_pantacor_update_policy(old_config[\"pantacor_id\"], new_value)\n            elif config_name == \"release_channel\":\n                _change_pantacor_release_channel(old_config[\"pantacor_id\"], new_value)\n            elif config_name == \"ssh_public_key\":\n                _change_pantacor_ssh_key(old_config[\"pantacor_id\"], new_value)\n"
  },
  {
    "path": "shared/selene/api/public_endpoint.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nimport hashlib\nimport json\nimport uuid\n\nfrom flask import current_app, request, Response, after_this_request\nfrom flask import g as global_context\nfrom flask.views import MethodView\n\nfrom selene.api.etag import ETagManager\nfrom selene.data.account import AccountRepository\nfrom selene.data.metric import AccountActivityRepository\nfrom selene.util.auth import AuthenticationError\nfrom selene.util.cache import DEVICE_ACCESS_TOKEN_KEY, SeleneCache\nfrom selene.util.db import connect_to_db\nfrom selene.util.exceptions import NotModifiedException\n\nONE_DAY = 86400\n\n\ndef track_account_activity(db, device_id: str):\n    \"\"\"Use the device ID to find the account, update active timestamp and metrics.\"\"\"\n    account_repository = AccountRepository(db)\n    account = account_repository.get_account_by_device_id(device_id)\n    account_repository.update_last_activity_ts(account.id)\n    account_activity_repository = AccountActivityRepository(db)\n    account_activity_repository.increment_activity(account)\n\n\ndef check_oauth_token():\n    global_context.url = request.url\n    exclude_paths = [\n        \"/v1/device/code\",\n        \"/v1/device/activate\",\n        \"/api/account\",\n        \"/v1/auth/token\",\n        \"/v1/auth/callback\",\n        \"/v1/user/stripe/webhook\",\n    ]\n    exclude = any(request.path.startswith(path) for path in exclude_paths)\n    if not exclude:\n        headers = request.headers\n        if \"Authorization\" not in headers:\n            raise AuthenticationError(\"Oauth token not found\")\n        token_header = headers[\"Authorization\"]\n        device_authenticated = False\n        if token_header.startswith(\"Bearer \"):\n            token = token_header[len(\"Bearer \") :]\n            session = current_app.config[\"SELENE_CACHE\"].get(\n                \"device.token.access:{access}\".format(access=token)\n            )\n            if session:\n                device_authenticated = True\n        if not device_authenticated:\n            raise AuthenticationError(\"device not authorized\")\n\n\ndef generate_device_login(device_id: str, cache: SeleneCache) -> dict:\n    \"\"\"Generates a login session for a given device id\"\"\"\n    sha512 = hashlib.sha512()\n    sha512.update(bytes(str(uuid.uuid4()), \"utf-8\"))\n    access = sha512.hexdigest()\n    sha512.update(bytes(str(uuid.uuid4()), \"utf-8\"))\n    refresh = sha512.hexdigest()\n    login = dict(\n        uuid=device_id, accessToken=access, refreshToken=refresh, expiration=ONE_DAY\n    )\n    login_json = json.dumps(login)\n    # Storing device access token for one:\n    cache.set_with_expiration(\n        \"device.token.access:{access}\".format(access=access), login_json, ONE_DAY\n    )\n    # Storing device refresh token for ever:\n    cache.set(\"device.token.refresh:{refresh}\".format(refresh=refresh), login_json)\n\n    # Storing the login session by uuid (that allows us to delete session using the uuid)\n    cache.set(\"device.session:{uuid}\".format(uuid=device_id), login_json)\n    return login\n\n\ndef delete_device_login(device_id: str, cache: SeleneCache):\n    session = cache.get(\"device.session:{uuid}\".format(uuid=device_id))\n    if session is not None:\n        session = json.loads(session)\n        access_token = session[\"accessToken\"]\n        cache.delete(\"device.token.access:{access}\".format(access=access_token))\n        refresh_token = session[\"refreshToken\"]\n        cache.delete(\"device.refresh.token:{refresh}\".format(refresh=refresh_token))\n        cache.delete(\"device.session:{uuid}\".format(uuid=device_id))\n\n\nclass PublicEndpoint(MethodView):\n    \"\"\"Abstract class for all endpoints used by Mycroft devices\"\"\"\n\n    def __init__(self):\n        self.request_id = uuid.uuid4()\n        global_context.url = request.url\n        self.config: dict = current_app.config\n        self.request = request\n        self.cache: SeleneCache = self.config[\"SELENE_CACHE\"]\n        global_context.cache = self.cache\n        self.etag_manager: ETagManager = ETagManager(self.cache, self.config)\n        self.device_id = None\n\n    @property\n    def db(self):\n        if \"db\" not in global_context:\n            global_context.db = connect_to_db(\n                current_app.config[\"DB_CONNECTION_CONFIG\"]\n            )\n\n        return global_context.db\n\n    def _authenticate(self, device_id: str = None):\n        token = self._get_oauth_token_from_request()\n        self._get_device_id_from_token(token)\n        if device_id is not None:\n            self._validate_request_device_id(device_id)\n\n    def _get_oauth_token_from_request(self):\n        authorization_header = self.request.headers.get(\"Authorization\", \"\")\n        if authorization_header.startswith(\"Bearer \"):\n            token = authorization_header[len(\"Bearer \") :]\n        else:\n            raise AuthenticationError(\"Oauth token not found in request\")\n\n        return token\n\n    def _get_device_id_from_token(self, token):\n        session = self.cache.get(DEVICE_ACCESS_TOKEN_KEY.format(access=token))\n        if session is None:\n            raise AuthenticationError(\"No device matches authentication token\")\n        else:\n            session = json.loads(session)\n            global_context.device_id = session[\"uuid\"]\n            self.device_id = session[\"uuid\"]\n\n    def _validate_request_device_id(self, request_device_id):\n        if request_device_id != self.device_id:\n            raise AuthenticationError(\n                \"Device ID specified in request does not match device ID for the Oauth \"\n                \"token found in the Authorization header\"\n            )\n\n    def _add_etag(self, key):\n        \"\"\"Add a etag header to the response. We try to get the etag from the cache using the given key.\n        If the cache has the etag, we use it, otherwise we generate a etag, store it and add it to the response\"\"\"\n        etag = self.etag_manager.get(key)\n\n        @after_this_request\n        def set_etag_header(response: Response):\n            response.headers[\"ETag\"] = etag\n            return response\n\n    def _validate_etag(self, key):\n        etag_from_request = self.request.headers.get(\"If-None-Match\")\n        if etag_from_request is not None:\n            etag_from_cache = self.cache.get(key)\n            not_modified = (\n                etag_from_cache is not None\n                and etag_from_request == etag_from_cache.decode(\"utf-8\")\n            )\n            if not_modified:\n                raise NotModifiedException()\n"
  },
  {
    "path": "shared/selene/api/response.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom dataclasses import is_dataclass, asdict\nimport re\n\nfrom flask import jsonify, Response\n\nsnake_pattern = re.compile(r\"_([a-z])\")\n\n\ndef snake_to_camel(name):\n    \"\"\"Converts a string from snake case to camel case\"\"\"\n    return snake_pattern.sub(lambda x: x.group(1).upper(), name)\n\n\ndef coerce_response(response_data):\n    \"\"\"Coerce response data to JSON serializable object with camelCase keys\n\n    Recursively walk through the response object in case there are things\n    like nested dataclasses that need to be reformatted\n\n    :param response_data: data returned from the API request\n    :returns: the same data but with keys in camelCase\n    \"\"\"\n    if is_dataclass(response_data):\n        coerced = {\n            snake_to_camel(key): coerce_response(value)\n            for key, value in asdict(response_data).items()\n        }\n    elif isinstance(response_data, dict):\n        coerced = {\n            snake_to_camel(key): coerce_response(value)\n            for key, value in response_data.items()\n        }\n    elif isinstance(response_data, list):\n        coerced = [coerce_response(item) for item in response_data]\n    else:\n        coerced = response_data\n\n    return coerced\n\n\nclass SeleneResponse(Response):\n    @classmethod\n    def force_type(cls, rv, environ=None):\n        if isinstance(rv, dict) or isinstance(rv, list) or is_dataclass(rv):\n            reformat = coerce_response(rv)\n            rv = jsonify(reformat)\n        return super(SeleneResponse, cls).force_type(rv, environ)\n"
  },
  {
    "path": "shared/selene/batch/__init__.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom .base import SeleneScript\n"
  },
  {
    "path": "shared/selene/batch/base.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\n\"\"\"\nBase class that should be inherited by any batch job.\n\nThe logic in this class handles boilerplate code that is required for all batch\njobs, such as argument parsing, logging setup and database connectivity.\n\"\"\"\nfrom argparse import ArgumentParser\nfrom datetime import date, datetime\nfrom os import environ, path\nimport sys\n\nfrom selene.data.metric import JobMetric, JobRepository\nfrom selene.util.db import DatabaseConnectionConfig, connect_to_db\nfrom selene.util.log import configure_selene_logger, get_selene_logger\n\n\nclass SeleneScript(object):\n    _db = None\n    _job_name = None\n\n    def __init__(self, job_file_path):\n        self._job_file_path = job_file_path\n        configure_selene_logger(self.job_name)\n        self.log = get_selene_logger(self.job_name)\n        self._arg_parser = ArgumentParser()\n        self.args = None\n        self.start_ts = datetime.now()\n        self.end_ts = None\n        self.success = False\n\n    @property\n    def job_name(self):\n        if self._job_name is None:\n            job_file_name = path.basename(self._job_file_path)\n            self._job_name = job_file_name[:-3]\n\n        return self._job_name\n\n    @property\n    def db(self):\n        \"\"\"Connect to the mycroft database\"\"\"\n        if self._db is None:\n            db_connection_config = DatabaseConnectionConfig(\n                host=environ[\"DB_HOST\"],\n                db_name=environ[\"DB_NAME\"],\n                password=environ[\"DB_PASSWORD\"],\n                port=environ.get(\"DB_PORT\", 5432),\n                user=environ[\"DB_USER\"],\n                sslmode=environ.get(\"DB_SSLMODE\"),\n            )\n            self._db = connect_to_db(db_connection_config)\n\n        return self._db\n\n    def run(self):\n        \"\"\"Call this method to run the job.\"\"\"\n        try:\n            self._start_job()\n            self._run()\n            self.success = True\n        except:\n            self.log.exception(\"An exception occurred - aborting script\")\n            raise\n        finally:\n            self._finish_job()\n\n    def _start_job(self):\n        \"\"\"Initialization tasks.\"\"\"\n        # Logger builds daily files, delineate start in case of multiple runs\n        self.log.info(\"* * * * *  START OF JOB  * * * * *\")\n        self._define_args()\n        self.args = self._arg_parser.parse_args()\n\n    def _define_args(self):\n        \"\"\"Define the command-line arguments for the batch job.\"\"\"\n        self._arg_parser.add_argument(\n            \"--date\",\n            default=date.today(),\n            help=\"Processing date in YYYY-MM-DD format\",\n            type=lambda dt: datetime.strptime(dt, \"%Y-%m-%d\").date(),\n        )\n\n    def _run(self):\n        \"\"\"Subclass must override this to perform job-specific logic\"\"\"\n        raise NotImplementedError\n\n    def _finish_job(self):\n        \"\"\"Tie up any loose ends.\"\"\"\n        self.end_ts = datetime.now()\n        self._insert_metrics()\n        self.db.close()\n        self.log.info(\"script run time: \" + str(self.end_ts - self.start_ts))\n        # Logger builds daily files, delineate end in case of multiple runs\n        self.log.info(\"* * * * *  END OF JOB  * * * * *\")\n\n    def _insert_metrics(self):\n        \"\"\"Add a row to the job metric table for monitoring purposes.\"\"\"\n        if self.args is not None:\n            job_repository = JobRepository(self.db)\n            job_metric = JobMetric(\n                job_name=self.job_name,\n                batch_date=self.args.date,\n                start_ts=self.start_ts,\n                end_ts=self.end_ts,\n                command=\" \".join(sys.argv),\n                success=self.success,\n            )\n            job_id = job_repository.add(job_metric)\n            self.log.info(\"Job ID: \" + job_id)\n"
  },
  {
    "path": "shared/selene/data/__init__.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n"
  },
  {
    "path": "shared/selene/data/account/__init__.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom .entity.account import Account, AccountAgreement, AccountMembership\nfrom .entity.agreement import Agreement, PRIVACY_POLICY, TERMS_OF_USE, OPEN_DATASET\nfrom .entity.membership import Membership\nfrom .entity.skill import AccountSkill\nfrom .repository.account import AccountRepository\nfrom .repository.agreement import AgreementRepository\nfrom .repository.membership import (\n    MembershipRepository,\n    MONTHLY_MEMBERSHIP,\n    YEARLY_MEMBERSHIP,\n)\nfrom .repository.skill import AccountSkillRepository\n"
  },
  {
    "path": "shared/selene/data/account/entity/__init__.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n"
  },
  {
    "path": "shared/selene/data/account/entity/account.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Data entities representing a user account.\"\"\"\n\nfrom dataclasses import dataclass\nfrom datetime import date, datetime\nfrom typing import List\n\n\n@dataclass\nclass AccountAgreement:\n    \"\"\"Representation of a 'signed' agreement\"\"\"\n\n    type: str\n    accept_date: date\n    id: str = None\n\n\n@dataclass\nclass AccountMembership:\n    \"\"\"Represents the subscription plan chosen by the user\"\"\"\n\n    type: str\n    start_date: date\n    payment_method: str\n    payment_account_id: str\n    payment_id: str\n    id: str = None\n    end_date: date = None\n\n\n@dataclass\nclass Account:\n    \"\"\"Representation of a Mycroft user account.\"\"\"\n\n    email_address: str\n    federated_login: bool\n    agreements: List[AccountAgreement]\n    last_activity: datetime = None\n    membership: AccountMembership = None\n    username: str = None\n    id: str = None\n"
  },
  {
    "path": "shared/selene/data/account/entity/agreement.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom dataclasses import dataclass\nfrom datetime import date\n\nTERMS_OF_USE = \"Terms of Use\"\nPRIVACY_POLICY = \"Privacy Policy\"\nOPEN_DATASET = \"Open Dataset\"\n\n\n@dataclass\nclass Agreement(object):\n    type: str\n    version: str\n    effective_date: date\n    id: str = None\n    content: str = None\n"
  },
  {
    "path": "shared/selene/data/account/entity/membership.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom dataclasses import dataclass\nfrom decimal import Decimal\n\n\n@dataclass\nclass Membership(object):\n    type: str\n    rate: Decimal\n    rate_period: str\n    stripe_plan: str\n    id: str = None\n"
  },
  {
    "path": "shared/selene/data/account/entity/skill.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom dataclasses import dataclass\nfrom typing import List\n\n\n@dataclass\nclass AccountSkill(object):\n    skill_id: str\n    skill_name: str\n    devices: List[str]\n    display_name: str = None\n    settings_version: str = None\n    settings_display: dict = None\n    settings: dict = None\n"
  },
  {
    "path": "shared/selene/data/account/repository/__init__.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n"
  },
  {
    "path": "shared/selene/data/account/repository/account.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Defines CRUD operations for a user account.\"\"\"\nfrom datetime import datetime, timedelta\nfrom logging import getLogger\nfrom os import environ\nfrom typing import Optional\n\nfrom passlib.hash import sha512_crypt\n\nfrom selene.util.db import DatabaseRequest, use_transaction\nfrom ..entity.account import Account, AccountAgreement, AccountMembership\nfrom ..entity.agreement import OPEN_DATASET\nfrom ...repository_base import RepositoryBase\n\n_log = getLogger(__name__)\n\n\ndef _encrypt_password(password: str) -> str:\n    \"\"\"Encrypts the plain-text password using a secret salt and SHA512 encryption.\n\n    :param password: the plain text password as entered by the user\n    :returns: the password value stored in the database.\n    \"\"\"\n    salt = environ[\"SALT\"]\n    hash_result = sha512_crypt.using(salt=salt, rounds=5000).hash(password)\n    hashed_password_index = hash_result.rindex(\"$\") + 1\n\n    return hash_result[hashed_password_index:]\n\n\nclass AccountRepository(RepositoryBase):\n    \"\"\"Collection of methods that access or manipulate the account table.\"\"\"\n\n    def __init__(self, db):\n        super().__init__(db, __file__)\n        self.db = db\n\n    @use_transaction\n    def add(self, account: Account, password: str) -> str:\n        \"\"\"Adds the account and required agreements to the database.\n\n        :param account: dataclass representation of an account entity\n        :param password: password supplied by the user\n        :returns: the internal identifier for the account\n        \"\"\"\n        account_id = self._add_account(account, password)\n        for agreement in account.agreements:\n            self.add_agreement(account_id, agreement)\n        _log.info(\"Added account %s\", account.email_address)\n\n        return account_id\n\n    def _add_account(self, account: Account, password: str):\n        \"\"\"Inserts a row into the account table.\n\n        :param account: dataclass representation of an account entity\n        :param password: password supplied by the user\n        :returns: the internal identifier for the account\n        \"\"\"\n        if password is None:\n            encrypted_password = None\n        else:\n            encrypted_password = _encrypt_password(password)\n        request = self._build_db_request(\n            sql_file_name=\"add_account.sql\",\n            args=dict(\n                email_address=account.email_address,\n                password=encrypted_password,\n                username=account.username,\n            ),\n        )\n        result = self.cursor.insert_returning(request)\n\n        return result[\"id\"]\n\n    def add_agreement(self, account_id: str, agreement: AccountAgreement):\n        \"\"\"Adds the agreements required at account creation time to the database.\n\n        :param account_id: internal identifier for an account\n        :param agreement: data necessary to insert a row into account_agreement table.\n        \"\"\"\n        request = self._build_db_request(\n            sql_file_name=\"add_account_agreement.sql\",\n            args=dict(account_id=account_id, agreement_name=agreement.type),\n        )\n        self.cursor.insert(request)\n\n    def remove(self, account: Account):\n        \"\"\"Delete and account and all of its children.\n\n        As part of the privacy focus of Mycroft AI, all data related to a user is\n        deleted when the account is deleted.  This is enforced in the database using\n        cascading deletes on any table with a foreign key to the account table.\n\n        :param account: instance of the account entity.\n        \"\"\"\n        request = self._build_db_request(\n            sql_file_name=\"remove_account.sql\", args=dict(id=account.id)\n        )\n        self.cursor.delete(request)\n        log_msg = \"Deleted account {} and all it's related data\"\n        _log.info(log_msg.format(account.email_address))\n\n    def get_account_by_id(self, account_id: str) -> Optional[Account]:\n        \"\"\"Use a given uuid to query the database for an account\n\n        :param account_id: uuid\n        :return: an account entity, if one is found\n        \"\"\"\n        account_id_resolver = \"%(account_id)s\"\n        request = self._build_db_request(\n            sql_file_name=\"get_account.sql\",\n            args=dict(account_id=account_id),\n        )\n        request.sql = request.sql.format(account_id_resolver=account_id_resolver)\n\n        return self._get_account(request)\n\n    def get_account_by_email(self, email_address: str) -> Optional[Account]:\n        \"\"\"Retrieves the account using the email address provided by the user at login.\n\n        :param email_address: email address provided by user at login\n        :return: the account associated with the given email address\n        \"\"\"\n        account_id_resolver = (\n            \"(SELECT id FROM account.account \"\n            \"WHERE email_address = %(email_address)s)\"\n        )\n        request = self._build_db_request(\n            sql_file_name=\"get_account.sql\",\n            args=dict(email_address=email_address),\n        )\n        request.sql = request.sql.format(account_id_resolver=account_id_resolver)\n\n        return self._get_account(request)\n\n    def get_account_from_credentials(\n        self, email: str, password: str\n    ) -> Optional[Account]:\n        \"\"\"Validates email/password combination against the database\n\n        :param email: the user provided email address\n        :param password: the user provided password\n        :return: the matching account record, if one is found\n        \"\"\"\n        account_id_resolver = (\n            \"(SELECT id FROM account.account \"\n            \"WHERE email_address = %(email_address)s and password=%(password)s)\"\n        )\n        encrypted_password = _encrypt_password(password)\n        request = self._build_db_request(\n            sql_file_name=\"get_account.sql\",\n            args=dict(email_address=email, password=encrypted_password),\n        )\n        request.sql = request.sql.format(account_id_resolver=account_id_resolver)\n\n        return self._get_account(request)\n\n    def get_account_by_device_id(self, device_id: str) -> Optional[Account]:\n        \"\"\"Return the account associated with the specified device.\n\n        :param device_id: internal identifier of a Mycroft voice assistant device\n        \"\"\"\n        request = self._build_db_request(\n            sql_file_name=\"get_account_by_device_id.sql\", args=dict(device_id=device_id)\n        )\n        return self._get_account(request)\n\n    def _get_account(self, db_request: DatabaseRequest) -> Optional[Account]:\n        \"\"\"Builds an account entity from the database.\n\n        There are many ways an account can be retrieved using differing WHERE clauses.\n        The query is built appending one of these WHERE clauses to a common SELECT\n        clause.\n\n        :param db_request: the SQL and arguments necessary for the query\n        :return: An account entity if a match to the query is found on the database\n        \"\"\"\n        account = None\n        result = self.cursor.select_one(db_request)\n\n        if result is not None:\n            account_agreements = []\n            if result[\"account\"][\"agreements\"] is not None:\n                for agreement in result[\"account\"][\"agreements\"]:\n                    account_agreements.append(AccountAgreement(**agreement))\n            result[\"account\"][\"agreements\"] = account_agreements\n            if result[\"account\"][\"membership\"] is not None:\n                result[\"account\"][\"membership\"] = AccountMembership(\n                    **result[\"account\"][\"membership\"]\n                )\n            if result[\"account\"][\"last_activity\"] is not None:\n                no_milliseconds = len(result[\"account\"][\"last_activity\"]) == 19\n                if no_milliseconds:\n                    parse_string = \"%Y-%m-%dT%H:%M:%S\"\n                else:\n                    parse_string = \"%Y-%m-%dT%H:%M:%S.%f\"\n                result[\"account\"][\"last_activity\"] = datetime.strptime(\n                    result[\"account\"][\"last_activity\"], parse_string\n                )\n            account = Account(**result[\"account\"])\n\n        return account\n\n    def update_password(self, account_id: str, password: str):\n        \"\"\"Changes the password for an account on the database.\n\n        :param account_id: internal account identifier\n        :param password: unencrypted password supplied by user\n        \"\"\"\n        encrypted_password = _encrypt_password(password)\n        db_request = self._build_db_request(\n            sql_file_name=\"change_password.sql\",\n            args=dict(account_id=account_id, password=encrypted_password),\n        )\n        self.cursor.update(db_request)\n\n    def update_email_address(self, account_id: str, email_address: str):\n        \"\"\"Update the email address for the specified account ID.\n\n        :param account_id: Internal identifier for a user's account\n        :param email_address: The new email address for the account\n        \"\"\"\n        db_request = self._build_db_request(\n            sql_file_name=\"change_email_address.sql\",\n            args=dict(account_id=account_id, email_address=email_address),\n        )\n        self.cursor.update(db_request)\n\n    def update_username(self, account_id: str, username: str):\n        \"\"\"Changes the username associated with an account.\n\n        :param account_id: internal account identifier\n        :param username: the new value of the username\n        \"\"\"\n        db_request = self._build_db_request(\n            sql_file_name=\"update_username.sql\",\n            args=dict(account_id=account_id, username=username),\n        )\n        self.cursor.update(db_request)\n\n    def expire_open_dataset_agreement(self, account_id: str):\n        \"\"\"Expires the open dataset agreement when the user opts out.\n\n        :param account_id: internal account identifier\n        \"\"\"\n        db_request = self._build_db_request(\n            sql_file_name=\"expire_account_agreement.sql\",\n            args=dict(account_id=account_id, agreement_type=OPEN_DATASET),\n        )\n        self.cursor.delete(db_request)\n\n    def update_last_activity_ts(self, account_id: str):\n        \"\"\"Updates the user's last activity time when activity is detected.\n\n        :param account_id: internal account identifier\n        \"\"\"\n        db_request = self._build_db_request(\n            sql_file_name=\"update_last_activity_ts.sql\",\n            args=dict(account_id=account_id, last_activity_ts=datetime.utcnow()),\n        )\n\n        self.cursor.update(db_request)\n\n    def daily_report(self, date: datetime):\n        \"\"\"Builds a daily report from account data on the database.\n\n        :param date: the date of the report\n        \"\"\"\n        base = date - timedelta(days=1)\n        end_date = base.strftime(\"%Y-%m-%d\")\n        start_date_1_day = (base - timedelta(days=1)).strftime(\"%Y-%m-%d\")\n        start_date_15_days = (base - timedelta(days=15)).strftime(\"%Y-%m-%d\")\n        start_date_30_days = (base - timedelta(days=30)).strftime(\"%Y-%m-%d\")\n        db_request = self._build_db_request(\n            sql_file_name=\"daily_report.sql\",\n            args=dict(start_date=start_date_1_day, end_date=end_date),\n        )\n        report_1_day = self.cursor.select_one(db_request)\n        db_request = self._build_db_request(\n            sql_file_name=\"daily_report.sql\",\n            args=dict(start_date=start_date_15_days, end_date=end_date),\n        )\n        report_15_days = self.cursor.select_one(db_request)\n        db_request = self._build_db_request(\n            sql_file_name=\"daily_report.sql\",\n            args=dict(start_date=start_date_30_days, end_date=end_date),\n        )\n        report_30_days = self.cursor.select_one(db_request)\n\n        report_table = [\n            dict(\n                type=\"User\",\n                current=report_1_day[\"total\"],\n                oneDay=report_1_day[\"total\"] - report_1_day[\"total_new\"],\n                oneDayDelta=report_1_day[\"total_new\"],\n                oneDayMinus=0,\n                fifteenDays=report_15_days[\"total\"] - report_15_days[\"total_new\"],\n                fifteenDaysDelta=report_15_days[\"total_new\"],\n                fifteenDaysMinus=0,\n                thirtyDays=report_30_days[\"total\"] - report_30_days[\"total_new\"],\n                thirtyDaysDelta=report_30_days[\"total_new\"],\n                thirtyDaysMinus=0,\n            ),\n            dict(\n                type=\"Free Account\",\n                current=report_1_day[\"total\"] - report_1_day[\"paid_total\"],\n                oneDay=report_1_day[\"total\"]\n                - report_1_day[\"paid_total\"]\n                - report_1_day[\"total_new\"]\n                + report_1_day[\"paid_new\"],\n                oneDayDelta=report_1_day[\"total_new\"] - report_1_day[\"paid_new\"],\n                oneDayMinus=0,\n                fifteenDays=report_15_days[\"total\"]\n                - report_15_days[\"paid_total\"]\n                - report_15_days[\"total_new\"]\n                + report_15_days[\"paid_new\"],\n                fifteenDaysDelta=report_15_days[\"total_new\"]\n                - report_15_days[\"paid_new\"],\n                fifteenDaysMinus=0,\n                thirtyDays=report_30_days[\"total\"]\n                - report_30_days[\"paid_total\"]\n                - report_30_days[\"total_new\"]\n                + report_30_days[\"paid_new\"],\n                thirtyDaysDelta=report_30_days[\"total_new\"]\n                - report_30_days[\"paid_new\"],\n                thirtyDaysMinus=0,\n            ),\n            dict(\n                type=\"Monthly Account\",\n                current=report_1_day[\"monthly_total\"],\n                oneDay=report_1_day[\"monthly_total\"]\n                - report_1_day[\"monthly_new\"]\n                + report_1_day[\"monthly_minus\"],\n                oneDayDelta=report_1_day[\"monthly_new\"],\n                oneDayMinus=report_1_day[\"monthly_minus\"],\n                fifteenDays=report_15_days[\"monthly_total\"]\n                - report_15_days[\"monthly_new\"]\n                + report_15_days[\"monthly_minus\"],\n                fifteenDaysDelta=report_15_days[\"monthly_new\"],\n                fifteenDaysMinus=report_15_days[\"monthly_minus\"],\n                thirtyDays=report_30_days[\"monthly_total\"]\n                - report_30_days[\"monthly_new\"]\n                + report_30_days[\"monthly_minus\"],\n                thirtyDaysDelta=report_30_days[\"monthly_new\"],\n                thirtyDaysMinus=report_30_days[\"monthly_minus\"],\n            ),\n            dict(\n                type=\"Yearly Account\",\n                current=report_1_day[\"yearly_total\"],\n                oneDay=report_1_day[\"yearly_total\"]\n                - report_1_day[\"yearly_new\"]\n                + report_1_day[\"yearly_minus\"],\n                oneDayDelta=report_1_day[\"yearly_new\"],\n                oneDayMinus=report_1_day[\"yearly_minus\"],\n                fifteenDays=report_15_days[\"yearly_total\"]\n                - report_15_days[\"yearly_new\"]\n                + report_15_days[\"yearly_minus\"],\n                fifteenDaysDelta=report_15_days[\"yearly_new\"],\n                fifteenDaysMinus=report_15_days[\"yearly_minus\"],\n                thirtyDays=report_30_days[\"yearly_total\"]\n                - report_30_days[\"yearly_new\"]\n                + report_30_days[\"yearly_minus\"],\n                thirtyDaysDelta=report_30_days[\"yearly_new\"],\n                thirtyDaysMinus=report_30_days[\"yearly_minus\"],\n            ),\n            dict(\n                type=\"Paid Account\",\n                current=report_1_day[\"paid_total\"],\n                oneDay=report_1_day[\"paid_total\"]\n                - report_1_day[\"paid_new\"]\n                + report_1_day[\"paid_minus\"],\n                oneDayDelta=report_1_day[\"paid_new\"],\n                oneDayMinus=report_1_day[\"paid_minus\"],\n                fifteenDays=report_15_days[\"paid_total\"]\n                - report_15_days[\"paid_new\"]\n                + report_15_days[\"paid_minus\"],\n                fifteenDaysDelta=report_15_days[\"paid_new\"],\n                fifteenDaysMinus=report_15_days[\"paid_minus\"],\n                thirtyDays=report_30_days[\"paid_total\"]\n                - report_30_days[\"paid_new\"]\n                + report_30_days[\"paid_minus\"],\n                thirtyDaysDelta=report_30_days[\"paid_new\"],\n                thirtyDaysMinus=report_30_days[\"paid_minus\"],\n            ),\n        ]\n\n        return report_table\n\n    def add_membership(self, acct_id: str, membership: AccountMembership):\n        \"\"\"Adds a membership to the database if one was selected by the user.\n\n        :param acct_id: internal identifier for the account\n        :param membership: data used to populate the account_membership table.\n        \"\"\"\n        request = self._build_db_request(\n            sql_file_name=\"add_account_membership.sql\",\n            args=dict(\n                account_id=acct_id,\n                membership_type=membership.type,\n                payment_method=membership.payment_method,\n                payment_account_id=membership.payment_account_id,\n                payment_id=membership.payment_id,\n            ),\n        )\n        self.cursor.insert(request)\n\n    def end_membership(self, membership: AccountMembership):\n        \"\"\"Expires a membership when a user discontinues payment.\n\n        :param membership: the membership to expire\n        \"\"\"\n        db_request = self._build_db_request(\n            sql_file_name=\"end_membership.sql\",\n            args=dict(\n                id=membership.id,\n                membership_ts_range=f\"[{membership.start_date},{membership.end_date}]\",\n            ),\n        )\n        self.cursor.update(db_request)\n\n    def end_active_membership(self, customer_id: str):\n        \"\"\"Expires a membership when a user discontinues payment.\n\n        :param customer_id: Payment system identifier of the user's subscription\n        \"\"\"\n        db_request = self._build_db_request(\n            sql_file_name=\"get_active_membership_by_payment_account_id.sql\",\n            args=dict(payment_account_id=customer_id),\n        )\n        db_result = self.cursor.select_one(db_request)\n        if db_result is not None:\n            account_membership = AccountMembership(**db_result)\n            account_membership.end_date = datetime.utcnow()\n            self.end_membership(account_membership)\n\n    def get_active_account_membership(self, account_id) -> Optional[AccountMembership]:\n        \"\"\"Retrieves an active membership for the given account, if one exists.\n\n        :param account_id: internal account identifier\n        :returns: membership information if the account is a member\n        \"\"\"\n        account_membership = None\n        db_request = self._build_db_request(\n            sql_file_name=\"get_active_membership_by_account_id.sql\",\n            args=dict(account_id=account_id),\n        )\n        db_result = self.cursor.select_one(db_request)\n        if db_result:\n            account_membership = AccountMembership(**db_result)\n\n        return account_membership\n"
  },
  {
    "path": "shared/selene/data/account/repository/agreement.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom datetime import date, timedelta\nfrom os import environ, path\nfrom logging import getLogger\n\nfrom psycopg2.extras import DateRange\n\nfrom selene.util.db import Cursor, DatabaseRequest, get_sql_from_file, use_transaction\nfrom ..entity.agreement import Agreement\n\nSQL_DIR = path.join(path.dirname(__file__), \"sql\")\n\n_log = getLogger(__name__)\n\n\nclass AgreementRepository(object):\n    def __init__(self, db):\n        self.db = db\n        self.cursor = Cursor(db)\n        self.skip_no_agreement_error = False\n\n    @use_transaction\n    def add(self, agreement: Agreement) -> str:\n        self.skip_no_agreement_error = True\n        expire_date = agreement.effective_date - timedelta(days=1)\n        self.expire(agreement, expire_date)\n        content_id = self._add_agreement_content(agreement.content)\n        agreement_id = self._add_agreement(agreement, content_id)\n\n        return agreement_id\n\n    def _add_agreement_content(self, content):\n        if content is None:\n            agreement_oid = None\n        else:\n            large_object = self.db.lobject(0, \"b\")\n            large_object.write(content)\n            agreement_oid = large_object.oid\n\n        return agreement_oid\n\n    def _add_agreement(self, agreement: Agreement, content_id: int) -> str:\n        date_range = DateRange(agreement.effective_date, None)\n        request = DatabaseRequest(\n            sql=get_sql_from_file(path.join(SQL_DIR, \"add_agreement.sql\")),\n            args=dict(\n                agreement_type=agreement.type,\n                version=agreement.version,\n                date_range=date_range,\n                content_id=content_id,\n            ),\n        )\n        result = self.cursor.insert_returning(request)\n        _log.info(\n            \"added {} agreement version {} starting {}\".format(\n                agreement.type, agreement.version, agreement.effective_date\n            )\n        )\n\n        return result[\"id\"]\n\n    def expire(self, agreement: Agreement, expire_date: date):\n        active_agreement = self.get_active_for_type(agreement.type)\n        if active_agreement is not None:\n            date_range = DateRange(active_agreement.effective_date, expire_date)\n            request = DatabaseRequest(\n                sql=get_sql_from_file(path.join(SQL_DIR, \"expire_agreement.sql\")),\n                args=dict(agreement_type=agreement.type, date_range=date_range),\n            )\n            self.cursor.update(request)\n            log_msg = \"set expire date of active {} agreement to {}\"\n            _log.info(log_msg.format(agreement.type, expire_date))\n        else:\n            _log.info(\"no active {} agreement to expire\".format(agreement.type))\n\n    @use_transaction\n    def remove(self, agreement: Agreement):\n        \"\"\"AGREEMENTS SHOULD NEVER BE REMOVED!  ONLY USE IN TEST CODE!\"\"\"\n        if environ[\"SELENE_ENVIRONMENT\"] == \"dev\":\n            content_id = self._get_agreement_content_id(agreement.id)\n            if content_id is not None:\n                large_object = self.db.lobject(content_id)\n                large_object.unlink()\n            request = DatabaseRequest(\n                sql=get_sql_from_file(path.join(SQL_DIR, \"delete_agreement.sql\")),\n                args=dict(agreement_id=agreement.id),\n            )\n            self.cursor.delete(request)\n            log_msg = \"deleted {} agreement version {}\"\n            _log.info(log_msg.format(agreement.type, agreement.version))\n\n    def _get_agreement_content_id(self, agreement_id: str) -> int:\n        request = DatabaseRequest(\n            sql=get_sql_from_file(path.join(SQL_DIR, \"get_agreement_content_id.sql\")),\n            args=dict(agreement_id=agreement_id),\n        )\n        result = self.cursor.select_one(request)\n\n        return result[\"content_id\"]\n\n    @use_transaction\n    def get_active(self):\n        agreements = []\n        request = DatabaseRequest(\n            sql=get_sql_from_file(path.join(SQL_DIR, \"get_current_agreements.sql\"))\n        )\n        for row in self.cursor.select_all(request):\n            content = self._get_agreement_content(row[\"content_id\"])\n            agreements.append(\n                Agreement(\n                    id=row[\"id\"],\n                    type=row[\"agreement\"],\n                    version=row[\"version\"],\n                    content=content,\n                    effective_date=row[\"effective_date\"],\n                )\n            )\n\n        if not agreements and not self.skip_no_agreement_error:\n            _log.error(\"no agreements found with effective date of today\")\n\n        return agreements\n\n    def get_active_for_type(self, agreement_type):\n        agreement = None\n        for active_agreement in self.get_active():\n            if active_agreement.type == agreement_type:\n                agreement = active_agreement\n\n        return agreement\n\n    def _get_agreement_content(self, content_id):\n        content = None\n        if content_id is not None:\n            large_object = self.db.lobject(content_id, \"r\")\n            content = large_object.read()\n\n        return content\n"
  },
  {
    "path": "shared/selene/data/account/repository/membership.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom selene.data.account import AccountMembership\nfrom ..entity.membership import Membership\nfrom ...repository_base import RepositoryBase\n\nMONTHLY_MEMBERSHIP = \"Monthly Membership\"\nYEARLY_MEMBERSHIP = \"Yearly Membership\"\n\n\nclass MembershipRepository(RepositoryBase):\n    def __init__(self, db):\n        super(MembershipRepository, self).__init__(db, __file__)\n\n    def get_membership_types(self):\n        db_request = self._build_db_request(sql_file_name=\"get_membership_types.sql\")\n        db_result = self.cursor.select_all(db_request)\n\n        return [Membership(**row) for row in db_result]\n\n    def get_membership_by_type(self, membership_type: str):\n        db_request = self._build_db_request(\n            sql_file_name=\"get_membership_by_type.sql\", args=dict(type=membership_type)\n        )\n        db_result = self.cursor.select_one(db_request)\n        return Membership(**db_result)\n\n    def add(self, membership: Membership):\n        db_request = self._build_db_request(\n            \"add_membership.sql\",\n            args=dict(\n                membership_type=membership.type,\n                rate=membership.rate,\n                rate_period=membership.rate_period,\n            ),\n        )\n        result = self.cursor.insert_returning(db_request)\n\n        return result[\"id\"]\n\n    def remove(self, membership: Membership):\n        db_request = self._build_db_request(\n            sql_file_name=\"delete_membership.sql\",\n            args=dict(membership_id=membership.id),\n        )\n        self.cursor.delete(db_request)\n"
  },
  {
    "path": "shared/selene/data/account/repository/skill.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom typing import List\n\nfrom selene.data.account.entity.skill import AccountSkill\nfrom selene.data.repository_base import RepositoryBase\n\n\nclass AccountSkillRepository(RepositoryBase):\n    def __init__(self, db, account_id):\n        super(AccountSkillRepository, self).__init__(db, __file__)\n        self.account_id = account_id\n\n    def get_skills_for_account(self) -> List[AccountSkill]:\n        db_request = self._build_db_request(\n            sql_file_name=\"get_account_skills.sql\",\n            args=dict(account_id=self.account_id),\n        )\n        db_result = self.cursor.select_all(db_request)\n\n        return [AccountSkill(**row) for row in db_result]\n"
  },
  {
    "path": "shared/selene/data/account/repository/sql/add_account.sql",
    "content": "INSERT INTO\n    account.account (email_address, password, username)\nVALUES\n    (%(email_address)s, %(password)s, %(username)s)\nRETURNING\n    id\n"
  },
  {
    "path": "shared/selene/data/account/repository/sql/add_account_agreement.sql",
    "content": "INSERT INTO\n    account.account_agreement (account_id, agreement_id, accept_date)\nVALUES\n    (\n        %(account_id)s,\n        (\n            SELECT\n                id\n            FROM\n                account.agreement\n            WHERE\n                agreement = %(agreement_name)s\n                AND effective @> CURRENT_DATE\n        ),\n        '[now,]'\n    )\n"
  },
  {
    "path": "shared/selene/data/account/repository/sql/add_account_membership.sql",
    "content": "INSERT INTO\n    account.account_membership (\n        account_id,\n        membership_id,\n        membership_ts_range,\n        payment_method,\n        payment_account_id,\n        payment_id\n    )\nVALUES\n    (\n        %(account_id)s,\n        (\n            SELECT\n                id\n            FROM\n                account.membership\n            WHERE\n                type = %(membership_type)s\n        ),\n        '[now,]',\n        %(payment_method)s,\n        %(payment_account_id)s,\n        %(payment_id)s\n    )\n"
  },
  {
    "path": "shared/selene/data/account/repository/sql/add_agreement.sql",
    "content": "INSERT INTO\n    account.agreement (agreement, version, effective, content_id)\nVALUES\n    (%(agreement_type)s, %(version)s, %(date_range)s, %(content_id)s)\nRETURNING\n    id\n"
  },
  {
    "path": "shared/selene/data/account/repository/sql/add_membership.sql",
    "content": "INSERT INTO\n    account.membership (type, rate, rate_period)\nVALUES\n    (%(membership_type)s, %(rate)s, %(rate_period)s)\nRETURNING\n    id\n"
  },
  {
    "path": "shared/selene/data/account/repository/sql/change_email_address.sql",
    "content": "UPDATE\n    account.account\nSET\n    email_address = %(email_address)s\nWHERE\n    id = %(account_id)s\n"
  },
  {
    "path": "shared/selene/data/account/repository/sql/change_password.sql",
    "content": "UPDATE\n    account.account\nSET\n    password = %(password)s\nWHERE\n    id = %(account_id)s\n"
  },
  {
    "path": "shared/selene/data/account/repository/sql/daily_report.sql",
    "content": "SELECT\n\tCOUNT(acc) FILTER(WHERE acc.insert_ts::DATE <= %(end_date)s) AS total,\n\tCOUNT(acc) FILTER(WHERE acc.insert_ts::DATE > %(start_date)s AND acc.insert_ts::DATE <= %(end_date)s) AS total_new,\n\tCOUNT(mem) FILTER(WHERE mem.rate_period = 'month' AND UPPER(acc_mem.membership_ts_range) IS NULL AND LOWER(acc_mem.membership_ts_range)::DATE <= %(end_date)s) AS monthly_total,\n\tCOUNT(mem) FILTER(WHERE mem.rate_period = 'month' AND UPPER(acc_mem.membership_ts_range) IS NULL AND LOWER(acc_mem.membership_ts_range)::DATE > %(start_date)s AND LOWER(acc_mem.membership_ts_range)::DATE <= %(end_date)s) AS monthly_new,\n\tCOUNT(mem) FILTER(WHERE mem.rate_period = 'month' AND UPPER(acc_mem.membership_ts_range) IS NOT NULL AND UPPER(acc_mem.membership_ts_range)::DATE > %(start_date)s AND UPPER(acc_mem.membership_ts_range)::DATE <= %(end_date)s) AS monthly_minus,\n\tCOUNT(mem) FILTER(WHERE mem.rate_period = 'year' AND UPPER(acc_mem.membership_ts_range) IS NULL AND LOWER(acc_mem.membership_ts_range)::DATE <= %(end_date)s) AS yearly_total,\n\tCOUNT(mem) FILTER(WHERE mem.rate_period = 'year' AND UPPER(acc_mem.membership_ts_range) IS NULL AND LOWER(acc_mem.membership_ts_range)::DATE > %(start_date)s AND LOWER(acc_mem.membership_ts_range)::DATE <= %(end_date)s) AS yearly_new,\n\tCOUNT(mem) FILTER(WHERE mem.rate_period = 'year' AND UPPER(acc_mem.membership_ts_range) IS NOT NULL AND UPPER(acc_mem.membership_ts_range)::DATE > %(start_date)s AND UPPER(acc_mem.membership_ts_range)::DATE <= %(end_date)s) as yearly_minus,\n\tCOUNT(acc_mem) FILTER(WHERE UPPER(acc_mem.membership_ts_range) IS NULL AND LOWER(acc_mem.membership_ts_range)::DATE <= %(end_date)s) AS paid_total,\n\tCOUNT(acc_mem) FILTER(WHERE UPPER(acc_mem.membership_ts_range) IS NULL AND LOWER(acc_mem.membership_ts_range)::DATE > %(start_date)s AND LOWER(acc_mem.membership_ts_range)::DATE <= %(end_date)s) AS paid_new,\n\tCOUNT(acc_mem) FILTER(WHERE UPPER(acc_mem.membership_ts_range) IS NOT NULL AND UPPER(acc_mem.membership_ts_range)::DATE > %(start_date)s AND UPPER(acc_mem.membership_ts_range)::DATE <= %(end_date)s) AS paid_minus\nFROM\n\taccount.account acc\nLEFT JOIN\n\taccount.account_membership acc_mem ON acc.id = acc_mem.account_id\nLEFT JOIN\n\taccount.membership mem ON acc_mem.membership_id = mem.id"
  },
  {
    "path": "shared/selene/data/account/repository/sql/delete_agreement.sql",
    "content": "DELETE FROM\n    account.agreement\nWHERE\n    id = %(agreement_id)s\n"
  },
  {
    "path": "shared/selene/data/account/repository/sql/delete_membership.sql",
    "content": "DELETE FROM\n    account.membership\nWHERE\n    id = %(membership_id)s\n"
  },
  {
    "path": "shared/selene/data/account/repository/sql/end_membership.sql",
    "content": "UPDATE\n    account.account_membership\nSET\n    membership_ts_range = %(membership_ts_range)s\nWHERE\n    id = %(id)s"
  },
  {
    "path": "shared/selene/data/account/repository/sql/expire_account_agreement.sql",
    "content": "DELETE FROM\n    account.account_agreement\nWHERE\n    account_id = %(account_id)s\n    AND agreement_id in (SELECT id FROM account.agreement WHERE agreement = %(agreement_type)s)\n"
  },
  {
    "path": "shared/selene/data/account/repository/sql/expire_agreement.sql",
    "content": "UPDATE\n    account.agreement\nSET\n    effective = %(date_range)s\nWHERE\n    agreement = %(agreement_type)s\n    AND upper(effective) IS NULL\n"
  },
  {
    "path": "shared/selene/data/account/repository/sql/get_account.sql",
    "content": "WITH\n    agreements AS (\n        SELECT\n            array_agg(\n                json_build_object(\n                    'id', aa.id,\n                    'type', ag.agreement,\n                    'accept_date', aa.accept_date\n                )\n            )\n        FROM\n            account.account_agreement aa\n            INNER JOIN account.agreement ag ON ag.id = aa.agreement_id\n        WHERE\n            aa.account_id = {account_id_resolver}\n    ),\n    membership AS (\n        SELECT\n            json_build_object(\n                'id', am.id,\n                'type', m.type,\n                'start_date', lower(am.membership_ts_range)::DATE,\n                'payment_method', am.payment_method,\n                'payment_account_id', am.payment_account_id,\n                'payment_id', am.payment_id\n            )\n        FROM\n            account.account_membership am\n            INNER JOIN account.membership m ON am.membership_id = m.id\n        WHERE\n            am.account_id = {account_id_resolver}\n            AND upper(am.membership_ts_range) IS NULL\n    )\nSELECT\n    json_build_object(\n        'id', id,\n        'email_address', email_address,\n        'federated_login', password is null,\n        'username', username,\n        'last_activity', last_activity_ts,\n        'membership', (SELECT * FROM membership),\n        'agreements', (SELECT * FROM agreements)\n    ) as account\nFROM\n    account.account\nWHERE\n    id = {account_id_resolver}\n"
  },
  {
    "path": "shared/selene/data/account/repository/sql/get_account_by_device_id.sql",
    "content": "WITH\n    agreements AS (\n        SELECT\n            array_agg(\n                json_build_object(\n                    'id', aa.id,\n                    'type', ag.agreement,\n                    'accept_date', aa.accept_date\n                )\n            )\n        FROM\n            account.account_agreement aa\n        INNER JOIN\n            account.agreement ag ON ag.id = aa.agreement_id\n        INNER JOIN\n            account.account acc ON aa.account_id = acc.id\n        INNER JOIN\n            device.device dev ON acc.id = dev.account_id\n        WHERE\n            dev.id = %(device_id)s\n    ),\n    membership AS (\n        SELECT\n            json_build_object(\n                'id', acc_mem.id,\n                'type', mem.type,\n                'start_date', lower(acc_mem.membership_ts_range)::DATE,\n                'payment_method', acc_mem.payment_method,\n                'payment_account_id', acc_mem.payment_account_id,\n                'payment_id', acc_mem.payment_id\n            )\n        FROM\n            account.account_membership acc_mem\n        INNER JOIN\n            account.membership mem ON acc_mem.membership_id = mem.id\n        INNER JOIN\n            account.account acc ON acc_mem.account_id = acc.id\n        INNER JOIN\n            device.device dev ON acc.id = dev.account_id\n        WHERE\n            dev.id = %(device_id)s\n            AND upper(acc_mem.membership_ts_range) IS NULL\n    )\nSELECT\n    json_build_object(\n        'id', acc.id,\n        'email_address', acc.email_address,\n        'federated_login', acc.password is null,\n        'username', acc.username,\n        'last_activity', acc.last_activity_ts,\n        'membership', (SELECT * FROM membership),\n        'agreements', (SELECT * FROM agreements)\n    ) as account\nFROM\n    account.account acc\nINNER JOIN\n    device.device dev ON acc.id = dev.account_id\nWHERE\n    dev.id = %(device_id)s\n"
  },
  {
    "path": "shared/selene/data/account/repository/sql/get_account_skills.sql",
    "content": "-- get all combinations of skills and skill setting meta for an account\nWITH\n    skills AS (\n        SELECT\n            s.id AS skill_id,\n            s.name AS skill_name,\n            ds.skill_setting_meta_id,\n            ds.settings::jsonb,\n            array_agg(d.name) AS devices\n        FROM\n            skill.skill s\n            INNER JOIN device.device_skill ds ON ds.skill_id = s.id\n            INNER JOIN device.device d ON d.id = ds.device_id\n        WHERE\n            d.account_id = %(account_id)s\n        GROUP BY\n            s.id,\n            s.name,\n            ds.skill_setting_meta_id,\n            ds.settings::jsonb\n    ),\n    skill_meta AS (\n        SELECT\n            skill_id,\n            display_name,\n            max(branch)\n        FROM\n            skill.branch\n        GROUP BY\n            skill_id,\n            display_name\n    )\nSELECT\n    s.skill_id,\n    s.skill_name,\n    s.settings,\n    s.devices,\n    skm.display_name,\n    sm.version AS settings_version,\n    sm.settings_meta\nFROM\n    skills s\n    LEFT JOIN skill.setting_meta sm ON s.skill_setting_meta_id = sm.id\n    LEFT JOIN skill_meta skm ON skm.skill_id = s.skill_id\n"
  },
  {
    "path": "shared/selene/data/account/repository/sql/get_active_membership_by_account_id.sql",
    "content": "SELECT\n    acc_mem.id,\n    mem.type,\n    LOWER(acc_mem.membership_ts_range)::date start_date,\n    acc_mem.payment_method,\n    payment_account_id,\n    payment_id\nFROM\n    account.account_membership acc_mem\nINNER JOIN\n    account.membership mem ON acc_mem.membership_id = mem.id\nWHERE\n    account_id = %(account_id)s AND UPPER(acc_mem.membership_ts_range) IS NULL\n"
  },
  {
    "path": "shared/selene/data/account/repository/sql/get_active_membership_by_payment_account_id.sql",
    "content": "SELECT\n    acc_mem.id,\n    mem.type,\n    LOWER(acc_mem.membership_ts_range)::date start_date,\n    acc_mem.payment_method,\n    payment_account_id,\n    payment_id\nFROM\n    account.account_membership acc_mem\nINNER JOIN\n    account.membership mem ON acc_mem.membership_id = mem.id\nWHERE\n    acc_mem.payment_account_id = %(payment_account_id)s AND UPPER(acc_mem.membership_ts_range) IS NULL\n"
  },
  {
    "path": "shared/selene/data/account/repository/sql/get_agreement_content_id.sql",
    "content": "SELECT\n    content_id\nFROM\n    account.agreement\nWHERE\n    id = %(agreement_id)s\n"
  },
  {
    "path": "shared/selene/data/account/repository/sql/get_current_agreements.sql",
    "content": "SELECT\n    id,\n    agreement,\n    version,\n    content_id,\n    lower(effective) as effective_date\nFROM\n    account.agreement\nWHERE\n    effective @> CURRENT_DATE\n"
  },
  {
    "path": "shared/selene/data/account/repository/sql/get_membership_by_type.sql",
    "content": "SELECT\n    id,\n    type,\n    rate,\n    rate_period,\n    stripe_plan\nFROM\n    account.membership\nWHERE\n    type = %(type)s"
  },
  {
    "path": "shared/selene/data/account/repository/sql/get_membership_types.sql",
    "content": "SELECT\n    id,\n    type,\n    rate,\n    rate_period,\n    stripe_plan\nFROM\n    account.membership\n"
  },
  {
    "path": "shared/selene/data/account/repository/sql/remove_account.sql",
    "content": "-- Perform a cascading delete on an account.  All children of the account\n-- table should have ON DELETE CASCADE clauses.  If this request fails, a missing\n-- ON DELETE CASCADE clause may be the culprit.\nDELETE FROM\n    account.account\nWHERE\n    id = %(id)s\n"
  },
  {
    "path": "shared/selene/data/account/repository/sql/update_last_activity_ts.sql",
    "content": "UPDATE\n    account.account\nSET\n    last_activity_ts = %(last_activity_ts)s\nWHERE\n    id = %(account_id)s\n"
  },
  {
    "path": "shared/selene/data/account/repository/sql/update_username.sql",
    "content": "UPDATE\n    account.account\nSET\n    username = %(username)s\nWHERE\n    id = %(account_id)s\n"
  },
  {
    "path": "shared/selene/data/device/__init__.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Exposed API into the device data access layer.\"\"\"\n\nfrom .entity.device import Device, PantacorConfig\nfrom .entity.device_skill import ManifestSkill, AccountSkillSettings\nfrom .entity.geography import Geography\nfrom .entity.preference import AccountPreferences\nfrom .entity.text_to_speech import TextToSpeech\nfrom .repository.default import DefaultsRepository\nfrom .repository.device import DeviceRepository\nfrom .repository.device_skill import DeviceSkillRepository\nfrom .repository.geography import GeographyRepository\nfrom .repository.preference import PreferenceRepository\nfrom .repository.setting import SettingRepository\nfrom .repository.text_to_speech import TextToSpeechRepository\n"
  },
  {
    "path": "shared/selene/data/device/entity/__init__.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n"
  },
  {
    "path": "shared/selene/data/device/entity/default.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Define data entities for the device.account_defaults table\"\"\"\nfrom dataclasses import dataclass\n\nfrom selene.data.geography import City, Country, Region, Timezone\nfrom selene.data.wake_word.entity.wake_word import WakeWord\nfrom .text_to_speech import TextToSpeech\n\n\n@dataclass\nclass AccountDefaults:\n    \"\"\"Data representation of the device.account_defaults table.\"\"\"\n\n    city: City = None\n    country: Country = None\n    region: Region = None\n    timezone: Timezone = None\n    voice: TextToSpeech = None\n    wake_word: WakeWord = None\n    id: str = None\n"
  },
  {
    "path": "shared/selene/data/device/entity/device.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Data representation of a device running Mycroft Core.\"\"\"\nfrom dataclasses import dataclass\nfrom datetime import datetime\n\nfrom selene.data.geography import City, Country, Region, Timezone\nfrom selene.data.wake_word.entity.wake_word import WakeWord\nfrom .text_to_speech import TextToSpeech\n\n\n@dataclass\nclass PantacorConfig:\n    \"\"\"Data representation of Pantacor configuration for devices that use it.\"\"\"\n\n    auto_update: bool\n    ip_address: str\n    pantacor_id: str\n    release_channel: str\n    claimed: bool = None\n    ssh_public_key: str = None\n\n\n@dataclass\nclass Device:\n    \"\"\"Representation of a Device\"\"\"\n\n    account_id: str\n    city: City\n    country: Country\n    core_version: str\n    enclosure_version: str\n    id: str\n    name: str\n    platform: str\n    region: Region\n    text_to_speech: TextToSpeech\n    timezone: Timezone\n    wake_word: WakeWord\n    last_contact_ts: datetime = None\n    placement: str = None\n    add_ts: datetime = None\n    pantacor_config: PantacorConfig = None\n"
  },
  {
    "path": "shared/selene/data/device/entity/device_skill.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom dataclasses import dataclass\nfrom datetime import datetime\nfrom typing import List\n\n\n@dataclass\nclass ManifestSkill(object):\n    device_id: str\n    install_method: str\n    install_status: str\n    skill_gid: str\n    install_failure_reason: str = None\n    install_ts: datetime = None\n    skill_id: str = None\n    update_ts: datetime = None\n    id: str = None\n\n\n@dataclass\nclass AccountSkillSettings(object):\n    install_method: str\n    skill_id: str\n    device_ids: List[str] = None\n    settings_values: dict = None\n    settings_display_id: str = None\n\n\n@dataclass\nclass DeviceSkillSettings(object):\n    skill_id: str\n    skill_gid: str\n    settings_values: dict = None\n    settings_display_id: str = None\n"
  },
  {
    "path": "shared/selene/data/device/entity/geography.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom dataclasses import dataclass\nfrom decimal import Decimal\n\n\n@dataclass\nclass Geography(object):\n    country: str\n    region: str\n    city: str\n    time_zone: str\n    latitude: Decimal = None\n    longitude: Decimal = None\n    id: str = None\n"
  },
  {
    "path": "shared/selene/data/device/entity/preference.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom dataclasses import dataclass\n\n\n@dataclass\nclass AccountPreferences(object):\n    date_format: str\n    time_format: str\n    measurement_system: str\n    id: str = None\n"
  },
  {
    "path": "shared/selene/data/device/entity/text_to_speech.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom dataclasses import dataclass\n\n\n@dataclass\nclass TextToSpeech(object):\n    setting_name: str\n    display_name: str\n    engine: str\n    id: str = None\n"
  },
  {
    "path": "shared/selene/data/device/repository/__init__.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom .device import Device\n"
  },
  {
    "path": "shared/selene/data/device/repository/default.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Data access and manipulation for account defaults.\"\"\"\nfrom selene.data.geography import City, Country, Region, Timezone\nfrom selene.data.wake_word.entity.wake_word import WakeWord\nfrom ..entity.default import AccountDefaults\nfrom ..entity.text_to_speech import TextToSpeech\nfrom ...repository_base import RepositoryBase\n\n\nclass DefaultsRepository(RepositoryBase):\n    \"\"\"Methods to access and manipulate account defaults.\"\"\"\n\n    def __init__(self, db, account_id):\n        super().__init__(db, __file__)\n        self.account_id = account_id\n\n    def upsert(self, defaults):\n        \"\"\"Update account defaults if they exist, otherwise, add them\"\"\"\n        db_request_args = dict(account_id=self.account_id)\n        db_request_args.update(defaults)\n        db_request_args[\"wake_word\"] = db_request_args[\"wake_word\"]\n        db_request = self._build_db_request(\n            sql_file_name=\"upsert_defaults.sql\", args=db_request_args\n        )\n        self.cursor.insert(db_request)\n\n    def get_account_defaults(self) -> AccountDefaults:\n        \"\"\"Build an instance of the AccountDefaults dataclass\"\"\"\n        db_request = self._build_db_request(\n            sql_file_name=\"get_account_defaults.sql\",\n            args=dict(account_id=self.account_id),\n        )\n        db_result = self.cursor.select_one(db_request)\n        if db_result is None:\n            defaults = None\n        else:\n            db_result[\"city\"] = City(**db_result[\"city\"])\n            db_result[\"country\"] = Country(**db_result[\"country\"])\n            db_result[\"region\"] = Region(**db_result[\"region\"])\n            db_result[\"timezone\"] = Timezone(**db_result[\"timezone\"])\n            db_result[\"voice\"] = TextToSpeech(**db_result[\"voice\"])\n            db_result[\"wake_word\"] = WakeWord(**db_result[\"wake_word\"])\n            defaults = AccountDefaults(**db_result)\n\n        return defaults\n"
  },
  {
    "path": "shared/selene/data/device/repository/device.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Data retrieval and maintenance routines for devices.\"\"\"\nfrom datetime import datetime\nfrom typing import List\n\nfrom selene.data.geography import City, Country, Region, Timezone\nfrom selene.data.wake_word import WakeWord\nfrom ..entity.device import Device, PantacorConfig\nfrom ..entity.text_to_speech import TextToSpeech\nfrom ...repository_base import RepositoryBase\n\n\nclass DeviceRepository(RepositoryBase):\n    \"\"\"Data retrieval and maintenance routines for devices.\"\"\"\n\n    def __init__(self, db):\n        super().__init__(db, __file__)\n\n    def get_device_by_id(self, device_id: str) -> Device:\n        \"\"\"Fetch a device using a given device id\n\n        :param device_id: uuid\n        :return: Device entity\n        \"\"\"\n        db_request = self._build_db_request(\n            sql_file_name=\"get_device_by_id.sql\", args=dict(device_id=device_id)\n        )\n        db_result = self.cursor.select_one(db_request)\n\n        if db_result is None:\n            device = None\n        else:\n            device = self._build_device_from_row(db_result)\n\n        return device\n\n    def get_devices_by_account_id(self, account_id: str) -> List[Device]:\n        \"\"\"Fetch all devices associated to a user from a given account id\n\n        :param account_id: uuid\n        :return: List of User's devices\n        \"\"\"\n        db_request = self._build_db_request(\n            sql_file_name=\"get_devices_by_account_id.sql\",\n            args=dict(account_id=account_id),\n        )\n        db_results = self.cursor.select_all(db_request)\n\n        devices = []\n        for row in db_results:\n            device = self._build_device_from_row(row)\n            devices.append(device)\n\n        return devices\n\n    @staticmethod\n    def _build_device_from_row(row: dict) -> Device:\n        \"\"\"Build a Device dataclass instance from query results.\"\"\"\n        row[\"city\"] = City(**row[\"city\"])\n        row[\"country\"] = Country(**row[\"country\"])\n        row[\"region\"] = Region(**row[\"region\"])\n        row[\"timezone\"] = Timezone(**row[\"timezone\"])\n        row[\"wake_word\"] = WakeWord(**row[\"wake_word\"])\n        row[\"text_to_speech\"] = TextToSpeech(**row[\"text_to_speech\"])\n        row[\"pantacor_config\"] = PantacorConfig(**row[\"pantacor_config\"])\n\n        return Device(**row)\n\n    def get_account_device_count(self, account_id: str) -> int:\n        \"\"\"Returns the number of devices assigned to a specified account.\"\"\"\n        db_request = self._build_db_request(\n            sql_file_name=\"get_account_device_count.sql\",\n            args=dict(account_id=account_id),\n        )\n        db_results = self.cursor.select_one(db_request)\n\n        return db_results[\"device_count\"]\n\n    def get_all_device_ids(self) -> List:\n        \"\"\"Return a list of all the device IDs on the device table.\"\"\"\n        db_request = self._build_db_request(sql_file_name=\"get_all_device_ids.sql\")\n\n        return self.cursor.select_all(db_request)\n\n    def get_subscription_type_by_device_id(self, device_id: str):\n        \"\"\"Return the type of subscription of device's owner\n\n        :param device_id: device uuid\n        \"\"\"\n        subscription_type = None\n        db_request = self._build_db_request(\n            sql_file_name=\"get_subscription_type_by_device_id.sql\",\n            args=dict(device_id=device_id),\n        )\n        db_result = self.cursor.select_one(db_request)\n        if db_result:\n            rate_period = db_result[\"rate_period\"]\n            # TODO: Remove the @ in the API v2\n            subscription_type = {\n                \"@type\": \"free\" if rate_period is None else rate_period\n            }\n\n        return subscription_type\n\n    def add(self, account_id: str, device: dict) -> str:\n        \"\"\"Insert a row on the device table\"\"\"\n        db_request_args = dict(account_id=account_id)\n        db_request_args.update(device)\n        del db_request_args[\"pairing_code\"]\n        db_request = self._build_db_request(\n            sql_file_name=\"add_device.sql\", args=db_request_args\n        )\n        db_result = self.cursor.insert_returning(db_request)\n        return db_result[\"id\"]\n\n    def update_device_from_core(self, device_id: str, updates: dict):\n        \"\"\"Updates a device with data sent to the API from Mycroft core\"\"\"\n        db_request_args = dict(device_id=device_id)\n        db_request_args.update(updates)\n        db_request = self._build_db_request(\n            sql_file_name=\"update_device_from_core.sql\", args=db_request_args\n        )\n        self.cursor.update(db_request)\n\n    def add_text_to_speech(self, text_to_speech: TextToSpeech) -> str:\n        \"\"\"Add a row to the text to speech table\n\n        :param text_to_speech: text to speech entity\n        :return text to speech id\n        \"\"\"\n        db_request = self._build_db_request(\n            sql_file_name=\"add_text_to_speech.sql\",\n            args=dict(\n                setting_name=text_to_speech.setting_name,\n                display_name=text_to_speech.display_name,\n                engine=text_to_speech.engine,\n            ),\n        )\n        db_result = self.cursor.insert_returning(db_request)\n\n        return db_result[\"id\"]\n\n    def remove_wake_word(self, wake_word_id: str):\n        \"\"\"Remove a  wake word from the database using id\"\"\"\n        db_request = self._build_db_request(\n            sql_file_name=\"remove_wake_word.sql\", args=dict(wake_word_id=wake_word_id)\n        )\n        self.cursor.delete(db_request)\n\n    def remove_text_to_speech(self, text_to_speech_id: str):\n        \"\"\"Remove a text to speech from the database using id\"\"\"\n        db_request = self._build_db_request(\n            sql_file_name=\"remove_text_to_speech.sql\",\n            args=dict(text_to_speech_id=text_to_speech_id),\n        )\n        self.cursor.delete(db_request)\n\n    def remove(self, device_id: str):\n        \"\"\"Remove a row from the device tables and any related child tables.\n\n        :param device_id: UUID identifying a device.\n        \"\"\"\n        db_request = self._build_db_request(\n            sql_file_name=\"remove_device.sql\", args=dict(device_id=device_id)\n        )\n\n        self.cursor.delete(db_request)\n\n    def update_device_from_account(\n        self, account_id: str, device_id: str, updates: dict\n    ):\n        \"\"\"Updates a device with data sent to the API from account.mycroft.ai\n\n        :param account_id: UUID identifying the user's account\n        :param device_id: UUID identifying a device\n        :param updates: fields updated and the new values of those fields\n        \"\"\"\n        db_request_args = dict(account_id=account_id, device_id=device_id)\n        db_request_args.update(updates)\n        db_request = self._build_db_request(\n            sql_file_name=\"update_device_from_account.sql\", args=db_request_args\n        )\n\n        self.cursor.update(db_request)\n\n    def upsert_pantacor_config(self, device_id: str, pantacor_config: PantacorConfig):\n        \"\"\"Add Pantacor configuration to a device that uses this update mechanism.\n\n        If a row already exists for this device on the table, just update the\n        IP address.\n\n        :param device_id: UUID identifying a device\n        :param pantacor_config: dataclass object containing Pantacor-specific data.\n        \"\"\"\n        db_request_args = dict(\n            device_id=device_id,\n            pantacor_id=pantacor_config.pantacor_id,\n            ip_address=pantacor_config.ip_address,\n            auto_update=pantacor_config.auto_update,\n            release_channel=pantacor_config.release_channel,\n        )\n        db_request = self._build_db_request(\n            sql_file_name=\"upsert_pantacor_config.sql\", args=db_request_args\n        )\n\n        self.cursor.insert(db_request)\n\n    def update_pantacor_config(self, device_id: str, updates: dict):\n        \"\"\"Updates a device with data sent to the API from account.mycroft.ai\n\n        :param device_id: UUID identifying a device\n        :param updates: Pantacor configuration values being updated.\n        \"\"\"\n        db_request_args = dict(device_id=device_id)\n        db_request_args.update(updates)\n        db_request = self._build_db_request(\n            sql_file_name=\"update_pantacor_config.sql\", args=db_request_args\n        )\n\n        self.cursor.update(db_request)\n\n    def update_last_contact_ts(self, device_id: str, last_contact_ts: datetime):\n        \"\"\"Update the timestamp indicating the last time the device was heard from.\n\n        :param device_id: UUID identifying a device\n        :param last_contact_ts: timestamp representing last time device was seen.\n        \"\"\"\n        db_request = self._build_db_request(\n            sql_file_name=\"update_last_contact_ts.sql\",\n            args=dict(device_id=device_id, last_contact_ts=last_contact_ts),\n        )\n        self.cursor.update(db_request)\n"
  },
  {
    "path": "shared/selene/data/device/repository/device_skill.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\n\"\"\"Data repository code for the skills on a device\"\"\"\nimport json\nfrom dataclasses import asdict\nfrom typing import List\n\nfrom selene.data.skill import SettingsDisplay\nfrom ..entity.device_skill import (\n    AccountSkillSettings,\n    DeviceSkillSettings,\n    ManifestSkill,\n)\nfrom ...repository_base import RepositoryBase\n\n\nclass DeviceSkillRepository(RepositoryBase):\n    def __init__(self, db):\n        super(DeviceSkillRepository, self).__init__(db, __file__)\n\n    def get_skill_settings_for_account(\n        self, account_id: str, skill_id: str\n    ) -> List[AccountSkillSettings]:\n        return self._select_all_into_dataclass(\n            AccountSkillSettings,\n            sql_file_name=\"get_skill_settings_for_account.sql\",\n            args=dict(account_id=account_id, skill_id=skill_id),\n        )\n\n    def get_skill_settings_for_device(self, device_id, skill_id=None):\n        device_skills = self._select_all_into_dataclass(\n            DeviceSkillSettings,\n            sql_file_name=\"get_skill_settings_for_device.sql\",\n            args=dict(device_id=device_id),\n        )\n        if skill_id is None:\n            skill_settings = device_skills\n        else:\n            skill_settings = None\n            for skill in device_skills:\n                if skill.skill_id == skill_id:\n                    skill_settings = skill\n                    break\n\n        return skill_settings\n\n    def update_skill_settings(\n        self, account_id: str, device_names: tuple, skill_name: str\n    ):\n        db_request = self._build_db_request(\n            sql_file_name=\"update_skill_settings.sql\",\n            args=dict(\n                account_id=account_id, device_names=device_names, skill_name=skill_name\n            ),\n        )\n        self.cursor.update(db_request)\n\n    def upsert_device_skill_settings(\n        self,\n        device_ids: List[str],\n        settings_display: SettingsDisplay,\n        settings_values: dict,\n    ):\n        for device_id in device_ids:\n            if settings_values is None:\n                db_settings_values = None\n            else:\n                db_settings_values = json.dumps(settings_values)\n            db_request = self._build_db_request(\n                sql_file_name=\"upsert_device_skill_settings.sql\",\n                args=dict(\n                    device_id=device_id,\n                    skill_id=settings_display.skill_id,\n                    settings_values=db_settings_values,\n                    settings_display_id=settings_display.id,\n                ),\n            )\n            self.cursor.insert(db_request)\n\n    def update_device_skill_settings(self, device_id, device_skill):\n        \"\"\"Update the skill settings columns on the device_skill table.\"\"\"\n        if device_skill.settings_values is None:\n            db_settings_values = None\n        else:\n            db_settings_values = json.dumps(device_skill.settings_values)\n        db_request = self._build_db_request(\n            sql_file_name=\"update_device_skill_settings.sql\",\n            args=dict(\n                device_id=device_id,\n                skill_id=device_skill.skill_id,\n                settings_display_id=device_skill.settings_display_id,\n                settings_values=db_settings_values,\n            ),\n        )\n        self.cursor.update(db_request)\n\n    def get_skill_manifest_for_device(self, device_id: str) -> List[ManifestSkill]:\n        return self._select_all_into_dataclass(\n            dataclass=ManifestSkill,\n            sql_file_name=\"get_device_skill_manifest.sql\",\n            args=dict(device_id=device_id),\n        )\n\n    def get_skill_manifest_for_account(self, account_id: str) -> List[ManifestSkill]:\n        return self._select_all_into_dataclass(\n            dataclass=ManifestSkill,\n            sql_file_name=\"get_skill_manifest_for_account.sql\",\n            args=dict(account_id=account_id),\n        )\n\n    def update_manifest_skill(self, manifest_skill: ManifestSkill):\n        db_request = self._build_db_request(\n            sql_file_name=\"update_skill_manifest.sql\", args=asdict(manifest_skill)\n        )\n\n        self.cursor.update(db_request)\n\n    def add_manifest_skill(self, manifest_skill: ManifestSkill):\n        db_request = self._build_db_request(\n            sql_file_name=\"add_manifest_skill.sql\", args=asdict(manifest_skill)\n        )\n        db_result = self.cursor.insert_returning(db_request)\n\n        return db_result[\"id\"]\n\n    def remove_manifest_skill(self, manifest_skill: ManifestSkill):\n        db_request = self._build_db_request(\n            sql_file_name=\"remove_manifest_skill.sql\",\n            args=dict(\n                device_id=manifest_skill.device_id, skill_gid=manifest_skill.skill_gid\n            ),\n        )\n        self.cursor.delete(db_request)\n\n    def get_settings_display_usage(self, settings_display_id: str) -> int:\n        db_request = self._build_db_request(\n            sql_file_name=\"get_settings_display_usage.sql\",\n            args=dict(settings_display_id=settings_display_id),\n        )\n        db_result = self.cursor.select_one(db_request)\n\n        return db_result[\"usage\"]\n\n    def remove(self, device_id, skill_id):\n        db_request = self._build_db_request(\n            sql_file_name=\"delete_device_skill.sql\",\n            args=dict(device_id=device_id, skill_id=skill_id),\n        )\n        self.cursor.delete(db_request)\n"
  },
  {
    "path": "shared/selene/data/device/repository/geography.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom ..entity.geography import Geography\nfrom ...repository_base import RepositoryBase\n\n\nclass GeographyRepository(RepositoryBase):\n    def __init__(self, db, account_id):\n        super(GeographyRepository, self).__init__(db, __file__)\n        self.account_id = account_id\n\n    def get_account_geographies(self):\n        db_request = self._build_db_request(\n            sql_file_name=\"get_account_geographies.sql\",\n            args=dict(account_id=self.account_id),\n        )\n        db_response = self.cursor.select_all(db_request)\n\n        return [Geography(**row) for row in db_response]\n\n    def get_geography_id(self, geography: Geography):\n        geography_id = None\n        acct_geographies = self.get_account_geographies()\n        for acct_geography in acct_geographies:\n            match = (\n                acct_geography.city == geography.city\n                and acct_geography.country == geography.country\n                and acct_geography.region == geography.region\n                and acct_geography.time_zone == geography.time_zone\n            )\n            if match:\n                geography_id = acct_geography.id\n                break\n\n        return geography_id\n\n    def add(self, geography: Geography):\n        db_request = self._build_db_request(\n            sql_file_name=\"add_geography.sql\",\n            args=dict(\n                account_id=self.account_id,\n                city=geography.city,\n                country=geography.country,\n                region=geography.region,\n                timezone=geography.time_zone,\n            ),\n        )\n        db_result = self.cursor.insert_returning(db_request)\n\n        return db_result[\"id\"]\n\n    def get_location_by_device_id(self, device_id):\n        db_request = self._build_db_request(\n            sql_file_name=\"get_location_by_device_id.sql\",\n            args=dict(device_id=device_id),\n        )\n        return self.cursor.select_one(db_request)\n"
  },
  {
    "path": "shared/selene/data/device/repository/preference.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom dataclasses import asdict\n\nfrom ..entity.preference import AccountPreferences\nfrom ...repository_base import RepositoryBase\n\n\nclass PreferenceRepository(RepositoryBase):\n    def __init__(self, db, account_id):\n        super(PreferenceRepository, self).__init__(db, __file__)\n        self.account_id = account_id\n\n    def get_account_preferences(self) -> AccountPreferences:\n        db_request = self._build_db_request(\n            sql_file_name=\"get_account_preferences.sql\",\n            args=dict(account_id=self.account_id),\n        )\n\n        db_result = self.cursor.select_one(db_request)\n        if db_result is None:\n            preferences = None\n        else:\n            preferences = AccountPreferences(**db_result)\n\n        return preferences\n\n    def upsert(self, preferences: AccountPreferences):\n        db_request_args = dict(account_id=self.account_id)\n        db_request_args.update(asdict(preferences))\n        db_request = self._build_db_request(\n            sql_file_name=\"upsert_preferences.sql\", args=db_request_args\n        )\n        self.cursor.insert(db_request)\n"
  },
  {
    "path": "shared/selene/data/device/repository/setting.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Data access methods for the device settings.\"\"\"\nfrom os import path\nfrom typing import Optional\n\nfrom selene.util.db import get_sql_from_file, Cursor, DatabaseRequest\n\nSQL_DIR = path.join(path.dirname(__file__), \"sql\")\n\n\nclass SettingRepository:\n    \"\"\"Data access methods for the device settings.\"\"\"\n\n    def __init__(self, db):\n        self.cursor = Cursor(db)\n\n    def get_device_settings_by_device_id(self, device_id: str):\n        \"\"\"Retrieves device settings from database for a supplied device ID.\"\"\"\n        query = DatabaseRequest(\n            sql=get_sql_from_file(\n                path.join(SQL_DIR, \"get_device_settings_by_device_id.sql\")\n            ),\n            args=dict(device_id=device_id),\n        )\n        return self.cursor.select_one(query)\n\n    def convert_text_to_speech_setting(\n        self, setting_name: str, engine: str\n    ) -> (str, str):\n        \"\"\"Converts the Selene TTS engine into a value recognized by the device.\n\n        :param setting_name: Selene TTS setting name\n        :param engine: Selene TTS engine name\n        :return: TTS engine and setting name recognized by the device\n        \"\"\"\n        if engine == \"mimic\":\n            if setting_name == \"trinity\":\n                tts_engine = \"mimic\"\n                voice = \"trinity\"\n            elif setting_name == \"kusal\":\n                tts_engine = \"mimic2\"\n                voice = \"kusal\"\n            else:\n                tts_engine = \"mimic\"\n                voice = \"ap\"\n        else:\n            tts_engine = \"google\"\n            voice = \"\"\n\n        return tts_engine, voice\n\n    def _format_date_v1(self, date: str) -> str:\n        \"\"\"Converts Selene date format into value recognized by device.\n\n        :param date: date format recognized by Selene\n        :return: date format recognized by device\n        \"\"\"\n        if date == \"DD/MM/YYYY\":\n            date_format = \"DMY\"\n        else:\n            date_format = \"MDY\"\n\n        return date_format\n\n    def _format_time_v1(self, time: str) -> str:\n        \"\"\"Converts Selene time format into value recognized by device.\n\n        :param time: time format recognized by Selene\n        :return: time format recognized by device\n        \"\"\"\n        if time == \"24 Hour\":\n            time_format = \"full\"\n        else:\n            time_format = \"half\"\n\n        return time_format\n\n    def get_device_settings(self, device_id: str) -> Optional[dict]:\n        \"\"\"Retrieves device settings from the database for a supplied device ID.\n\n        :param device_id: device uuid\n        :return setting entity using the legacy format from the API v1\n        \"\"\"\n        settings = None\n        query_result = self.get_device_settings_by_device_id(device_id)\n        if query_result:\n            if query_result[\"listener_setting\"][\"uuid\"] is None:\n                del query_result[\"listener_setting\"]\n            tts_setting = query_result[\"tts_settings\"]\n            tts_setting = self.convert_text_to_speech_setting(\n                tts_setting[\"setting_name\"], tts_setting[\"engine\"]\n            )\n            tts_setting = {\n                \"module\": tts_setting[0],\n                tts_setting[0]: {\"voice\": tts_setting[1]},\n            }\n            open_dataset = self._get_open_dataset_agreement_by_device_id(device_id)\n            settings = dict(\n                uuid=query_result[\"uuid\"],\n                ttsSettings=tts_setting,\n                dateFormat=self._format_date_v1(query_result[\"date_format\"]),\n                timeFormat=self._format_time_v1(query_result[\"time_format\"]),\n                systemUnit=query_result[\"system_unit\"].lower(),\n                optIn=open_dataset is not None,\n            )\n\n        return settings\n\n    def _get_open_dataset_agreement_by_device_id(self, device_id: str) -> dict:\n        \"\"\"Retrieves the open dataset agreement from the database, if there is one.\n\n        :param device_id: the device ID to use in the query\n        :return: the open dataset agreement.\n        \"\"\"\n        query = DatabaseRequest(\n            sql=get_sql_from_file(\n                path.join(SQL_DIR, \"get_open_dataset_agreement_by_device_id.sql\")\n            ),\n            args=dict(device_id=device_id),\n        )\n\n        return self.cursor.select_one(query)\n"
  },
  {
    "path": "shared/selene/data/device/repository/sql/add_device.sql",
    "content": "INSERT INTO\n    device.device (account_id, name, placement, wake_word_id, text_to_speech_id, geography_id)\nVALUES\n    (\n        %(account_id)s,\n        %(name)s,\n        %(placement)s,\n        (SELECT id FROM wake_word.wake_word WHERE name = %(wake_word)s ORDER BY engine DESC LIMIT 1),\n        (SELECT id FROM device.text_to_speech WHERE display_name = %(voice)s),\n        %(geography_id)s\n    )\nRETURNING id\n"
  },
  {
    "path": "shared/selene/data/device/repository/sql/add_geography.sql",
    "content": "INSERT INTO\n    device.geography (account_id, country_id, region_id, city_id, timezone_id)\nVALUES\n    (\n        %(account_id)s,\n        (SELECT id FROM geography.country WHERE name = %(country)s),\n        (\n            SELECT\n                r.id\n            FROM\n                geography.region r\n                INNER JOIN geography.country c ON c.id = r.country_id\n            WHERE\n                r.name = %(region)s\n                AND c.name = %(country)s\n            ),\n        (\n            SELECT\n                c.id\n            FROM\n                geography.city c\n                INNER JOIN geography.region r ON r.id = c.region_id\n\n            WHERE\n                c.name = %(city)s\n                AND r.name = %(region)s\n            ),\n        (SELECT id FROM geography.timezone WHERE name = %(timezone)s)\n    )\nRETURNING\n    id\n"
  },
  {
    "path": "shared/selene/data/device/repository/sql/add_manifest_skill.sql",
    "content": "INSERT INTO\n    device.device_skill (\n        device_id,\n        skill_id,\n        install_method,\n        install_status,\n        install_failure_reason,\n        install_ts,\n        update_ts\n    )\nVALUES\n    (\n        %(device_id)s,\n        %(skill_id)s,\n        %(install_method)s,\n        %(install_status)s,\n        %(install_failure_reason)s,\n        %(install_ts)s,\n        %(update_ts)s\n    )\nRETURNING\n    id\n"
  },
  {
    "path": "shared/selene/data/device/repository/sql/add_text_to_speech.sql",
    "content": "INSERT INTO\n    device.text_to_speech (setting_name, display_name, engine)\nVALUES\n    (%(setting_name)s, %(display_name)s, %(engine)s)\nRETURNING id"
  },
  {
    "path": "shared/selene/data/device/repository/sql/delete_device_skill.sql",
    "content": "DELETE FROM\n    device.device_skill\nWHERE\n    device_id = %(device_id)s\n    AND skill_id = %(skill_id)s\n"
  },
  {
    "path": "shared/selene/data/device/repository/sql/get_account_defaults.sql",
    "content": "SELECT\n    ad.id,\n    json_build_object(\n        'id', cntry.id,\n        'iso_code', cntry.iso_code,\n        'name', cntry.name\n    ) AS country,\n    json_build_object(\n        'id', r.id,\n        'region_code', r.region_code,\n        'name', r.name\n    ) AS region,\n    json_build_object(\n        'id', cty.id,\n        'name', cty.name,\n        'latitude', cty.latitude,\n        'longitude', cty.longitude,\n        'timezone', tz.name\n    ) AS city,\n    json_build_object(\n        'id', tz.id,\n        'name', tz.name,\n        'dst_offset', tz.dst_offset,\n        'gmt_offset', tz.gmt_offset\n    ) AS timezone,\n    json_build_object(\n        'id', ww.id,\n        'name', ww.name,\n        'engine', ww.engine\n    ) AS wake_word,\n    json_build_object(\n        'id', tts.id,\n        'setting_name', tts.setting_name,\n        'display_name', tts.display_name,\n        'engine', tts.engine\n    ) AS voice\nFROM\n    device.account_defaults ad\n    LEFT JOIN geography.country cntry ON cntry.id = ad.country_id\n    LEFT JOIN geography.region r ON r.id = ad.region_id\n    LEFT JOIN geography.city cty ON cty.id = ad.city_id\n    LEFT JOIN geography.timezone tz ON tz.id = ad.timezone_id\n    LEFT JOIN wake_word.wake_word ww ON ad.wake_word_id = ww.id\n    LEFT JOIN device.text_to_speech tts ON ad.text_to_speech_id = tts.id\nWHERE\n    ad.account_id = %(account_id)s\n"
  },
  {
    "path": "shared/selene/data/device/repository/sql/get_account_device_count.sql",
    "content": "SELECT count(*) AS device_count FROM device.device WHERE account_id = %(account_id)s\n"
  },
  {
    "path": "shared/selene/data/device/repository/sql/get_account_geographies.sql",
    "content": "SELECT\n    g.id,\n    cntry.name AS country,\n    r.name as region,\n    cty.name AS city,\n    t.name as time_zone,\n    latitude,\n    longitude\nFROM\n    device.geography g\n    INNER JOIN geography.city cty ON g.city_id = cty.id\n    INNER JOIN geography.country cntry ON g.country_id = cntry.id\n    INNER JOIN geography.region r ON g.region_id = r.id\n    INNER JOIN geography.timezone t ON g.timezone_id = t.id\nWHERE\n    account_id = %(account_id)s\n"
  },
  {
    "path": "shared/selene/data/device/repository/sql/get_account_preferences.sql",
    "content": "SELECT\n    id,\n    measurement_system,\n    date_format,\n    time_format\nFROM\n    device.account_preferences\nWHERE\n    account_id = %(account_id)s\n"
  },
  {
    "path": "shared/selene/data/device/repository/sql/get_all_device_ids.sql",
    "content": "SELECT\n    id\nFROM\n    device.device\n"
  },
  {
    "path": "shared/selene/data/device/repository/sql/get_device_by_id.sql",
    "content": "SELECT\n    d.id,\n    d.account_id,\n    d.name,\n    d.platform,\n    d.enclosure_version,\n    d.core_version,\n    d.placement,\n    d.last_contact_ts,\n    d.insert_ts AS add_ts,\n    json_build_object(\n        'name', ww.name,\n        'engine', ww.engine,\n        'id', ww.id\n    ) AS wake_word,\n    json_build_object(\n        'setting_name', tts.setting_name,\n        'display_name', tts.display_name,\n        'engine', tts.engine,\n        'id', tts.id\n    ) AS text_to_speech,\n    json_build_object(\n        'id', ctry.id,\n        'name', ctry.name,\n        'iso_code', ctry.iso_code\n    ) AS country,\n    json_build_object(\n        'id', cty.id,\n        'name', cty.name,\n        'timezone', cty.name,\n        'latitude', cty.latitude,\n        'longitude', cty.longitude\n    ) AS city,\n    json_build_object(\n        'id', r.id,\n        'name', r.name,\n        'region_code', r.region_code\n    ) AS region,\n    json_build_object(\n        'id', tz.id,\n        'name',tz.name,\n        'dst_offset', tz.dst_offset,\n        'gmt_offset', tz.gmt_offset\n    ) AS timezone,\n    json_build_object(\n        'pantacor_id', pc.pantacor_id,\n        'auto_update', pc.auto_update,\n        'release_channel', pc.release_channel,\n        'ssh_public_key', pc.ssh_public_key,\n        'ip_address', pc.ip_address\n    ) AS pantacor_config\nFROM\n    device.device d\n    INNER JOIN wake_word.wake_word ww ON d.wake_word_id = ww.id\n    INNER JOIN device.text_to_speech tts ON d.text_to_speech_id = tts.id\n    INNER JOIN device.geography g ON d.geography_id = g.id\n    INNER JOIN geography.country ctry ON g.country_id = ctry.id\n    INNER JOIN geography.city cty ON g.city_id = cty.id\n    INNER JOIN geography.region r ON g.region_id = r.id\n    INNER JOIN geography.timezone tz ON g.timezone_id = tz.id\n    LEFT OUTER JOIN device.pantacor_config pc on pc.device_id = d.id\nWHERE\n    d.id = %(device_id)s\n"
  },
  {
    "path": "shared/selene/data/device/repository/sql/get_device_settings_by_device_id.sql",
    "content": "SELECT\n  acc.id as uuid,\n  acc.measurement_system as system_unit,\n  acc.date_format as date_format,\n  acc.time_format as time_format,\n  json_build_object('setting_name', tts.setting_name, 'engine', tts.engine) as tts_settings,\n  json_build_object(\n    'uuid', ps.id,\n    'sampleRate', ps.sample_rate,\n    'channels', ps.channels,\n    'wakeWord', ww.name,\n    'phonemes', ps.pronunciation,\n    'threshold', ps.threshold,\n    'multiplier', ps.threshold_multiplier,\n    'energyRatio', ps.dynamic_energy_ratio) as listener_setting\nFROM\n  device.device dev\nINNER JOIN\n  device.account_preferences acc ON dev.account_id = acc.account_id\nINNER JOIN\n  device.text_to_speech tts ON dev.text_to_speech_id = tts.id\nINNER JOIN\n  wake_word.wake_word ww ON dev.wake_word_id = ww.id\nLEFT JOIN\n  wake_word.pocketsphinx_settings ps ON ww.id = ps.wake_word_id\nWHERE\n  dev.id = %(device_id)s\n"
  },
  {
    "path": "shared/selene/data/device/repository/sql/get_device_skill_manifest.sql",
    "content": "SELECT\n    ds.id,\n    ds.device_id,\n    ds.install_failure_reason,\n    ds.install_method,\n    ds.install_status,\n    ds.install_ts,\n    ds.skill_id,\n    ds.update_ts,\n    s.skill_gid\nFROM\n    device.device d\n    INNER JOIN device.device_skill ds ON d.id = ds.device_id\n    INNER JOIN skill.skill s ON ds.skill_id = s.id\nWHERE\n    d.id = %(device_id)s\n"
  },
  {
    "path": "shared/selene/data/device/repository/sql/get_devices_by_account_id.sql",
    "content": "SELECT\n    d.id,\n    d.account_id,\n    d.name,\n    d.platform,\n    d.enclosure_version,\n    d.core_version,\n    d.placement,\n    d.last_contact_ts,\n    d.insert_ts AS add_ts,\n    json_build_object(\n        'name', ww.name,\n        'engine', ww.engine,\n        'id', ww.id\n    ) AS wake_word,\n    json_build_object(\n        'setting_name', tts.setting_name,\n        'display_name', tts.display_name,\n        'engine', tts.engine,\n        'id', tts.id\n    ) AS text_to_speech,\n    json_build_object(\n        'id', ctry.id,\n        'name', ctry.name,\n        'iso_code', ctry.iso_code\n    ) AS country,\n    json_build_object(\n        'id', cty.id,\n        'name', cty.name,\n        'timezone', cty.name,\n        'latitude', cty.latitude,\n        'longitude', cty.longitude\n    ) AS city,\n    json_build_object(\n        'id', r.id,\n        'name', r.name,\n        'region_code', r.region_code\n    ) AS region,\n    json_build_object(\n        'id', tz.id,\n        'name', tz.name,\n        'dst_offset', tz.dst_offset,\n        'gmt_offset', tz.gmt_offset\n    ) AS timezone,\n    json_build_object(\n        'pantacor_id', pc.pantacor_id,\n        'auto_update', pc.auto_update,\n        'release_channel', pc.release_channel,\n        'ssh_public_key', pc.ssh_public_key,\n        'ip_address', pc.ip_address\n    ) AS pantacor_config\nFROM\n    device.device d\n    INNER JOIN wake_word.wake_word ww ON d.wake_word_id = ww.id\n    INNER JOIN device.text_to_speech tts ON d.text_to_speech_id = tts.id\n    INNER JOIN device.geography g ON d.geography_id = g.id\n    INNER JOIN geography.country ctry ON g.country_id = ctry.id\n    INNER JOIN geography.city cty ON g.city_id = cty.id\n    INNER JOIN geography.region r ON g.region_id = r.id\n    INNER JOIN geography.timezone tz ON g.timezone_id = tz.id\n    LEFT OUTER JOIN device.pantacor_config pc on pc.device_id = d.id\nWHERE\n    d.account_id = %(account_id)s\n"
  },
  {
    "path": "shared/selene/data/device/repository/sql/get_location_by_device_id.sql",
    "content": "SELECT\n    json_build_object(\n        'latitude', city.latitude,\n        'longitude', city.longitude\n    ) as coordinate,\n    json_build_object(\n        'name', timezone.name,\n        'code', timezone.name,\n        'offset', trunc(timezone.gmt_offset * 60 * 60 * 1000),\n        'dstOffset', trunc(timezone.dst_offset * 60 * 60 * 1000)\n    ) as timezone,\n    json_build_object(\n        'name', city.name,\n        'state', json_build_object(\n            'name', region.name,\n            'code', region.region_code,\n            'country', json_build_object(\n                'name', country.name,\n                'code', country.iso_code\n            )\n        )\n    ) as city\nFROM\n    device.device dev\nINNER JOIN\n    device.geography geo ON dev.geography_id = geo.id\nINNER JOIN\n    geography.country country ON geo.country_id = country.id\nINNER JOIN\n    geography.region region ON geo.region_id = region.id\nINNER JOIN\n    geography.city city ON geo.city_id = city.id\nINNER JOIN\n    geography.timezone timezone ON geo.timezone_id = timezone.id\nWHERE\n    dev.id = %(device_id)s"
  },
  {
    "path": "shared/selene/data/device/repository/sql/get_open_dataset_agreement_by_device_id.sql",
    "content": "SELECT\n    acc_agr.id\nFROM\n    device.device dev\nINNER JOIN\n    account.account acc ON dev.account_id = acc.id\nINNER JOIN\n    account.account_agreement acc_agr ON acc.id = acc_agr.account_id\nINNER JOIN\n    account.agreement agr ON acc_agr.agreement_id = agr.id\nWHERE\n    dev.id = %(device_id)s AND agr.agreement = 'Open Dataset'"
  },
  {
    "path": "shared/selene/data/device/repository/sql/get_settings_display_usage.sql",
    "content": "SELECT\n    count(*) AS usage\nFROM\n    device.device_skill\nWHERE\n    skill_settings_display_id = %(settings_display_id)s\n"
  },
  {
    "path": "shared/selene/data/device/repository/sql/get_skill_manifest_for_account.sql",
    "content": "SELECT\n    ds.device_id,\n    ds.install_failure_reason,\n    ds.install_method,\n    ds.install_status,\n    ds.install_ts,\n    ds.skill_id,\n    ds.update_ts,\n    s.skill_gid\nFROM\n    device.device d\n    INNER JOIN device.device_skill ds ON d.id = ds.device_id\n    INNER JOIN skill.skill s ON ds.skill_id = s.id\nWHERE\n    d.account_id = %(account_id)s\n"
  },
  {
    "path": "shared/selene/data/device/repository/sql/get_skill_settings_for_account.sql",
    "content": "SELECT\n    dds.skill_settings_display_id AS settings_display_id,\n    dds.settings::jsonb AS settings_values,\n    dds.install_method,\n    dds.skill_id,\n    array_agg(dds.device_id::text) AS device_ids\nFROM\n    device.device dd\n    INNER JOIN device.device_skill dds ON dd.id = dds.device_id\nWHERE\n    dd.account_id = %(account_id)s\n    AND dds.skill_id = %(skill_id)s\nGROUP BY\n    dds.skill_settings_display_id,\n    dds.settings::jsonb,\n    dds.install_method,\n    dds.skill_id\n"
  },
  {
    "path": "shared/selene/data/device/repository/sql/get_skill_settings_for_device.sql",
    "content": "SELECT\n    ds.skill_settings_display_id AS settings_display_id,\n    ds.settings::jsonb AS settings_values,\n    ds.skill_id,\n    s.skill_gid\nFROM\n    device.device_skill ds\n    INNER JOIN skill.skill s ON ds.skill_id = s.id\nWHERE\n    device_id = %(device_id)s\n"
  },
  {
    "path": "shared/selene/data/device/repository/sql/get_voices.sql",
    "content": "SELECT\n    id,\n    setting_name,\n    display_name,\n    engine\nFROM\n    device.text_to_speech\n"
  },
  {
    "path": "shared/selene/data/device/repository/sql/remove_device.sql",
    "content": "DELETE FROM\n    device.device\nWHERE\n    id = %(device_id)s\n"
  },
  {
    "path": "shared/selene/data/device/repository/sql/remove_manifest_skill.sql",
    "content": "DELETE FROM\n    device.device_skill\nWHERE\n    device_id = %(device_id)s\n    AND skill_id = (SELECT id FROM skill.skill WHERE skill_gid = %(skill_gid)s)\n"
  },
  {
    "path": "shared/selene/data/device/repository/sql/remove_text_to_speech.sql",
    "content": "DELETE FROM\n    device.text_to_speech\nWHERE\n    id = %(text_to_speech_id)s"
  },
  {
    "path": "shared/selene/data/device/repository/sql/update_device_from_account.sql",
    "content": "UPDATE\n    device.device\nSET\n    name = %(name)s,\n    placement = %(placement)s,\n    geography_id = %(geography_id)s,\n    wake_word_id = (\n        SELECT\n            id\n        FROM\n            wake_word.wake_word\n        WHERE\n            name = %(wake_word)s\n        ORDER BY\n            engine DESC\n        LIMIT 1\n    ),\n    text_to_speech_id = (\n        SELECT\n            id\n        FROM\n            device.text_to_speech\n        WHERE\n            display_name = %(voice)s\n    )\nWHERE\n    id = %(device_id)s\n"
  },
  {
    "path": "shared/selene/data/device/repository/sql/update_device_from_core.sql",
    "content": "UPDATE\n    device.device\nSET\n    platform = %(platform)s,\n    enclosure_version = %(enclosure_version)s,\n    core_version = %(core_version)s\nWHERE\n    id = %(device_id)s"
  },
  {
    "path": "shared/selene/data/device/repository/sql/update_device_skill_settings.sql",
    "content": "UPDATE\n    device.device_skill\nSET\n    skill_settings_display_id = %(settings_display_id)s,\n    settings = %(settings_values)s\nWHERE\n    device_id = %(device_id)s\n    AND skill_id = %(skill_id)s\n"
  },
  {
    "path": "shared/selene/data/device/repository/sql/update_last_contact_ts.sql",
    "content": "UPDATE\n    device.device\nSET\n    last_contact_ts = %(last_contact_ts)s\nWHERE\n    id = %(device_id)s\n"
  },
  {
    "path": "shared/selene/data/device/repository/sql/update_pantacor_config.sql",
    "content": "UPDATE\n    device.pantacor_config\nSET\n    ssh_public_key = %(ssh_public_key)s,\n    auto_update = %(auto_update)s,\n    release_channel = %(release_channel)s\nWHERE\n    device_id = %(device_id)s\n"
  },
  {
    "path": "shared/selene/data/device/repository/sql/update_skill_manifest.sql",
    "content": "UPDATE\n    device.device_skill\nSET\n    install_method = %(install_method)s,\n    install_status = %(install_status)s,\n    install_failure_reason = %(failure_message)s,\n    install_ts = %(install_ts)s,\n    update_ts = %(update_ts)s\nWHERE\n    device_id = %(device_id)s\n    AND skill_id = (\n        SELECT\n            id\n        FROM\n            skill.skill\n        WHERE\n            skill_gid = %(skill_gid)s\n    )\n"
  },
  {
    "path": "shared/selene/data/device/repository/sql/update_skill_settings.sql",
    "content": "UPDATE\n    device.device_skill\nSET\n    settings = %(settings)s\nWHERE\n    device_id IN (\n        SELECT\n            id\n        FROM\n            device.device\n        WHERE\n            account_id = %(account_id)s AND\n            device_name IN %(device_names)s\n    )\n    AND skill_id = (SELECT id from skill.skill WHERE name = %(skill_name)s)\n"
  },
  {
    "path": "shared/selene/data/device/repository/sql/upsert_defaults.sql",
    "content": "WITH\n    country AS (\n        SELECT id FROM geography.country WHERE name = %(country)s\n    ),\n    region AS (\n        SELECT\n            r.id\n        FROM\n            geography.region r\n            INNER JOIN geography.country c ON c.id = r.country_id\n        WHERE\n            r.name = %(region)s\n            AND c.name = %(country)s\n\n    ),\n    city AS (\n        SELECT\n            c.id\n        FROM\n            geography.city c\n            INNER JOIN geography.region r ON r.id = c.region_id\n\n        WHERE\n            c.name = %(city)s\n            AND r.name = %(region)s\n    ),\n    timezone AS (\n        SELECT id FROM geography.timezone WHERE name = %(timezone)s\n    ),\n    wake_word AS (\n        SELECT id FROM wake_word.wake_word WHERE name = %(wake_word)s ORDER BY engine DESC LIMIT 1\n    ),\n    text_to_speech AS (\n        SELECT id FROM device.text_to_speech WHERE display_name = %(voice)s\n    )\nINSERT INTO\n    device.account_defaults (account_id, country_id, region_id, city_id, timezone_id, wake_word_id, text_to_speech_id)\nVALUES\n    (\n        %(account_id)s,\n        (SELECT id FROM country),\n        (SELECT id FROM region),\n        (SELECT id FROM city),\n        (SELECT id FROM timezone),\n        (SELECT id FROM wake_word),\n        (SELECT id FROM text_to_speech)\n    )\nON CONFLICT\n    (account_id)\nDO UPDATE SET\n    country_id = (SELECT id FROM country),\n    region_id = (SELECT id FROM region),\n    city_id = (SELECT id FROM city),\n    timezone_id = (SELECT id FROM timezone),\n    wake_word_id = (SELECT id FROM wake_word),\n    text_to_speech_id = (SELECT id FROM text_to_speech)\nRETURNING\n    id\n"
  },
  {
    "path": "shared/selene/data/device/repository/sql/upsert_device_skill_settings.sql",
    "content": "INSERT INTO\n    device.device_skill (\n        device_id,\n        skill_id,\n        skill_settings_display_id,\n        settings\n    )\nVALUES\n    (\n        %(device_id)s,\n        %(skill_id)s,\n        %(settings_display_id)s,\n        %(settings_values)s\n    )\nON CONFLICT\n    (device_id, skill_id)\nDO UPDATE SET\n    skill_settings_display_id = %(settings_display_id)s,\n    settings = %(settings_values)s\n"
  },
  {
    "path": "shared/selene/data/device/repository/sql/upsert_pantacor_config.sql",
    "content": "INSERT INTO\n    device.pantacor_config (device_id, pantacor_id, ip_address, auto_update, release_channel)\nVALUES\n    (%(device_id)s, %(pantacor_id)s, %(ip_address)s, %(auto_update)s, %(release_channel)s)\nON CONFLICT\n    (device_id)\nDO UPDATE SET\n    ip_address = %(ip_address)s\n"
  },
  {
    "path": "shared/selene/data/device/repository/sql/upsert_preferences.sql",
    "content": "INSERT INTO\n    device.account_preferences(\n        account_id,\n        date_format,\n        time_format,\n        measurement_system\n      )\nVALUES\n    (\n        %(account_id)s,\n        %(date_format)s,\n        %(time_format)s,\n        %(measurement_system)s\n    )\nON CONFLICT\n    (account_id)\nDO UPDATE SET\n    date_format = %(date_format)s,\n    time_format = %(time_format)s,\n    measurement_system = %(measurement_system)s\nRETURNING\n    id\n"
  },
  {
    "path": "shared/selene/data/device/repository/text_to_speech.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom ..entity.text_to_speech import TextToSpeech\nfrom ...repository_base import RepositoryBase\n\n\nclass TextToSpeechRepository(RepositoryBase):\n    def __init__(self, db):\n        super(TextToSpeechRepository, self).__init__(db, __file__)\n\n    def get_voices(self):\n        db_request = self._build_db_request(sql_file_name=\"get_voices.sql\")\n        db_result = self.cursor.select_all(db_request)\n\n        return [TextToSpeech(**row) for row in db_result]\n\n    def add(self, text_to_speech: TextToSpeech):\n        \"\"\"Adds a row to the text_to_speech table\n\n        :return wake word id\n        \"\"\"\n        db_request = self._build_db_request(\n            sql_file_name=\"add_text_to_speech.sql\",\n            args=dict(\n                wake_word=text_to_speech.setting_name,\n                account_id=text_to_speech.display_name,\n                engine=text_to_speech.engine,\n            ),\n        )\n        result = self.cursor.insert_returning(db_request)\n\n        return result[\"id\"]\n\n    # def remove(self, wake_word: WakeWord):\n    #     \"\"\"Delete a wake word from the wake_word table.\"\"\"\n    #     db_request = self._build_db_request(\n    #         sql_file_name='delete_wake_word.sql',\n    #         args=dict(wake_word_id=wake_word.id)\n    #     )\n    #     self.cursor.delete(db_request)\n"
  },
  {
    "path": "shared/selene/data/geography/__init__.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom .entity.city import City\nfrom .entity.country import Country\nfrom .entity.region import Region\nfrom .entity.timezone import Timezone\nfrom .repository.city import CityRepository\nfrom .repository.country import CountryRepository\nfrom .repository.region import RegionRepository\nfrom .repository.timezone import TimezoneRepository\n"
  },
  {
    "path": "shared/selene/data/geography/entity/__init__.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n"
  },
  {
    "path": "shared/selene/data/geography/entity/city.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom dataclasses import dataclass\n\n\n@dataclass\nclass City(object):\n    id: str\n    latitude: str\n    longitude: str\n    name: str\n    timezone: str\n\n\n@dataclass\nclass GeographicLocation(object):\n    city: str\n    country: str\n    region: str\n    latitude: str\n    longitude: str\n    timezone: str\n"
  },
  {
    "path": "shared/selene/data/geography/entity/country.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom dataclasses import dataclass\n\n\n@dataclass\nclass Country(object):\n    id: str\n    iso_code: str\n    name: str\n"
  },
  {
    "path": "shared/selene/data/geography/entity/region.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom dataclasses import dataclass\n\n\n@dataclass\nclass Region(object):\n    id: str\n    region_code: str\n    name: str\n"
  },
  {
    "path": "shared/selene/data/geography/entity/timezone.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom dataclasses import dataclass\nfrom decimal import Decimal\n\n\n@dataclass\nclass Timezone(object):\n    id: str\n    dst_offset: Decimal\n    gmt_offset: Decimal\n    name: str\n"
  },
  {
    "path": "shared/selene/data/geography/repository/__init__.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n"
  },
  {
    "path": "shared/selene/data/geography/repository/city.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom ..entity.city import City, GeographicLocation\nfrom ...repository_base import RepositoryBase\n\n\nclass CityRepository(RepositoryBase):\n    def __init__(self, db):\n        super(CityRepository, self).__init__(db, __file__)\n\n    def get_cities_by_region(self, region_id):\n        db_request = self._build_db_request(\n            sql_file_name=\"get_cities_by_region.sql\", args=dict(region_id=region_id)\n        )\n        db_result = self.cursor.select_all(db_request)\n\n        return [City(**row) for row in db_result]\n\n    def get_geographic_location_by_city(self, possible_city_names: list):\n        \"\"\"Return a list of all cities matching the list of possibilities\"\"\"\n        city_names = [nm.lower() for nm in possible_city_names]\n        return self._select_all_into_dataclass(\n            GeographicLocation,\n            sql_file_name=\"get_geographic_location_by_city.sql\",\n            args=dict(possible_city_names=tuple(city_names)),\n        )\n\n    def get_biggest_city_in_region(self, region_name):\n        \"\"\"Return the geolocation of the most populous city in a region.\"\"\"\n        return self._select_one_into_dataclass(\n            GeographicLocation,\n            sql_file_name=\"get_biggest_city_in_region.sql\",\n            args=dict(region=region_name.lower()),\n        )\n\n    def get_biggest_city_in_country(self, country_name):\n        \"\"\"Return the geolocation of the most populous city in a country.\"\"\"\n        return self._select_one_into_dataclass(\n            GeographicLocation,\n            sql_file_name=\"get_biggest_city_in_country.sql\",\n            args=dict(country=country_name.lower()),\n        )\n"
  },
  {
    "path": "shared/selene/data/geography/repository/country.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom ..entity.country import Country\nfrom ...repository_base import RepositoryBase\n\n\nclass CountryRepository(RepositoryBase):\n    def __init__(self, db):\n        super(CountryRepository, self).__init__(db, __file__)\n\n    def get_countries(self):\n        db_request = self._build_db_request(sql_file_name=\"get_countries.sql\")\n        db_result = self.cursor.select_all(db_request)\n\n        return [Country(**row) for row in db_result]\n"
  },
  {
    "path": "shared/selene/data/geography/repository/region.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom ..entity.region import Region\nfrom ...repository_base import RepositoryBase\n\n\nclass RegionRepository(RepositoryBase):\n    def __init__(self, db):\n        super(RegionRepository, self).__init__(db, __file__)\n\n    def get_regions_by_country(self, country_id):\n        db_request = self._build_db_request(\n            sql_file_name=\"get_regions_by_country.sql\", args=dict(country_id=country_id)\n        )\n        db_result = self.cursor.select_all(db_request)\n\n        return [Region(**row) for row in db_result]\n"
  },
  {
    "path": "shared/selene/data/geography/repository/sql/get_biggest_city_in_country.sql",
    "content": "SELECT\n    cty.latitude,\n    cty.longitude,\n    cty.name AS city,\n    cntry.name AS country,\n    r.name AS region,\n    t.name AS timezone\nFROM\n    geography.city cty\n    INNER JOIN geography.region r ON cty.region_id = r.id\n    INNER JOIN geography.country cntry ON r.country_id = cntry.id\n    INNER JOIN geography.timezone t ON cty.timezone_id = t.id\nWHERE\n    lower(cntry.name) = %(country)s\n    AND cty.population IS NOT NULL\nORDER BY\n    cty.population DESC\nLIMIT\n    1\n\n"
  },
  {
    "path": "shared/selene/data/geography/repository/sql/get_biggest_city_in_region.sql",
    "content": "SELECT\n    cty.latitude,\n    cty.longitude,\n    cty.name AS city,\n    cntry.name AS country,\n    r.name AS region,\n    t.name AS timezone\nFROM\n    geography.city cty\n    INNER JOIN geography.region r ON cty.region_id = r.id\n    INNER JOIN geography.country cntry ON r.country_id = cntry.id\n    INNER JOIN geography.timezone t ON cty.timezone_id = t.id\nWHERE\n    lower(r.name) = %(region)s\n    AND cty.population IS NOT NULL\nORDER BY\n    cty.population DESC\nLIMIT\n    1\n\n"
  },
  {
    "path": "shared/selene/data/geography/repository/sql/get_cities_by_region.sql",
    "content": "SELECT\n    c.id,\n    c.name,\n    c.latitude,\n    c.longitude,\n    t.name as timezone\nFROM\n    geography.city c\n    INNER JOIN geography.timezone t ON c.timezone_id = t.id\nWHERE\n    region_id = %(region_id)s\nORDER BY\n    c.name\n"
  },
  {
    "path": "shared/selene/data/geography/repository/sql/get_countries.sql",
    "content": "SELECT\n    id,\n    iso_code,\n    name\nFROM\n    geography.country\nORDER BY\n    name\n"
  },
  {
    "path": "shared/selene/data/geography/repository/sql/get_geographic_location_by_city.sql",
    "content": "SELECT\n    cty.latitude,\n    cty.longitude,\n    cty.name AS city,\n    cntry.name AS country,\n    r.name AS region,\n    t.name AS timezone\nFROM\n    geography.city cty\n    INNER JOIN geography.region r ON cty.region_id = r.id\n    INNER JOIN geography.country cntry ON r.country_id = cntry.id\n    INNER JOIN geography.timezone t ON cty.timezone_id = t.id\nWHERE\n    lower(cty.name) IN %(possible_city_names)s\nORDER BY\n    cty.population DESC\n\n"
  },
  {
    "path": "shared/selene/data/geography/repository/sql/get_regions_by_country.sql",
    "content": "SELECT\n    id,\n    region_code,\n    name\nFROM\n    geography.region\nWHERE\n    country_id = %(country_id)s\nORDER BY\n    name\n"
  },
  {
    "path": "shared/selene/data/geography/repository/sql/get_timezones_by_country.sql",
    "content": "SELECT\n    id,\n    name,\n    gmt_offset,\n    dst_offset\nFROM\n    geography.timezone\nWHERE\n    country_id = %(country_id)s\nORDER BY\n    name\n"
  },
  {
    "path": "shared/selene/data/geography/repository/timezone.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom ..entity.timezone import Timezone\nfrom ...repository_base import RepositoryBase\n\n\nclass TimezoneRepository(RepositoryBase):\n    def __init__(self, db):\n        super(TimezoneRepository, self).__init__(db, __file__)\n\n    def get_timezones_by_country(self, country_id):\n        db_request = self._build_db_request(\n            sql_file_name=\"get_timezones_by_country.sql\",\n            args=dict(country_id=country_id),\n        )\n        db_result = self.cursor.select_all(db_request)\n\n        return [Timezone(**row) for row in db_result]\n"
  },
  {
    "path": "shared/selene/data/metric/__init__.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Public API for the selene.data.metric package.\"\"\"\n\nfrom .entity.api import ApiMetric\nfrom .entity.core import CoreMetric, CoreInteraction\nfrom .entity.job import JobMetric\nfrom .entity.stt import SttTranscriptionMetric\nfrom .repository.account_activity import AccountActivityRepository\nfrom .repository.api import ApiMetricsRepository\nfrom .repository.core import CoreMetricRepository\nfrom .repository.job import JobRepository\nfrom .repository.stt import TranscriptionMetricRepository\n"
  },
  {
    "path": "shared/selene/data/metric/entity/__init__.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n"
  },
  {
    "path": "shared/selene/data/metric/entity/account_activity.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2020 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Defines data entities related to account activity metrics.\"\"\"\nfrom dataclasses import dataclass\n\n\n@dataclass\nclass AccountActivity:\n    \"\"\"Data class representing a row on the account_activity table.\"\"\"\n\n    accounts: int\n    accounts_added: int\n    accounts_deleted: int\n    accounts_active: int\n    members: int\n    members_added: int\n    members_expired: int\n    members_active: int\n    open_dataset: int\n    open_dataset_added: int\n    open_dataset_deleted: int\n    open_dataset_active: int\n"
  },
  {
    "path": "shared/selene/data/metric/entity/api.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom dataclasses import dataclass\nfrom datetime import datetime\nfrom decimal import Decimal\n\n\n@dataclass\nclass ApiMetric(object):\n    url: str\n    access_ts: datetime\n    api: str\n    duration: Decimal\n    http_method: str\n    http_status: int\n    id: str = None\n    account_id: str = None\n    device_id: str = None\n"
  },
  {
    "path": "shared/selene/data/metric/entity/core.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom dataclasses import dataclass\nfrom datetime import datetime\nfrom decimal import Decimal\n\n\n@dataclass\nclass CoreMetric(object):\n    device_id: str\n    metric_type: str\n    metric_value: dict\n    id: str = None\n\n\n@dataclass\nclass CoreInteraction(object):\n    core_id: str\n    device_id: str\n    start_ts: datetime\n    stt_engine: str = None\n    stt_transcription: str = None\n    stt_duration: Decimal = None\n    intent_type: str = None\n    intent_duration: Decimal = None\n    fallback_handler_duration: Decimal = None\n    skill_handler: str = None\n    skill_duration: Decimal = None\n    tts_engine: str = None\n    tts_utterance: str = None\n    tts_duration: str = None\n    speech_playback_duration: Decimal = None\n    user_latency: Decimal = None\n    id: str = None\n"
  },
  {
    "path": "shared/selene/data/metric/entity/job.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom dataclasses import dataclass\nfrom datetime import date, datetime\n\n\n@dataclass\nclass JobMetric(object):\n    job_name: str\n    batch_date: date\n    start_ts: datetime\n    end_ts: datetime\n    command: str\n    success: bool\n    id: str = None\n"
  },
  {
    "path": "shared/selene/data/metric/entity/stt.py",
    "content": "#  Mycroft Server - Backend\n#  Copyright (c) 2022 Mycroft AI Inc\n#  SPDX-License-Identifier: \tAGPL-3.0-or-later\n#  #\n#  This file is part of the Mycroft Server.\n#  #\n#  The Mycroft Server is free software: you can redistribute it and/or\n#  modify it under the terms of the GNU Affero General Public License as\n#  published by the Free Software Foundation, either version 3 of the\n#  License, or (at your option) any later version.\n#  #\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n#  GNU Affero General Public License for more details.\n#  #\n#  You should have received a copy of the GNU Affero General Public License\n#  along with this program. If not, see <https://www.gnu.org/licenses/>.\n#\n\"\"\"Defines data entities for STT metrics.\"\"\"\nfrom dataclasses import dataclass\nfrom datetime import date\nfrom decimal import Decimal\n\n\n@dataclass\nclass SttTranscriptionMetric:\n    \"\"\"Defines the entity representing the metric.stt_transcription table.\"\"\"\n\n    account_id: str\n    engine: str\n    success: bool\n    audio_duration: Decimal\n    transcription_duration: Decimal\n\n\n@dataclass\nclass SttEngineMetric:\n    \"\"\"Defines the entity representing the metric.stt_engine table.\"\"\"\n\n    activity_dt: date\n    engine: str\n    requests: int\n    success_rate: bool\n    transcription_speed: Decimal\n    audio_duration: Decimal\n    transcription_duration: Decimal\n"
  },
  {
    "path": "shared/selene/data/metric/repository/__init__.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n"
  },
  {
    "path": "shared/selene/data/metric/repository/account_activity.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2020 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"CRUD operations for the account_activity table in the metrics schema.\"\"\"\nfrom datetime import date, datetime\nfrom logging import getLogger\nfrom os import environ\n\nfrom selene.data.account import Account, OPEN_DATASET\nfrom ..entity.account_activity import AccountActivity\nfrom ...repository_base import RepositoryBase\n\n_log = getLogger(__name__)\n\n\nclass AccountActivityRepository(RepositoryBase):\n    \"\"\"Query and maintain the account_activity table.\"\"\"\n\n    def __init__(self, db):\n        super(AccountActivityRepository, self).__init__(db, __file__)\n\n    def increment_accounts_added(self):\n        \"\"\"Increment the accounts added metric on the account activity table.\"\"\"\n        request = self._build_db_request(sql_file_name=\"increment_accounts_added.sql\")\n        self._update_account_activity(request)\n\n    def increment_accounts_deleted(self):\n        \"\"\"Increment the deleted accounts metric on the account activity table.\"\"\"\n        request = self._build_db_request(sql_file_name=\"increment_accounts_deleted.sql\")\n        self._update_account_activity(request)\n\n    def increment_members_added(self):\n        \"\"\"Increment the deleted accounts metric on the account activity table.\"\"\"\n        request = self._build_db_request(sql_file_name=\"increment_members_added.sql\")\n        self._update_account_activity(request)\n\n    def increment_members_expired(self):\n        \"\"\"Increment the deleted accounts metric on the account activity table.\"\"\"\n        request = self._build_db_request(sql_file_name=\"increment_members_expired.sql\")\n        self._update_account_activity(request)\n\n    def increment_open_dataset_added(self):\n        \"\"\"Increment the deleted accounts metric on the account activity table.\"\"\"\n        request = self._build_db_request(\n            sql_file_name=\"increment_open_dataset_added.sql\"\n        )\n        self._update_account_activity(request)\n\n    def increment_open_dataset_deleted(self):\n        \"\"\"Increment the deleted accounts metric on the account activity table.\"\"\"\n        request = self._build_db_request(\n            sql_file_name=\"increment_open_dataset_deleted.sql\"\n        )\n        self._update_account_activity(request)\n\n    def increment_activity(self, account: Account):\n        \"\"\"Increment the activity counters depending on type of account.\"\"\"\n        first_activity_of_day = (\n            account.last_activity is None\n            or account.last_activity.date() < datetime.utcnow().date()\n        )\n        if first_activity_of_day:\n            member_increment = 1 if account.membership is not None else 0\n            agreements = [agreement.type for agreement in account.agreements]\n            open_dataset_increment = 1 if OPEN_DATASET in agreements else 0\n            request = self._build_db_request(\n                sql_file_name=\"increment_activity.sql\",\n                args=dict(\n                    member_increment=member_increment,\n                    open_dataset_increment=open_dataset_increment,\n                ),\n            )\n            self._update_account_activity(request)\n\n    def _update_account_activity(self, update_request):\n        \"\"\"Update today's account activity, adding a row if it doesn't exist.\"\"\"\n        row_updated = self.cursor.update(update_request)\n        if not row_updated:\n            self._add_account_activity_row()\n            self.cursor.update(update_request)\n\n    def _add_account_activity_row(self):\n        \"\"\"Adds a row to the account activity table for a day that does not exist.\"\"\"\n        request = self._build_db_request(sql_file_name=\"add_account_activity.sql\")\n        self.cursor.insert(request)\n\n    def get_activity_by_date(self, activity_date: date) -> AccountActivity:\n        \"\"\"Returns the account activity metrics for the given date.\"\"\"\n        return self._select_one_into_dataclass(\n            dataclass=AccountActivity,\n            sql_file_name=\"get_account_activity_by_date.sql\",\n            args=dict(activity_date=activity_date),\n        )\n\n    def delete_activity_by_date(self, activity_date: date):\n        \"\"\"ACCOUNT ACTIVITY SHOULD NEVER BE DELETED!  ONLY USE IN TEST CODE!\"\"\"\n        if environ[\"SELENE_ENVIRONMENT\"] == \"dev\":\n            request = self._build_db_request(\n                sql_file_name=\"delete_account_activity_date.sql\",\n                args=dict(activity_date=activity_date),\n            )\n            deleted_rows = self.cursor.delete(request)\n            if deleted_rows:\n                log_msg = \"account activity for {} deleted\"\n            else:\n                log_msg = \"no activity row found for {}\"\n            _log.info(log_msg.format(activity_date))\n"
  },
  {
    "path": "shared/selene/data/metric/repository/api.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\n\"\"\"CRUD operations for the metric.api and metric.api_history tables.\n\nThe metric.api table contains performance metric for the Selene APIs.  There\nare millions of API requests made per day.  This can lead to poor performance\nquerying the table after only a few days.  This problem is solved by\npartitioning the table into smaller daily tables.\n\nThe declarative partitioning scheme provided by Postgres is used to create\nthe partitions of the metric.api_history table\n\"\"\"\nimport os\nfrom dataclasses import asdict\nfrom datetime import date, datetime, time\n\nfrom ..entity.api import ApiMetric\nfrom ...repository_base import RepositoryBase\n\nDUMP_FILE_DIR = \"/opt/selene/dump\"\n\n\nclass ApiMetricsRepository(RepositoryBase):\n    def __init__(self, db):\n        super(ApiMetricsRepository, self).__init__(db, __file__)\n\n    def add(self, metric: ApiMetric):\n        db_request = self._build_db_request(\n            sql_file_name=\"add_api_metric.sql\", args=asdict(metric)\n        )\n        self.cursor.insert(db_request)\n\n    def create_partition(self, partition_date: date):\n        \"\"\"Create a daily partition for the metric.api_history table.\"\"\"\n        start_ts = datetime.combine(partition_date, time.min)\n        end_ts = datetime.combine(partition_date, time.max)\n        db_request = self._build_db_request(\n            sql_file_name=\"create_api_metric_partition.sql\",\n            args=dict(start_ts=str(start_ts), end_ts=str(end_ts)),\n            sql_vars=dict(partition=partition_date.strftime(\"%Y%m%d\")),\n        )\n        self.cursor.execute(db_request)\n\n        db_request = self._build_db_request(\n            sql_file_name=\"create_api_metric_partition_index.sql\",\n            sql_vars=dict(partition=partition_date.strftime(\"%Y%m%d\")),\n        )\n        self.cursor.execute(db_request)\n\n    def copy_to_partition(self, partition_date: date):\n        \"\"\"Copy rows from metric.api table to metric.api_history.\"\"\"\n        dump_file_name = \"api_metrics_\" + str(partition_date)\n        dump_file_path = os.path.join(DUMP_FILE_DIR, dump_file_name)\n        db_request = self._build_db_request(\n            sql_file_name=\"get_api_metrics_for_date.sql\",\n            args=dict(metrics_date=partition_date),\n        )\n        table_name = \"metric.api_history_\" + partition_date.strftime(\"%Y%m%d\")\n        self.cursor.dump_query_result_to_file(db_request, dump_file_path)\n        self.cursor.load_dump_file_to_table(table_name, dump_file_path)\n        os.remove(dump_file_path)\n\n    def remove_by_date(self, partition_date: date):\n        \"\"\"Delete from metric.api table after copying to metric.api_history\"\"\"\n        db_request = self._build_db_request(\n            sql_file_name=\"delete_api_metrics_by_date.sql\",\n            args=dict(delete_date=partition_date),\n        )\n        self.cursor.delete(db_request)\n"
  },
  {
    "path": "shared/selene/data/metric/repository/core.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nimport json\nfrom dataclasses import asdict\nfrom datetime import date\nfrom typing import List\nfrom ..entity.core import CoreMetric, CoreInteraction\nfrom ...repository_base import RepositoryBase\n\n\nclass CoreMetricRepository(RepositoryBase):\n    def __init__(self, db):\n        super(CoreMetricRepository, self).__init__(db, __file__)\n\n    def add(self, metric: CoreMetric):\n        db_request_args = asdict(metric)\n        db_request_args[\"metric_value\"] = json.dumps(db_request_args[\"metric_value\"])\n        db_request = self._build_db_request(\n            sql_file_name=\"add_core_metric.sql\", args=db_request_args\n        )\n        self.cursor.insert(db_request)\n\n    def get_metrics_by_device(self, device_id):\n        return self._select_all_into_dataclass(\n            CoreMetric,\n            sql_file_name=\"get_core_metric_by_device.sql\",\n            args=dict(device_id=device_id),\n        )\n\n    def get_metrics_by_date(self, metric_date: date) -> List[CoreMetric]:\n        return self._select_all_into_dataclass(\n            CoreMetric,\n            sql_file_name=\"get_core_timing_metrics_by_date.sql\",\n            args=dict(metric_date=metric_date),\n        )\n\n    def add_interaction(self, interaction: CoreInteraction) -> str:\n        db_request = self._build_db_request(\n            sql_file_name=\"add_core_interaction.sql\", args=asdict(interaction)\n        )\n        db_result = self.cursor.insert_returning(db_request)\n\n        return db_result.id\n"
  },
  {
    "path": "shared/selene/data/metric/repository/job.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom dataclasses import asdict\n\nfrom ...repository_base import RepositoryBase\nfrom ..entity.job import JobMetric\n\n\nclass JobRepository(RepositoryBase):\n    def __init__(self, db):\n        super(JobRepository, self).__init__(db, __file__)\n\n    def add(self, job: JobMetric):\n        db_request = self._build_db_request(\n            sql_file_name=\"add_job_metric.sql\", args=asdict(job)\n        )\n        db_result = self.cursor.insert_returning(db_request)\n\n        return db_result[\"id\"]\n"
  },
  {
    "path": "shared/selene/data/metric/repository/sql/add_account_activity.sql",
    "content": "INSERT INTO\n    metric.account_activity (\n        accounts,\n        members,\n        open_dataset\n    )\nVALUES\n    (\n        (SELECT count(*) FROM account.account),\n        (\n            SELECT\n                count(*)\n            FROM\n                account.account_membership\n            WHERE\n                membership_ts_range @> current_date::timestamp\n        ),\n        (\n            SELECT\n                count(aa.*)\n            FROM\n                account.account_agreement aa\n                INNER JOIN account.agreement a ON aa.agreement_id = a.id\n            WHERE\n                a.agreement = 'Open Dataset'\n        )\n    )\n"
  },
  {
    "path": "shared/selene/data/metric/repository/sql/add_api_metric.sql",
    "content": "INSERT INTO\n    metric.api (\n        url,\n        access_ts,\n        api,\n        account_id,\n        device_id,\n        duration,\n        http_method,\n        http_status\n    )\nVALUES\n    (\n        %(url)s,\n        %(access_ts)s,\n        %(api)s,\n        %(account_id)s,\n        %(device_id)s,\n        %(duration)s,\n        %(http_method)s,\n        %(http_status)s\n    )\n"
  },
  {
    "path": "shared/selene/data/metric/repository/sql/add_core_interaction.sql",
    "content": "INSERT INTO\n    metric.core_interaction (\n        device_id,\n        core_id,\n        start_ts,\n        stt_engine,\n        stt_transcription,\n        stt_duration,\n        intent_type,\n        intent_duration,\n        fallback_handler_duration,\n        skill_handler,\n        skill_duration,\n        tts_engine,\n        tts_utterance,\n        tts_duration,\n        speech_playback_duration,\n        user_latency\n    )\nVALUES\n    (\n        %(device_id)s,\n        %(core_id)s,\n        %(start_ts)s,\n        %(stt_engine)s,\n        %(stt_transcription)s,\n        %(stt_duration)s,\n        %(intent_type)s,\n        %(intent_duration)s,\n        %(fallback_handler_duration)s,\n        %(skill_handler)s,\n        %(skill_duration)s,\n        %(tts_engine)s,\n        %(tts_utterance)s,\n        %(tts_duration)s,\n        %(speech_playback_duration)s,\n        %(user_latency)s\n    )\nRETURNING\n    id\n"
  },
  {
    "path": "shared/selene/data/metric/repository/sql/add_core_metric.sql",
    "content": "INSERT INTO\n    metric.core (device_id, metric_type, metric_value)\nVALUES\n    (%(device_id)s, %(metric_type)s, %(metric_value)s)\n"
  },
  {
    "path": "shared/selene/data/metric/repository/sql/add_job_metric.sql",
    "content": "INSERT INTO\n    metric.job\nVALUES (\n    DEFAULT,\n    %(job_name)s,\n    %(batch_date)s,\n    %(start_ts)s,\n    %(end_ts)s,\n    %(command)s,\n    %(success)s\n)\nRETURNING\n    id\n"
  },
  {
    "path": "shared/selene/data/metric/repository/sql/add_tts_transcription_metric.sql",
    "content": "INSERT INTO\n    metric.stt_transcription\nVALUES (\n    DEFAULT,\n    %(account_id)s,\n    %(engine)s,\n    %(success)s,\n    %(audio_duration)s,\n    %(transcription_duration)s\n)\nRETURNING\n    id\n"
  },
  {
    "path": "shared/selene/data/metric/repository/sql/create_api_metric_partition.sql",
    "content": "CREATE TABLE IF NOT EXISTS\n    metric.api_history_{partition}\nPARTITION OF\n    metric  .api_history\nFOR VALUES FROM\n    (%(start_ts)s) TO (%(end_ts)s)\n"
  },
  {
    "path": "shared/selene/data/metric/repository/sql/create_api_metric_partition_index.sql",
    "content": "CREATE INDEX IF NOT EXISTS\n    api_history_{partition}_access_ts_idx\nON\n    metric.api_history_{partition} (access_ts)\n"
  },
  {
    "path": "shared/selene/data/metric/repository/sql/delete_account_activity_date.sql",
    "content": "DELETE FROM\n    metric.account_activity\nWHERE\n    activity_dt = %(activity_date)s\n"
  },
  {
    "path": "shared/selene/data/metric/repository/sql/delete_api_metrics_by_date.sql",
    "content": "DELETE FROM\n    metric.api\nWHERE\n    access_ts::date = %(delete_date)s\n"
  },
  {
    "path": "shared/selene/data/metric/repository/sql/delete_stt_transcription_by_date.sql",
    "content": "DELETE FROM\n    metric.stt_transcription\nWHERE\n    insert_ts::date = %(transcription_date)s\n"
  },
  {
    "path": "shared/selene/data/metric/repository/sql/get_account_activity_by_date.sql",
    "content": "SELECT\n    accounts,\n    accounts_added,\n    accounts_deleted,\n    accounts_active,\n    members,\n    members_added,\n    members_expired,\n    members_active,\n    open_dataset,\n    open_dataset_added,\n    open_dataset_deleted,\n    open_dataset_active\nFROM\n    metric.account_activity\nWHERE\n    activity_dt = %(activity_date)s\n"
  },
  {
    "path": "shared/selene/data/metric/repository/sql/get_api_metrics_for_date.sql",
    "content": "SELECT\n    *\nFROM\n    metric.api\nWHERE\n    access_ts::date = %(metrics_date)s\n"
  },
  {
    "path": "shared/selene/data/metric/repository/sql/get_core_metric_by_device.sql",
    "content": "SELECT\n    id,\n    device_id,\n    metric_type,\n    metric_value\nFROM\n    metric.core\nWHERE\n    device_id = %(device_id)s\n"
  },
  {
    "path": "shared/selene/data/metric/repository/sql/get_core_timing_metrics_by_date.sql",
    "content": "SELECT\n    device_id,\n    metric_type,\n    metric_value\nFROM\n    metric.core\nWHERE\n    metric_type = 'timing'\n    AND metric_value ->> 'id' NOT IN ('unknown', 'null')\n    AND insert_ts::date = %(metric_date)s\nORDER BY\n    metric_value ->> 'id',\n    metric_value ->> 'start_time'\n"
  },
  {
    "path": "shared/selene/data/metric/repository/sql/get_tts_transcription_by_account.sql",
    "content": "SELECT\n    account_id,\n    engine,\n    success,\n    audio_duration,\n    transcription_duration\nFROM\n    metric.stt_transcription\nWHERE\n    account_id = %(account_id)s\n"
  },
  {
    "path": "shared/selene/data/metric/repository/sql/increment_accounts_added.sql",
    "content": "UPDATE\n    metric.account_activity\nSET\n    accounts = accounts + 1,\n    accounts_added = accounts_added + 1\nWHERE\n    activity_dt = current_date\n"
  },
  {
    "path": "shared/selene/data/metric/repository/sql/increment_accounts_deleted.sql",
    "content": "UPDATE\n    metric.account_activity\nSET\n    accounts = accounts - 1,\n    accounts_deleted = accounts_deleted + 1\nWHERE\n    activity_dt = current_date\n"
  },
  {
    "path": "shared/selene/data/metric/repository/sql/increment_activity.sql",
    "content": "UPDATE\n    metric.account_activity\nSET\n    accounts_active = accounts_active + 1,\n    members_active = members_active + %(member_increment)s,\n    open_dataset_active = open_dataset_active + %(open_dataset_increment)s\nWHERE\n    activity_dt = current_date\n"
  },
  {
    "path": "shared/selene/data/metric/repository/sql/increment_members_added.sql",
    "content": "UPDATE\n    metric.account_activity\nSET\n    members = members + 1,\n    members_added = members_added + 1\nWHERE\n    activity_dt = current_date\n"
  },
  {
    "path": "shared/selene/data/metric/repository/sql/increment_members_expired.sql",
    "content": "UPDATE\n    metric.account_activity\nSET\n    members = members - 1,\n    members_expired = members_expired + 1\nWHERE\n    activity_dt = current_date\n"
  },
  {
    "path": "shared/selene/data/metric/repository/sql/increment_open_dataset_added.sql",
    "content": "UPDATE\n    metric.account_activity\nSET\n    open_dataset = open_dataset + 1,\n    open_dataset_added = open_dataset_added + 1\nWHERE\n    activity_dt = current_date\n"
  },
  {
    "path": "shared/selene/data/metric/repository/sql/increment_open_dataset_deleted.sql",
    "content": "UPDATE\n    metric.account_activity\nSET\n    open_dataset = open_dataset - 1,\n    open_dataset_deleted = open_dataset_deleted + 1\nWHERE\n    activity_dt = current_date\n"
  },
  {
    "path": "shared/selene/data/metric/repository/stt.py",
    "content": "#  Mycroft Server - Backend\n#  Copyright (c) 2022 Mycroft AI Inc\n#  SPDX-License-Identifier: \tAGPL-3.0-or-later\n#  #\n#  This file is part of the Mycroft Server.\n#  #\n#  The Mycroft Server is free software: you can redistribute it and/or\n#  modify it under the terms of the GNU Affero General Public License as\n#  published by the Free Software Foundation, either version 3 of the\n#  License, or (at your option) any later version.\n#  #\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n#  GNU Affero General Public License for more details.\n#  #\n#  You should have received a copy of the GNU Affero General Public License\n#  along with this program. If not, see <https://www.gnu.org/licenses/>.\n#\n\"\"\"Defines data access methods for the metric.stt_transcription table.\"\"\"\nfrom dataclasses import asdict\nfrom datetime import date\nfrom decimal import Decimal\nfrom typing import List\n\nfrom ...repository_base import RepositoryBase\nfrom ..entity.stt import SttTranscriptionMetric\n\n\nclass TranscriptionMetricRepository(RepositoryBase):\n    \"\"\"Defines data access methods for the metric.stt_transcription table.\"\"\"\n\n    def __init__(self, db):\n        super().__init__(db, __file__)\n\n    def add(self, metric: SttTranscriptionMetric) -> str:\n        \"\"\"Adds a row to the metric.stt_transcription table.\n\n        :param metric: the metric to insert into the database\n        :returns: the ID generated by the insert statement\n        \"\"\"\n        sql_args = asdict(metric)\n        sql_args.update(\n            audio_duration=metric.audio_duration.quantize(Decimal(\"0.001\")),\n            transcription_duration=metric.transcription_duration.quantize(\n                Decimal(\"0.001\")\n            ),\n        )\n        db_request = self._build_db_request(\n            sql_file_name=\"add_tts_transcription_metric.sql\", args=sql_args\n        )\n        db_result = self.cursor.insert_returning(db_request)\n\n        return db_result[\"id\"]\n\n    def get_by_account(self, account_id: str) -> List[SttTranscriptionMetric]:\n        \"\"\"Get all the STT transcription metrics for an account.\n\n        :param account_id: identifier of the account to use in the query\n        :returns: query results\n        \"\"\"\n        return self._select_all_into_dataclass(\n            SttTranscriptionMetric,\n            sql_file_name=\"get_tts_transcription_by_account.sql\",\n            args=dict(account_id=account_id),\n        )\n\n    def delete_by_date(self, transcription_date: date):\n        \"\"\"Delete all STT transcription metrics for a day.\n\n        The data on the metric.stt_transcription table is attributable to and account.\n        After aggregating the data for a day into the metric.stt_engine table, delete\n        the attributable data to comply with our privacy policy.\n\n        :param transcription_date: The date the transcription was requested\n        \"\"\"\n        db_request = self._build_db_request(\n            sql_file_name=\"add_tts_transcription_metric.sql\",\n            args=dict(transcription_date=transcription_date),\n        )\n        self.cursor.delete(db_request)\n"
  },
  {
    "path": "shared/selene/data/repository_base.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\n\"\"\"Base class that all data repository classes should inherit from\n\nThis class contains boilerplate code that is necessary for all repository\nclasses such as building a database request and a cursor object.\n\"\"\"\nfrom os import path\nfrom typing import List\n\nfrom selene.util.db import (\n    Cursor,\n    DatabaseRequest,\n    DatabaseBatchRequest,\n    get_sql_from_file,\n)\n\n\ndef _instantiate_dataclass(dataclass, db_result):\n    \"\"\"Build a dataclass instance using a row of a query result.\n\n    Depending on the cursor factory assigned to the database connection, the\n    keyword arguments used to instantiate a dataclass may need to be converted\n    to a dictionary first.\n    \"\"\"\n    try:\n        dataclass_instance = dataclass(**db_result)\n    except TypeError:\n        dataclass_instance = dataclass(**db_result._asdict())\n\n    return dataclass_instance\n\n\nclass RepositoryBase(object):\n    def __init__(self, db, repository_path):\n        self.db = db\n        self.cursor = Cursor(db)\n        self.sql_dir = path.join(path.dirname(repository_path), \"sql\")\n\n    def _build_db_request(\n        self, sql_file_name: str, args: dict = None, sql_vars: dict = None\n    ):\n        \"\"\"Build a DatabaseRequest object containing a query and args\"\"\"\n        sql = get_sql_from_file(path.join(self.sql_dir, sql_file_name))\n        if sql_vars is not None:\n            sql = sql.format(**sql_vars)\n\n        return DatabaseRequest(sql, args)\n\n    def _build_db_batch_request(self, sql_file_name: str, args: List[dict]):\n        \"\"\"Build a DatabaseBatchRequest object containing a query and args\"\"\"\n        return DatabaseBatchRequest(\n            sql=get_sql_from_file(path.join(self.sql_dir, sql_file_name)), args=args\n        )\n\n    def _select_one_into_dataclass(self, dataclass, sql_file_name, args=None):\n        \"\"\"Execute a query and instantiate the dataclass with its results.\"\"\"\n        db_request = self._build_db_request(sql_file_name, args)\n        db_result = self.cursor.select_one(db_request)\n        if db_result is None:\n            dataclass_instance = None\n        else:\n            dataclass_instance = _instantiate_dataclass(dataclass, db_result)\n\n        return dataclass_instance\n\n    def _select_all_into_dataclass(self, dataclass, sql_file_name, args=None):\n        \"\"\"Execute a query and instantiate the dataclass with its results.\"\"\"\n        db_request = self._build_db_request(sql_file_name, args)\n        db_result = self.cursor.select_all(db_request)\n\n        return [_instantiate_dataclass(dataclass, row) for row in db_result]\n"
  },
  {
    "path": "shared/selene/data/skill/__init__.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom .entity.display import SkillDisplay\nfrom .entity.skill import Skill\nfrom .entity.skill_setting import (\n    AccountSkillSetting,\n    DeviceSkillSetting,\n    SettingsDisplay,\n)\nfrom .repository.display import SkillDisplayRepository\nfrom .repository.setting import SkillSettingRepository\nfrom .repository.settings_display import SettingsDisplayRepository\nfrom .repository.skill import extract_family_from_global_id, SkillRepository\n"
  },
  {
    "path": "shared/selene/data/skill/entity/__init__.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n"
  },
  {
    "path": "shared/selene/data/skill/entity/display.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom dataclasses import dataclass\n\n\n@dataclass\nclass SkillDisplay(object):\n    skill_id: str\n    core_version: str\n    display_data: dict\n    id: str = None\n"
  },
  {
    "path": "shared/selene/data/skill/entity/skill.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom dataclasses import dataclass\nfrom typing import List\n\n\n@dataclass\nclass SkillVersion(object):\n    version: str\n    display_name: str\n\n\n@dataclass\nclass Skill(object):\n    skill_gid: str\n    id: str = None\n\n\n@dataclass\nclass SkillFamily(object):\n    display_name: str\n    family_name: str\n    has_settings: bool\n    market_id: str\n    skill_ids: List[str]\n"
  },
  {
    "path": "shared/selene/data/skill/entity/skill_setting.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom dataclasses import dataclass\nfrom typing import List\n\n\n@dataclass\nclass AccountSkillSetting(object):\n    settings_definition: dict\n    settings_values: dict\n    device_names: List[str]\n\n\n@dataclass\nclass DeviceSkillSetting(object):\n    settings_display: dict\n    settings_values: dict\n    skill_id: str\n\n\n@dataclass\nclass SettingsDisplay(object):\n    skill_id: str\n    display_data: dict\n    id: str = None\n"
  },
  {
    "path": "shared/selene/data/skill/repository/__init__.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n"
  },
  {
    "path": "shared/selene/data/skill/repository/display.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom ..entity.display import SkillDisplay\nfrom ...repository_base import RepositoryBase\n\n\nclass SkillDisplayRepository(RepositoryBase):\n    def __init__(self, db):\n        super(SkillDisplayRepository, self).__init__(db, __file__)\n\n        # TODO: Change this to a value that can be passed in\n        self.core_version = \"21.02\"\n\n    def get_display_data_for_skills(self):\n        return self._select_all_into_dataclass(\n            dataclass=SkillDisplay,\n            sql_file_name=\"get_display_data_for_skills.sql\",\n            args=dict(core_version=self.core_version),\n        )\n\n    def get_display_data_for_skill(self, skill_display_id) -> SkillDisplay:\n        return self._select_one_into_dataclass(\n            dataclass=SkillDisplay,\n            sql_file_name=\"get_display_data_for_skill.sql\",\n            args=dict(skill_display_id=skill_display_id),\n        )\n\n    def upsert(self, skill_display: SkillDisplay):\n        db_request = self._build_db_request(\n            sql_file_name=\"upsert_skill_display_data.sql\",\n            args=dict(\n                skill_id=skill_display.skill_id,\n                core_version=skill_display.core_version,\n                display_data=skill_display.display_data,\n            ),\n        )\n\n        self.cursor.insert(db_request)\n"
  },
  {
    "path": "shared/selene/data/skill/repository/setting.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nimport json\nfrom typing import List\n\nfrom selene.util.db import use_transaction\nfrom .skill import SkillRepository\nfrom ..entity.skill_setting import AccountSkillSetting, DeviceSkillSetting\nfrom ...repository_base import RepositoryBase\n\n\nclass SkillSettingRepository(RepositoryBase):\n    def __init__(self, db):\n        super(SkillSettingRepository, self).__init__(db, __file__)\n        self.db = db\n\n    def get_family_settings(\n        self, account_id: str, family_name: str\n    ) -> List[AccountSkillSetting]:\n        return self._select_all_into_dataclass(\n            AccountSkillSetting,\n            sql_file_name=\"get_settings_for_skill_family.sql\",\n            args=dict(family_name=family_name, account_id=account_id),\n        )\n\n    def get_installer_settings(self, account_id) -> List[AccountSkillSetting]:\n        skill_repo = SkillRepository(self.db)\n        skills = skill_repo.get_skills_for_account(account_id)\n        installer_skill_id = None\n        for skill in skills:\n            if skill.display_name == \"Installer\":\n                installer_skill_id = skill.id\n\n        skill_settings = None\n        if installer_skill_id is not None:\n            skill_settings = self.get_family_settings(account_id, installer_skill_id)\n\n        return skill_settings\n\n    @use_transaction\n    def update_skill_settings(\n        self, account_id, new_skill_settings: AccountSkillSetting, skill_ids: List[str]\n    ):\n        if new_skill_settings.settings_values is None:\n            serialized_settings_values = None\n        else:\n            serialized_settings_values = json.dumps(new_skill_settings.settings_values)\n        db_request = self._build_db_request(\n            \"update_device_skill_settings.sql\",\n            args=dict(\n                account_id=account_id,\n                settings_values=serialized_settings_values,\n                skill_id=tuple(skill_ids),\n                device_names=tuple(new_skill_settings.device_names),\n            ),\n        )\n        self.cursor.update(db_request)\n\n    def get_skill_settings_for_device(self, device_id: str):\n        \"\"\"Return all skills and their settings for a given device id\"\"\"\n        return self._select_all_into_dataclass(\n            DeviceSkillSetting,\n            sql_file_name=\"get_skill_setting_by_device.sql\",\n            args=dict(device_id=device_id),\n        )\n"
  },
  {
    "path": "shared/selene/data/skill/repository/settings_display.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nimport json\n\nfrom ...repository_base import RepositoryBase\nfrom ..entity.skill_setting import SettingsDisplay\n\n\nclass SettingsDisplayRepository(RepositoryBase):\n    def __init__(self, db):\n        super(SettingsDisplayRepository, self).__init__(db, __file__)\n\n    def add(self, settings_display: SettingsDisplay) -> str:\n        \"\"\"Add a new row to the skill.settings_display table.\"\"\"\n        db_request = self._build_db_request(\n            sql_file_name=\"add_settings_display.sql\",\n            args=dict(\n                skill_id=settings_display.skill_id,\n                display_data=json.dumps(settings_display.display_data),\n            ),\n        )\n        result = self.cursor.insert_returning(db_request)\n\n        return result[\"id\"]\n\n    def get_settings_display_id(self, settings_display: SettingsDisplay):\n        \"\"\"Get the ID of a skill's settings definition.\"\"\"\n        db_request = self._build_db_request(\n            sql_file_name=\"get_settings_display_id.sql\",\n            args=dict(\n                skill_id=settings_display.skill_id,\n                display_data=json.dumps(settings_display.display_data),\n            ),\n        )\n        result = self.cursor.select_one(db_request)\n\n        return None if result is None else result[\"id\"]\n\n    def get_settings_definitions_by_gid(self, global_id):\n        \"\"\"Get all matching settings definitions for a global skill ID.\n\n        There can be more than one settings definition for a global skill ID.\n        An example of when this could happen is if a skill author changed the\n        settings definition and not all devices have updated to the latest.\n        \"\"\"\n        return self._select_all_into_dataclass(\n            SettingsDisplay,\n            sql_file_name=\"get_settings_definition_by_gid.sql\",\n            args=dict(global_id=global_id),\n        )\n\n    def remove(self, settings_display_id: str):\n        \"\"\"Delete a settings definition that is no longer used by any device\"\"\"\n        db_request = self._build_db_request(\n            sql_file_name=\"delete_settings_display.sql\",\n            args=dict(settings_display_id=settings_display_id),\n        )\n        self.cursor.delete(db_request)\n"
  },
  {
    "path": "shared/selene/data/skill/repository/skill.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Data access methods for the skill.skill table.\"\"\"\nfrom typing import List\n\nfrom ..entity.skill import Skill, SkillFamily\nfrom ...repository_base import RepositoryBase\n\n\ndef extract_family_from_global_id(skill_gid: str) -> str:\n    \"\"\"Extracts a common skill name for different GIDs of the same skill.\n\n    :param skill_gid: The global identifier of the skill.\n    :returns: Common name for skill for use in GUI\n    \"\"\"\n    id_parts = skill_gid.split(\"|\")\n    if id_parts[0].startswith(\"@\"):\n        family_name = id_parts[1]\n    else:\n        family_name = id_parts[0]\n\n    if skill_gid.endswith(\".mark2\"):\n        family_name = skill_gid[: -len(\".mark2\")]\n\n    return family_name\n\n\nclass SkillRepository(RepositoryBase):\n    \"\"\"Defines data access methods for the skill.skill table.\"\"\"\n\n    def __init__(self, db):\n        self.db = db\n        super().__init__(db, __file__)\n\n    def get_skills_for_account(self, account_id: str) -> List[SkillFamily]:\n        \"\"\"Retrieves a list of distinct skills on all devices for an account.\n\n        :param account_id: the internal identifier of the account\n        :returns: a list of skills by skill family name\n        \"\"\"\n        skills = []\n        db_request = self._build_db_request(\n            \"get_skills_for_account.sql\", args=dict(account_id=account_id)\n        )\n        db_result = self.cursor.select_all(db_request)\n        if db_result is not None:\n            for row in db_result:\n                skills.append(SkillFamily(**row))\n\n        return skills\n\n    def get_skill_by_global_id(self, skill_global_id: str) -> Skill:\n        \"\"\"Retrieves a skill from the database using its Global ID.\n\n        :param skill_global_id: Identifier of a skill\n        :returns the entry on the skill table for the provided global ID\n        \"\"\"\n        return self._select_one_into_dataclass(\n            dataclass=Skill,\n            sql_file_name=\"get_skill_by_global_id.sql\",\n            args=dict(skill_global_id=skill_global_id),\n        )\n\n    def ensure_skill_exists(self, skill_global_id: str) -> str:\n        \"\"\"Adds skill to the database if its global id does not already exist.\n\n        :param skill_global_id: identifier of a skill\n        :return: the internal identifier for the skill\n        \"\"\"\n        skill = self.get_skill_by_global_id(skill_global_id)\n        if skill is None:\n            family_name = extract_family_from_global_id(skill_global_id)\n            skill_id = self._add_skill(skill_global_id, family_name)\n        else:\n            skill_id = skill.id\n\n        return skill_id\n\n    def _add_skill(self, skill_gid: str, name: str) -> str:\n        \"\"\"Adds a skill to the database.\n\n        :param skill_gid: identifier of a skill\n        :param name: human readable skill name\n        :return: internal identifier of the new skill\n        \"\"\"\n        db_request = self._build_db_request(\n            sql_file_name=\"add_skill.sql\",\n            args=dict(skill_gid=skill_gid, family_name=name),\n        )\n        db_result = self.cursor.insert_returning(db_request)\n\n        # handle both dictionary cursors and namedtuple cursors\n        try:\n            skill_id = db_result[\"id\"]\n        except TypeError:\n            skill_id = db_result.id\n\n        return skill_id\n\n    def remove_by_gid(self, skill_gid):\n        \"\"\"Deletes a skill from the database.\n\n        :param skill_gid: identifier of a skill\n        \"\"\"\n        db_request = self._build_db_request(\n            sql_file_name=\"remove_skill_by_gid.sql\", args=dict(skill_gid=skill_gid)\n        )\n        self.cursor.delete(db_request)\n"
  },
  {
    "path": "shared/selene/data/skill/repository/sql/add_device_skill.sql",
    "content": "INSERT INTO\n    device.device_skill (device_id, skill_id, skill_settings_display_id, settings)\nVALUES\n    (%(device_id)s, %(skill_id)s, %(skill_settings_display_id)s, %(settings_value)s)\n"
  },
  {
    "path": "shared/selene/data/skill/repository/sql/add_settings_display.sql",
    "content": "INSERT INTO\n    skill.settings_display (skill_id, settings_display)\nVALUES\n    (%(skill_id)s, %(display_data)s)\nRETURNING\n    id\n"
  },
  {
    "path": "shared/selene/data/skill/repository/sql/add_skill.sql",
    "content": "INSERT INTO\n    skill.skill (skill_gid, family_name)\nVALUES\n    (%(skill_gid)s, %(family_name)s)\nRETURNING\n    id\n"
  },
  {
    "path": "shared/selene/data/skill/repository/sql/delete_device_skill.sql",
    "content": "DELETE  FROM\n    device.device_skill\nWHERE\n    id = (\n        SELECT\n            ds.id\n        FROM\n            device.device_skill ds\n        INNER JOIN\n            skill.skill s ON ds.skill_id = s.id\n        WHERE\n            ds.device_id = %(device_id)s AND s.skill_gid = %(skill_gid)s\n    )"
  },
  {
    "path": "shared/selene/data/skill/repository/sql/delete_settings_display.sql",
    "content": "DELETE FROM\n    skill.settings_display\nWHERE\n    id = %(settings_display_id)s\n"
  },
  {
    "path": "shared/selene/data/skill/repository/sql/get_display_data_for_skill.sql",
    "content": "SELECT\n    id,\n    skill_id,\n    core_version,\n    display_data\nFROM\n    skill.display\nWHERE\n    id = %(skill_display_id)s\n"
  },
  {
    "path": "shared/selene/data/skill/repository/sql/get_display_data_for_skills.sql",
    "content": "SELECT\n    id,\n    skill_id,\n    core_version,\n    display_data\nFROM\n    skill.display\nWHERE\n    core_version = %(core_version)s\n"
  },
  {
    "path": "shared/selene/data/skill/repository/sql/get_settings_definition_by_gid.sql",
    "content": "SELECT\n    sd.id,\n    sd.skill_id,\n    sd.settings_display as display_data\nFROM\n    skill.settings_display sd\n    INNER JOIN skill.skill s ON sd.skill_id = s.id\nWHERE\n    s.skill_gid = %(global_id)s\n"
  },
  {
    "path": "shared/selene/data/skill/repository/sql/get_settings_display_id.sql",
    "content": "SELECT\n    id\nFROM\n    skill.settings_display\nWHERE\n    skill_id = %(skill_id)s\n    AND settings_display = %(display_data)s\n"
  },
  {
    "path": "shared/selene/data/skill/repository/sql/get_settings_for_skill_family.sql",
    "content": "SELECT\n    sd.settings_display::jsonb -> 'skillMetadata' AS settings_definition,\n    ds.settings::jsonb AS settings_values,\n    array_agg(d.name) AS device_names\nFROM\n    device.device_skill ds\n    INNER JOIN device.device d ON ds.device_id = d.id\n    INNER JOIN skill.skill s ON ds.skill_id = s.id\n    LEFT JOIN skill.settings_display sd ON ds.skill_settings_display_id = sd.id\nWHERE\n    s.family_name = %(family_name)s\n    AND d.account_id = %(account_id)s\nGROUP BY\n    settings_definition,\n    settings_values\n"
  },
  {
    "path": "shared/selene/data/skill/repository/sql/get_skill_by_global_id.sql",
    "content": "SELECT\n    id,\n    skill_gid\nFROM\n    skill.skill\nWHERE\n    skill_gid = %(skill_global_id)s;\n"
  },
  {
    "path": "shared/selene/data/skill/repository/sql/get_skill_setting_by_device.sql",
    "content": "SELECT\n    dds.skill_id,\n    dds.settings AS settings_values,\n    ssd.settings_display\nFROM\n    device.device_skill dds\n    INNER JOIN skill.skill ss ON dds.skill_id = ss.id\n    INNER JOIN skill.settings_display ssd ON dds.skill_settings_display_id = ssd.id\nWHERE\n    dds.device_id = %(device_id)s\n"
  },
  {
    "path": "shared/selene/data/skill/repository/sql/get_skills_for_account.sql",
    "content": "SELECT DISTINCT\n    ss.family_name,\n    sd.id AS market_id,\n    ssd.settings_display -> 'skillMetadata' IS NOT NULL AS has_settings,\n    ssd.settings_display -> 'display_name' AS display_name,\n    array_agg(DISTINCT ss.id::text) AS skill_ids\nFROM\n    skill.skill ss\n    INNER JOIN device.device_skill dds ON dds.skill_id = ss.id\n    INNER JOIN device.device dd ON dd.id = dds.device_id\n    LEFT JOIN skill.display sd ON ss.id = sd.skill_id\n    LEFT JOIN skill.settings_display ssd ON ssd.skill_id = ss.id\nWHERE\n    ssd.id = dds.skill_settings_display_id\n    AND dd.account_id = %(account_id)s\nGROUP BY\n    1, 2, 3, 4\n"
  },
  {
    "path": "shared/selene/data/skill/repository/sql/remove_skill_by_gid.sql",
    "content": "DELETE FROM\n    skill.skill\nWHERE\n    skill_gid = %(skill_gid)s\n"
  },
  {
    "path": "shared/selene/data/skill/repository/sql/update_device_skill_settings.sql",
    "content": "UPDATE\n    device.device_skill\nSET\n    settings = %(settings_values)s\nWHERE\n    skill_id IN %(skill_id)s\n    AND device_id IN (\n        SELECT\n            id\n        FROM\n            device.device\n        WHERE\n            account_id = %(account_id)s\n            AND name IN %(device_names)s\n    )\n"
  },
  {
    "path": "shared/selene/data/skill/repository/sql/upsert_skill_display_data.sql",
    "content": "INSERT INTO\n    skill.display (skill_id, core_version, display_data)\nVALUES\n    (\n        %(skill_id)s,\n        %(core_version)s,\n        %(display_data)s\n    )\nON CONFLICT\n    (skill_id, core_version)\nDO UPDATE SET\n    display_data = %(display_data)s\nRETURNING\n    id\n"
  },
  {
    "path": "shared/selene/data/tagging/__init__.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2020 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Public API into the tagging data repository.\"\"\"\n\nfrom .entity.file_designation import FileDesignation\nfrom .entity.file_location import TaggingFileLocation\nfrom .entity.file_tag import FileTag\nfrom .entity.tag import Tag\nfrom .entity.tagger import Tagger\nfrom .entity.tag_value import TagValue\nfrom .entity.wake_word_file import TaggableFile, WakeWordFile\nfrom .repository.file_designation import FileDesignationRepository\nfrom .repository.file_location import TaggingFileLocationRepository\nfrom .repository.file_tag import FileTagRepository\nfrom .repository.session import SessionRepository\nfrom .repository.tag import TagRepository\nfrom .repository.tagger import TaggerRepository\nfrom .repository.wake_word_file import (\n    build_tagging_file_name,\n    DELETED_STATUS,\n    PENDING_DELETE_STATUS,\n    UPLOADED_STATUS,\n    WakeWordFileRepository,\n)\n"
  },
  {
    "path": "shared/selene/data/tagging/entity/__init__.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2020 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n"
  },
  {
    "path": "shared/selene/data/tagging/entity/file_designation.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2020 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Data entities representing a designation applied to a wake word file.\"\"\"\nfrom dataclasses import dataclass\n\n\n@dataclass\nclass FileDesignation:\n    \"\"\"Representation a designation applied to a wake word file.\"\"\"\n\n    file_id: str\n    tag_id: str\n    tag_value_id: str\n    id: str = None\n    file_name: str = None\n    file_directory: str = None\n"
  },
  {
    "path": "shared/selene/data/tagging/entity/file_location.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2020 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Data entities representing possible locations of machine learning training files.\"\"\"\nfrom dataclasses import dataclass\n\n\n@dataclass\nclass TaggingFileLocation:\n    \"\"\"Data representation of a directory containing machine learning training files.\"\"\"\n\n    server: str\n    directory: str\n    id: str = None\n"
  },
  {
    "path": "shared/selene/data/tagging/entity/file_tag.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2020 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Data entities representing a tag applied to a wake word file.\"\"\"\nfrom dataclasses import dataclass\n\n\n@dataclass\nclass FileTag:\n    \"\"\"Representation a tag applied to a wake word file and its possible values.\"\"\"\n\n    file_id: str\n    session_id: str\n    tag_id: str\n    tag_value_id: str\n    id: str = None\n"
  },
  {
    "path": "shared/selene/data/tagging/entity/tag.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2020 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Data entities representing a type of tag used to describe an audio file.\"\"\"\nfrom dataclasses import dataclass\nfrom typing import List\n\nfrom .tag_value import TagValue\n\n\n@dataclass\nclass Tag:\n    \"\"\"Dataclass representation of a tag and its values.\"\"\"\n\n    id: str\n    name: str\n    title: str\n    instructions: str\n    priority: str\n    values: List[TagValue]\n"
  },
  {
    "path": "shared/selene/data/tagging/entity/tag_value.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2020 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Data entities representing a value of a tag.\"\"\"\nfrom dataclasses import dataclass\n\n\n@dataclass\nclass TagValue:\n    \"\"\"Representation of a value of a tag.\"\"\"\n\n    value: str\n    display: str\n    id: str = None\n"
  },
  {
    "path": "shared/selene/data/tagging/entity/tagger.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2020 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Data entities representing possible locations of machine learning training files.\"\"\"\nfrom dataclasses import dataclass\n\n\n@dataclass\nclass Tagger:\n    \"\"\"Data representation of a tagging entity.\"\"\"\n\n    entity_type: str\n    entity_id: str\n    id: str = None\n"
  },
  {
    "path": "shared/selene/data/tagging/entity/wake_word_file.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2020 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Data entities representing a wake word sample.\"\"\"\nfrom dataclasses import dataclass\nfrom datetime import date\nfrom typing import List\n\nfrom selene.data.wake_word import WakeWord\nfrom .file_location import TaggingFileLocation\n\n\n@dataclass\nclass WakeWordFile:\n    \"\"\"Data representation of a wake word sample.\"\"\"\n\n    wake_word: WakeWord\n    name: str\n    origin: str\n    submission_date: date\n    location: TaggingFileLocation\n    status: str\n    account_id: str = None\n    id: str = None\n\n\n@dataclass\nclass TaggableFile:\n    \"\"\"Data representation of a wake word file that requires further tagging.\"\"\"\n\n    id: str\n    name: str\n    location: TaggingFileLocation\n    designations: List\n    tag: str\n"
  },
  {
    "path": "shared/selene/data/tagging/repository/__init__.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2020 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n"
  },
  {
    "path": "shared/selene/data/tagging/repository/file_designation.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2020 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Data access and manipulation for the tagging.wake_word_file_tag table.\"\"\"\nfrom dataclasses import asdict\nfrom typing import List\n\nfrom ..entity.file_designation import FileDesignation\nfrom ...repository_base import RepositoryBase\n\n\nclass FileDesignationRepository(RepositoryBase):\n    \"\"\"Data access and manipulation for the tagging.wake_word_file_tag table.\"\"\"\n\n    def __init__(self, db):\n        super().__init__(db, __file__)\n\n    def add(self, file_designation: FileDesignation):\n        \"\"\"Add a tag to a wake word file.\"\"\"\n        db_request = self._build_db_request(\n            sql_file_name=\"add_wake_word_file_designation.sql\",\n            args=asdict(file_designation),\n        )\n        self.cursor.insert(db_request)\n\n    def get_from_date(self, wake_word, start_date) -> List[FileDesignation]:\n        \"\"\"Retrieve files with designations for the given wake word and start date.\"\"\"\n        db_request = self._build_db_request(\n            sql_file_name=\"get_designations_from_date.sql\",\n            args=dict(wake_word=wake_word, start_date=start_date),\n        )\n        result = self.cursor.select_all(db_request)\n        designations = [FileDesignation(**row) for row in result]\n\n        return designations\n"
  },
  {
    "path": "shared/selene/data/tagging/repository/file_location.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2020 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Data access and manipulation for the wake_word.sample table.\"\"\"\nfrom ..entity.file_location import TaggingFileLocation\nfrom ...repository_base import RepositoryBase\n\n\nclass TaggingFileLocationRepository(RepositoryBase):\n    \"\"\"Data access and manipulation for the wake_word.sample table.\"\"\"\n\n    def __init__(self, db):\n        super().__init__(db, __file__)\n\n    def ensure_location_exists(\n        self, server: str, directory: str\n    ) -> TaggingFileLocation:\n        \"\"\"If the file location does not exist in the database, add one.\n\n        :param server: IP address of the server the file resides on\n        :param directory: fully qualified directory where the file resides\n        \"\"\"\n        file_location = TaggingFileLocation(server=server, directory=str(directory))\n        file_location.id = self.get_id(file_location)\n        if file_location.id is None:\n            file_location.id = self.add(file_location)\n\n        return file_location\n\n    def add(self, file_location: TaggingFileLocation) -> str:\n        \"\"\"Inserts a row on the tagging.file_location table.\n\n        :param file_location: object representing the table row to insert.\n        :return: the primary key that was auto-generated as part of the insert.\n        \"\"\"\n        db_request = self._build_db_request(\n            sql_file_name=\"add_file_location.sql\",\n            args=dict(server=file_location.server, directory=file_location.directory),\n        )\n        result = self.cursor.insert_returning(db_request)\n\n        return result[\"id\"]\n\n    def get_id(self, file_location: TaggingFileLocation) -> str:\n        \"\"\"Get the ID of the specified file location.\n\n        :param file_location: object representing the location of the file.\n        :return: the primary key representation of the file.\n        \"\"\"\n        db_request = self._build_db_request(\n            sql_file_name=\"get_file_location_id.sql\",\n            args=dict(server=file_location.server, directory=file_location.directory),\n        )\n        result = self.cursor.select_one(db_request)\n\n        return None if result is None else result[\"id\"]\n\n    def remove(self, file_location: TaggingFileLocation):\n        \"\"\"Delete a row from the file_location table.\"\"\"\n        db_request = self._build_db_request(\n            sql_file_name=\"remove_file_location.sql\", args=dict(id=file_location.id),\n        )\n        self.cursor.delete(db_request)\n"
  },
  {
    "path": "shared/selene/data/tagging/repository/file_tag.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2020 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Data access and manipulation for the tagging.wake_word_file_tag table.\"\"\"\nfrom collections import defaultdict\nfrom dataclasses import asdict\n\nfrom ..entity.file_tag import FileTag\nfrom ...repository_base import RepositoryBase\n\n\nclass FileTagRepository(RepositoryBase):\n    \"\"\"Data access and manipulation for the tagging.wake_word_file_tag table.\"\"\"\n\n    def __init__(self, db):\n        super().__init__(db, __file__)\n\n    def add(self, file_tag: FileTag):\n        \"\"\"Add a tag to a wake word file.\"\"\"\n        db_request = self._build_db_request(\n            sql_file_name=\"add_wake_word_file_tag.sql\", args=asdict(file_tag)\n        )\n        self.cursor.insert(db_request)\n\n    def get_designation_candidates(self) -> defaultdict:\n        \"\"\"Retrieve file tags that have not yet been converted to designations.\"\"\"\n\n        designation_candidates = defaultdict(list)\n        db_request = self._build_db_request(\n            sql_file_name=\"get_designation_candidates.sql\",\n        )\n        result = self.cursor.select_all(db_request)\n        for row in result:\n            file_tag = FileTag(\n                file_id=row[\"wake_word_file_id\"],\n                session_id=row[\"session_id\"],\n                tag_id=row[\"tag_id\"],\n                tag_value_id=row[\"tag_value_id\"],\n            )\n            designation_candidates[row[\"wake_word\"]].append(file_tag)\n\n        return designation_candidates\n"
  },
  {
    "path": "shared/selene/data/tagging/repository/session.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2020 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Data access and manipulation for the tagging.session table.\"\"\"\nfrom datetime import datetime, timedelta\nfrom ..entity.tagger import Tagger\nfrom ...repository_base import RepositoryBase\n\n\nclass SessionRepository(RepositoryBase):\n    \"\"\"Data access and manipulation for the tagging.session table.\"\"\"\n\n    def __init__(self, db):\n        super().__init__(db, __file__)\n\n    def ensure_session_exists(self, tagger: Tagger):\n        \"\"\"Insert an active session in the database if one does not exist\n\n        :param tagger: the entity that is performing tagging operations\n        :return: primary key of the active session\n        \"\"\"\n        new_session_id = None\n        active_session_id, last_session_tag_ts = self._get_active(tagger)\n        if active_session_id is None:\n            new_session_id = self.add(tagger)\n        else:\n            one_hour_ago = datetime.utcnow() - timedelta(hours=1)\n            if last_session_tag_ts is not None and last_session_tag_ts < one_hour_ago:\n                self._end_session(active_session_id, last_session_tag_ts)\n                new_session_id = self.add(tagger)\n\n        return active_session_id or new_session_id\n\n    def add(self, tagger: Tagger, note: str = None):\n        \"\"\"Insert a row into the tagging.session table\n\n        :param tagger: the entity that is performing tagging operations\n        :param note: free form text related to the tagging session\n        :return: The unique identifier of the inserted row\n        \"\"\"\n        db_request = self._build_db_request(\n            sql_file_name=\"add_session.sql\", args=dict(note=note, tagger_id=tagger.id)\n        )\n        result = self.cursor.insert_returning(db_request)\n\n        return result[\"id\"]\n\n    def _get_active(self, tagger: Tagger):\n        \"\"\"Get the active session (i.e. session without and end timestamp).\n\n        :param tagger: the entity that is performing tagging operations\n        :return a two item tuple containing the ID of the active session (if there is\n            one) and the timestamp of the last tag applied in this session.\n        \"\"\"\n        active_session_id = None\n        last_session_tag_ts = None\n\n        db_request = self._build_db_request(\n            sql_file_name=\"get_active_session.sql\",\n            args=dict(entity_id=tagger.entity_id),\n        )\n        result = self.cursor.select_one(db_request)\n        if result is not None:\n            active_session_id = result[\"id\"]\n            last_session_tag_ts = result[\"last_tag_ts\"]\n\n        return active_session_id, last_session_tag_ts\n\n    def _end_session(self, session_id, end_ts):\n        \"\"\"Update the active timestamp range tagging.session to the current timestamp\n\n        :param session_id: the session to end\n        :param end_ts: the date and time the session ended.\n        \"\"\"\n        db_request = self._build_db_request(\n            sql_file_name=\"update_session_end_ts.sql\",\n            args=dict(session_id=session_id, end_ts=end_ts),\n        )\n        self.cursor.update(db_request)\n"
  },
  {
    "path": "shared/selene/data/tagging/repository/sql/add_file_location.sql",
    "content": "INSERT INTO\n    tagging.file_location (server, directory)\nVALUES\n    (%(server)s, %(directory)s)\nRETURNING\n    id\n"
  },
  {
    "path": "shared/selene/data/tagging/repository/sql/add_session.sql",
    "content": "INSERT INTO\n    tagging.session (tagger_id, session_ts_range)\nVALUES\n    (%(tagger_id)s, '[now,]'::tsrange)\nRETURNING\n    id\n"
  },
  {
    "path": "shared/selene/data/tagging/repository/sql/add_tagger.sql",
    "content": "INSERT INTO\n    tagging.tagger (entity_type, entity_id)\nVALUES\n    (%(entity_type)s, %(entity_id)s)\nRETURNING\n    id\n"
  },
  {
    "path": "shared/selene/data/tagging/repository/sql/add_tagging_session.sql",
    "content": "INSERT INTO\n    tagging.session (tagger_id)\nVALUES\n    (%(tagger_id)s)\n"
  },
  {
    "path": "shared/selene/data/tagging/repository/sql/add_wake_word_file.sql",
    "content": "INSERT INTO\n    tagging.wake_word_file (\n        wake_word_id,\n        name,\n        origin,\n        submission_date,\n        account_id,\n        file_location_id\n    )\nVALUES\n    (\n        %(wake_word_id)s,\n        %(file_name)s,\n        %(origin)s,\n        %(submission_date)s,\n        %(account_id)s,\n        %(file_location_id)s\n    )\n"
  },
  {
    "path": "shared/selene/data/tagging/repository/sql/add_wake_word_file_designation.sql",
    "content": "INSERT INTO\n    tagging.wake_word_file_designation (wake_word_file_id, tag_id, tag_value_id)\nVALUES\n    (%(file_id)s, %(tag_id)s, %(tag_value_id)s)\n"
  },
  {
    "path": "shared/selene/data/tagging/repository/sql/add_wake_word_file_tag.sql",
    "content": "INSERT INTO\n    tagging.wake_word_file_tag (wake_word_file_id, session_id, tag_id, tag_value_id)\nVALUES\n    (%(file_id)s, %(session_id)s, %(tag_id)s, %(tag_value_id)s)\n"
  },
  {
    "path": "shared/selene/data/tagging/repository/sql/change_account_file_status.sql",
    "content": "UPDATE\n    tagging.wake_word_file\nSET\n    status = %(status)s\nWHERE\n    account_id = %(account_id)s\n"
  },
  {
    "path": "shared/selene/data/tagging/repository/sql/change_file_location.sql",
    "content": "UPDATE\n    tagging.wake_word_file\nSET\n    file_location_id = %(file_location_id)s,\n    status = 'stored'::tagging_file_status_enum\nWHERE\n    id = %(wake_word_file_id)s\n"
  },
  {
    "path": "shared/selene/data/tagging/repository/sql/change_file_status.sql",
    "content": "UPDATE\n    tagging.wake_word_file\nSET\n    status = %(status)s\nWHERE\n    name = %(file_name)s\n"
  },
  {
    "path": "shared/selene/data/tagging/repository/sql/get_active_session.sql",
    "content": "SELECT\n    s.id,\n    max(ft.insert_ts) AS last_tag_ts\nFROM\n    tagging.session s\n    LEFT JOIN tagging.wake_word_file_tag ft ON s.id = ft.session_id\nWHERE\n    tagger_id = (\n        SELECT\n            id::text\n        FROM\n            tagging.tagger\n        WHERE\n            entity_type = 'account'\n            AND entity_id = %(entity_id)s\n    )\n    AND upper(session_ts_range) IS NULL\nGROUP BY\n    1\n"
  },
  {
    "path": "shared/selene/data/tagging/repository/sql/get_designation_candidates.sql",
    "content": "SELECT\n    ww.name AS wake_word,\n    wwft.wake_word_file_id,\n    wwft.session_id,\n    wwft.tag_id,\n    wwft.tag_value_id\nFROM\n    tagging.wake_word_file wwf\n        INNER JOIN wake_word.wake_word ww ON wwf.wake_word_id = ww.id\n        INNER JOIN tagging.wake_word_file_tag wwft ON wwf.id = wwft.wake_word_file_id\n        LEFT JOIN tagging.wake_word_file_designation wwfd ON wwf.id = wwfd.wake_word_file_id AND wwft.tag_id = wwfd.tag_id\nWHERE\n    wwfd.id IS NULL\nORDER BY\n    ww.name,\n    wwft.wake_word_file_id,\n    wwft.tag_id,\n    wwft.tag_value_id\n"
  },
  {
    "path": "shared/selene/data/tagging/repository/sql/get_designations_from_date.sql",
    "content": "SELECT\n    wwf.id AS file_id,\n    wwf.name AS file_name,\n    fl.directory AS file_directory,\n    wwfd.tag_id,\n    wwfd.tag_value_id\nFROM\n    tagging.wake_word_file_designation wwfd\n    INNER JOIN tagging.wake_word_file wwf ON wwf.id = wwfd.wake_word_file_id\n    INNER JOIN tagging.file_location fl on fl.id = wwf.file_location_id\nWHERE\n    wwfd.insert_ts::date >= %(start_date)s\n"
  },
  {
    "path": "shared/selene/data/tagging/repository/sql/get_file_location_id.sql",
    "content": "SELECT\n    id\nFROM\n    tagging.file_location\nWHERE\n    server = %(server)s\n    AND directory = %(directory)s\n"
  },
  {
    "path": "shared/selene/data/tagging/repository/sql/get_taggable_wake_word_file.sql",
    "content": "-- Select a single file to be presented to the wake word tagger.\nWITH not_speaking AS (\n    SELECT\n        t.id as tag_id,\n        tv.id AS tag_value_id\n    FROM\n        tagging.tag t\n        INNER JOIN tagging.tag_value tv ON t.id = tv.tag_id\n    WHERE\n        t.name = 'speaking'\n        AND tv.value = 'no'\n),\nfile AS (\n    -- Get all files for the specified wake word that have not been designated as noise\n    SELECT\n        wwf.id AS file_id,\n        wwf.name AS file_name,\n        fl.server,\n        fl.directory\n    FROM\n        tagging.wake_word_file wwf\n        INNER JOIN wake_word.wake_word ww ON wwf.wake_word_id = ww.id\n        INNER JOIN tagging.file_location fl ON fl.id = wwf.file_location_id\n        LEFT JOIN tagging.wake_word_file_designation wwfd ON wwf.id = wwfd.wake_word_file_id\n    WHERE\n        ww.name = %(wake_word)s\n        AND (\n            wwfd.tag_id IS NULL\n            OR wwfd.tag_id::TEXT || wwfd.tag_value_id::TEXT != (\n                SELECT\n                    tag_id::TEXT || tag_value_id::TEXT\n                FROM\n                    not_speaking\n           )\n        )\n),\nfile_designation AS (\n    -- Get all the designations assigned to the files\n    SELECT\n        f.file_id,\n        array_agg(wwfd.tag_id) AS designations\n    FROM\n        file f\n        INNER JOIN tagging.wake_word_file_designation wwfd ON f.file_id = wwfd.wake_word_file_id\n    GROUP BY\n        f.file_id\n),\nfile_tag AS (\n    -- Get all the file tags for tag types that have not been designated\n    SELECT\n        f.file_id,\n        wwft.tag_id AS tag,\n        array_agg(session_id) AS sessions\n    FROM\n        file f\n        INNER JOIN tagging.wake_word_file_tag wwft ON f.file_id = wwft.wake_word_file_id\n        LEFT JOIN file_designation fd ON f.file_id = fd.file_id\n    WHERE\n        array_position(fd.designations, wwft.tag_id) IS NULL\n    GROUP BY\n        f.file_id,\n        wwft.tag_id\n)\n-- Use the results of the above nested table expressions to select a single file\n-- that is not fully designated and has not already been tagged in the specified session.\nSELECT\n    f.file_id AS id,\n    f.file_name as name,\n    f.server,\n    f.directory,\n    fd.designations,\n    ft.tag\nFROM\n    file AS f\n    LEFT JOIN file_tag ft ON f.file_id = ft.file_id\n    LEFT JOIN file_designation fd ON f.file_id = fd.file_id\nWHERE\n    (fd.designations IS NULL OR cardinality(fd.designations) < %(tag_count)s)\n    AND array_position(ft.sessions, %(session_id)s) IS NULL\nORDER BY\n    cardinality(ft.sessions)\nLIMIT 1\n"
  },
  {
    "path": "shared/selene/data/tagging/repository/sql/get_tagger_by_entity.sql",
    "content": "SELECT\n    id\nFROM\n    tagging.tagger\nWHERE\n    entity_type = %(entity_type)s\n    AND entity_id = %(entity_id)s\n"
  },
  {
    "path": "shared/selene/data/tagging/repository/sql/get_tags.sql",
    "content": "SELECT\n    t.id,\n    t.name,\n    t.title,\n    t.instructions,\n    t.priority,\n    json_agg(\n        json_build_object(\n            'value', tv.value,\n            'display', tv.display,\n            'id', tv.id\n        )\n    ) AS values\nFROM\n    tagging.tag t\n    LEFT JOIN tagging.tag_value tv ON t.id = tv.tag_id\nGROUP BY\n    t.id,\n    t.name,\n    t.title,\n    t.instructions,\n    t.priority\n"
  },
  {
    "path": "shared/selene/data/tagging/repository/sql/get_wake_word_files.sql",
    "content": "SELECT\n    json_build_object(\n        'name', ww.name,\n        'engine', ww.engine,\n        'id', ww.id\n    ) AS wake_word,\n    wwf.name,\n    wwf.origin,\n    wwf.submission_date,\n    wwf.account_id,\n    wwf.status,\n    wwf.id,\n    json_build_object(\n        'server', fl.server,\n        'directory', fl.directory,\n        'id', ww.id\n    ) AS location\nFROM\n    tagging.wake_word_file wwf\n    INNER JOIN tagging.file_location fl ON fl.id = wwf.file_location_id\n    INNER JOIN wake_word.wake_word ww on ww.id = wwf.wake_word_id\nWHERE\n    {where_clause}\n"
  },
  {
    "path": "shared/selene/data/tagging/repository/sql/remove_file_location.sql",
    "content": "DELETE FROM\n    tagging.file_location\nWHERE\n    id = %(id)s\n"
  },
  {
    "path": "shared/selene/data/tagging/repository/sql/remove_wake_word_file.sql",
    "content": "DELETE FROM\n    tagging.wake_word_file\nWHERE\n    id = %(id)s\n"
  },
  {
    "path": "shared/selene/data/tagging/repository/sql/update_session_end_ts.sql",
    "content": "UPDATE\n    tagging.session\nSET\n    session_ts_range = tsrange(lower(session_ts_range), %(end_ts)s)\nWHERE\n    id = %(session_id)s\n"
  },
  {
    "path": "shared/selene/data/tagging/repository/tag.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2020 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Data access and manipulation functions for the tagging.tag table.\"\"\"\nfrom typing import List\nfrom ..entity.tag import Tag\nfrom ..entity.tag_value import TagValue\nfrom ...repository_base import RepositoryBase\n\n\nclass TagRepository(RepositoryBase):\n    \"\"\"Data access and manipulation functions for the tagging.tag table.\"\"\"\n\n    def __init__(self, db):\n        super().__init__(db, __file__)\n\n    def get_all(self) -> List[Tag]:\n        \"\"\"Return a list of all the tags.\"\"\"\n        tags = []\n        db_request = self._build_db_request(sql_file_name=\"get_tags.sql\")\n        for row in self.cursor.select_all(db_request):\n            tag_values = []\n            for tag_value in row[\"values\"]:\n                tag_values.append(\n                    TagValue(\n                        id=tag_value[\"id\"],\n                        value=tag_value[\"value\"],\n                        display=tag_value[\"display\"],\n                    )\n                )\n                row.update(values=tag_values)\n            tags.append(Tag(**row))\n\n        return tags\n"
  },
  {
    "path": "shared/selene/data/tagging/repository/tagger.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2020 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Data access and manipulation for the tagging.tagger table.\"\"\"\nfrom dataclasses import asdict\nfrom ..entity.tagger import Tagger\nfrom ...repository_base import RepositoryBase\n\n\nclass TaggerRepository(RepositoryBase):\n    \"\"\"Data access and manipulation for the tagging.tagger table.\"\"\"\n\n    def __init__(self, db):\n        super().__init__(db, __file__)\n\n    def ensure_tagger_exists(self, tagger: Tagger) -> str:\n        \"\"\"Insert an active session in the database if one does not exist\n\n        :param tagger: the entity that is performing tagging operations\n        :return: primary key of the inserted database row\n        \"\"\"\n        tagger_id = self._get_by_entity(tagger)\n        if tagger_id is None:\n            tagger_id = self._add(tagger)\n\n        return tagger_id\n\n    def _get_by_entity(self, tagger: Tagger):\n        \"\"\"Check for existing tagger row\n\n        :param tagger: the entity that is performing tagging operations\n        :return: the unique identifier representing the entity.\n        \"\"\"\n        tagger_id = None\n        db_request = self._build_db_request(\n            sql_file_name=\"get_tagger_by_entity.sql\", args=asdict(tagger)\n        )\n        result = self.cursor.select_one(db_request)\n        if result is not None:\n            tagger_id = result[\"id\"]\n\n        return tagger_id\n\n    def _add(self, tagger: Tagger):\n        \"\"\"Insert a new row into the tagger.tagger table.\n\n        :param tagger: the entity that is performing tagging operations\n        :return: the unique identifier representing the entity.\n        \"\"\"\n        db_request = self._build_db_request(\n            sql_file_name=\"add_tagger.sql\", args=asdict(tagger)\n        )\n        result = self.cursor.insert_returning(db_request)\n\n        return result[\"id\"]\n"
  },
  {
    "path": "shared/selene/data/tagging/repository/wake_word_file.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2020 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Data access and manipulation for the tagging.wake_word_file table.\"\"\"\nimport hashlib\nfrom collections import defaultdict\nfrom datetime import date\nfrom logging import getLogger\nfrom typing import List\n\nfrom psycopg2 import IntegrityError\n\nfrom selene.data.wake_word import WakeWord\nfrom ..entity.wake_word_file import TaggableFile, WakeWordFile\nfrom ..entity.file_location import TaggingFileLocation\nfrom ...repository_base import RepositoryBase\n\nDELETED_STATUS = \"deleted\"\nPENDING_DELETE_STATUS = \"pending delete\"\nUPLOADED_STATUS = \"uploaded\"\nUNIQUE_VIOLATION = \"23505\"\n\n_log = getLogger(__name__)\n\n\ndef build_tagging_file_name(file_contents):\n    \"\"\"Use a SHA1 hash of the file contents to build the name of the file.\"\"\"\n    audio_file_hash = hashlib.new(\"sha1\")\n    audio_file_hash.update(file_contents)\n    file_name = f\"{audio_file_hash.hexdigest()}.wav\"\n\n    return file_name\n\n\nclass WakeWordFileRepository(RepositoryBase):\n    \"\"\"Data access and manipulation for the tagging.wake_word_file table.\"\"\"\n\n    def __init__(self, db):\n        super().__init__(db, __file__)\n\n    def add(self, wake_word_file: WakeWordFile):\n        \"\"\"Adds a row to the wake word file table\n\n        File names are SHA1 hashes of the file content.  It is possible that a hash\n        collision occurs.  In this case, alter the file name to be unique.\n\n        :param wake_word_file: a wake word file for machine learning training\n        :return None if no collisions or the new file name if collision found\n        \"\"\"\n        db_request = self._build_db_request(\n            sql_file_name=\"add_wake_word_file.sql\",\n            args=dict(\n                wake_word_id=wake_word_file.wake_word.id,\n                file_name=wake_word_file.name,\n                origin=wake_word_file.origin,\n                submission_date=wake_word_file.submission_date,\n                account_id=wake_word_file.account_id,\n                file_location_id=wake_word_file.location.id,\n            ),\n        )\n        collisions = 0\n        new_file_name = None\n        while True:\n            try:\n                self.cursor.insert(db_request)\n                break\n            except IntegrityError as integrity_err:\n                if integrity_err.pgcode == UNIQUE_VIOLATION:\n                    new_file_name = self._handle_file_name_collision(\n                        wake_word_file.name, collisions\n                    )\n                    db_request.args.update(file_name=new_file_name)\n                    _log.info(\n                        \"Wake word file name {file_name} exists. Trying \"\n                        \"{new_file_name}\".format(\n                            file_name=wake_word_file.name, new_file_name=new_file_name\n                        )\n                    )\n                    collisions += 1\n                else:\n                    _log.exception(f\"Insert of file {wake_word_file.name} failed\")\n                    raise\n\n        return new_file_name\n\n    @staticmethod\n    def _handle_file_name_collision(file_name: str, collisions: int):\n        \"\"\"Make the file name unique if the generated hash exists in the database.\n\n        :param file_name: the name of the file that had a collision\n        :param collisions: number of collisions encountered\n        :return: the new name to try on next insert attempt\n        \"\"\"\n        file_name_parts = file_name.split(\".\")\n        if len(file_name_parts) == 2:\n            new_name = \".\".join(\n                [file_name_parts[0], str(collisions), file_name_parts[1]]\n            )\n        else:\n            new_name = \".\".join(\n                [file_name_parts[0], str(collisions), file_name_parts[2]]\n            )\n\n        return new_name\n\n    def get_by_wake_word(self, wake_word: WakeWord) -> List[WakeWordFile]:\n        \"\"\"Get a sample file reference based on the file name.\n\n        :param wake_word: identifies the wake word related to the file.\n        :return: WakeWordFile object containing the retrieved row\n        \"\"\"\n        wake_word_files = []\n        db_request = self._build_db_request(\n            sql_file_name=\"get_wake_word_files.sql\",\n            args=dict(wake_word=wake_word.name, engine=wake_word.engine),\n        )\n        db_request.sql = db_request.sql.format(\n            where_clause=\"ww.name = %(wake_word)s AND ww.engine = %(engine)s\"\n        )\n        for row in self.cursor.select_all(db_request):\n            wake_word_files.append(self._convert_db_row_to_dataclass(row))\n\n        return wake_word_files\n\n    def get_by_submission_date(self, submission_date: date) -> List[WakeWordFile]:\n        \"\"\"Get sample file references based on the submission date.\n\n        :param submission_date: identifies the date the file was submitted.\n        :return: list of WakeWordFile objects containing the retrieved row\n        \"\"\"\n        wake_word_files = []\n        db_request = self._build_db_request(\n            sql_file_name=\"get_wake_word_files.sql\",\n            args=dict(submission_date=submission_date),\n        )\n        db_request.sql = db_request.sql.format(\n            where_clause=\"ww.name = %(wake_word)s AND ww.engine = %(engine)s\"\n        )\n        for row in self.cursor.select_all(db_request):\n            wake_word_files.append(self._convert_db_row_to_dataclass(row))\n\n        return wake_word_files\n\n    def get_pending_delete(self) -> dict:\n        \"\"\"Get wake word files scheduled to be removed from the file system.\n\n        :return: list of WakeWordFile objects containing the retrieved row\n        \"\"\"\n        wake_word_files = defaultdict(list)\n        db_request = self._build_db_request(sql_file_name=\"get_wake_word_files.sql\")\n        db_request.sql = db_request.sql.format(\n            where_clause=\"wwf.status = 'pending delete'::tagging_file_status_enum\"\n        )\n        for row in self.cursor.select_all(db_request):\n            wake_word_files[row[\"account_id\"]].append(\n                self._convert_db_row_to_dataclass(row)\n            )\n\n        return wake_word_files\n\n    def get_taggable_file(\n        self, wake_word: str, tag_count: int, session_id: str\n    ) -> TaggableFile:\n        \"\"\"Retrieve a file that needs to be tagged from the database.\n\n        :param wake_word: the wake word being tagged\n        :param tag_count: the number of tag types defined in the database\n        :param session_id: identifier of the current tagging session\n        :return: an object containing the result of the query\n        \"\"\"\n        taggable_file = None\n        db_request = self._build_db_request(\n            sql_file_name=\"get_taggable_wake_word_file.sql\",\n            args=dict(wake_word=wake_word, tag_count=tag_count, session_id=session_id),\n        )\n        result = self.cursor.select_one(db_request)\n        if result is not None:\n            file_location = TaggingFileLocation(\n                server=result[\"server\"], directory=result[\"directory\"]\n            )\n            taggable_file = TaggableFile(\n                id=result[\"id\"],\n                name=result[\"name\"],\n                location=file_location,\n                designations=result[\"designations\"],\n                tag=result[\"tag\"],\n            )\n\n        return taggable_file\n\n    @staticmethod\n    def _convert_db_row_to_dataclass(row) -> WakeWordFile:\n        row.update(\n            wake_word=WakeWord(**row[\"wake_word\"]),\n            location=TaggingFileLocation(**row[\"location\"]),\n        )\n        return WakeWordFile(**row)\n\n    def change_file_location(self, wake_word_file_id: str, file_location_id: str):\n        \"\"\"Change the database representation of the file system location\n\n        :param wake_word_file_id: UUID of the file being moved\n        :param file_location_id: UUID of the destination on the file server\n        \"\"\"\n        db_request = self._build_db_request(\n            sql_file_name=\"change_file_location.sql\",\n            args=dict(\n                file_location_id=file_location_id, wake_word_file_id=wake_word_file_id\n            ),\n        )\n        self.cursor.update(db_request)\n\n    def change_account_file_status(self, account_id: str, status: str):\n        \"\"\"Change the status of all files for a given account.\n\n        :param account_id: UUID of the affected account\n        :param status: new status of the files\n        \"\"\"\n        db_request = self._build_db_request(\n            sql_file_name=\"change_account_file_status.sql\",\n            args=dict(account_id=account_id, status=status),\n        )\n        self.cursor.update(db_request)\n\n    def change_file_status(self, wake_word_file: WakeWordFile, status: str):\n        \"\"\"Change the status of a single wake word file.\n\n        :param wake_word_file: dataclass instance representing a wake word file\n        :param status: new status of the files\n        \"\"\"\n        db_request = self._build_db_request(\n            sql_file_name=\"change_file_status.sql\",\n            args=dict(file_name=wake_word_file.name, status=status),\n        )\n        self.cursor.update(db_request)\n\n    def remove(self, wake_word_file: WakeWordFile):\n        \"\"\"Delete a single wake word file.\n\n        :param wake_word_file: Object representing the wake word file to delete.\n        \"\"\"\n        db_request = self._build_db_request(\n            sql_file_name=\"remove_wake_word_file.sql\", args=dict(id=wake_word_file.id),\n        )\n        self.cursor.delete(db_request)\n"
  },
  {
    "path": "shared/selene/data/wake_word/__init__.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom .entity.wake_word import WakeWord\nfrom .entity.pocketsphinx_settings import PocketsphinxSettings\nfrom .repository.wake_word import WakeWordRepository\n"
  },
  {
    "path": "shared/selene/data/wake_word/entity/__init__.py",
    "content": ""
  },
  {
    "path": "shared/selene/data/wake_word/entity/pocketsphinx_settings.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2020 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom dataclasses import dataclass\n\n\n@dataclass\nclass PocketsphinxSettings(object):\n    id: str\n    sample_rate: int\n    channels: int\n    pronunciation: str\n    threshold: str\n    threshold_multiplier: float\n    dynamic_energy_ratio: float\n    account_id: str = None\n"
  },
  {
    "path": "shared/selene/data/wake_word/entity/wake_word.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom dataclasses import dataclass\n\n\n@dataclass\nclass WakeWord(object):\n    name: str\n    engine: str\n    id: str = None\n"
  },
  {
    "path": "shared/selene/data/wake_word/repository/__init__.py",
    "content": ""
  },
  {
    "path": "shared/selene/data/wake_word/repository/sql/add_wake_word.sql",
    "content": "INSERT INTO\n    wake_word.wake_word (name, engine)\nVALUES\n    (%(name)s, %(engine)s)\nRETURNING id\n"
  },
  {
    "path": "shared/selene/data/wake_word/repository/sql/get_wake_word_id.sql",
    "content": "SELECT\n    id\nFROM\n    wake_word.wake_word\nWHERE\n    name = %(name)s\n    AND engine = %(engine)s\n"
  },
  {
    "path": "shared/selene/data/wake_word/repository/sql/get_wake_words_for_web.sql",
    "content": "SELECT\n    ww.id,\n    ww.name,\n    ww.engine\nFROM\n    wake_word.wake_word ww\n    INNER JOIN wake_word.pocketsphinx_settings ps ON ww.id = ps.wake_word_id\nWHERE\n    ps.account_id is NULL\n"
  },
  {
    "path": "shared/selene/data/wake_word/repository/sql/remove_wake_word.sql",
    "content": "DELETE FROM\n    wake_word.wake_word\nWHERE\n    id = %(wake_word_id)s\n"
  },
  {
    "path": "shared/selene/data/wake_word/repository/wake_word.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Data access and manipulation of the wake_word.wake_word table.\"\"\"\nfrom typing import List\n\nfrom selene.data.wake_word.entity.wake_word import WakeWord\nfrom selene.data.repository_base import RepositoryBase\n\nPRECISE_ENGINE = \"precise\"\n\n\nclass WakeWordRepository(RepositoryBase):\n    \"\"\"Data access and manipulation methods for the wake_word.wake_word table.\"\"\"\n\n    def __init__(self, db):\n        super(WakeWordRepository, self).__init__(db, __file__)\n\n    def get_wake_words_for_web(self) -> List[WakeWord]:\n        \"\"\"Get the list of wake words that are presented to the user in the GUI.\n\n        :return list of objects representing the wake words to display\n        \"\"\"\n        # TODO: replace list of hardcoded wake words in SQL with references\n        wake_words = self._select_all_into_dataclass(\n            dataclass=WakeWord, sql_file_name=\"get_wake_words_for_web.sql\"\n        )\n\n        return wake_words\n\n    def ensure_wake_word_exists(self, name: str, engine: str) -> WakeWord:\n        \"\"\"Add a wake word to the database if it does not exist.\n\n        :param name: the words that comprise the wake word\n        :param engine: identifies the wake word recognizer framework\n        :return an object representing the wake word\n        \"\"\"\n        wake_word = WakeWord(name=name, engine=engine)\n        wake_word.id = self.get_id(wake_word)\n        if wake_word.id is None:\n            wake_word.id = self.add(wake_word)\n\n        return wake_word\n\n    def get_id(self, wake_word: WakeWord) -> str:\n        \"\"\"Get the ID of the specified wake word.\n\n        :param wake_word: representation of the wake word to query\n        :return the UUID of the wake word row or None if wake word does not exist\n        \"\"\"\n        db_request = self._build_db_request(\n            sql_file_name=\"get_wake_word_id.sql\",\n            args=dict(name=wake_word.name, engine=wake_word.engine),\n        )\n        result = self.cursor.select_one(db_request)\n\n        return None if result is None else result[\"id\"]\n\n    def add(self, wake_word: WakeWord) -> str:\n        \"\"\"Adds a row to the wake_word table\n\n        :return UUID representing the wake word ID\n        \"\"\"\n        db_request = self._build_db_request(\n            sql_file_name=\"add_wake_word.sql\",\n            args=dict(name=wake_word.name, engine=wake_word.engine),\n        )\n        result = self.cursor.insert_returning(db_request)\n\n        return result[\"id\"]\n\n    def remove(self, wake_word: WakeWord):\n        \"\"\"Delete a wake word from the wake_word table.\n\n        :param wake_word: representation of the the wake word to delete\n        \"\"\"\n        db_request = self._build_db_request(\n            sql_file_name=\"remove_wake_word.sql\", args=dict(wake_word_id=wake_word.id)\n        )\n        self.cursor.delete(db_request)\n"
  },
  {
    "path": "shared/selene/testing/__init__.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n"
  },
  {
    "path": "shared/selene/testing/account.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Common test functions for accounts.\"\"\"\n\nfrom datetime import date\nfrom typing import Any\n\nfrom selene.data.account import (\n    Account,\n    AccountAgreement,\n    AccountMembership,\n    AccountRepository,\n    OPEN_DATASET,\n    PRIVACY_POLICY,\n    TERMS_OF_USE,\n)\n\n\ndef build_test_account(**overrides: Any):\n    \"\"\"Builds and account object for use in testing.\n\n    :param overrides: keyword arguments that override default account attributes\n    \"\"\"\n    test_agreements = [\n        AccountAgreement(type=PRIVACY_POLICY, accept_date=date.today()),\n        AccountAgreement(type=TERMS_OF_USE, accept_date=date.today()),\n        AccountAgreement(type=OPEN_DATASET, accept_date=date.today()),\n    ]\n    return Account(\n        email_address=overrides.get(\"email_address\") or \"foo@mycroft.ai\",\n        federated_login=False,\n        username=overrides.get(\"username\") or \"foobar\",\n        agreements=overrides.get(\"agreements\") or test_agreements,\n    )\n\n\ndef add_account(db, **overrides: Any) -> Account:\n    \"\"\"Adds an account to the database for use in testing.\n\n    :param db: instance of the Selene database\n    :param overrides: keyword arguments that override default account attributes\n    :return: An instance of an account object\n    \"\"\"\n    acct_repository = AccountRepository(db)\n    account = build_test_account(**overrides)\n    password = overrides.get(\"password\") or \"test_password\"\n    account.id = acct_repository.add(account, password)\n    if account.membership is not None:\n        acct_repository.add_membership(account.id, account.membership)\n\n    return account\n\n\ndef remove_account(db, account: Account):\n    \"\"\"Removes a test account from the database.\n\n    :param db: Instance of the Selene database\n    :param account: Account used in testing\n    \"\"\"\n    account_repository = AccountRepository(db)\n    account_repository.remove(account)\n\n\ndef build_test_membership(**overrides: Any) -> AccountMembership:\n    \"\"\"Builds an instance of an account membership for testing purposes.\n\n    :param overrides: keyword arguments that override default account attributes\n    :return: An instance of the account membership for use in testing\n    \"\"\"\n    stripe_acct = \"test_stripe_acct_id\"\n    return AccountMembership(\n        type=overrides.get(\"type\") or \"Monthly Membership\",\n        start_date=overrides.get(\"start_date\") or date.today(),\n        payment_method=overrides.get(\"payment_method\") or \"Stripe\",\n        payment_account_id=overrides.get(\"payment_account_id\") or stripe_acct,\n        payment_id=overrides.get(\"payment_id\") or \"test_stripe_payment_id\",\n    )\n\n\ndef add_account_membership(db, account_id: str, **overrides: Any) -> AccountMembership:\n    \"\"\"Adds a membership to a test account.\n\n    :param db: instance of the Selene database\n    :param account_id: identifier of the account\n    :param overrides: keyword arguments that override default account attributes\n    :return: the membership that was added to the database\n    \"\"\"\n    membership = build_test_membership(**overrides)\n    acct_repository = AccountRepository(db)\n    acct_repository.add_membership(account_id, membership)\n\n    return membership\n"
  },
  {
    "path": "shared/selene/testing/account_activity.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\nfrom dataclasses import asdict\nfrom datetime import datetime\n\nfrom hamcrest import assert_that, equal_to, greater_than\n\nfrom selene.data.metric import AccountActivityRepository\n\n\ndef get_account_activity(db):\n    acct_activity_repository = AccountActivityRepository(db)\n    return acct_activity_repository.get_activity_by_date(datetime.utcnow().date())\n\n\ndef remove_account_activity(db):\n    acct_activity_repository = AccountActivityRepository(db)\n    acct_activity_repository.delete_activity_by_date(datetime.utcnow().date())\n\n\ndef check_account_metrics(context, total, changed):\n    \"\"\"Abstract function for checking that account activity metrics were updated.\"\"\"\n    acct_activity_repository = AccountActivityRepository(context.db)\n    account_activity = acct_activity_repository.get_activity_by_date(\n        datetime.utcnow().date()\n    )\n    before = asdict(context.account_activity)\n    after = asdict(account_activity)\n    if context.account_activity is None:\n        assert_that(after[total], greater_than(0))\n        assert_that(after[changed], equal_to(1))\n    else:\n        if \"added\" in changed:\n            assert_that(after[total], equal_to(before[total] + 1))\n        else:\n            assert_that(after[total], equal_to(before[total] - 1))\n        assert_that(after[changed], equal_to(before[changed] + 1))\n"
  },
  {
    "path": "shared/selene/testing/account_geography.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom selene.data.device import Geography, GeographyRepository\n\n\ndef add_account_geography(db, account, **overrides):\n    geography = Geography(\n        country=overrides.get(\"country\") or \"United States\",\n        region=overrides.get(\"region\") or \"Missouri\",\n        city=overrides.get(\"city\") or \"Kansas City\",\n        time_zone=overrides.get(\"time_zone\") or \"America/Chicago\",\n    )\n    geo_repository = GeographyRepository(db, account.id)\n    account_geography_id = geo_repository.add(geography)\n\n    return account_geography_id\n"
  },
  {
    "path": "shared/selene/testing/account_preference.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom selene.data.device import AccountPreferences, PreferenceRepository\n\n\ndef add_account_preference(db, account_id):\n    account_preferences = AccountPreferences(\n        date_format=\"MM/DD/YYYY\", time_format=\"12 Hour\", measurement_system=\"Imperial\"\n    )\n    preference_repo = PreferenceRepository(db, account_id)\n    preference_repo.upsert(account_preferences)\n"
  },
  {
    "path": "shared/selene/testing/agreement.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nimport json\nfrom dataclasses import asdict\nfrom datetime import date, timedelta\nfrom typing import Tuple, List\n\nfrom hamcrest import assert_that, equal_to\n\nfrom selene.data.account import (\n    Agreement,\n    AgreementRepository,\n    OPEN_DATASET,\n    PRIVACY_POLICY,\n    TERMS_OF_USE,\n)\n\n\ndef _build_test_terms_of_use():\n    return Agreement(\n        type=TERMS_OF_USE,\n        version=\"Holy Grail\",\n        content=\"I agree that all the tests I write for this application will \"\n        \"be in the theme of Monty Python and the Holy Grail.  If you \"\n        'do not agree with these terms, I will be forced to say \"Ni!\" '\n        \"until such time as you agree\",\n        effective_date=date.today() - timedelta(days=1),\n    )\n\n\ndef _build_test_privacy_policy():\n    return Agreement(\n        type=PRIVACY_POLICY,\n        version=\"Holy Grail\",\n        content=\"First, shalt thou take out the Holy Pin.  Then shalt thou \"\n        \"count to three.  No more.  No less.  Three shalt be the \"\n        \"number thou shalt count and the number of the counting shall \"\n        \"be three.  Four shalt thou not count, nor either count thou \"\n        \"two, excepting that thou then proceed to three.  Five is \"\n        \"right out.  Once the number three, being the third number, \"\n        \"be reached, then lobbest thou Holy Hand Grenade of Antioch \"\n        \"towards thy foe, who, being naughty in My sight, \"\n        \"shall snuff it.\",\n        effective_date=date.today() - timedelta(days=1),\n    )\n\n\ndef _build_open_dataset():\n    return Agreement(\n        type=OPEN_DATASET,\n        version=\"Holy Grail\",\n        effective_date=date.today() - timedelta(days=1),\n    )\n\n\ndef add_agreements(context):\n    \"\"\"Add agreements to database and set a context variable for each.\"\"\"\n    terms_of_use = _build_test_terms_of_use()\n    privacy_policy = _build_test_privacy_policy()\n    open_dataset = _build_open_dataset()\n    agreement_repository = AgreementRepository(context.db)\n    terms_of_use.id = agreement_repository.add(terms_of_use)\n    privacy_policy.id = agreement_repository.add(privacy_policy)\n    open_dataset.id = agreement_repository.add(open_dataset)\n    context.terms_of_use = terms_of_use\n    context.privacy_policy = privacy_policy\n    context.open_dataset = open_dataset\n\n\ndef remove_agreements(db, agreements: List[Agreement]):\n    for agreement in agreements:\n        agreement_repository = AgreementRepository(db)\n        agreement_repository.remove(agreement)\n\n\ndef get_agreements_from_api(context, agreement):\n    \"\"\"Abstracted so both account and single sign on APIs use in their tests\"\"\"\n    if agreement == PRIVACY_POLICY:\n        url = \"/api/agreement/privacy-policy\"\n    elif agreement == TERMS_OF_USE:\n        url = \"/api/agreement/terms-of-use\"\n    else:\n        raise ValueError(\"invalid agreement type\")\n\n    context.response = context.client.get(url)\n\n\ndef validate_agreement_response(context, agreement):\n    response_data = json.loads(context.response.data)\n    if agreement == PRIVACY_POLICY:\n        expected_response = asdict(context.privacy_policy)\n    elif agreement == TERMS_OF_USE:\n        expected_response = asdict(context.terms_of_use)\n    else:\n        raise ValueError(\"invalid agreement type\")\n\n    del expected_response[\"effective_date\"]\n    assert_that(response_data, equal_to(expected_response))\n"
  },
  {
    "path": "shared/selene/testing/api.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\nfrom http import HTTPStatus\n\nfrom hamcrest import assert_that, equal_to, has_item, is_in\n\nfrom selene.data.account import Account, AccountRepository\nfrom selene.util.auth import AuthenticationToken\nfrom selene.util.db import connect_to_db\n\nACCESS_TOKEN_COOKIE_KEY = \"seleneAccess\"\nONE_MINUTE = 60\nTWO_MINUTES = 120\nREFRESH_TOKEN_COOKIE_KEY = \"seleneRefresh\"\n\n\ndef generate_access_token(context, duration=ONE_MINUTE):\n    access_token = AuthenticationToken(context.client_config[\"ACCESS_SECRET\"], duration)\n    account = context.accounts[context.username]\n    access_token.generate(account.id)\n\n    return access_token\n\n\ndef set_access_token_cookie(context, duration=ONE_MINUTE):\n    context.client.set_cookie(\n        context.client_config[\"DOMAIN\"],\n        ACCESS_TOKEN_COOKIE_KEY,\n        context.access_token.jwt,\n        max_age=duration,\n    )\n\n\ndef generate_refresh_token(context, duration=TWO_MINUTES):\n    refresh_token = AuthenticationToken(\n        context.client_config[\"REFRESH_SECRET\"], duration\n    )\n    account = context.accounts[context.username]\n    refresh_token.generate(account.id)\n\n    return refresh_token\n\n\ndef set_refresh_token_cookie(context, duration=TWO_MINUTES):\n    context.client.set_cookie(\n        context.client_config[\"DOMAIN\"],\n        REFRESH_TOKEN_COOKIE_KEY,\n        context.refresh_token.jwt,\n        max_age=duration,\n    )\n\n\ndef validate_token_cookies(context, expired=False):\n    for cookie in context.response.headers.getlist(\"Set-Cookie\"):\n        ingredients = _parse_cookie(cookie)\n        ingredient_names = list(ingredients.keys())\n        if ACCESS_TOKEN_COOKIE_KEY in ingredient_names:\n            context.access_token = ingredients[ACCESS_TOKEN_COOKIE_KEY]\n        elif REFRESH_TOKEN_COOKIE_KEY in ingredient_names:\n            context.refresh_token = ingredients[REFRESH_TOKEN_COOKIE_KEY]\n        for ingredient_name in (\"Domain\", \"Expires\", \"Max-Age\"):\n            assert_that(ingredient_names, has_item(ingredient_name))\n        if expired:\n            assert_that(ingredients[\"Max-Age\"], equal_to(\"0\"))\n\n    assert hasattr(context, \"access_token\"), \"no access token in response\"\n    assert hasattr(context, \"refresh_token\"), \"no refresh token in response\"\n    if expired:\n        assert_that(context.access_token, equal_to(\"\"))\n        assert_that(context.refresh_token, equal_to(\"\"))\n\n\ndef _parse_cookie(cookie: str) -> dict:\n    ingredients = {}\n    for ingredient in cookie.split(\"; \"):\n        if \"=\" in ingredient:\n            key, value = ingredient.split(\"=\")\n            ingredients[key] = value\n        else:\n            ingredients[ingredient] = None\n\n    return ingredients\n\n\ndef get_account(context) -> Account:\n    db = connect_to_db(context.client[\"DB_CONNECTION_CONFIG\"])\n    acct_repository = AccountRepository(db)\n    account = acct_repository.get_account_by_id(context.account.id)\n\n    return account\n\n\ndef check_http_success(context):\n    assert_that(\n        context.response.status_code, is_in([HTTPStatus.OK, HTTPStatus.NO_CONTENT])\n    )\n\n\ndef check_http_error(context, error_type):\n    if error_type == \"a bad request\":\n        assert_that(context.response.status_code, equal_to(HTTPStatus.BAD_REQUEST))\n    elif error_type == \"an unauthorized\":\n        assert_that(context.response.status_code, equal_to(HTTPStatus.UNAUTHORIZED))\n    else:\n        raise ValueError(\"unsupported error_type\")\n"
  },
  {
    "path": "shared/selene/testing/device.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Testing helper functions for testing devices.\"\"\"\nfrom selene.data.device import DeviceRepository, PantacorConfig\n\n\ndef add_device(db, account_id, geography_id):\n    \"\"\"Add a row to the device table for testing.\"\"\"\n    device = dict(\n        name=\"Selene Test Device\",\n        pairing_code=\"ABC123\",\n        placement=\"kitchen\",\n        geography_id=geography_id,\n        country=\"United States\",\n        region=\"Missouri\",\n        city=\"Kansas City\",\n        timezone=\"America/Chicago\",\n        wake_word=\"hey selene\",\n        voice=\"Selene Test Voice\",\n    )\n    device_repository = DeviceRepository(db)\n    device_id = device_repository.add(account_id, device)\n\n    return device_id\n\n\ndef add_pantacor_config(db, device_id):\n    \"\"\"Add a row to the pantacor_config table for testing Pantacor capabilities.\"\"\"\n    device_repository = DeviceRepository(db)\n    pantacor_config = PantacorConfig(\n        pantacor_id=\"test_pantacor_id\",\n        ip_address=\"192.168.1.2\",\n        auto_update=False,\n        release_channel=\"myc200_dev\",\n    )\n    device_repository.upsert_pantacor_config(device_id, pantacor_config)\n"
  },
  {
    "path": "shared/selene/testing/device_skill.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nimport json\nfrom datetime import datetime\n\nfrom selene.data.device import DeviceSkillRepository, ManifestSkill\n\n\ndef add_device_skill(db, device_id, skill):\n    manifest_skill = ManifestSkill(\n        device_id=device_id,\n        install_method=\"test_install_method\",\n        install_status=\"test_install_status\",\n        skill_id=skill.id,\n        skill_gid=skill.skill_gid,\n        install_ts=datetime.utcnow(),\n        update_ts=datetime.utcnow(),\n    )\n    device_skill_repo = DeviceSkillRepository(db)\n    manifest_skill.id = device_skill_repo.add_manifest_skill(manifest_skill)\n\n    return manifest_skill\n\n\ndef add_device_skill_settings(db, device_id, settings_display, settings_values):\n    device_skill_repo = DeviceSkillRepository(db)\n    device_skill_repo.upsert_device_skill_settings(\n        [device_id], settings_display, settings_values\n    )\n\n\ndef remove_device_skill(db, manifest_skill):\n    device_skill_repo = DeviceSkillRepository(db)\n    device_skill_repo.remove_manifest_skill(manifest_skill)\n"
  },
  {
    "path": "shared/selene/testing/membership.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom decimal import Decimal\n\nfrom selene.data.account import (\n    Membership,\n    MembershipRepository,\n    MONTHLY_MEMBERSHIP,\n    YEARLY_MEMBERSHIP,\n)\n\nmonthly_membership = dict(\n    type=MONTHLY_MEMBERSHIP, rate=Decimal(\"1.99\"), rate_period=\"monthly\"\n)\n\nyearly_membership = dict(\n    type=YEARLY_MEMBERSHIP, rate=Decimal(\"19.99\"), rate_period=\"yearly\"\n)\n\n\ndef insert_memberships(db):\n    monthly = Membership(**monthly_membership)\n    yearly = Membership(**yearly_membership)\n    membership_repository = MembershipRepository(db)\n    monthly.id = membership_repository.add(monthly)\n    yearly.id = membership_repository.add(yearly)\n\n    return monthly, yearly\n\n\ndef delete_memberships(db, memberships):\n    membership_repository = MembershipRepository(db)\n    for membership in memberships:\n        membership_repository.remove(membership)\n"
  },
  {
    "path": "shared/selene/testing/skill.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom selene.data.skill import (\n    SettingsDisplay,\n    SettingsDisplayRepository,\n    Skill,\n    SkillRepository,\n)\n\n\ndef build_text_field():\n    return dict(\n        name=\"textfield\",\n        type=\"text\",\n        label=\"Text Field\",\n        placeholder=\"Text Placeholder\",\n    )\n\n\ndef build_checkbox_field():\n    return dict(name=\"checkboxfield\", type=\"checkbox\", label=\"Checkbox Field\")\n\n\ndef build_label_field():\n    return dict(type=\"label\", label=\"This is a section label.\")\n\n\ndef _build_display_data(skill_gid, fields):\n    gid_parts = skill_gid.split(\"|\")\n    if len(gid_parts) == 3:\n        skill_name = gid_parts[1]\n\n    else:\n        skill_name = gid_parts[0]\n    skill_identifier = skill_name + \"-123456\"\n    settings_display = dict(\n        skill_gid=skill_gid, identifier=skill_identifier, display_name=skill_name,\n    )\n    if fields is not None:\n        settings_display.update(\n            skillMetadata=dict(sections=[dict(name=\"Section Name\", fields=fields)])\n        )\n\n    return settings_display\n\n\ndef add_skill(db, skill_global_id, settings_fields=None):\n    display_data = _build_display_data(skill_global_id, settings_fields)\n    skill_repo = SkillRepository(db)\n    skill_id = skill_repo.ensure_skill_exists(skill_global_id)\n    skill = Skill(skill_global_id, skill_id)\n    settings_display = SettingsDisplay(skill_id, display_data)\n    settings_display_repo = SettingsDisplayRepository(db)\n    settings_display.id = settings_display_repo.add(settings_display)\n\n    return skill, settings_display\n\n\ndef remove_skill(db, skill):\n    skill_repo = SkillRepository(db)\n    skill_repo.remove_by_gid(skill_gid=skill.skill_gid)\n"
  },
  {
    "path": "shared/selene/testing/tagging.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Testing helper functions for the tagging schema.\"\"\"\nfrom datetime import datetime\nfrom selene.data.tagging import (\n    WakeWordFile,\n    WakeWordFileRepository,\n    TaggingFileLocationRepository,\n    UPLOADED_STATUS,\n)\n\n\ndef remove_wake_word_files(db, wake_word_file):\n    \"\"\"Remove a wake word files by wake word and their associated data.\"\"\"\n    file_repository = WakeWordFileRepository(db)\n    file_repository.remove(wake_word_file)\n\n\ndef add_wake_word_file(context, file_name):\n    \"\"\"Add a row to the tagging.file table for testing.\"\"\"\n    location_repository = TaggingFileLocationRepository(context.db)\n    file_location = location_repository.ensure_location_exists(\n        server=\"127.0.0.1\", directory=\"some/dummy/file/directory\"\n    )\n    wake_word_file = WakeWordFile(\n        wake_word=context.wake_words[\"hey selene\"],\n        name=file_name,\n        origin=\"mycroft\",\n        submission_date=datetime.utcnow().date(),\n        location=file_location,\n        status=UPLOADED_STATUS,\n        account_id=context.account.id,\n    )\n    file_repository = WakeWordFileRepository(context.db)\n    file_repository.add(wake_word_file)\n"
  },
  {
    "path": "shared/selene/testing/test_db.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom psycopg2 import connect\n\nconnection_config = dict(\n    host=\"127.0.0.1\", dbname=\"postgres\", user=\"mycroft\", password=\"holmes\"\n)\n\n\ndef create_test_db():\n    db = connect(**connection_config)\n    db.autocommit = True\n    cursor = db.cursor()\n    cursor.execute(\n        \"CREATE DATABASE \"\n        \"   mycroft_test \"\n        \"WITH TEMPLATE \"\n        \"    mycroft_template \"\n        \"OWNER \"\n        \"    mycroft;\"\n    )\n\n\ndef drop_test_db():\n    db = connect(**connection_config)\n    db.autocommit = True\n    cursor = db.cursor()\n    cursor.execute(\n        \"SELECT pg_terminate_backend(pid) \"\n        \"FROM pg_stat_activity \"\n        \"WHERE datname = 'mycroft_test';\"\n    )\n    cursor.execute(\"DROP DATABASE mycroft_test\")\n"
  },
  {
    "path": "shared/selene/testing/text_to_speech.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom selene.data.device import DeviceRepository, TextToSpeech\n\n\ndef _build_voice():\n    return TextToSpeech(\n        setting_name=\"selene_test_voice\",\n        display_name=\"Selene Test Voice\",\n        engine=\"mimic\",\n    )\n\n\ndef add_text_to_speech(db):\n    voice = _build_voice()\n    device_repository = DeviceRepository(db)\n    voice.id = device_repository.add_text_to_speech(voice)\n\n    return voice\n\n\ndef remove_text_to_speech(db, voice):\n    device_repository = DeviceRepository(db)\n    device_repository.remove_text_to_speech(voice.id)\n"
  },
  {
    "path": "shared/selene/testing/wake_word.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Testing helper functions for wake words.\"\"\"\nfrom selene.data.tagging import WakeWordFileRepository\nfrom selene.data.wake_word import WakeWord, WakeWordRepository\n\n\ndef add_wake_word(db) -> WakeWord:\n    \"\"\"Add a wake word for use in testing.\n\n    :param db: Database connection to the Mycroft DB\n    :return:\n    \"\"\"\n    wake_word = WakeWord(name=\"hey selene\", engine=\"precise\")\n    wake_word_repository = WakeWordRepository(db)\n    wake_word.id = wake_word_repository.add(wake_word)\n\n    return wake_word\n\n\ndef remove_wake_word(db, wake_word: WakeWord):\n    \"\"\"Remove a wake word and any related sample files from the database\n\n    :param db: Database connection to the Mycroft DB\n    :param wake_word: the wake word to delete\n    \"\"\"\n    file_repository = WakeWordFileRepository(db)\n    wake_word_repository = WakeWordRepository(db)\n    if wake_word.id is None:\n        wake_word.id = wake_word_repository.get_id(wake_word)\n    for wake_word_file in file_repository.get_by_wake_word(wake_word):\n        file_repository.remove(wake_word_file)\n    wake_word_repository.remove(wake_word)\n"
  },
  {
    "path": "shared/selene/util/__init__.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n"
  },
  {
    "path": "shared/selene/util/auth.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\n\"\"\"Logic for generating and validating JWT authentication tokens.\"\"\"\nimport json\nimport os\nfrom datetime import datetime\nfrom http import HTTPStatus\nfrom logging import getLogger\nfrom time import time\n\nfrom facebook import GraphAPI\nimport jwt\nimport requests\n\n_log = getLogger(__name__)\n\n\nclass AuthenticationError(Exception):\n    \"\"\"Throw this exception when Selene authentication fails.\"\"\"\n\n\nclass AuthenticationToken:\n    \"\"\"Defines a Selene authentication token.\"\"\"\n\n    # TODO: move duration argument to generate method\n    def __init__(self, secret: str, duration: int):\n        self.secret = secret\n        self.duration = duration\n        self.jwt: str = \"\"\n        self.account_id = None\n        self.is_valid: bool = None\n        self.is_expired: bool = None\n\n    def generate(self, account_id: str):\n        \"\"\"Generates a JWT token\n\n        :param: account_id: the account the token is being generated for\n        \"\"\"\n        self.account_id = account_id\n        payload = dict(\n            iat=datetime.utcnow(), exp=time() + self.duration, sub=account_id\n        )\n        self.jwt = jwt.encode(payload, self.secret, algorithm=\"HS256\")\n\n    def validate(self):\n        \"\"\"Decodes the auth token and performs some preliminary validation.\"\"\"\n        self.is_expired = False\n        self.is_valid = True\n        self.account_id = None\n\n        if self.jwt is None:\n            self.is_expired = True\n        else:\n            try:\n                payload = jwt.decode(self.jwt, self.secret, algorithms=[\"HS256\"])\n                self.account_id = payload[\"sub\"]\n            except jwt.ExpiredSignatureError:\n                self.is_expired = True\n            except jwt.InvalidTokenError:\n                _log.exception(\"Invalid JWT\")\n                self.is_valid = False\n\n\ndef get_google_account_email(token: str) -> str:\n    \"\"\"Get the email address from a Google login to match to the account table.\n\n    :param token: the token returned from Google after authenticating\n    :returns: the email address related to the account\n    \"\"\"\n    google_response = requests.get(\n        \"https://oauth2.googleapis.com/tokeninfo?id_token=\" + token, timeout=5\n    )\n    if google_response.status_code == HTTPStatus.OK:\n        google_account = json.loads(google_response.content)\n        email_address = google_account[\"email\"]\n    else:\n        raise AuthenticationError(\"invalid Google token\")\n\n    return email_address\n\n\ndef get_facebook_account_email(token: str) -> str:\n    \"\"\"Get the email address from a Facebook login to match to the account table.\n\n    :param token: the token returned from Facebook after authenticating\n    :returns: the email address related to the account\n    \"\"\"\n    facebook_api = GraphAPI(token)\n    facebook_account = facebook_api.get_object(id=\"me?fields=email\")\n\n    return facebook_account[\"email\"]\n\n\ndef get_github_account_email(token: str) -> str:\n    \"\"\"Get the email address from a Github login to match to the account table.\n\n    :param token: the token returned from GitHub after authenticating\n    :returns: the email address related to the account\n    \"\"\"\n    github_email = None\n    github_user = requests.get(\n        \"https://api.github.com/user/emails\",\n        headers=dict(Authorization=\"token \" + token, Accept=\"application/json\"),\n        timeout=5,\n    )\n    if github_user.status_code == HTTPStatus.OK:\n        for email in json.loads(github_user.content):\n            if email[\"primary\"]:\n                github_email = email[\"email\"]\n\n    return github_email\n\n\ndef get_github_authentication_token(access_code: str, state: str) -> str:\n    \"\"\"Use the GitHub API to retrieve an authentication token for Selene log in.\n\n    :param access_code: Code obtained from login attempt\n    :param state: State of the login attempt\n    :return: access token for Selene login\n    \"\"\"\n    params = [\n        \"client_id=\" + os.environ[\"GITHUB_CLIENT_ID\"],\n        \"client_secret=\" + os.environ[\"GITHUB_CLIENT_SECRET\"],\n        \"code=\" + access_code,\n        \"state=\" + state,\n    ]\n    github_response = requests.post(\n        \"https://github.com/login/oauth/access_token?\" + \"&\".join(params),\n        headers=dict(Accept=\"application/json\"),\n        timeout=5,\n    )\n    response_content = json.loads(github_response.content)\n\n    return response_content.get(\"access_token\")\n"
  },
  {
    "path": "shared/selene/util/cache.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nimport os\n\nfrom redis import Redis\n\nDEVICE_LAST_CONTACT_KEY = \"device:last_contact:{device_id}\"\nDEVICE_SKILL_ETAG_KEY = \"device.skill.etag:{device_id}\"\nDEVICE_PAIRING_CODE_KEY = \"pairing.code:{pairing_code}\"\nDEVICE_PAIRING_TOKEN_KEY = \"pairing.token:{pairing_token}\"\nDEVICE_ACCESS_TOKEN_KEY = \"device.token.access:{access}\"\n\n\nclass SeleneCache(object):\n    def __init__(self):\n        # should the variables host and port be in the config class?\n        redis_host = os.environ[\"REDIS_HOST\"]\n        redis_port = int(os.environ[\"REDIS_PORT\"])\n        self.redis = Redis(host=redis_host, port=redis_port)\n\n    def set_if_not_exists_with_expiration(\n        self, key: str, value: str, expiration: int\n    ) -> bool:\n        \"\"\"Sets a key only if it doesn't exist and using a given expiration time\n\n        :return True if the set operation is successful, False if not.  Will\n            return False if the value already exists for this key\n        \"\"\"\n        if expiration > 0:\n            # Setting the \"nx\" argument to True will ensure the set will fail\n            # if a value already exists for this key.\n            return self.redis.set(name=key, value=value, ex=expiration, nx=True)\n\n    def set_with_expiration(self, key, value, expiration: int):\n        \"\"\"Sets a key with a given expiration\"\"\"\n        if expiration > 0:\n            return self.redis.set(name=key, value=value, ex=expiration)\n\n    def get(self, key):\n        \"\"\"Returns the value stored in a key\"\"\"\n        return self.redis.get(name=key)\n\n    def delete(self, key):\n        \"\"\"Deletes a key from the cache\"\"\"\n        return self.redis.delete(key)\n\n    def set(self, key, value):\n        \"\"\"Sets a key with a given value\"\"\"\n        return self.redis.set(name=key, value=value)\n"
  },
  {
    "path": "shared/selene/util/db/__init__.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom .connection import connect_to_db, DatabaseConnectionConfig\nfrom .connection_pool import (\n    allocate_db_connection_pool,\n    get_db_connection,\n    get_db_connection_from_pool,\n    return_db_connection_to_pool,\n)\nfrom .cursor import Cursor, DatabaseRequest, DatabaseBatchRequest, get_sql_from_file\nfrom .transaction import use_transaction\n"
  },
  {
    "path": "shared/selene/util/db/connection.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\n\"\"\"Utility code for interacting with a database.\n\nExample Usage:\n    from util.db import get_sql_from_file, mycroft_db_ro\n    sql = get_sql_from_file(<fully qualified path to .sql file>)\n    query_result = mycroft_db_ro.execute_sql(sql)\n\"\"\"\n\nfrom dataclasses import dataclass, field, InitVar\nfrom logging import getLogger\n\nfrom psycopg2 import connect\nfrom psycopg2.extras import RealDictCursor, NamedTupleCursor\n\n_log = getLogger(__name__)\n\n\nclass DBConnectionError(Exception):\n    \"\"\"Raise this exception when an error occurs connecting to the Selene database.\"\"\"\n\n\n@dataclass\nclass DatabaseConnectionConfig:\n    \"\"\"attributes required to connect to a Postgres database.\"\"\"\n\n    host: str\n    db_name: str\n    user: str\n    password: str\n    port: int = field(default=5432)\n    sslmode: str = None\n    autocommit: str = True\n    cursor_factory = RealDictCursor\n    use_namedtuple_cursor: InitVar[bool] = False\n\n    def __post_init__(self, use_namedtuple_cursor: bool):\n        if use_namedtuple_cursor:\n            self.cursor_factory = NamedTupleCursor\n\n\ndef connect_to_db(connection_config: DatabaseConnectionConfig):\n    \"\"\"\n    Return a connection to the mycroft database for the specified user.\n\n    Use this function when connecting to a database in an application that\n    does not benefit from connection pooling (e.g. a batch script or a\n    python notebook)\n\n    :param connection_config: data needed to establish a connection\n    :return: database connection\n    \"\"\"\n    log_msg = \"establishing connection to the {db_name} database\"\n    _log.debug(log_msg.format(db_name=connection_config.db_name))\n    db = connect(\n        host=connection_config.host,\n        dbname=connection_config.db_name,\n        user=connection_config.user,\n        password=connection_config.password,\n        port=connection_config.port,\n        cursor_factory=connection_config.cursor_factory,\n        sslmode=connection_config.sslmode,\n    )\n    db.autocommit = connection_config.autocommit\n\n    return db\n"
  },
  {
    "path": "shared/selene/util/db/connection_pool.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\n\"\"\"Postgres database connection pooling helpers\"\"\"\n\nfrom contextlib import contextmanager\nfrom logging import getLogger\n\nfrom psycopg2.extras import RealDictCursor\nfrom psycopg2.pool import ThreadedConnectionPool\n\nfrom .connection import DatabaseConnectionConfig\n\n_log = getLogger(__name__)\n\n\ndef allocate_db_connection_pool(\n    connection_config: DatabaseConnectionConfig, max_connections: int = 20\n) -> ThreadedConnectionPool:\n    \"\"\"\n    Allocate a pool of database connections for an application\n\n    Connecting to a database can be a costly operation for stateless\n    applications that jump in and out of a database frequently,\n    like a REST APIs. To combat this, a connection pool provides a set of\n    persistent connections that preclude these applications from constantly\n    connecting and disconnecting from the database.\n\n    :param connection_config: data needed to establish a connection\n    :param max_connections: maximum connections allocated to the application\n    :return: a pool of database connections to be used by the application\n    \"\"\"\n    log_msg = (\n        \"Allocating a pool of connections to the {db_name} database with \"\n        \"a maximum of {max_connections} connections.\"\n    )\n    _log.info(\n        log_msg.format(\n            db_name=connection_config.db_name, max_connections=max_connections\n        )\n    )\n    return ThreadedConnectionPool(\n        minconn=1,\n        maxconn=max_connections,\n        database=connection_config.db_name,\n        user=connection_config.user,\n        password=connection_config.password,\n        host=connection_config.host,\n        port=connection_config.port,\n        cursor_factory=RealDictCursor,\n    )\n\n\n@contextmanager\ndef get_db_connection(connection_pool, autocommit=True):\n    \"\"\"Obtain a database connection from a pool and release it when finished\n\n    :param connection_pool: pool of connections used by the applications\n    :param autocommit: indicates if transactions should commit automatically\n    :return: context object containing a database connection from the pool\n    \"\"\"\n    db_connection = None\n    try:\n        db_connection = connection_pool.getconn()\n        db_connection.autocommit = autocommit\n        yield db_connection\n    finally:\n        # return the db connection to the pool when exiting the context\n        # manager's scope\n        if db_connection is not None:\n            connection_pool.putconn(db_connection)\n\n\ndef get_db_connection_from_pool(connection_pool, autocommit=True):\n    \"\"\"Obtain a database connection from a pool and release it when finished\n\n    :param connection_pool: pool of connections used by the applications\n    :param autocommit: indicates if transactions should commit automatically\n    :return: context object containing a database connection from the pool\n    \"\"\"\n    db_connection = connection_pool.getconn()\n    db_connection.autocommit = autocommit\n\n    return db_connection\n\n\ndef return_db_connection_to_pool(connection_pool, connection):\n    \"\"\"Returns a connection to the connection pool when it is no longer needed.\"\"\"\n    connection_pool.putconn(connection)\n"
  },
  {
    "path": "shared/selene/util/db/cursor.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\n\"\"\"Utility code for interacting with a database.\n\nExample Usage:\n    from util.db import get_sql_from_file, mycroft_db_ro\n    sql = get_sql_from_file(<fully qualified path to .sql file>)\n    query_result = mycroft_db_ro.execute_sql(sql)\n\"\"\"\n\nfrom dataclasses import dataclass, field\nfrom logging import getLogger\nfrom os import path\nfrom typing import List\n\n_log = getLogger(__name__)\n\n\ndef get_sql_from_file(file_path: str) -> str:\n    \"\"\"Read a .sql file and return its contents as a string.\n\n    All the SQL to access relational databases will be written in .sql files\n\n    Args:\n        file_path: absolute file system of the .sql file.\n    Returns:\n        raw SQL for use in a database interface, such as psycopg\n    \"\"\"\n    with open(path.join(file_path)) as sql_file:\n        raw_sql = sql_file.read()\n\n    return raw_sql\n\n\n@dataclass\nclass DatabaseRequest:\n    \"\"\"Small data object for the sql and the args needed for a database request\"\"\"\n\n    sql: str\n    args: dict = field(default=None)\n\n\n@dataclass\nclass DatabaseBatchRequest:\n    \"\"\"Small data object for the arguments needed for a batch database request.\"\"\"\n\n    sql: str\n    args: List[dict]\n\n\nclass Cursor:\n    \"\"\"Wrapper around the psycopg cursor offering convenience functions.\"\"\"\n\n    def __init__(self, db):\n        self.db = db\n\n    def _fetch(self, db_request: DatabaseRequest, singleton=False):\n        \"\"\"Fetch all or one row from the database.\n\n        :param db_request: parameters used to determine how to fetch the data\n        :return: the query results; will be a results object if a singleton\n            select was issued, a list of results objects otherwise.\n        \"\"\"\n        with self.db.cursor() as cursor:\n            _log.debug(cursor.mogrify(db_request.sql, db_request.args).decode())\n            cursor.execute(db_request.sql, db_request.args)\n            if singleton:\n                execution_result = cursor.fetchone()\n            else:\n                execution_result = cursor.fetchall()\n\n            _log.debug(\"query returned {} rows\".format(cursor.rowcount))\n\n        return execution_result\n\n    def select_one(self, db_request: DatabaseRequest):\n        \"\"\"\n        Fetch a single row from the database.\n\n        :param db_request: parameters used to determine how to fetch the data\n        :return: a single results object\n        \"\"\"\n        return self._fetch(db_request, singleton=True)\n\n    def select_all(self, db_request: DatabaseRequest):\n        \"\"\"\n        Fetch all rows resulting from the database request.\n\n        :param db_request: parameters used to determine how to fetch the data\n        :return: a single results object\n        \"\"\"\n        return self._fetch(db_request)\n\n    def execute(self, db_request: DatabaseRequest):\n        \"\"\"Fetch all or one row from the database.\n\n        :param db_request: parameters used to determine how to fetch the data\n        :return: the query results; will be a results object if a singleton\n            select was issued, a list of results objects otherwise.\n        \"\"\"\n        with self.db.cursor() as cursor:\n            _log.debug(cursor.mogrify(db_request.sql, db_request.args).decode())\n            cursor.execute(db_request.sql, db_request.args)\n            _log.debug(f\"{str(cursor.rowcount)} rows affected\")\n            return cursor.rowcount\n\n    def _execute_batch(self, db_request: DatabaseBatchRequest):\n        \"\"\"Execute a batch database request.\"\"\"\n        with self.db.cursor() as cursor:\n            cursor.executemany(db_request.sql, db_request.args)\n            # execute_batch(cursor, db_request.sql, db_request.args)\n\n    def delete(self, db_request: DatabaseRequest):\n        \"\"\"Helper function for SQL delete statements\"\"\"\n        deleted_rows = self.execute(db_request)\n        return deleted_rows\n\n    def insert(self, db_request: DatabaseRequest):\n        \"\"\"Helper functions for SQL insert statements\"\"\"\n        self.execute(db_request)\n\n    def insert_returning(self, db_request: DatabaseRequest):\n        \"\"\"Helper function for SQL inserts returning values.\"\"\"\n        return self._fetch(db_request, singleton=True)\n\n    def update(self, db_request: DatabaseRequest):\n        \"\"\"Helper function for SQL update statements.\"\"\"\n        updated_rows = self.execute(db_request)\n        return updated_rows\n\n    def batch_update(self, db_request: DatabaseBatchRequest):\n        \"\"\"Executes a batch update.\"\"\"\n        self._execute_batch(db_request)\n\n    def dump_query_result_to_file(\n        self, db_request: DatabaseRequest, dump_file_path: str\n    ) -> int:\n        \"\"\"Writes the results of the specified query into the specified file.\"\"\"\n        with self.db.cursor() as cursor:\n            query = cursor.mogrify(db_request.sql, db_request.args).decode()\n            copy_command = \"COPY ({query}) TO STDOUT\".format(query=query)\n            _log.debug(f'dumping results of \"{query}\" to {dump_file_path}')\n            with open(dump_file_path, \"w\") as dump_file:\n                cursor.copy_expert(copy_command, dump_file)\n            _log.info(f\"{str(cursor.rowcount)} rows copied to dump file\")\n\n            return cursor.rowcount\n\n    def load_dump_file_to_table(self, table_name: str, dump_file_path: str):\n        \"\"\"Loads the contents of a delimited file into a table on the Selene DB.\"\"\"\n        with self.db.cursor() as cursor:\n            _log.info(f\"loading {dump_file_path} into the {table_name} table\")\n            with open(dump_file_path) as dump_file:\n                cursor.copy_from(dump_file, table_name)\n            _log.info(f\"{str(cursor.rowcount)} rows copied to table\")\n\n            return cursor.rowcount\n"
  },
  {
    "path": "shared/selene/util/db/transaction.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\n\"\"\"Tools for executing sql within a transaction.\"\"\"\nfrom functools import wraps\n\n\ndef use_transaction(func):\n    \"\"\"Execute all sql statements within the wrapped function in a transaction\n\n    This is a decorator that assumes the function it is wrapping is a method\n    of a class with a \"db\" attribute that is a psycopg connection object.\n\n    :param func: function being decorated\n    :return: decorated function\n    \"\"\"\n\n    @wraps(func)\n    def execute_in_transaction(*args, **kwargs):\n        instance = args[0]\n        return_value = None\n        if hasattr(instance, \"db\"):\n            prev_autocommit = instance.db.autocommit\n            instance.db.autocommit = False\n            try:\n                return_value = func(*args, **kwargs)\n            except:\n                instance.db.rollback()\n                raise\n            else:\n                instance.db.commit()\n            instance.db.autocommit = prev_autocommit\n\n        return return_value\n\n    return execute_in_transaction\n"
  },
  {
    "path": "shared/selene/util/email/__init__.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Public API into the selene.util.email package.\"\"\"\n\nfrom .email import EmailMessage, SeleneMailer, validate_email_address\n"
  },
  {
    "path": "shared/selene/util/email/email.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Utilities to send and email using SendGrid.\"\"\"\nfrom dataclasses import dataclass\nfrom logging import getLogger\nfrom os import environ\nfrom pathlib import Path\nfrom typing import Optional, Tuple\n\nfrom email_validator import validate_email, EmailNotValidError\nfrom jinja2 import Environment, PackageLoader, select_autoescape\nfrom python_http_client import HTTPError\nfrom sendgrid import SendGridAPIClient\nfrom sendgrid.helpers.mail import Content, Mail\n\n_log = getLogger(__name__)\n\n\ndef validate_email_address(email_address: str) -> Tuple[Optional[str], Optional[str]]:\n    \"\"\"Uses a third party package to validate an email address.\n\n    :param email_address: email address supplied by user\n    :return: the normalized email address if it is valid, and an error if not\n    \"\"\"\n    normalized_address = None\n    try:\n        normalized_address = validate_email(email_address).email\n    except EmailNotValidError as exc:\n        error = str(exc)\n    else:\n        error = None\n\n    return normalized_address, error\n\n\n@dataclass\nclass EmailMessage:\n    \"\"\"Data representation of an email to be sent.\"\"\"\n\n    recipient: str\n    sender: str\n    subject: str\n    body: str = None\n    template_file_name: str = None\n    template_variables: dict = None\n    content_type: str = \"text/html\"\n\n    def __post_init__(self):\n        if self.body is not None and self.template_file_name is not None:\n            raise ValueError(\"Specify body or template file name, not both.\")\n        if self.body is None and self.template_file_name is None:\n            raise ValueError(\"One of body or template file name must be supplied.\")\n\n\nclass SeleneMailer:  # pylint: disable=too-few-public-methods\n    \"\"\"Use the SendGrid API to send an email.\"\"\"\n\n    template_directory = Path(__file__).parent.joinpath(\"templates\")\n\n    def __init__(self, message: EmailMessage):\n        self.mailer = SendGridAPIClient(api_key=environ[\"SENDGRID_API_KEY\"])\n        self.message = message\n\n    def send(self, using_jinja: bool = False):\n        \"\"\"Send the email.\n\n        :param using_jinja: indicates if the Jinja templating engine should be used\n        :raises: HTTPError if the email delivery is unsuccessful\n        \"\"\"\n        message = Mail(\n            from_email=self.message.sender,\n            to_emails=[self.message.recipient],\n            subject=self.message.subject,\n            html_content=self._build_content(using_jinja),\n        )\n        try:\n            self.mailer.client.mail.send.post(request_body=message.get())\n        except HTTPError as exc:\n            _log.exception(f\"Email failed to send: {exc.to_dict}\")\n            raise\n        else:\n            _log.info(\"Email sent successfully\")\n\n    def _build_content(self, using_jinja: bool) -> Content:\n        \"\"\"Build the content of the email from a template or a predefined body.\n\n        :param using_jinja: indicates if the Jinja templating engine should be used\n        :returns the email as it will be recognized in the SendGrid package\n        \"\"\"\n        if self.message.body:\n            message_content = self.message.body\n        else:\n            if using_jinja:\n                message_content = self._build_content_from_jinja_template()\n            else:\n                message_content = self._build_content_from_html_template()\n\n        return Content(self.message.content_type, message_content)\n\n    def _build_content_from_html_template(self):\n        \"\"\"Format an HTML template that will be the email body.\"\"\"\n        template_path = self.template_directory.joinpath(\n            self.message.template_file_name\n        )\n        with open(template_path, encoding=\"utf-8\") as template_file:\n            email_content = template_file.read()\n        if self.message.template_variables is not None:\n            email_content = email_content.format(**self.message.template_variables)\n\n        return email_content\n\n    def _build_content_from_jinja_template(self):\n        \"\"\"Uses the Jinja templating engine to populate the email content.\"\"\"\n        jinja_env = Environment(\n            loader=PackageLoader(\"selene.util.email\", \"templates\"),\n            autoescape=select_autoescape([\"html\"]),\n        )\n        template = jinja_env.get_template(self.message.template_file_name)\n        email_content = template.render(self.message.template_variables or {})\n\n        return email_content\n"
  },
  {
    "path": "shared/selene/util/email/templates/account_not_found.html",
    "content": "<div style=\"background-color: #e6e8ea; width: 100%; height:100%;\">\n    <img\n            style=\"display: block; height: 60px; margin: auto; padding-top: 32px; color: white; font-family: Arial,sans-serif; font-size: 18px\"\n            src=\"https://mycroftai.imgus11.com/public//dd014aa691ea98bb0a4b194b19bb2a0e.png?r=537470560\"\n            alt=\"MYCROFT AI\"\n    />\n    <div style=\"\n            background-color: white;\n            border-radius: 4px;\n            box-shadow: 0 8px 16px 0 rgba(0,0,0,0.40);\n            color: #2c3e50;\n            font-family: Helvetica Neue,Helvetica,arial,sans-serif;\n            height: 420px;\n            margin: 16px auto;\n            width: 480px;\n        \"\n    >\n        <div style=\"padding: 32px\">\n            <p>Greetings,</p>\n            <p style=\"margin-top: 16px;\">\n                You (or someone posing as you) requested a password change of a Mycroft AI account using this\n                email address.  However, an account with this email address does not exist in our database of\n                registered users.\n            </p>\n            <p style=\"margin-top: 16px;\">\n                If you have a Mycroft AI account and were expecting this email, please try again\n                using the email address provided when creating the account.\n            </p>\n            <p style=\"margin-top: 16px;\">\n                If you do not have a Mycroft AI account, you can ignore this message.\n            </p>\n            <p style=\"margin-top: 32px;\">\n                Regards,\n            </p>\n            <p style=\"margin-top: 16px;\">\n                The Mycroft AI Team\n            </p>\n        </div>\n    </div>\n</div>\n"
  },
  {
    "path": "shared/selene/util/email/templates/base.html",
    "content": "<div style=\"background-color: #e4f1fe; width: 100%; height:100%; font-family: Helvetica Neue,Helvetica,arial,sans-serif;\">\n    <img\n        style=\"display: block; height: 60px; margin: auto; padding-top: 32px; color: white; font-size: 18px\"\n        src=\"https://mycroftai.imgus11.com/public//dd014aa691ea98bb0a4b194b19bb2a0e.png?r=537470560\"\n        alt=\"MYCROFT AI\"\n    />\n    <div style=\"\n        background-color: white;\n        border-radius: 8px;\n        box-shadow: 0 4px 8px 0 rgba(0,0,0,0.40);\n        color: #2c3e50;\n        height: 400px;\n        margin: 16px auto;\n        width: 480px;\n    \">\n        <div style=\"padding: 32px\">\n            <p>Greetings,</p>\n            {% block email_body %}\n            {% endblock email_body %}\n            <p style=\"margin-top: 32px;\">Regards,</p>\n            <p style=\"margin-top: 16px;\">The Mycroft AI Team</p>\n        </div>\n    </div>\n</div>\n"
  },
  {
    "path": "shared/selene/util/email/templates/email_change.html",
    "content": "{% extends \"base.html\" %}\n{% block email_body %}\n    <p style=\"margin-top: 16px;\">\n        A change to the email address associated with your Mycroft account has been requested.  If you did not make\n        this request, please contact us immediately at support@mycroft.ai.  Otherwise, check the inbox for the new email\n        address for verification instructions.  If you don't see the verification email in your inbox, please check your\n        spam folder.\n\n        Your account will not be updated until the new email address is verified.\n    </p>\n{% endblock email_body %}\n"
  },
  {
    "path": "shared/selene/util/email/templates/email_verification.html",
    "content": "{% extends \"base.html\" %}\n{% block email_body %}\n    <p style=\"margin-top: 16px;\">\n        A change to the email address associated with your Mycroft account has been requested.  Before this change can\n        be applied, the new email address must be verified.\n    </p>\n    <p  style=\"margin-top: 16px;\">To verify this email address, click the button below.</p>\n    <a\n            href=\"{{ email_verification_url }}\"\n            style=\"\n                background-color: #22a7f0;\n                border-radius: 10px;\n                color: white;\n                display: table;\n                height: 32px;\n                margin-top: 32px;\n                text-decoration: none;\n                width: 220px;\n            \"\n    >\n        <span style=\"margin: 16px auto; display: table; text-align: center; width: 200px\">VERIFY EMAIL ADDRESS</span>\n    </a>\n{% endblock email_body %}\n"
  },
  {
    "path": "shared/selene/util/email/templates/metrics.html",
    "content": "<html>\n<head>\n    <meta http-equiv=\"Content-Language\" content=\"en\">\n</head>\n<body>\n<div class=\"main\"\n     style=\"display:table;margin-top:0;margin-bottom:0;margin-right:auto;margin-left:auto;font-family:'Verdana';\">\n    <h2 style=\"font-weigth:normal;display:table;margin-top:20px;margin-right:auto;margin-left:auto;color:#2b3344;margin-bottom:40px;\">\n        Mycroft Daily Report</h2>\n    <table style=\"display:table;margin-top:0;margin-bottom:0;margin-right:auto;margin-left:auto;border-width:1px;border-style:solid;border-color:#EEE;border-collapse:collapse;width:100%;min-width:300px;max-width:10000px;font-size:11px;\">\n        <thead style=\"background-color:#2b3344;background-image:none;background-repeat:repeat;background-position:top left;background-attachment:scroll;border-style:none;color:#FFF;font-size:12px;\">\n        <tr>\n            <th style=\"padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;text-align:left;border-width:1px;border-color:#DDD;margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;border-style:none;\">\n                Type\n            </th>\n            <th style=\"padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;text-align:left;border-width:1px;border-color:#DDD;margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;border-style:none;\">\n                Current\n            </th>\n            <th style=\"padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;text-align:left;border-width:1px;border-color:#DDD;margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;border-style:none;\">\n                1 day\n            </th>\n            <th style=\"padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;text-align:left;border-width:1px;border-color:#DDD;margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;border-style:none;\">\n                15 day\n            </th>\n            <th style=\"padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;text-align:left;border-width:1px;border-color:#DDD;margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;border-style:none;\">\n                30 day\n            </th>\n        </tr>\n        </thead>\n        <tbody>\n        {% for user_metric in user_metrics %}\n        <tr>\n            <td style=\"padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;text-align:left;border-width:1px;border-style:solid;border-color:#DDD;\">\n                {{ user_metric.type }}\n            </td>\n            <td style=\"padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;text-align:left;border-width:1px;border-style:solid;border-color:#DDD;\">\n                {{ user_metric.current }}\n            </td>\n            <td style=\"padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;text-align:left;border-width:1px;border-style:solid;border-color:#DDD;\">\n                {{ user_metric.oneDay }} (<span style=\"color: green;\">{{ user_metric.oneDayDelta }} </span><span\n                    style=\"color: red;\">{{ user_metric.oneDayMinus }}</span>)\n            </td>\n            <td style=\"padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;text-align:left;border-width:1px;border-style:solid;border-color:#DDD;\">\n                {{ user_metric.fifteenDays }} (<span style=\"color: green;\">{{ user_metric.fifteenDaysDelta }} </span><span\n                    style=\"color: red;\">{{ user_metric.fifteenDaysMinus }}</span>)\n            </td>\n            <td style=\"padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;text-align:left;border-width:1px;border-style:solid;border-color:#DDD;\">\n                {{ user_metric.thirtyDays }} (<span style=\"color: green;\">{{ user_metric.thirtyDaysDelta }} </span><span\n                    style=\"color: red;\">{{ user_metric.thirtyDaysMinus }}</span>)\n            </td>\n        </tr>\n        {% endfor %}\n        </tbody>\n    </table>\n</div>\n</body>\n</html>\n  \n"
  },
  {
    "path": "shared/selene/util/email/templates/password_change.html",
    "content": "{% extends \"base.html\" %}\n{% block email_body %}\n    <p style=\"margin-top: 16px;\">\n        The password for the Mycroft account associated with this email address has been changed.  If you\n        did not update your password, please contact us immediately at support@mycroft.ai.  Otherwise, you can\n        ignore this message.\n    </p>\n{% endblock email_body %}\n"
  },
  {
    "path": "shared/selene/util/email/templates/reset_password.html",
    "content": "{% extends \"base.html\" %}\n{% block email_body %}\n    <p style=\"margin-top: 16px;\">\n        A password reset was requested for the Mycroft account associated with this email address.  If you did\n        not make this request, you can ignore this message.\n    </p>\n    <p  style=\"margin-top: 16px;\">To reset your password, click the button below.</p>\n    <a\n            href=\"{{ reset_password_url }}\"\n            style=\"\n                background-color: #22a7f0;\n                border-radius: 12px;\n                color: white;\n                display: table;\n                height: 32px;\n                margin-top: 32px;\n                text-decoration: none;\n                width: 220px;\n            \"\n    >\n        <span style=\"margin: 16px auto; display: table; text-align: center; width: 200px\">RESET PASSWORD</span>\n    </a>\n{% endblock email_body %}\n"
  },
  {
    "path": "shared/selene/util/exceptions.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\n\"\"\"Define custom exceptions used in Selene APIs and scripts\"\"\"\n\n\nclass NotModifiedException(Exception):\n    \"\"\"Raise this exception when a request contains an etag found in cache.\n\n    The Flask blueprint will catch this exception and return a HTTP 304 code.\n    \"\"\"\n\n    pass\n"
  },
  {
    "path": "shared/selene/util/github.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\n\"\"\"\nLogic that uses the Github REST API to extract repository-related metadata\n\"\"\"\nfrom logging import getLogger\nfrom urllib.request import urlopen\n\nfrom github import Github\n\n_log = getLogger(__name__)\n\n\ndef log_into_github(user_name: str, user_password: str) -> Github:\n    \"\"\"Uses the GitHub Python library to log a user in via this method.\"\"\"\n    _log.info('logging into GitHub as \"{}\"'.format(user_name))\n    return Github(user_name, user_password)\n\n\ndef download_repository_file(\n    github: Github, repository_name: str, branch: str, file_path: str\n):\n    \"\"\"Downloads a file from a git repository in GitHub's MycroftAI organization.\n\n    Args:\n        github: instance of a GitHub API\n        repository_name: the name of the MycroftAI repository\n        branch: the repository branch\n        file_path: the location on the file system to save the downloaded file.\n    \"\"\"\n    organization = github.get_organization(\"MycroftAI\")\n    repository = organization.get_repo(repository_name)\n    repository_contents = repository.get_contents(file_path, ref=branch)\n\n    with urlopen(repository_contents.download_url) as repository_file:\n        return repository_file.read()\n"
  },
  {
    "path": "shared/selene/util/log.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\n\"\"\"Standardize logger setup.\n\nStandardizations:\n    * applications logs go into a /var/log/mycroft/<application_name>.log file\n        be sure that whatever host you are logging on has the /var/log/mycroft\n        directory, which is owned by the application user.\n    * log files contain all log messages of all levels by default.  this can be\n        very handy to debug issues with an application but won't clog up a\n        console log viewer\n    * log files will be rotated on a daily basis at midnight.  this makes logs\n        easier to manage and use, especially when debug messages are included\n    * log messages at warning level and above will be streamed to the console\n        by default.  this will bring attention to any issues or potential\n        issues without clogging up the console with a potentially massive\n        amount of log messages.\n    * log messages will be formatted as such:\n        YYYY-MM-DD HH:MM:SS,FFF | LEVEL | PID | LOGGER | LOG MESSAGE\n\nAny of the above standardizations can be overridden by changing an instance\nattribute on the LoggingConfig class.  In general, this should not be done.\nPossible exceptions include increasing verbosity for debugging.\n\"\"\"\n\nfrom os import environ\nimport logging.config\n\n\ndef _generate_log_config(service: str) -> dict:\n    \"\"\"Uses Python's dictionary config for logging to setup Selene logs.\n\n    Args:\n        service: the name of the service initiating the log setup\n\n    Returns:\n        The logging configuration in dictionary format.\n    \"\"\"\n    log_format = (\n        \"{asctime} | {levelname:8} | {process:5} | {name}.{funcName} | {message}\"\n    )\n    default_formatter = {\"format\": log_format, \"style\": \"{\"}\n    console_handler = {\n        \"class\": \"logging.StreamHandler\",\n        \"formatter\": \"default\",\n        \"stream\": \"ext://sys.stdout\",\n    }\n    file_handler = {\n        \"class\": \"logging.handlers.TimedRotatingFileHandler\",\n        \"formatter\": \"default\",\n        \"filename\": f\"/var/log/mycroft/{service}.log\",\n        \"backupCount\": 30,\n        \"when\": \"midnight\",\n    }\n\n    return {\n        \"version\": 1,\n        \"formatters\": {\"default\": default_formatter},\n        \"handlers\": {\"console\": console_handler, \"file\": file_handler},\n        \"root\": {\"level\": \"INFO\", \"handlers\": [\"file\"]},\n    }\n\n\ndef configure_selene_logger(service):\n    \"\"\"Configures the base logger for any Selene service or application.\n\n    Args:\n        service: the name of the service initiating the log setup\n    \"\"\"\n    log_level = environ.get(\"SELENE_LOG_LEVEL\", \"INFO\")\n    log_config = _generate_log_config(service)\n    selene_logger = {\n        \"selene\": {\"level\": log_level, \"handlers\": [\"console\", \"file\"], \"propagate\": 0}\n    }\n    log_config[\"loggers\"] = selene_logger\n    logging.config.dictConfig(log_config)\n    logging.getLogger(\"selene\")\n\n\ndef get_selene_logger(module_name: str):\n    \"\"\"Returns a logger instance based on the Selene logger.\"\"\"\n    return logging.getLogger(\"selene.\" + module_name)\n"
  },
  {
    "path": "shared/selene/util/payment/__init__.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nfrom .stripe import (\n    cancel_stripe_subscription,\n    create_stripe_account,\n    create_stripe_subscription,\n)\n"
  },
  {
    "path": "shared/selene/util/payment/stripe.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\nimport os\n\nimport stripe\n\n\ndef create_stripe_account(token: str, email: str):\n    stripe.api_key = os.environ[\"STRIPE_PRIVATE_KEY\"]\n    customer = stripe.Customer.create(source=token, email=email)\n    return customer.id\n\n\ndef create_stripe_subscription(customer_id, plan):\n    stripe.api_key = os.environ[\"STRIPE_PRIVATE_KEY\"]\n    request = stripe.Subscription.create(customer=customer_id, items=[{\"plan\": plan}])\n\n    return request.id\n\n\ndef cancel_stripe_subscription(subscription_id):\n    stripe.api_key = os.environ[\"STRIPE_PRIVATE_KEY\"]\n    active_stripe_subscription = stripe.Subscription.retrieve(subscription_id)\n    active_stripe_subscription.delete()\n"
  },
  {
    "path": "shared/selene/util/ssh/__init__.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2020 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Public API into the ssh package.\"\"\"\nfrom .sftp import get_remote_file\nfrom .ssh import SshClientConfig, validate_rsa_public_key\n"
  },
  {
    "path": "shared/selene/util/ssh/sftp.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2020 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Library for re-usable SFTP functions\"\"\"\nfrom pathlib import Path\n\nfrom paramiko import Transport, RSAKey\nfrom paramiko.sftp_client import SFTPClient\n\nfrom .ssh import SshClientConfig\n\n\ndef get_remote_file(ssh_config: SshClientConfig, local_path: Path, remote_path: Path):\n    \"\"\"Use SFTP to copy a file from a remote server to the local server.\n\n    :param ssh_config: Configuration of the SSH session used for SFTP\n    :param local_path: Destination path of the file being transferred\n    :param remote_path: Source path of the file being transferred\n    \"\"\"\n    ssh_transport = Transport((ssh_config.remote_server, int(ssh_config.ssh_port)))\n    ssh_key = RSAKey.from_private_key_file(\"/home/mycroft/.ssh/id_rsa\")\n    ssh_transport.connect(hostkey=None, username=ssh_config.remote_user, pkey=ssh_key)\n    sftp_client = SFTPClient.from_transport(ssh_transport)\n    sftp_client.get(str(remote_path), str(local_path))\n    sftp_client.close()\n    ssh_transport.close()\n"
  },
  {
    "path": "shared/selene/util/ssh/ssh.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2020 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\"\"\"Library for re-usable SFTP functions\"\"\"\nfrom base64 import b64decode\nfrom dataclasses import dataclass\nfrom logging import getLogger\nfrom pathlib import Path\nfrom struct import unpack\nfrom typing import Tuple\n\nfrom paramiko import AutoAddPolicy, RSAKey, SSHClient\nfrom paramiko.auth_handler import AuthenticationException, SSHException\n\nBIG_ENDIAN_UNSIGNED_INT = \">I\"\nINTEGER_BYTES = 4\n\n_log = getLogger(__name__)\n\n\n@dataclass()\nclass SshClientConfig:\n    \"\"\"Represents the configuration parameters needed to establish a SSH connection\"\"\"\n\n    remote_server: str\n    remote_user: str\n    local_user: str\n    ssh_port: int = 22\n    ssh_key_directory: Path = None\n    ssh_key_file_name: str = None\n\n    def __post_init__(self):\n        \"\"\"Set defaults for the key file directory and name if they are not supplied.\"\"\"\n        if self.ssh_key_directory is None:\n            self.ssh_key_directory = Path(f\"/home/{self.local_user}/.ssh\")\n        if self.ssh_key_file_name is None:\n            self.ssh_key_file_name = \"id_rsa\"\n\n\nclass SeleneSshClient:\n    \"\"\"Leverage the paramiko library to establish a connection over SSH.\"\"\"\n\n    _client = None\n\n    def __init__(self, config: SshClientConfig):\n        self.config = config\n        self.ssh_key_file_path = self.config.ssh_key_directory.joinpath(\n            self.config.ssh_key_file_name\n        )\n        self._check_ssh_key()\n\n    def _check_ssh_key(self):\n        \"\"\"Fetch locally stored SSH key.\"\"\"\n        try:\n            RSAKey.from_private_key_file(str(self.ssh_key_file_path))\n            _log.info(f\"Found valid SSH key at {self.ssh_key_file_path}\")\n        except SSHException:\n            _log.exception(\n                f\"The file at {self.ssh_key_file_path} does not contain a valid ssh key\"\n            )\n\n    @property\n    def client(self):\n        \"\"\"Open connection to remote host.\"\"\"\n        if self._client is None:\n            self._client = SSHClient()\n            self._client.load_system_host_keys()\n            self._client.set_missing_host_key_policy(AutoAddPolicy())\n\n        return self._client\n\n    def connect(self):\n        \"\"\"Establish an SSH connection to the remote server.\"\"\"\n        try:\n            self.client.connect(\n                self.config.remote_server,\n                port=self.config.ssh_port,\n                username=self.config.remote_user,\n                key_filename=str(self.ssh_key_file_path),\n                look_for_keys=True,\n                timeout=5,\n            )\n        except AuthenticationException:\n            _log.exception(\n                f\"SSH authentication failed for {self.config.remote_user}@\"\n                f\"{self.config.remote_server} did you remember to put the SSH key \"\n                f\"in the authorized_keys file?\"\n            )\n            raise\n\n    def disconnect(self):\n        \"\"\"Close ssh connection.\"\"\"\n        self.client.close()\n\n\ndef validate_rsa_public_key(public_key: str) -> bool:\n    \"\"\"Check the specified public key to determine if it is a well-formed RSA key.\n\n    According to the the specification, the first part of the key is a length-prefixed\n    string. The length is packed as a big-endian unsigned integer.  The expected value\n    is 7 because the following string, 'ssh-rsa', is 7 bytes long.\n\n    :param public_key: key to validate\n    :return: boolean indicating if validation check passed.\n    \"\"\"\n    is_valid = False\n    key_type, key = _parse_public_key(public_key)\n    if key_type is not None and key is not None:\n        decoded_key = b64decode(key)\n        try:\n            unpack_result = unpack(BIG_ENDIAN_UNSIGNED_INT, decoded_key[:INTEGER_BYTES])\n        except Exception:\n            _log.exception(\"Failed to unpack first four bytes of public key\")\n        else:\n            length_of_subsequent_string = unpack_result[0]\n            if length_of_subsequent_string == 7:\n                is_valid = decoded_key[4:11].decode() == key_type\n\n    return is_valid\n\n\ndef _parse_public_key(public_key: str) -> Tuple[str, str]:\n    \"\"\"Assign the parts of the public key to variables.\n\n    An RSA key can have a comment at the end of it or not.  Both ways are valid.\n\n    :param public_key: key to validate\n    :return: they key type (e.g. ssh-rsa) and the key value\n    \"\"\"\n    key_type = None\n    key = None\n    public_key_parts = public_key.split()\n    if len(public_key_parts) == 3:\n        key_type, key, _ = public_key.split()\n    elif len(public_key_parts) == 2:\n        key_type, key = public_key.split()\n    else:\n        _log.error(\"Public key malformed\")\n\n    return key_type, key\n"
  },
  {
    "path": "shared/setup.py",
    "content": "# Mycroft Server - Backend\n# Copyright (C) 2019 Mycroft AI Inc\n# SPDX-License-Identifier: \tAGPL-3.0-or-later\n#\n# This file is part of the Mycroft Server.\n#\n# The Mycroft Server is free software: you can redistribute it and/or\n# modify it under the terms of the GNU Affero General Public License as\n# published by the Free Software Foundation, either version 3 of the\n# License, or (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n# GNU Affero General Public License for more details.\n#\n# You should have received a copy of the GNU Affero General Public License\n# along with this program. If not, see <https://www.gnu.org/licenses/>.\n\n\"\"\"\nThis is used to support pipenv installing the shared code to the virtual\nenvironments used in development of Selene APIs and services.\n\"\"\"\nfrom setuptools import setup, find_namespace_packages\n\nsetup(\n    name=\"selene\",\n    version=\"0.0.0\",\n    packages=find_namespace_packages(),\n    include_package_data=True,\n    install_requires=[\n        \"email-validator\",\n        \"facebook-sdk\",\n        \"flask\",\n        \"paramiko\",\n        \"passlib\",\n        \"pygithub\",\n        \"pyhamcrest\",\n        \"pyjwt\",\n        \"psycopg2-binary\",\n        \"redis\",\n        \"sendgrid\",\n        \"schematics\",\n        \"stripe\",\n        \"schedule\",\n    ],\n)\n"
  }
]