diff --git a/.github/workflows/minimap-api-build.yml b/.github/workflows/minimap-api-build.yml new file mode 100644 index 00000000..073e93a4 --- /dev/null +++ b/.github/workflows/minimap-api-build.yml @@ -0,0 +1,16 @@ +name: Build Minimap API Docker Image + +on: + push: + workflow_dispatch: + + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - name: Build Docker Image + run: docker build -f wowskarma_api_minimap/Dockerfile.dev -t wowskarma_api_minimap:tag ./wowskarma_api_minimap \ No newline at end of file diff --git a/.github/workflows/minimap-api-push.yml b/.github/workflows/minimap-api-push.yml new file mode 100644 index 00000000..6f2ca1a3 --- /dev/null +++ b/.github/workflows/minimap-api-push.yml @@ -0,0 +1,39 @@ +name: Push Minimap API Docker Image + +on: + workflow_dispatch: + push: + branches: [ main ] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + dockerhub: [ + # { registry: "docker.io", username: "${{ secrets.DOCKERHUB_USERNAME }}", password: "${{ secrets.DOCKERHUB_TOKEN }}" }, + { registry: "ghcr.io", username: "${{ github.actor }}", password: "${{ github.token }}" } + ] + + steps: + - uses: actions/checkout@v2 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Login to DockerHub + uses: docker/login-action@v2 + with: + registry: ${{ matrix.dockerhub.registry }} + username: ${{ matrix.dockerhub.username }} + password: ${{ matrix.dockerhub.password }} + + - name: Build and push + uses: docker/build-push-action@v4 + with: + context: . + push: true + tags: wowskarma_api_minimap:latest,wowskarma_api_minimap:$(date +%Y%m%d),wowskarma_api_minimap:$(git rev-parse --short HEAD) \ No newline at end of file diff --git a/WowsKarma.Api/Services/PlayerService.cs b/WowsKarma.Api/Services/PlayerService.cs index 5034b49a..1949b86c 100644 --- a/WowsKarma.Api/Services/PlayerService.cs +++ b/WowsKarma.Api/Services/PlayerService.cs @@ -116,11 +116,15 @@ public async Task GetPlayerAsync(uint accountId, bool includeRelated = f return player; } - public IQueryable GetPlayersFullKarma(IEnumerable accountIds) + private static readonly Func, IEnumerable> CompiledPlayersFullKarmaQuery = + EF.CompileQuery(static (ApiDbContext db, IEnumerable accountIds) => db.Players.AsNoTracking() + .Where(p => accountIds.Contains(p.Id)) + .Select(p => new AccountFullKarmaDTO(p.Id, p.GameKarma, p.SiteKarma, p.PerformanceRating, p.TeamplayRating, p.CourtesyRating))); + + public IEnumerable GetPlayersFullKarma(IEnumerable accountIds) { - return from p in _context.Players.AsNoTracking() - where accountIds.Contains(p.Id) - select new AccountFullKarmaDTO(p.Id, p.SiteKarma, p.PerformanceRating, p.TeamplayRating, p.CourtesyRating); + // Use the compiled query + return CompiledPlayersFullKarmaQuery.Invoke(_context, accountIds); } /// diff --git a/WowsKarma.Common/Models/DTOs/AccountKarmaDTO.cs b/WowsKarma.Common/Models/DTOs/AccountKarmaDTO.cs index b3bf35d9..c99ab390 100644 --- a/WowsKarma.Common/Models/DTOs/AccountKarmaDTO.cs +++ b/WowsKarma.Common/Models/DTOs/AccountKarmaDTO.cs @@ -2,7 +2,7 @@ // Used by WOWS Monitor -public record AccountFullKarmaDTO(uint Id, int Karma, int Performance, int Teamplay, int Courtesy) : AccountKarmaDTO(Id, Karma); +public record AccountFullKarmaDTO(uint Id, int GameKarma, int PlatformKarma, int Performance, int Teamplay, int Courtesy) : AccountKarmaDTO(Id, PlatformKarma); public record AccountKarmaDTO(uint Id, int Karma) { diff --git a/wowskarma_api_minimap/.coveragerc b/wowskarma_api_minimap/.coveragerc new file mode 100644 index 00000000..487beb5d --- /dev/null +++ b/wowskarma_api_minimap/.coveragerc @@ -0,0 +1,8 @@ +[run] +source = wowskarma_api_minimap + +[report] +omit = + */python?.?/* + */site-packages/nose/* + wowskarma_api_minimap/__main__.py diff --git a/wowskarma_api_minimap/.gitignore b/wowskarma_api_minimap/.gitignore new file mode 100644 index 00000000..811f9573 --- /dev/null +++ b/wowskarma_api_minimap/.gitignore @@ -0,0 +1,137 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# db files +*.db + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# templates +.github/templates/* +.idea/ + diff --git a/wowskarma_api_minimap/.secrets.toml b/wowskarma_api_minimap/.secrets.toml new file mode 100644 index 00000000..4f38087a --- /dev/null +++ b/wowskarma_api_minimap/.secrets.toml @@ -0,0 +1,18 @@ +[development] +dynaconf_merge = true + +[development.security] +# openssl rand -hex 32 +SECRET_KEY = "ONLYFORDEVELOPMENT" + +[production] +dynaconf_merge = true + +[production.security] +SECRET_KEY = "@vault path/to/vault/secret" + +[testing] +dynaconf_merge = true + +[testing.security] +SECRET_KEY = "ONLYFORTESTING" diff --git a/wowskarma_api_minimap/ABOUT_THIS_TEMPLATE.md b/wowskarma_api_minimap/ABOUT_THIS_TEMPLATE.md new file mode 100644 index 00000000..70ecb156 --- /dev/null +++ b/wowskarma_api_minimap/ABOUT_THIS_TEMPLATE.md @@ -0,0 +1,198 @@ +# About this template + +Hi, I created this template to help you get started with a new project. + +I have created and maintained a number of python libraries, applications and +frameworks and during those years I have learned a lot about how to create a +project structure and how to structure a project to be as modular and simple +as possible. + +Some decisions I have made while creating this template are: + + - Create a project structure that is as modular as possible. + - Keep it simple and easy to maintain. + - Allow for a lot of flexibility and customizability. + - Low dependency (this template doesn't add dependencies) + +## Structure + +Lets take a look at the structure of this template: + +```text +├── Containerfile # The file to build a container using buildah or docker +├── CONTRIBUTING.md # Onboarding instructions for new contributors +├── docs # Documentation site (add more .md files here) +│   └── index.md # The index page for the docs site +├── .github # Github metadata for repository +│   ├── release_message.sh # A script to generate a release message +│   └── workflows # The CI pipeline for Github Actions +├── .gitignore # A list of files to ignore when pushing to Github +├── HISTORY.md # Auto generated list of changes to the project +├── LICENSE # The license for the project +├── Makefile # A collection of utilities to manage the project +├── MANIFEST.in # A list of files to include in a package +├── mkdocs.yml # Configuration for documentation site +├── wowskarma_api_minimap # The main python package for the project +│   ├── base.py # The base module for the project +│   ├── __init__.py # This tells Python that this is a package +│   ├── __main__.py # The entry point for the project +│   └── VERSION # The version for the project is kept in a static file +├── README.md # The main readme for the project +├── setup.py # The setup.py file for installing and packaging the project +├── requirements.txt # An empty file to hold the requirements for the project +├── requirements-test.txt # List of requirements for testing and devlopment +├── setup.py # The setup.py file for installing and packaging the project +└── tests # Unit tests for the project (add mote tests files here) + ├── conftest.py # Configuration, hooks and fixtures for pytest + ├── __init__.py # This tells Python that this is a test package + └── test_base.py # The base test case for the project +``` + +## FAQ + +Frequent asked questions. + +### Why this template is not using [Poetry](https://python-poetry.org/) ? + +I really like Poetry and I think it is a great tool to manage your python projects, +if you want to switch to poetry, you can run `make switch-to-poetry`. + +But for this template I wanted to keep it simple. + +Setuptools is the most simple and well supported way of packaging a Python project, +it doesn't require extra dependencies and is the easiest way to install the project. + +Also, poetry doesn't have a good support for installing projects in development mode yet. + +### Why the `requirements.txt` is empty ? + +This template is a low dependency project, so it doesn't have any extra dependencies. +You can add new dependencies as you will or you can use the `make init` command to +generate a `requirements.txt` file based on the template you choose `flask, fastapi, click etc`. + +### Why there is a `requirements-test.txt` file ? + +This file lists all the requirements for testing and development, +I think the development environment and testing environment should be as similar as possible. + +Except those tools that are up to the developer choice (like ipython, ipdb etc). + +### Why the template doesn't have a `pyproject.toml` file ? + +It is possible to run `pip install https://github.com/name/repo/tarball/main` and +have pip to download the package direcly from Git repo. + +For that to work you need to have a `setup.py` file, and `pyproject.toml` is not +supported for that kind of installation. + +I think it is easier for example you want to install specific branch or tag you can +do `pip install https://github.com/name/repo/tarball/{TAG|REVISON|COMMIT}` + +People automating CI for your project will be grateful for having a setup.py file + +### Why isn't this template made as a cookiecutter template? + +I really like [cookiecutter](https://github.com/cookiecutter/cookiecutter) and it is a great way to create new projects, +but for this template I wanted to use the Github `Use this template` button, +to use this template doesn't require to install extra tooling such as cookiecutter. + +Just click on [Use this template](https://github.com/rochacbruno/fastapi-project-template/generate) and you are good to go. + +The substituions are done using github actions and a simple sed script. + +### Why `VERSION` is kept in a static plain text file? + +I used to have my version inside my main module in a `__version__` variable, then +I had to do some tricks to read that version variable inside the setuptools +`setup.py` file because that would be available only after the installation. + +I decided to keep the version in a static file because it is easier to read from +wherever I want without the need to install the package. + +e.g: `cat wowskarma_api_minimap/VERSION` will get the project version without harming +with module imports or anything else, it is useful for CI, logs and debugging. + +### Why to include `tests`, `history` and `Containerfile` as part of the release? + +The `MANIFEST.in` file is used to include the files in the release, once the +project is released to PyPI all the files listed on MANIFEST.in will be included +even if the files are static or not related to Python. + +Some build systems such as RPM, DEB, AUR for some Linux distributions, and also +internal repackaging systems tends to run the tests before the packaging is performed. + +The Containerfile can be useful to provide a safer execution environment for +the project when running on a testing environment. + +I added those files to make it easier for packaging in different formats. + +### Why conftest includes a go_to_tmpdir fixture? + +When your project deals with file system operations, it is a good idea to use +a fixture to create a temporary directory and then remove it after the test. + +Before executing each test pytest will create a temporary directory and will +change the working directory to that path and run the test. + +So the test can create temporary artifacts isolated from other tests. + +After the execution Pytest will remove the temporary directory. + +### Why this template is not using [pre-commit](https://pre-commit.com/) ? + +pre-commit is an excellent tool to automate checks and formatting on your code. + +However I figured out that pre-commit adds extra dependency and it an entry barrier +for new contributors. + +Having the linting, checks and formatting as simple commands on the [Makefile](Makefile) +makes it easier to undestand and change. + +Once the project is bigger and complex, having pre-commit as a dependency can be a good idea. + +### Why the CLI is not using click? + +I wanted to provide a simple template for a CLI application on the project main entry point +click and typer are great alternatives but are external dependencies and this template +doesn't add dependencies besides those used for development. + +### Why this doesn't provide a full example of application using Flask or Django? + +as I said before, I want it to be simple and multipurpose, so I decided to not include +external dependencies and programming design decisions. + +It is up to you to decide if you want to use Flask or Django and to create your application +the way you think is best. + +This template provides utilities in the Makefile to make it easier to you can run: + +```bash +$ make init +Which template do you want to apply? [flask, fastapi, click, typer]? > flask +Generating a new project with Flask ... +``` + +Then the above will download the Flask template and apply it to the project. + +## The Makefile + +All the utilities for the template and project are on the Makefile + +```bash +❯ make +Usage: make + +Targets: +help: ## Show the help. +install: ## Install the project in dev mode. +fmt: ## Format code using black & isort. +lint: ## Run pep8, black, mypy linters. +test: lint ## Run tests and generate coverage report. +watch: ## Run tests on every change. +clean: ## Clean unused files. +virtualenv: ## Create a virtual environment. +release: ## Create a new tag for release. +docs: ## Build the documentation. +switch-to-poetry: ## Switch to poetry package manager. +init: ## Initialize the project based on an application template. +``` diff --git a/wowskarma_api_minimap/CONTRIBUTING.md b/wowskarma_api_minimap/CONTRIBUTING.md new file mode 100644 index 00000000..c62b8a86 --- /dev/null +++ b/wowskarma_api_minimap/CONTRIBUTING.md @@ -0,0 +1,113 @@ +# How to develop on this project + +wowskarma_api_minimap welcomes contributions from the community. + +**You need PYTHON3!** + +This instructions are for linux base systems. (Linux, MacOS, BSD, etc.) +## Setting up your own fork of this repo. + +- On github interface click on `Fork` button. +- Clone your fork of this repo. `git clone git@github.com:YOUR_GIT_USERNAME/wowskarma_api_minimap.git` +- Enter the directory `cd wowskarma_api_minimap` +- Add upstream repo `git remote add upstream https://github.com/SakuraIsayeki/wowskarma_api_minimap` + +## Setting up your own virtual environment + +Run `make virtualenv` to create a virtual environment. +then activate it with `source .venv/bin/activate`. + +## Install the project in develop mode + +Run `make install` to install the project in develop mode. + +## Run the tests to ensure everything is working + +Run `make test` to run the tests. + +## Create a new branch to work on your contribution + +Run `git checkout -b my_contribution` + +## Make your changes + +Edit the files using your preferred editor. (we recommend VIM or VSCode) + +## Format the code + +Run `make fmt` to format the code. + +## Run the linter + +Run `make lint` to run the linter. + +## Test your changes + +Run `make test` to run the tests. + +Ensure code coverage report shows `100%` coverage, add tests to your PR. + +## Build the docs locally + +Run `make docs` to build the docs. + +Ensure your new changes are documented. + +## Commit your changes + +This project uses [conventional git commit messages](https://www.conventionalcommits.org/en/v1.0.0/). + +Example: `fix(package): update setup.py arguments 🎉` (emojis are fine too) + +## Push your changes to your fork + +Run `git push origin my_contribution` + +## Submit a pull request + +On github interface, click on `Pull Request` button. + +Wait CI to run and one of the developers will review your PR. +## Makefile utilities + +This project comes with a `Makefile` that contains a number of useful utility. + +```bash +❯ make +Usage: make + +Targets: +help: ## Show the help. +install: ## Install the project in dev mode. +fmt: ## Format code using black & isort. +lint: ## Run pep8, black, mypy linters. +test: lint ## Run tests and generate coverage report. +watch: ## Run tests on every change. +clean: ## Clean unused files. +virtualenv: ## Create a virtual environment. +release: ## Create a new tag for release. +docs: ## Build the documentation. +switch-to-poetry: ## Switch to poetry package manager. +init: ## Initialize the project based on an application template. +``` + +## Making a new release + +This project uses [semantic versioning](https://semver.org/) and tags releases with `X.Y.Z` +Every time a new tag is created and pushed to the remote repo, github actions will +automatically create a new release on github and trigger a release on PyPI. + +For this to work you need to setup a secret called `PIPY_API_TOKEN` on the project settings>secrets, +this token can be generated on [pypi.org](https://pypi.org/account/). + +To trigger a new release all you need to do is. + +1. If you have changes to add to the repo + * Make your changes following the steps described above. + * Commit your changes following the [conventional git commit messages](https://www.conventionalcommits.org/en/v1.0.0/). +2. Run the tests to ensure everything is working. +4. Run `make release` to create a new tag and push it to the remote repo. + +the `make release` will ask you the version number to create the tag, ex: type `0.1.1` when you are asked. + +> **CAUTION**: The make release will change local changelog files and commit all the unstaged changes you have. diff --git a/wowskarma_api_minimap/Dockerfile.dev b/wowskarma_api_minimap/Dockerfile.dev new file mode 100644 index 00000000..5299cdfb --- /dev/null +++ b/wowskarma_api_minimap/Dockerfile.dev @@ -0,0 +1,35 @@ +# Base Image for builder +FROM python:3.10 as builder + +# Install Requirements +COPY requirements.txt / +RUN pip wheel --no-cache-dir --no-deps --wheel-dir /wheels -r requirements.txt + + +# Build the app image +FROM python:3.10 + +# Create directory for the app user +RUN mkdir -p /home/app + +# Create the app user +RUN groupadd app && useradd -g app app + +# Create the home directory +ENV HOME=/home/app +ENV APP_HOME=/home/app/web +RUN mkdir $APP_HOME +WORKDIR $APP_HOME + +# Install Requirements +COPY --from=builder /wheels /wheels +COPY --from=builder requirements.txt . +RUN pip install --no-cache /wheels/* + +COPY . $APP_HOME + +RUN chown -R app:app $APP_HOME + +USER app + +CMD ["uvicorn", "src.app:app", "--host=0.0.0.0","--port=8000","--reload"] diff --git a/wowskarma_api_minimap/MANIFEST.in b/wowskarma_api_minimap/MANIFEST.in new file mode 100644 index 00000000..8beaf5f7 --- /dev/null +++ b/wowskarma_api_minimap/MANIFEST.in @@ -0,0 +1,3 @@ +include LICENSE +include Containerfile +graft src diff --git a/wowskarma_api_minimap/Makefile b/wowskarma_api_minimap/Makefile new file mode 100644 index 00000000..df0a18ba --- /dev/null +++ b/wowskarma_api_minimap/Makefile @@ -0,0 +1,146 @@ +.ONESHELL: +ENV_PREFIX=$(shell python -c "if __import__('pathlib').Path('.venv/bin/pip').exists(): print('.venv/bin/')") +USING_POETRY=$(shell grep "tool.poetry" pyproject.toml && echo "yes") + +.PHONY: help +help: ## Show the help. + @echo "Usage: make " + @echo "" + @echo "Targets:" + @fgrep "##" Makefile | fgrep -v fgrep + + +.PHONY: show +show: ## Show the current environment. + @echo "Current environment:" + @if [ "$(USING_POETRY)" ]; then poetry env info && exit; fi + @echo "Running using $(ENV_PREFIX)" + @$(ENV_PREFIX)python -V + @$(ENV_PREFIX)python -m site + +.PHONY: install +install: ## Install the project in dev mode. + @if [ "$(USING_POETRY)" ]; then poetry install && exit; fi + @echo "Don't forget to run 'make virtualenv' if you got errors." + $(ENV_PREFIX)pip install -e .[test] + +.PHONY: fmt +fmt: ## Format code using black & isort. + $(ENV_PREFIX)isort src/ + $(ENV_PREFIX)black -l 79 src/ + $(ENV_PREFIX)black -l 79 tests/ + +.PHONY: lint +lint: ## Run pep8, black, mypy linters. + $(ENV_PREFIX)flake8 src/ + $(ENV_PREFIX)black -l 79 --check src/ + $(ENV_PREFIX)black -l 79 --check tests/ + $(ENV_PREFIX)mypy --ignore-missing-imports src/ + +.PHONY: test +test: lint ## Run tests and generate coverage report. + $(ENV_PREFIX)pytest -v --cov-config .coveragerc --cov=wowskarma_api_minimap -l --tb=short --maxfail=1 tests/ + $(ENV_PREFIX)coverage xml + $(ENV_PREFIX)coverage html + +.PHONY: watch +watch: ## Run tests on every change. + ls **/**.py | entr $(ENV_PREFIX)pytest --picked=first -s -vvv -l --tb=long --maxfail=1 tests/ + +.PHONY: clean +clean: ## Clean unused files. + @find ./ -name '*.pyc' -exec rm -f {} \; + @find ./ -name '__pycache__' -exec rm -rf {} \; + @find ./ -name 'Thumbs.db' -exec rm -f {} \; + @find ./ -name '*~' -exec rm -f {} \; + @rm -rf .cache + @rm -rf .pytest_cache + @rm -rf .mypy_cache + @rm -rf build + @rm -rf dist + @rm -rf *.egg-info + @rm -rf htmlcov + @rm -rf .tox/ + @rm -rf docs/_build + +.PHONY: virtualenv +virtualenv: ## Create a virtual environment. + @if [ "$(USING_POETRY)" ]; then poetry install && exit; fi + @echo "creating virtualenv ..." + @rm -rf .venv + @python3 -m venv .venv + @./.venv/bin/pip install -U pip + @./.venv/bin/pip install -e .[test] + @echo + @echo "!!! Please run 'source .venv/bin/activate' to enable the environment !!!" + +.PHONY: release +release: ## Create a new tag for release. + @echo "WARNING: This operation will create s version tag and push to github" + @read -p "Version? (provide the next x.y.z semver) : " TAG + @echo "creating git tag : $${TAG}" + @git tag $${TAG} + @echo "$${TAG}" > src/VERSION + @$(ENV_PREFIX)gitchangelog > HISTORY.md + @git add src/VERSION HISTORY.md + @git commit -m "release: version $${TAG} 🚀" + @git push -u origin HEAD --tags + @echo "Github Actions will detect the new tag and release the new version." + +.PHONY: docs +docs: ## Build the documentation. + @echo "building documentation ..." + @$(ENV_PREFIX)mkdocs build + URL="site/index.html"; xdg-open $$URL || sensible-browser $$URL || x-www-browser $$URL || gnome-open $$URL || open $$URL + +.PHONY: switch-to-poetry +switch-to-poetry: ## Switch to poetry package manager. + @echo "Switching to poetry ..." + @if ! poetry --version > /dev/null; then echo 'poetry is required, install from https://python-poetry.org/'; exit 1; fi + @rm -rf .venv + @poetry init --no-interaction --name=a_flask_test --author=rochacbruno + @echo "" >> pyproject.toml + @echo "[tool.poetry.scripts]" >> pyproject.toml + @echo "wowskarma_api_minimap = 'wowskarma_api_minimap.__main__:main'" >> pyproject.toml + @cat requirements.txt | while read in; do poetry add --no-interaction "$${in}"; done + @cat requirements-test.txt | while read in; do poetry add --no-interaction "$${in}" --dev; done + @poetry install --no-interaction + @mkdir -p .github/backup + @mv requirements* .github/backup + @mv setup.py .github/backup + @echo "You have switched to https://python-poetry.org/ package manager." + @echo "Please run 'poetry shell' or 'poetry run wowskarma_api_minimap'" + +.PHONY: init +init: ## Initialize the project based on an application template. + @./.github/init.sh + +.PHONY: shell +shell: ## Open a shell in the project. + @if [ "$(USING_POETRY)" ]; then poetry shell; exit; fi + @./.venv/bin/ipython -c "from wowskarma_api_minimap import *" + +.PHONY: docker-build +docker-build: ## Builder docker images + @docker-compose -f docker-compose.yaml -p wowskarma_api_minimap build + +.PHONY: docker-run +docker-run: ## Run docker development images + @docker-compose -f docker-compose.yaml -p wowskarma_api_minimap up -d + +.PHONY: docker-stop +docker-stop: ## Bring down docker dev environment + @docker-compose -f docker-compose.yaml -p wowskarma_api_minimap down + +.PHONY: docker-ps +docker-ps: ## Bring down docker dev environment + @docker-compose -f docker-compose.yaml -p wowskarma_api_minimap ps + +.PHONY: docker-log +docker-logs: ## Bring down docker dev environment + @docker-compose -f docker-compose.yaml -p wowskarma_api_minimap logs -f app + +# This project has been generated from rochacbruno/fastapi-project-template +# __author__ = 'rochacbruno' +# __repo__ = https://github.com/rochacbruno/fastapi-project-template +# __sponsor__ = https://github.com/sponsors/rochacbruno/ diff --git a/wowskarma_api_minimap/README.md b/wowskarma_api_minimap/README.md new file mode 100644 index 00000000..9f9d961e --- /dev/null +++ b/wowskarma_api_minimap/README.md @@ -0,0 +1,231 @@ +# WOWS Karma - Minimap API + +[![codecov](https://codecov.io/gh/SakuraIsayeki/wowskarma_api_minimap/branch/main/graph/badge.svg?token=wowskarma_api_minimap_token_here)](https://codecov.io/gh/SakuraIsayeki/wowskarma_api_minimap) +[![CI](https://github.com/SakuraIsayeki/wowskarma_api_minimap/actions/workflows/main.yml/badge.svg)](https://github.com/SakuraIsayeki/wowskarma_api_minimap/actions/workflows/main.yml) + +Standalone Minimap rendering microservice to render World of Warships replays. + +## Install + +from source +```bash +git clone https://github.com/SakuraIsayeki/WOWS-Karma wows-karma +cd wows-karma/wowskarma_api_minimap +make install +``` + +from pypi + +```bash +pip install wowskarma_api_minimap +``` + +## Executing + +```bash +$ wowskarma_api_minimap run --port 8080 +``` + +or + +```bash +python -m wowskarma_api_minimap run --port 8080 +``` + +or + +```bash +$ uvicorn wowskarma_api_minimap:app +``` + +## CLI + +```bash +❯ wowskarma_api_minimap --help +Usage: wowskarma_api_minimap [OPTIONS] COMMAND [ARGS]... + +Options: + --install-completion [bash|zsh|fish|powershell|pwsh] + Install completion for the specified shell. + --show-completion [bash|zsh|fish|powershell|pwsh] + Show completion for the specified shell, to + copy it or customize the installation. + --help Show this message and exit. + +Commands: + create-user Create user + run Run the API server. + shell Opens an interactive shell with objects auto imported +``` + +### Creating a user + +```bash +❯ wowskarma_api_minimap create-user --help +Usage: src create-user [OPTIONS] USERNAME PASSWORD + + Create user + +Arguments: + USERNAME [required] + PASSWORD [required] + +Options: + --superuser / --no-superuser [default: no-superuser] + --help +``` + +**IMPORTANT** To create an admin user on the first run: + +```bash +wowskarma_api_minimap create-user admin admin --superuser +``` + +### The Shell + +You can enter an interactive shell with all the objects imported. + +```bash +❯ wowskarma_api_minimap shell +Auto imports: ['app', 'settings', 'User', 'engine', 'cli', 'create_user', 'select', 'session', 'Content'] + +In [1]: session.query(Content).all() +Out[1]: [Content(text='string', title='string', created_time='2021-09-14T19:25:00.050441', user_id=1, slug='string', id=1, published=False, tags='string')] + +In [2]: user = session.get(User, 1) + +In [3]: user.contents +Out[3]: [Content(text='string', title='string', created_time='2021-09-14T19:25:00.050441', user_id=1, slug='string', id=1, published=False, tags='string')] +``` + +## API + +Run with `wowskarma_api_minimap run` and access http://127.0.0.1:8000/docs + +![](https://raw.githubusercontent.com/rochacbruno/fastapi-project-template/master/docs/api.png) + + +**For some api calls you must authenticate** using the user created with `wowskarma_api_minimap create-user`. + +## Testing + +``` bash +❯ make test +Black All done! ✨ 🍰 ✨ +13 files would be left unchanged. +Isort All done! ✨ 🍰 ✨ +6 files would be left unchanged. +Success: no issues found in 13 source files +================================ test session starts =========================== +platform linux -- Python 3.9.6, pytest-6.2.5, py-1.10.0, pluggy-1.0.0 -- +/fastapi-project-template/.venv/bin/python3 +cachedir: .pytest_cache +rootdir: /fastapi-project-template +plugins: cov-2.12.1 +collected 10 items + +tests/test_app.py::test_using_testing_db PASSED [ 10%] +tests/test_app.py::test_index PASSED [ 20%] +tests/test_cli.py::test_help PASSED [ 30%] +tests/test_cli.py::test_cmds_help[run-args0---port] PASSED [ 40%] +tests/test_cli.py::test_cmds_help[create-user-args1-create-user] PASSED [ 50%] +tests/test_cli.py::test_cmds[create-user-args0-created admin2 user] PASSED[ 60%] +tests/test_content_api.py::test_content_create PASSED [ 70%] +tests/test_content_api.py::test_content_list PASSED [ 80%] +tests/test_user_api.py::test_user_list PASSED [ 90%] +tests/test_user_api.py::test_user_create PASSED [100%] + +----------- coverage: platform linux, python 3.9.6-final-0 ----------- +Name Stmts Miss Cover +----------------------------------------------------- +wowskarma_api_minimap/__init__.py 4 0 100% +wowskarma_api_minimap/app.py 16 1 94% +wowskarma_api_minimap/cli.py 21 0 100% +wowskarma_api_minimap/config.py 5 0 100% +wowskarma_api_minimap/db.py 10 0 100% +wowskarma_api_minimap/models/__init__.py 0 0 100% +wowskarma_api_minimap/models/content.py 47 1 98% +wowskarma_api_minimap/routes/__init__.py 11 0 100% +wowskarma_api_minimap/routes/content.py 52 25 52% +wowskarma_api_minimap/routes/security.py 15 1 93% +wowskarma_api_minimap/routes/user.py 52 26 50% +wowskarma_api_minimap/security.py 103 12 88% +----------------------------------------------------- +TOTAL 336 66 80% + + +========================== 10 passed in 2.34s ================================== + +``` + +## Linting and Formatting + +```bash +make lint # checks for linting errors +make fmt # formats the code +``` + + +## Configuration + +This project uses [Dynaconf](https://dynaconf.com) to manage configuration. + +```py +from wowskarma_api_minimap.config import settings +``` + +## Acessing variables + +```py +settings.get("SECRET_KEY", default="sdnfjbnfsdf") +settings["SECRET_KEY"] +settings.SECRET_KEY +settings.db.uri +settings["db"]["uri"] +settings["db.uri"] +settings.DB__uri +``` + +## Defining variables + +### On files + +settings.toml + +```toml +[development] +dynaconf_merge = true + +[development.db] +echo = true +``` + +> `dynaconf_merge` is a boolean that tells if the settings should be merged with the default settings defined in wowskarma_api_minimap/default.toml. + +### As environment variables +```bash +export wowskarma_api_minimap_KEY=value +export wowskarma_api_minimap_KEY="@int 42" +export wowskarma_api_minimap_KEY="@jinja {{ this.db.uri }}" +export wowskarma_api_minimap_DB__uri="@jinja {{ this.db.uri | replace('db', 'data') }}" +``` + +### Secrets + +There is a file `.secrets.toml` where your sensitive variables are stored, +that file must be ignored by git. (add that to .gitignore) + +Or store your secrets in environment variables or a vault service, Dynaconf +can read those variables. + +### Switching environments + +```bash +wowskarma_api_minimap_ENV=production wowskarma_api_minimap run +``` + +Read more on https://dynaconf.com + +## Development + +Read the [CONTRIBUTING.md](CONTRIBUTING.md) file. diff --git a/wowskarma_api_minimap/docker-compose.yaml b/wowskarma_api_minimap/docker-compose.yaml new file mode 100644 index 00000000..4c505c1d --- /dev/null +++ b/wowskarma_api_minimap/docker-compose.yaml @@ -0,0 +1,29 @@ +version: '3.9' + +services: + app: + build: + context: . + dockerfile: Dockerfile.dev + ports: + - "8000:8000" + environment: + - ENVVAR_PREFIX_FOR_DYNACONF=WOWSKARMA_API_MINIMAP + - WOWSKARMA_API_MINIMAP_ENV=development + - WOWSKARMA_API_MINIMAP_DB__URI=postgresql://postgres:postgres@db:5432/wowskarma_api_minimap_dev + - WOWSKARMA_API_MINIMAP_DB__CONNECT_ARGS={} + volumes: + - .:/home/app/web + depends_on: + - db + db: + build: postgres + image: wowskarma_api_minimap_postgres-13-alpine-multi-user + volumes: + - $HOME/.postgres/wowskarma_api_minimap_db/data/postgresql:/var/lib/postgresql/data + ports: + - 5435:5432 + environment: + - POSTGRES_DBS=wowskarma_api_minimap_dev + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres diff --git a/wowskarma_api_minimap/docs/api.png b/wowskarma_api_minimap/docs/api.png new file mode 100644 index 00000000..0e1fa717 Binary files /dev/null and b/wowskarma_api_minimap/docs/api.png differ diff --git a/wowskarma_api_minimap/docs/index.md b/wowskarma_api_minimap/docs/index.md new file mode 100644 index 00000000..000ea345 --- /dev/null +++ b/wowskarma_api_minimap/docs/index.md @@ -0,0 +1,17 @@ +# Welcome to MkDocs + +For full documentation visit [mkdocs.org](https://www.mkdocs.org). + +## Commands + +* `mkdocs new [dir-name]` - Create a new project. +* `mkdocs serve` - Start the live-reloading docs server. +* `mkdocs build` - Build the documentation site. +* `mkdocs -h` - Print help message and exit. + +## Project layout + + mkdocs.yml # The configuration file. + docs/ + index.md # The documentation homepage. + ... # Other markdown pages, images and other files. diff --git a/wowskarma_api_minimap/mkdocs.yml b/wowskarma_api_minimap/mkdocs.yml new file mode 100644 index 00000000..09e266bf --- /dev/null +++ b/wowskarma_api_minimap/mkdocs.yml @@ -0,0 +1,2 @@ +site_name: WOWS Karma - Minimap API +theme: readthedocs diff --git a/wowskarma_api_minimap/postgres/Dockerfile b/wowskarma_api_minimap/postgres/Dockerfile new file mode 100644 index 00000000..b1c2e5fe --- /dev/null +++ b/wowskarma_api_minimap/postgres/Dockerfile @@ -0,0 +1,2 @@ +FROM postgres:alpine3.14 +COPY create-databases.sh /docker-entrypoint-initdb.d/ \ No newline at end of file diff --git a/wowskarma_api_minimap/postgres/create-databases.sh b/wowskarma_api_minimap/postgres/create-databases.sh new file mode 100644 index 00000000..c389825b --- /dev/null +++ b/wowskarma_api_minimap/postgres/create-databases.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +set -e +set -u + +function create_user_and_database() { + local database=$1 + echo "Creating user and database '$database'" + psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" <<-EOSQL + CREATE USER $database PASSWORD '$database'; + CREATE DATABASE $database; + GRANT ALL PRIVILEGES ON DATABASE $database TO $database; + +EOSQL +} + +if [ -n "$POSTGRES_DBS" ]; then + echo "Creating DB(s): $POSTGRES_DBS" + for db in $(echo $POSTGRES_DBS | tr ',' ' '); do + create_user_and_database $db + done + echo "Multiple databases created" +fi diff --git a/wowskarma_api_minimap/requirements.txt b/wowskarma_api_minimap/requirements.txt new file mode 100644 index 00000000..79935fc3 --- /dev/null +++ b/wowskarma_api_minimap/requirements.txt @@ -0,0 +1,17 @@ +# This template is a low-dependency template. +# By default there is no requirements added here. +# Add the requirements you need to this file. +# or run `make init` to create this file automatically based on the template. +# You can also run `make switch-to-poetry` to use the poetry package manager. +fastapi +uvicorn +sqlmodel +typer +dynaconf +jinja2 +python-jose[cryptography] +passlib[bcrypt] +python-multipart +psycopg2-binary + +git+https://github.com/SakuraIsayeki/wowskarma_minimap_renderer.git \ No newline at end of file diff --git a/wowskarma_api_minimap/settings.toml b/wowskarma_api_minimap/settings.toml new file mode 100644 index 00000000..bbc450b8 --- /dev/null +++ b/wowskarma_api_minimap/settings.toml @@ -0,0 +1,26 @@ +[development] +dynaconf_merge = true + +[development.db] +echo = true + +[development.server] +log_level = "debug" +reload = true +cors_origins = ["http://localhost:3000", "http://localhost:4200"] + +[production] +dynaconf_merge = true + +[production.db] +echo = false + +[production.server] +log_level = "error" +reload = false + +[testing] +dynaconf_merge = true + +[testing.server] +cors_origins = ["http://localhost:3000", "http://localhost:4200"] diff --git a/wowskarma_api_minimap/setup.py b/wowskarma_api_minimap/setup.py new file mode 100644 index 00000000..fed3eeeb --- /dev/null +++ b/wowskarma_api_minimap/setup.py @@ -0,0 +1,47 @@ +"""Python setup.py for src package""" +import io +import os +from setuptools import find_packages, setup + + +def read(*paths, **kwargs): + """Read the contents of a text file safely. + >>> read("src", "VERSION") + '0.1.0' + >>> read("README.md") + ... + """ + + content = "" + with io.open( + os.path.join(os.path.dirname(__file__), *paths), + encoding=kwargs.get("encoding", "utf8"), + ) as open_file: + content = open_file.read().strip() + return content + + +def read_requirements(path): + return [ + line.strip() + for line in read(path).split("\n") + if not line.startswith(('"', "#", "-", "git+")) + ] + + +setup( + name="wowskarma_api_minimap", + version=read("src", "VERSION"), + description="Standalone Minimap rendering microservice to render World of Warships replays.", + url="https://github.com/SakuraIsayeki/WOWS-Karma/", + long_description=read("README.md"), + long_description_content_type="text/markdown", + author="SakuraIsayeki", + packages=find_packages(exclude=["tests", ".github"]), + install_requires=read_requirements("requirements.txt"), + entry_points={ + "console_scripts": [ + "wowskarma_api_minimap = wowskarma_api_minimap.__main__:main" + ] + }, +) diff --git a/wowskarma_api_minimap/src/VERSION b/wowskarma_api_minimap/src/VERSION new file mode 100644 index 00000000..6e8bf73a --- /dev/null +++ b/wowskarma_api_minimap/src/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/wowskarma_api_minimap/src/__init__.py b/wowskarma_api_minimap/src/__init__.py new file mode 100644 index 00000000..c749ee9c --- /dev/null +++ b/wowskarma_api_minimap/src/__init__.py @@ -0,0 +1,5 @@ +from .app import app +from .config import settings +from .db import engine + +__all__ = ["app", "cli", "engine", "settings"] diff --git a/wowskarma_api_minimap/src/__main__.py b/wowskarma_api_minimap/src/__main__.py new file mode 100644 index 00000000..bfecd700 --- /dev/null +++ b/wowskarma_api_minimap/src/__main__.py @@ -0,0 +1,7 @@ +# pragma: no cover +from .cli import cli + +main = cli + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/wowskarma_api_minimap/src/app.py b/wowskarma_api_minimap/src/app.py new file mode 100644 index 00000000..7e30c701 --- /dev/null +++ b/wowskarma_api_minimap/src/app.py @@ -0,0 +1,50 @@ +import io +import os + +from fastapi import FastAPI +from starlette.middleware.cors import CORSMiddleware + +from .config import settings +from .db import create_db_and_tables, engine +from .routes import main_router + + +def read(*paths, **kwargs): + """Read the contents of a text file safely. + >>> read("VERSION") + """ + content = "" + with io.open( + os.path.join(os.path.dirname(__file__), *paths), + encoding=kwargs.get("encoding", "utf8"), + ) as open_file: + content = open_file.read().strip() + return content + + +app = FastAPI( + title="WOWS Karma - Minimap API", + description="Standalone Minimap rendering microservice to render World of Warships replays.", + version=read("VERSION"), + contact={ + "name": "Sakura Isayeki", + "url": "https://github.com/SakuraIsayeki", + "email": "sakura.isayeki@nodsoft.net", + }, +) + +if settings.server and settings.server.get("cors_origins", None): + app.add_middleware( + CORSMiddleware, + allow_origins=settings.server.cors_origins, + allow_credentials=settings.get("server.cors_allow_credentials", True), + allow_methods=settings.get("server.cors_allow_methods", ["*"]), + allow_headers=settings.get("server.cors_allow_headers", ["*"]), + ) + +app.include_router(main_router) + + +@app.on_event("startup") +def on_startup(): + create_db_and_tables(engine) diff --git a/wowskarma_api_minimap/src/cli.py b/wowskarma_api_minimap/src/cli.py new file mode 100644 index 00000000..f5403433 --- /dev/null +++ b/wowskarma_api_minimap/src/cli.py @@ -0,0 +1,64 @@ +import typer +import uvicorn +from sqlmodel import Session, select + +from .app import app +from .config import settings +from .db import create_db_and_tables, engine +from .security import User + +cli = typer.Typer(name="WOWS Karma - Minimap API") + + +@cli.command() +def run( + port: int = settings.server.port, + host: str = settings.server.host, + log_level: str = settings.server.log_level, + reload: bool = settings.server.reload, +): # pragma: no cover + """Run the API server.""" + uvicorn.run( + "wowskarma_api_minimap.app:app", + host=host, + port=port, + log_level=log_level, + reload=reload, + ) + + +@cli.command() +def create_user(username: str, password: str, superuser: bool = False): + """Create user""" + create_db_and_tables(engine) + with Session(engine) as session: + user = User(username=username, password=password, superuser=superuser) + session.add(user) + session.commit() + session.refresh(user) + typer.echo(f"created {username} user") + return user + + +@cli.command() +def shell(): # pragma: no cover + """Opens an interactive shell with objects auto imported""" + _vars = { + "app": app, + "settings": settings, + "User": User, + "engine": engine, + "cli": cli, + "create_user": create_user, + "select": select, + "session": Session(engine), + } + typer.echo(f"Auto imports: {list(_vars.keys())}") + try: + from IPython import start_ipython + + start_ipython(argv=[], user_ns=_vars) + except ImportError: + import code + + code.InteractiveConsole(_vars).interact() diff --git a/wowskarma_api_minimap/src/config.py b/wowskarma_api_minimap/src/config.py new file mode 100644 index 00000000..65b493d8 --- /dev/null +++ b/wowskarma_api_minimap/src/config.py @@ -0,0 +1,60 @@ +import os + +from dynaconf import Dynaconf + +HERE = os.path.dirname(os.path.abspath(__file__)) + +settings = Dynaconf( + envvar_prefix="wowskarma_api_minimap", + preload=[os.path.join(HERE, "default.toml")], + settings_files=["settings.toml", ".secrets.toml"], + environments=["development", "production", "testing"], + env_switcher="wowskarma_api_minimap_env", + load_dotenv=False, +) + + +""" +# How to use this application settings + +``` +from wowskarma_api_minimap.config import settings +``` + +## Acessing variables + +``` +settings.get("SECRET_KEY", default="sdnfjbnfsdf") +settings["SECRET_KEY"] +settings.SECRET_KEY +settings.db.uri +settings["db"]["uri"] +settings["db.uri"] +settings.DB__uri +``` + +## Modifying variables + +### On files + +settings.toml +``` +[development] +KEY=value +``` + +### As environment variables +``` +export wowskarma_api_minimap_KEY=value +export wowskarma_api_minimap_KEY="@int 42" +export wowskarma_api_minimap_KEY="@jinja {{ this.db.uri }}" +export wowskarma_api_minimap_DB__uri="@jinja {{ this.db.uri | replace('db', 'data') }}" +``` + +### Switching environments +``` +wowskarma_api_minimap_ENV=production wowskarma_api_minimap run +``` + +Read more on https://dynaconf.com +""" diff --git a/wowskarma_api_minimap/src/db.py b/wowskarma_api_minimap/src/db.py new file mode 100644 index 00000000..4840b117 --- /dev/null +++ b/wowskarma_api_minimap/src/db.py @@ -0,0 +1,22 @@ +from fastapi import Depends +from sqlmodel import Session, SQLModel, create_engine + +from .config import settings + +engine = create_engine( + settings.db.uri, + echo=settings.db.echo, + connect_args=settings.db.connect_args, +) + + +def create_db_and_tables(engine): + SQLModel.metadata.create_all(engine) + + +def get_session(): + with Session(engine) as session: + yield session + + +ActiveSession = Depends(get_session) diff --git a/wowskarma_api_minimap/src/default.toml b/wowskarma_api_minimap/src/default.toml new file mode 100644 index 00000000..8706ab1d --- /dev/null +++ b/wowskarma_api_minimap/src/default.toml @@ -0,0 +1,25 @@ +[default] + +[default.security] +# Set secret key in .secrets.toml +# SECRET_KEY = "" +ALGORITHM = "HS384" +ACCESS_TOKEN_EXPIRE_MINUTES = 30 +REFRESH_TOKEN_EXPIRE_MINUTES = 600 + +[default.server] +port = 8000 +host = "127.0.0.1" +log_level = "info" +reload = false + +[default.db] +uri = "@jinja sqlite:///{{ this.current_env | lower }}.db" +connect_args = {check_same_thread=false} +echo = false + +[default.replay] +max_file_size = 5242880 # Max replay file size in bytes. Default is 5MB +temp_workdir = "/tmp/wows-karma/minimap" # Temporary directory for replay processing +fps = 15 # Frames per second for replay processing +quality = 5 # Quality of the output video. 1 is the lowest quality, 9 is the highest \ No newline at end of file diff --git a/wowskarma_api_minimap/src/models/__init__.py b/wowskarma_api_minimap/src/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/wowskarma_api_minimap/src/routes/__init__.py b/wowskarma_api_minimap/src/routes/__init__.py new file mode 100644 index 00000000..3296746a --- /dev/null +++ b/wowskarma_api_minimap/src/routes/__init__.py @@ -0,0 +1,18 @@ +from fastapi import APIRouter + +from .profile import router as profile_router +from .security import router as security_router +from .user import router as user_router +from .render import router as render_router + +main_router = APIRouter() + +main_router.include_router(render_router, tags=["render"]) +main_router.include_router(profile_router, tags=["user"]) +main_router.include_router(security_router, tags=["security"]) +main_router.include_router(user_router, prefix="/user", tags=["user"]) + + +@main_router.get("/") +async def index(): + return {"message": "Hello World!"} diff --git a/wowskarma_api_minimap/src/routes/profile.py b/wowskarma_api_minimap/src/routes/profile.py new file mode 100644 index 00000000..2ca34307 --- /dev/null +++ b/wowskarma_api_minimap/src/routes/profile.py @@ -0,0 +1,10 @@ +from fastapi import APIRouter + +from ..security import AuthenticatedUser, User, UserResponse + +router = APIRouter() + + +@router.get("/profile", response_model=UserResponse) +async def my_profile(current_user: User = AuthenticatedUser): + return current_user diff --git a/wowskarma_api_minimap/src/routes/render.py b/wowskarma_api_minimap/src/routes/render.py new file mode 100644 index 00000000..e8183387 --- /dev/null +++ b/wowskarma_api_minimap/src/routes/render.py @@ -0,0 +1,81 @@ +import base64 +import binascii +import random +import tempfile +import os + +import pathlib + +from fastapi import APIRouter, HTTPException, status, UploadFile, BackgroundTasks +from fastapi.logger import logger +from fastapi.responses import FileResponse +from renderer.data import ReplayData +from renderer.render import Renderer +from replay_parser import ReplayParser + +from ..config import settings +from ..security import AuthenticatedUser + +router = APIRouter() + +def on_job_finished(job_id: str, filepath): + logger.info(f"Render job {job_id} finished. Filesize: {os.stat(filepath).st_size / 1024 / 1024:.2f} MiB") + os.remove(filepath) + + +@router.post("/render", dependencies=[AuthenticatedUser]) +def render_replay( + file: UploadFile, + background_tasks: BackgroundTasks, + replay_id: str | None = None, + target_player_id: int | None = None +): + # First check if the file is a replay file (extension .wowsreplay) + if not file.filename.endswith(".wowsreplay"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"File is not a replay file ({file.filename})", + ) + + # Then check if the file is too large. + if file.size > settings.replay.max_file_size: + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail="File is too large" + ) + + # Log the new job. + job_id = str(binascii.b2a_hex(os.urandom(7))).split("'")[1] + logger.info(f"Started new render job: Job ID: {job_id}, Replay ID: {replay_id or 'None'}, " + f"Filename: {file.filename}.") + + # Ensure the work folder exists, if not, create it. + os.makedirs(settings.replay.temp_workdir, exist_ok=True) + + # Render time! + filepath = pathlib.Path(settings.replay.temp_workdir, f"{job_id}.mp4") + replay_info = ReplayParser(file.file, True).get_info() + replay_data: ReplayData = replay_info["hidden"]["replay_data"] + renderer = Renderer( + replay_data=replay_data, + enable_chat=True, + team_tracers=True, + target_player_id=target_player_id, + ) + + renderer.start( + path=str(filepath), + quality=settings.replay.quality, + fps=settings.replay.fps + ) + background_tasks.add_task(on_job_finished, job_id, filepath) + + # Log the finished job. + logger.info(f"Render job {job_id} finished. Filesize: {os.stat(filepath).st_size / 1024 / 1024:.2f} MiB") + + return FileResponse( + path=filepath, + media_type="video/mp4", + filename=f"{replay_id or 'replay'}.mp4" + ) + diff --git a/wowskarma_api_minimap/src/routes/security.py b/wowskarma_api_minimap/src/routes/security.py new file mode 100644 index 00000000..8fd5e0e0 --- /dev/null +++ b/wowskarma_api_minimap/src/routes/security.py @@ -0,0 +1,73 @@ +from datetime import timedelta + +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm + +from ..config import settings +from ..security import ( + RefreshToken, + Token, + User, + authenticate_user, + create_access_token, + create_refresh_token, + get_user, + validate_token, +) + +ACCESS_TOKEN_EXPIRE_MINUTES = settings.security.access_token_expire_minutes +REFRESH_TOKEN_EXPIRE_MINUTES = settings.security.refresh_token_expire_minutes + +router = APIRouter() + + +@router.post("/token", response_model=Token) +async def login_for_access_token( + form_data: OAuth2PasswordRequestForm = Depends(), +): + user = authenticate_user(get_user, form_data.username, form_data.password) + if not user or not isinstance(user, User): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + + access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = create_access_token( + data={"sub": user.username, "fresh": True}, + expires_delta=access_token_expires, + ) + + refresh_token_expires = timedelta(minutes=REFRESH_TOKEN_EXPIRE_MINUTES) + refresh_token = create_refresh_token( + data={"sub": user.username}, expires_delta=refresh_token_expires + ) + + return { + "access_token": access_token, + "refresh_token": refresh_token, + "token_type": "bearer", + } + + +@router.post("/refresh_token", response_model=Token) +async def refresh_token(form_data: RefreshToken): + user = await validate_token(token=form_data.refresh_token) + + access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = create_access_token( + data={"sub": user.username, "fresh": False}, + expires_delta=access_token_expires, + ) + + refresh_token_expires = timedelta(minutes=REFRESH_TOKEN_EXPIRE_MINUTES) + refresh_token = create_refresh_token( + data={"sub": user.username}, expires_delta=refresh_token_expires + ) + + return { + "access_token": access_token, + "refresh_token": refresh_token, + "token_type": "bearer", + } diff --git a/wowskarma_api_minimap/src/routes/user.py b/wowskarma_api_minimap/src/routes/user.py new file mode 100644 index 00000000..3ffedb2b --- /dev/null +++ b/wowskarma_api_minimap/src/routes/user.py @@ -0,0 +1,127 @@ +from typing import List, Union + +from fastapi import APIRouter, Request +from fastapi.exceptions import HTTPException +from sqlmodel import Session, or_, select + +from ..db import ActiveSession +from ..security import ( + AdminUser, + AuthenticatedFreshUser, + AuthenticatedUser, + User, + UserCreate, + UserPasswordPatch, + UserResponse, + get_current_user, + get_password_hash, +) + +router = APIRouter() + + +@router.get("/", response_model=List[UserResponse], dependencies=[AdminUser]) +async def list_users(*, session: Session = ActiveSession): + users = session.exec(select(User)).all() + return users + + +@router.post("/", response_model=UserResponse) +async def create_user(*, session: Session = ActiveSession, user: UserCreate): + + # verify user with username doesn't already exist + try: + await query_user(session=session, username=user.username) + except HTTPException: + pass + else: + raise HTTPException(status_code=422, detail="Username is already taken") + + # If the new user is marked as superuser, check the current user is superuser too + try: + current_user: User = get_current_user() + except Exception as e: + current_user = None + + if user.superuser and (not current_user or not current_user.superuser): + raise HTTPException(status_code=403, detail="Only superusers can create superuser accounts") + + # If this is the first user in database, make it a superuser + if not session.exec(select(User)).first(): + user.superuser = True + + # Create the user + db_user = User.from_orm(user) + session.add(db_user) + session.commit() + session.refresh(db_user) + return db_user + + +@router.patch( + "/{user_id}/password/", + response_model=UserResponse, + dependencies=[AuthenticatedFreshUser], +) +async def update_user_password( + *, + user_id: int, + session: Session = ActiveSession, + request: Request, + patch: UserPasswordPatch, +): + # Query the content + user = session.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # Check the user can update the password + current_user: User = get_current_user(request=request) + if user.id != current_user.id and not current_user.superuser: + raise HTTPException( + status_code=403, detail="You can't update this user password" + ) + + if not patch.password == patch.password_confirm: + raise HTTPException(status_code=400, detail="Passwords don't match") + + # Update the password + user.password = get_password_hash(patch.password) + + # Commit the session + session.commit() + session.refresh(user) + return user + + +@router.get( + "/{user_id_or_username}/", + response_model=UserResponse, + dependencies=[AuthenticatedUser], +) +async def query_user( + *, session: Session = ActiveSession, username: Union[str, int] +): + user = session.query(User).where(User.username == username) + + if not user.first(): + raise HTTPException(status_code=404, detail="User not found") + return user.first() + + +@router.delete("/{user_id}/", dependencies=[AdminUser]) +def delete_user( + *, session: Session = ActiveSession, request: Request, user_id: int +): + user = session.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="Content not found") + # Check the user is not deleting himself + current_user = get_current_user(request=request) + if user.id == current_user.id: + raise HTTPException( + status_code=403, detail="You can't delete yourself" + ) + session.delete(user) + session.commit() + return {"ok": True} diff --git a/wowskarma_api_minimap/src/security.py b/wowskarma_api_minimap/src/security.py new file mode 100644 index 00000000..756683e8 --- /dev/null +++ b/wowskarma_api_minimap/src/security.py @@ -0,0 +1,218 @@ +import uuid as uuid_pkg +from datetime import datetime, timedelta +from typing import Callable, List, Optional, Union + +from fastapi import Depends, HTTPException, Request, status +from fastapi.security import OAuth2PasswordBearer +from jose import JWTError, jwt +from passlib.context import CryptContext +from pydantic import BaseModel +from sqlmodel import Field, Relationship, Session, SQLModel + +from .config import settings +from .db import engine + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") + +SECRET_KEY = settings.security.secret_key +ALGORITHM = settings.security.algorithm + + +class Token(BaseModel): + access_token: str + refresh_token: str + token_type: str + + +class RefreshToken(BaseModel): + refresh_token: str + + +class TokenData(BaseModel): + username: Optional[str] = None + + +class HashedPassword(str): + """Takes a plain text password and hashes it. + + use this as a field in your SQLModel + + class User(SQLModel, table=True): + username: str + password: HashedPassword + + """ + + @classmethod + def __get_validators__(cls): + # one or more validators may be yielded which will be called in the + # order to validate the input, each validator will receive as an input + # the value returned from the previous validator + yield cls.validate + + @classmethod + def validate(cls, v): + """Accepts a plain text password and returns a hashed password.""" + if not isinstance(v, str): + raise TypeError("string required") + + hashed_password = get_password_hash(v) + # you could also return a string here which would mean model.password + # would be a string, pydantic won't care but you could end up with some + # confusion since the value's type won't match the type annotation + # exactly + return cls(hashed_password) + + +class User(SQLModel, table=True): + id: uuid_pkg.UUID = Field(default_factory=uuid_pkg.uuid4, primary_key=True, index=True, nullable=False) + username: str = Field(unique=True, index=True, nullable=False) + password: HashedPassword + superuser: bool = False + disabled: bool = False + +class UserResponse(BaseModel): + """This is the User model to be used as a response_model + it doesn't include the password. + """ + + id: uuid_pkg.UUID + username: str + disabled: bool + superuser: bool + + +class UserCreate(BaseModel): + """This is the User model to be used when creating a new user.""" + + username: str + password: str + superuser: bool = False + disabled: bool = False + + +class UserPasswordPatch(SQLModel): + """This is to accept password for changing""" + + password: str + password_confirm: str + + +def verify_password(plain_password, hashed_password) -> bool: + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password) -> str: + return pwd_context.hash(password) + + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=15) + to_encode.update({"exp": expire, "scope": "access_token"}) + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + + +def create_refresh_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=15) + to_encode.update({"exp": expire, "scope": "refresh_token"}) + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + + +def authenticate_user( + get_user: Callable, username: str, password: str +) -> Union[User, bool]: + user = get_user(username) + if not user: + return False + if not verify_password(password, user.password): + return False + return user + + +def get_user(username) -> Optional[User]: + with Session(engine) as session: + return session.query(User).where(User.username == username).first() + + +def get_current_user( + token: str = Depends(oauth2_scheme), request: Request = None +) -> User: + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + if request: + if authorization := request.headers.get("authorization"): + try: + token = authorization.split(" ")[1] + except IndexError: + raise credentials_exception + + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + username: str = payload.get("sub") + + if username is None: + raise credentials_exception + token_data = TokenData(username=username) + except JWTError: + raise credentials_exception + user = get_user(username=token_data.username) + if user is None: + raise credentials_exception + # if fresh and (not payload["fresh"] and not user.superuser): + # raise credentials_exception + + return user + + +async def get_current_active_user( + current_user: User = Depends(get_current_user), +) -> User: + if current_user.disabled: + raise HTTPException(status_code=400, detail="Inactive user") + return current_user + + +AuthenticatedUser = Depends(get_current_active_user) + + +def get_current_fresh_user( + token: str = Depends(oauth2_scheme), request: Request = None +) -> User: + return get_current_user(token, request, True) + + +AuthenticatedFreshUser = Depends(get_current_fresh_user) + + +async def get_current_admin_user( + current_user: User = Depends(get_current_user), +) -> User: + if not current_user.superuser: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Not an admin user" + ) + return current_user + + +AdminUser = Depends(get_current_admin_user) + + +async def validate_token(token: str = Depends(oauth2_scheme)) -> User: + + user = get_current_user(token=token) + return user diff --git a/wowskarma_api_minimap/tests/__init__.py b/wowskarma_api_minimap/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/wowskarma_api_minimap/tests/conftest.py b/wowskarma_api_minimap/tests/conftest.py new file mode 100644 index 00000000..cafb5679 --- /dev/null +++ b/wowskarma_api_minimap/tests/conftest.py @@ -0,0 +1,81 @@ +import os +import sys +import pytest +from fastapi.testclient import TestClient +from typer.testing import CliRunner +from sqlalchemy.exc import IntegrityError + +# This next line ensures tests uses its own database and settings environment +os.environ["FORCE_ENV_FOR_DYNACONF"] = "testing" # noqa +# WARNING: Ensure imports from `src` comes after this line +from wowskarma_api_minimap import app, settings, db # noqa +from wowskarma_api_minimap.cli import create_user, cli # noqa + + +# each test runs on cwd to its temp dir +@pytest.fixture(autouse=True) +def go_to_tmpdir(request): + # Get the fixture dynamically by its name. + tmpdir = request.getfixturevalue("tmpdir") + # ensure local test created packages can be imported + sys.path.insert(0, str(tmpdir)) + # Chdir only for the duration of the test. + with tmpdir.as_cwd(): + yield + + +@pytest.fixture(scope="function", name="app") +def _app(): + return app + + +@pytest.fixture(scope="function", name="cli") +def _cli(): + return cli + + +@pytest.fixture(scope="function", name="settings") +def _settings(): + return settings + + +@pytest.fixture(scope="function") +def api_client(): + return TestClient(app) + + +@pytest.fixture(scope="function") +def api_client_authenticated(): + + try: + create_user("admin", "admin", superuser=True) + except IntegrityError: + pass + + client = TestClient(app) + token = client.post( + "/token", + data={"username": "admin", "password": "admin"}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ).json()["access_token"] + client.headers["Authorization"] = f"Bearer {token}" + return client + + +@pytest.fixture(scope="function") +def cli_client(): + return CliRunner() + + +def remove_db(): + # Remove the database file + try: + os.remove("testing.db") + except FileNotFoundError: + pass + + +@pytest.fixture(scope="session", autouse=True) +def initialize_db(request): + db.create_db_and_tables(db.engine) + request.addfinalizer(remove_db) diff --git a/wowskarma_api_minimap/tests/test_app.py b/wowskarma_api_minimap/tests/test_app.py new file mode 100644 index 00000000..303ab450 --- /dev/null +++ b/wowskarma_api_minimap/tests/test_app.py @@ -0,0 +1,40 @@ +def test_using_testing_db(settings): + assert settings.db.uri == "sqlite:///testing.db" + + +def test_index(api_client): + response = api_client.get("/") + assert response.status_code == 200 + result = response.json() + assert result["message"] == "Hello World!" + + +def test_cors_header(api_client): + valid_origin = ["http://localhost:3000", "http://localhost:4200"] + invalid_origin = ["http://localhost:3200", "http://localhost:4000"] + + valid_responses = [ + api_client.options( + "/", + headers={ + "Origin": f"{url}", + }, + ) + for url in valid_origin + ] + + for res, url in zip(valid_responses, valid_origin): + assert res.headers.get("access-control-allow-origin") == url + + invalid_responses = [ + api_client.options( + "/", + headers={ + "Origin": f"{url}", + }, + ) + for url in invalid_origin + ] + + for res in invalid_responses: + assert res.headers.get("access-control-allow-origin") is None diff --git a/wowskarma_api_minimap/tests/test_cli.py b/wowskarma_api_minimap/tests/test_cli.py new file mode 100644 index 00000000..0e25b23e --- /dev/null +++ b/wowskarma_api_minimap/tests/test_cli.py @@ -0,0 +1,38 @@ +import pytest + +given = pytest.mark.parametrize + + +def test_help(cli_client, cli): + result = cli_client.invoke(cli, ["--help"]) + assert result.exit_code == 0 + assert "create-user" in result.stdout + + +@given( + "cmd,args,msg", + [ + ("run", ["--help"], "--port"), + ("create-user", ["--help"], "create-user"), + ], +) +def test_cmds_help(cli_client, cli, cmd, args, msg): + result = cli_client.invoke(cli, [cmd, *args]) + assert result.exit_code == 0 + assert msg in result.stdout + + +@given( + "cmd,args,msg", + [ + ( + "create-user", + ["admin2", "admin2"], + "created admin2 user", + ), + ], +) +def test_cmds(cli_client, cli, cmd, args, msg): + result = cli_client.invoke(cli, [cmd, *args]) + assert result.exit_code == 0 + assert msg in result.stdout diff --git a/wowskarma_api_minimap/tests/test_content_api.py b/wowskarma_api_minimap/tests/test_content_api.py new file mode 100644 index 00000000..5ff85338 --- /dev/null +++ b/wowskarma_api_minimap/tests/test_content_api.py @@ -0,0 +1,20 @@ +def test_content_create(api_client_authenticated): + response = api_client_authenticated.post( + "/content/", + json={ + "title": "hello test", + "text": "this is just a test", + "published": True, + "tags": ["test", "hello"], + }, + ) + assert response.status_code == 200 + result = response.json() + assert result["slug"] == "hello-test" + + +def test_content_list(api_client_authenticated): + response = api_client_authenticated.get("/content/") + assert response.status_code == 200 + result = response.json() + assert result[0]["slug"] == "hello-test" diff --git a/wowskarma_api_minimap/tests/test_profile.py b/wowskarma_api_minimap/tests/test_profile.py new file mode 100644 index 00000000..ef8ee4b8 --- /dev/null +++ b/wowskarma_api_minimap/tests/test_profile.py @@ -0,0 +1,10 @@ +def test_profile(api_client_authenticated): + response = api_client_authenticated.get("/profile") + assert response.status_code == 200 + result = response.json() + assert "admin" in result["username"] + + +def test_profile_no_auth(api_client): + response = api_client.get("/profile") + assert response.status_code == 401 diff --git a/wowskarma_api_minimap/tests/test_user_api.py b/wowskarma_api_minimap/tests/test_user_api.py new file mode 100644 index 00000000..d9439464 --- /dev/null +++ b/wowskarma_api_minimap/tests/test_user_api.py @@ -0,0 +1,288 @@ +def test_user_list(api_client_authenticated): + response = api_client_authenticated.get("/user/") + assert response.status_code == 200 + result = response.json() + assert "admin" in result[0]["username"] + + +def test_user_create(api_client_authenticated): + response = api_client_authenticated.post( + "/user/", + json={ + "username": "foo", + "password": "bar", + "superuser": False, + "disabled": False, + }, + ) + assert response.status_code == 200 + result = response.json() + assert result["username"] == "foo" + + # verify a username can't be used for multiple accounts + response = api_client_authenticated.post( + "/user/", + json={ + "username": "foo", + "password": "bar", + "superuser": False, + "disabled": False, + }, + ) + assert response.status_code == 422 + + +def test_user_by_id(api_client_authenticated): + response = api_client_authenticated.get("/user/1") + assert response.status_code == 200 + result = response.json() + assert "admin" in result["username"] + + +def test_user_by_username(api_client_authenticated): + response = api_client_authenticated.get("/user/admin") + assert response.status_code == 200 + result = response.json() + assert "admin" in result["username"] + + +def test_user_by_bad_id(api_client_authenticated): + response = api_client_authenticated.get("/user/42") + result = response.json() + assert response.status_code == 404 + + +def test_user_by_bad_username(api_client_authenticated): + response = api_client_authenticated.get("/user/nouser") + assert response.status_code == 404 + + +def test_user_change_password_no_auth(api_client): + + # user doesn't exist + response = api_client.patch( + "/user/1/password/", + json={"password": "foobar!", "password_confirm": "foobar!"}, + ) + assert response.status_code == 401 + + +def test_user_change_password_insufficient_auth(api_client): + + # login as non-superuser + token = api_client.post( + "/token", + data={"username": "foo", "password": "bar"}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ).json()["access_token"] + api_client.headers["Authorization"] = f"Bearer {token}" + + # try to update admin user (id: 1) + response = api_client.patch( + "/user/1/password/", + json={"password": "foobar!", "password_confirm": "foobar!"}, + ) + assert response.status_code == 403 + + # log out + del api_client.headers["Authorization"] + + +def test_user_change_password(api_client_authenticated): + + # user doesn't exist + response = api_client_authenticated.patch( + "/user/42/password/", + json={"password": "foobar!", "password_confirm": "foobar!"}, + ) + assert response.status_code == 404 + + foo_user = api_client_authenticated.get("/user/foo").json() + assert "id" in foo_user + + # passwords don't match + response = api_client_authenticated.patch( + f"/user/{foo_user['id']}/password/", + json={"password": "foobar!", "password_confirm": "foobar"}, + ) + assert response.status_code == 400 + + # passwords do match + response = api_client_authenticated.patch( + f"/user/{foo_user['id']}/password/", + json={"password": "foobar!", "password_confirm": "foobar!"}, + ) + assert response.status_code == 200 + + +def test_user_delete_no_auth(api_client): + + # user doesn't exist + response = api_client.delete("/user/42/") + assert response.status_code == 401 + + # valid delete request but not authorized + response = api_client.delete(f"/user/1/") + assert response.status_code == 401 + + +def test_user_delete(api_client_authenticated): + + # user doesn't exist + response = api_client_authenticated.delete("/user/42/") + assert response.status_code == 404 + + # try to delete yourself + me = api_client_authenticated.get("/profile").json() + assert "id" in me + + response = api_client_authenticated.delete(f"/user/{me['id']}/") + assert response.status_code == 403 + + # try to delete "foo" user + foo_user = api_client_authenticated.get("/user/foo").json() + assert "id" in foo_user + + # valid delete request + response = api_client_authenticated.delete(f"/user/{foo_user['id']}/") + assert response.status_code == 200 + + +def test_bad_login(api_client): + + response = api_client.post( + "/token", + data={"username": "admin", "password": "admin1"}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + assert response.status_code == 401 + + +def test_good_login(api_client): + + response = api_client.post( + "/token", + data={"username": "admin", "password": "admin"}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + assert response.status_code == 200 + + +def test_refresh_token(api_client_authenticated): + + # create dummy account for test + response = api_client_authenticated.post( + "/user/", + json={ + "username": "foo", + "password": "bar", + "superuser": False, + "disabled": False, + }, + ) + + assert response.status_code == 200 + result = response.json() + assert result["id"] + user_id = result["id"] + + # retrieve access_token and refresh_token from newly created user + response = api_client_authenticated.post( + "/token", + data={"username": "foo", "password": "bar"}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + + assert response.status_code == 200 + result = response.json() + assert result["access_token"] + assert result["refresh_token"] + + # use refresh_token to update access_token and refresh_token + response = api_client_authenticated.post( + "/refresh_token", json={"refresh_token": result["refresh_token"]} + ) + + assert response.status_code == 200 + result = response.json() + assert result["access_token"] + assert result["refresh_token"] + + # save refresh_token + refresh_token = result["refresh_token"] + + # delete dummy account + response = api_client_authenticated.delete(f"/user/{user_id}/") + assert response.status_code == 200 + + # confirm account was deleted + response = api_client_authenticated.get(f"/user/{user_id}/") + assert response.status_code == 404 + + # try to refresh tokens + response = api_client_authenticated.post( + "/refresh_token", json={"refresh_token": refresh_token} + ) + + result = response.json() + assert response.status_code == 401 + + +def test_bad_refresh_token(api_client): + + BAD_TOKEN = "thisaintnovalidtoken" + + response = api_client.post( + "/refresh_token", json={"refresh_token": BAD_TOKEN} + ) + + assert response.status_code == 401 + + +# Need to add test for updating passwords with stale tokens +def test_stale_token(api_client_authenticated): + + # create non-admin account + response = api_client_authenticated.post( + "/user/", + json={ + "username": "foo", + "password": "bar", + "superuser": False, + "disabled": False, + }, + ) + assert response.status_code == 200 + result = response.json() + user_id = result["id"] + + # retrieve access_token and refresh_token from newly created user + response = api_client_authenticated.post( + "/token", + data={"username": "foo", "password": "bar"}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + + assert response.status_code == 200 + result = response.json() + + # use refresh_token to update access_token and refresh_token + response = api_client_authenticated.post( + "/refresh_token", json={"refresh_token": result["refresh_token"]} + ) + + assert response.status_code == 200 + result = response.json() + + # set stale access_token + api_client_authenticated.headers[ + "Authorization" + ] = f"Bearer {result['access_token']}" + + response = api_client_authenticated.patch( + f"/user/{user_id}/password/", + json={"password": "foobar!", "password_confirm": "foobar!"}, + ) + assert response.status_code == 401 + + del api_client_authenticated.headers["Authorization"]