From d661be0ba0bae86fce4929c72b64e56669dda75f Mon Sep 17 00:00:00 2001 From: Sakura Akeno Isayeki Date: Sat, 1 Jul 2023 11:18:36 +0200 Subject: [PATCH 01/18] Optimize Player Full Karma querying and update DTO The method for querying a player's full karma has been optimized by compiling the EF query. Compiling the query improves the performance as it removes the overhead of query compilation at runtime. This change ensures that the application is more performant and responsive to the end user. Moreover, the AccountFullKarmaDTO model was updated to better reflect the structure of the Karma system, enhancing readability and maintainability of the code. Signed-off-by: Sakura Akeno Isayeki --- WowsKarma.Api/Services/PlayerService.cs | 12 ++++++++---- WowsKarma.Common/Models/DTOs/AccountKarmaDTO.cs | 2 +- 2 files changed, 9 insertions(+), 5 deletions(-) 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) { From 149a4317effc18039f8505fc647076767de099fa Mon Sep 17 00:00:00 2001 From: Sakura Akeno Isayeki Date: Wed, 19 Jul 2023 20:02:19 +0200 Subject: [PATCH 02/18] feat(minimap): Add initial files & structure for `wowskarma.api.minimap` --- wowskarma.api.minimap/.coveragerc | 8 + wowskarma.api.minimap/.gitignore | 137 +++++++++ wowskarma.api.minimap/.secrets.toml | 18 ++ wowskarma.api.minimap/ABOUT_THIS_TEMPLATE.md | 198 ++++++++++++ wowskarma.api.minimap/CONTRIBUTING.md | 113 +++++++ wowskarma.api.minimap/Dockerfile.dev | 36 +++ wowskarma.api.minimap/MANIFEST.in | 3 + wowskarma.api.minimap/Makefile | 146 +++++++++ wowskarma.api.minimap/README.md | 231 ++++++++++++++ wowskarma.api.minimap/docker-compose-dev.yaml | 27 ++ wowskarma.api.minimap/docs/api.png | Bin 0 -> 236641 bytes wowskarma.api.minimap/docs/index.md | 17 ++ wowskarma.api.minimap/mkdocs.yml | 2 + wowskarma.api.minimap/postgres/Dockerfile | 2 + .../postgres/create-databases.sh | 23 ++ wowskarma.api.minimap/requirements.txt | 15 + wowskarma.api.minimap/settings.toml | 26 ++ wowskarma.api.minimap/setup.py | 45 +++ wowskarma.api.minimap/src/VERSION | 1 + wowskarma.api.minimap/src/__init__.py | 5 + wowskarma.api.minimap/src/__main__.py | 7 + wowskarma.api.minimap/src/app.py | 59 ++++ wowskarma.api.minimap/src/cli.py | 66 ++++ wowskarma.api.minimap/src/config.py | 60 ++++ wowskarma.api.minimap/src/db.py | 22 ++ wowskarma.api.minimap/src/default.toml | 19 ++ wowskarma.api.minimap/src/models/__init__.py | 0 wowskarma.api.minimap/src/models/content.py | 75 +++++ wowskarma.api.minimap/src/routes/__init__.py | 18 ++ wowskarma.api.minimap/src/routes/content.py | 105 +++++++ wowskarma.api.minimap/src/routes/profile.py | 10 + wowskarma.api.minimap/src/routes/security.py | 73 +++++ wowskarma.api.minimap/src/routes/user.py | 118 +++++++ wowskarma.api.minimap/src/security.py | 228 ++++++++++++++ wowskarma.api.minimap/tests/__init__.py | 0 wowskarma.api.minimap/tests/conftest.py | 81 +++++ wowskarma.api.minimap/tests/test_app.py | 40 +++ wowskarma.api.minimap/tests/test_cli.py | 38 +++ .../tests/test_content_api.py | 20 ++ wowskarma.api.minimap/tests/test_profile.py | 10 + wowskarma.api.minimap/tests/test_user_api.py | 288 ++++++++++++++++++ 41 files changed, 2390 insertions(+) create mode 100644 wowskarma.api.minimap/.coveragerc create mode 100644 wowskarma.api.minimap/.gitignore create mode 100644 wowskarma.api.minimap/.secrets.toml create mode 100644 wowskarma.api.minimap/ABOUT_THIS_TEMPLATE.md create mode 100644 wowskarma.api.minimap/CONTRIBUTING.md create mode 100644 wowskarma.api.minimap/Dockerfile.dev create mode 100644 wowskarma.api.minimap/MANIFEST.in create mode 100644 wowskarma.api.minimap/Makefile create mode 100644 wowskarma.api.minimap/README.md create mode 100644 wowskarma.api.minimap/docker-compose-dev.yaml create mode 100644 wowskarma.api.minimap/docs/api.png create mode 100644 wowskarma.api.minimap/docs/index.md create mode 100644 wowskarma.api.minimap/mkdocs.yml create mode 100644 wowskarma.api.minimap/postgres/Dockerfile create mode 100644 wowskarma.api.minimap/postgres/create-databases.sh create mode 100644 wowskarma.api.minimap/requirements.txt create mode 100644 wowskarma.api.minimap/settings.toml create mode 100644 wowskarma.api.minimap/setup.py create mode 100644 wowskarma.api.minimap/src/VERSION create mode 100644 wowskarma.api.minimap/src/__init__.py create mode 100644 wowskarma.api.minimap/src/__main__.py create mode 100644 wowskarma.api.minimap/src/app.py create mode 100644 wowskarma.api.minimap/src/cli.py create mode 100644 wowskarma.api.minimap/src/config.py create mode 100644 wowskarma.api.minimap/src/db.py create mode 100644 wowskarma.api.minimap/src/default.toml create mode 100644 wowskarma.api.minimap/src/models/__init__.py create mode 100644 wowskarma.api.minimap/src/models/content.py create mode 100644 wowskarma.api.minimap/src/routes/__init__.py create mode 100644 wowskarma.api.minimap/src/routes/content.py create mode 100644 wowskarma.api.minimap/src/routes/profile.py create mode 100644 wowskarma.api.minimap/src/routes/security.py create mode 100644 wowskarma.api.minimap/src/routes/user.py create mode 100644 wowskarma.api.minimap/src/security.py create mode 100644 wowskarma.api.minimap/tests/__init__.py create mode 100644 wowskarma.api.minimap/tests/conftest.py create mode 100644 wowskarma.api.minimap/tests/test_app.py create mode 100644 wowskarma.api.minimap/tests/test_cli.py create mode 100644 wowskarma.api.minimap/tests/test_content_api.py create mode 100644 wowskarma.api.minimap/tests/test_profile.py create mode 100644 wowskarma.api.minimap/tests/test_user_api.py diff --git a/wowskarma.api.minimap/.coveragerc b/wowskarma.api.minimap/.coveragerc new file mode 100644 index 00000000..93ad3e55 --- /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..cf7f0713 --- /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..63373294 --- /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..156a5783 --- /dev/null +++ b/wowskarma.api.minimap/Dockerfile.dev @@ -0,0 +1,36 @@ + +# Base Image for builder +FROM python:3.9 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.9 + +# 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", "wowskarma.api.minimap.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..0f53bad2 --- /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-dev.yaml -p wowskarma.api.minimap build + +.PHONY: docker-run +docker-run: ## Run docker development images + @docker-compose -f docker-compose-dev.yaml -p wowskarma.api.minimap up -d + +.PHONY: docker-stop +docker-stop: ## Bring down docker dev environment + @docker-compose -f docker-compose-dev.yaml -p wowskarma.api.minimap down + +.PHONY: docker-ps +docker-ps: ## Bring down docker dev environment + @docker-compose -f docker-compose-dev.yaml -p wowskarma.api.minimap ps + +.PHONY: docker-log +docker-logs: ## Bring down docker dev environment + @docker-compose -f docker-compose-dev.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..b470f60f --- /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-dev.yaml b/wowskarma.api.minimap/docker-compose-dev.yaml new file mode 100644 index 00000000..fa4e4781 --- /dev/null +++ b/wowskarma.api.minimap/docker-compose-dev.yaml @@ -0,0 +1,27 @@ +version: '3.9' + +services: + app: + build: + context: . + dockerfile: Dockerfile.dev + ports: + - "8000:8000" + environment: + - wowskarma.api.minimap_DB__uri=postgresql://postgres:postgres@db:5432/src + - 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=src, wowskarma.api.minimap_test + - 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 0000000000000000000000000000000000000000..0e1fa717c1359731f240094c01c43c7c7eb5997a GIT binary patch literal 236641 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�}`Y*8wK8Q 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
    ZS6*c1{t^g1!Elu&WD~xufl2M8 zPh6GEyin>mr)%N3W{ISe=*1ZpK+n*Bc|h7f#|;c$@v9S{`gcAo;cdfHjLYHtu7gCj zc8Hrcd{)(-U?y3ZTeR$9S!}I#@8|{Gw3FwYNdBeUJHdeTe1YWTiVHL z1??Hk_CtWG>VCJ8T$thK4wv407XEu8=y;U!)|~HBO@-!jQ3Jkd`T6mgnG@EWTTq4< zR(Cio#E+br*-ClG0?tZu02rAQJ60#La;? z;8aGYu_r{?2C;!%hr3B~Bw~~#9>Ljio!!O`=0by3>+~*4c%ZhFRPEj8Uks#lzGhjn z5{cz|9(kVAbbE-xZStGFLZ$j2jvJGz7Sans%x2j)P~=gnpmql8NPc&1R{{;A>fXYM zCt=lQRyy}L+*boeKpKAifkvB#*lG)}}MhL|=DdYX80zGoM9j zWuXVwYwZ-3iq840-j}(Qk$*BFvjN|sT-@PlE0k!>Epj>Rmm%Y`+6VAac&w1Rqhuzy(tsSXh zIi7YrHDP-QuW^Uw{K!J(yrIJHmwz!Tc+PKS+d!{y-xOhr@Ag=sB?T^nx8!_Hm!?orDw0pGBSzCQl4uc8dgY(a@h?S}yCM)ysmT%<=z zb$3eD&5amFun2#Zd?RTVTB8gs9k@`x!q(MR;WY6OoXdf3H@Dv^3maniIehkcl4?FX6CpfROsluBZnwsEbHVopc4LZz|voKLGJM&*EnbV%{L1r3}h(kCg!i(CoZ)&C9QD7yA@tMme;WP|3s3OsKXM4N4)mQC=>|N)@{oO~ zfNdirEOTT&DrNO>^>Urs4<{E7QNJuLGQ9cOf!5E#;xpN^;}kHje)fAtCX?xrxYetH z7Ir>=Uy8rA0whk5gIR^%K0d`ooR9qrZ%_hP4@Y1-sgiJkOoWKB@^yL=W(>60-y~aY z$o__jxW*nK;p0H%uk@d94~X>GralV~z;2!gWNVVa+Jr^PP0u-7LxF9IHcTbXELz|= z?R_`i$Ykb2Jem2=Fm<_k?S3L+IiMPLrMdx{5hJiQK+w~A*3VKRoP=RudnM>S7`mhflEOy!GHsV6{}hukAfHFLg2t0y;{~D0XICqRRIdo`%q|z((3Sjk zwJ4syf;5kb=qWiH1`Zg0)R^1;h*PP#oAcJYr6M>1youS8JgXZ58+_pW-4OP#b!Gcv z40XXx-}^$v^-$}T(0o*1eVvKq${XDHISgAZSH$y0{@`Eii1$mdHBXosQw@bylW{z< z_iXQp`?_5@*Sak`&x9D>0qgMij~d%UeWjj-*Hm-+O`X@-Yz9nwYQ?KZy0QiL1bM{9&ycN`j50cz?gZFz^pA2~`LCwW(D> zQ{b+9-z~$`tzaA_zuPG=uk_DX?gd?R8h^M?;2H73!f5>`Se{!ChYPgG{(R+j(0+CA za}k1P>ixgD@S|2l9-bCB9q)g1;QSWQ{=MZ-&kEcK9~zYn literal 0 HcmV?d00001 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..2c7d93d7 --- /dev/null +++ b/wowskarma.api.minimap/mkdocs.yml @@ -0,0 +1,2 @@ +site_name: src +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..99e05e36 --- /dev/null +++ b/wowskarma.api.minimap/requirements.txt @@ -0,0 +1,15 @@ +# 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 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..48a5e142 --- /dev/null +++ b/wowskarma.api.minimap/setup.py @@ -0,0 +1,45 @@ +"""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("wowskarma.api.minimap", "VERSION"), + description="Awesome src created by SakuraIsayeki", + url="https://github.com/SakuraIsayeki/wowskarma.api.minimap/", + 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..ecce1979 --- /dev/null +++ b/wowskarma.api.minimap/src/app.py @@ -0,0 +1,59 @@ +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 + + +description = """ +src API helps you do awesome stuff. 🚀 +""" + +app = FastAPI( + title="src", + description=description, + version=read("VERSION"), + terms_of_service="http://wowskarma.api.minimap.com/terms/", + contact={ + "name": "SakuraIsayeki", + "url": "http://wowskarma.api.minimap.com/contact/", + "email": "SakuraIsayeki@src.com", + }, + license_info={ + "name": "The Unlicense", + "url": "https://unlicense.org", + }, +) + +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..58a567c4 --- /dev/null +++ b/wowskarma.api.minimap/src/cli.py @@ -0,0 +1,66 @@ +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 .models.content import Content +from .security import User + +cli = typer.Typer(name="src 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( + "src.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), + "Content": Content, + } + 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..64f8b1a4 --- /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="src", + 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 src.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 src 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..6dedb980 --- /dev/null +++ b/wowskarma.api.minimap/src/default.toml @@ -0,0 +1,19 @@ +[default] + +[default.security] +# Set secret key in .secrets.toml +# SECRET_KEY = "" +ALGORITHM = "HS256" +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 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/models/content.py b/wowskarma.api.minimap/src/models/content.py new file mode 100644 index 00000000..6cb57085 --- /dev/null +++ b/wowskarma.api.minimap/src/models/content.py @@ -0,0 +1,75 @@ +from datetime import datetime +from typing import TYPE_CHECKING, List, Optional, Union + +from pydantic import BaseModel, Extra +from sqlmodel import Field, Relationship, SQLModel + +if TYPE_CHECKING: + from wowskarma.api.minimap.security import User + + +class Content(SQLModel, table=True): + """This is an example model for your application. + + Replace with the *things* you do in your application. + """ + + id: Optional[int] = Field(default=None, primary_key=True) + title: str + slug: str = Field(default=None) + text: str + published: bool = False + created_time: str = Field( + default_factory=lambda: datetime.now().isoformat() + ) + tags: str = Field(default="") + user_id: Optional[int] = Field(foreign_key="user.id") + + # It populates a `.contents` attribute to the `User` model. + user: Optional["User"] = Relationship(back_populates="contents") + + +class ContentResponse(BaseModel): + """This the serializer exposed on the API""" + + id: int + title: str + slug: str + text: str + published: bool + created_time: str + tags: List[str] + user_id: int + + def __init__(self, *args, **kwargs): + # tags to model representation + tags = kwargs.pop("tags", None) + if tags and isinstance(tags, str): + kwargs["tags"] = tags.split(",") + super().__init__(*args, **kwargs) + + +class ContentIncoming(BaseModel): + """This is the serializer used for POST/PATCH requests""" + + title: Optional[str] + text: Optional[str] + published: Optional[bool] = False + tags: Optional[Union[List[str], str]] + + class Config: + extra = Extra.allow + arbitrary_types_allowed = True + + def __init__(self, *args, **kwargs): + # tags to database representation + tags = kwargs.pop("tags", None) + if tags and isinstance(tags, list): + kwargs["tags"] = ",".join(tags) + super().__init__(*args, **kwargs) + self.generate_slug() + + def generate_slug(self): + """Generate a slug from the title.""" + if self.title: + self.slug = self.title.lower().replace(" ", "-") diff --git a/wowskarma.api.minimap/src/routes/__init__.py b/wowskarma.api.minimap/src/routes/__init__.py new file mode 100644 index 00000000..95224be4 --- /dev/null +++ b/wowskarma.api.minimap/src/routes/__init__.py @@ -0,0 +1,18 @@ +from fastapi import APIRouter + +from .content import router as content_router +from .profile import router as profile_router +from .security import router as security_router +from .user import router as user_router + +main_router = APIRouter() + +main_router.include_router(content_router, prefix="/content", tags=["content"]) +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/content.py b/wowskarma.api.minimap/src/routes/content.py new file mode 100644 index 00000000..7ee33ec6 --- /dev/null +++ b/wowskarma.api.minimap/src/routes/content.py @@ -0,0 +1,105 @@ +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 ..models.content import Content, ContentIncoming, ContentResponse +from ..security import AuthenticatedUser, User, get_current_user + +router = APIRouter() + + +@router.get("/", response_model=List[ContentResponse]) +async def list_contents(*, session: Session = ActiveSession): + contents = session.exec(select(Content)).all() + return contents + + +@router.get("/{id_or_slug}/", response_model=ContentResponse) +async def query_content( + *, id_or_slug: Union[str, int], session: Session = ActiveSession +): + content = session.query(Content).where( + or_( + Content.id == id_or_slug, + Content.slug == id_or_slug, + ) + ) + if not content: + raise HTTPException(status_code=404, detail="Content not found") + return content.first() + + +@router.post( + "/", response_model=ContentResponse, dependencies=[AuthenticatedUser] +) +async def create_content( + *, + session: Session = ActiveSession, + request: Request, + content: ContentIncoming, +): + # set the ownsership of the content to the current user + db_content = Content.from_orm(content) + user: User = get_current_user(request=request) + db_content.user_id = user.id + session.add(db_content) + session.commit() + session.refresh(db_content) + return db_content + + +@router.patch( + "/{content_id}/", + response_model=ContentResponse, + dependencies=[AuthenticatedUser], +) +async def update_content( + *, + content_id: int, + session: Session = ActiveSession, + request: Request, + patch: ContentIncoming, +): + # Query the content + content = session.get(Content, content_id) + if not content: + raise HTTPException(status_code=404, detail="Content not found") + + # Check the user owns the content + current_user: User = get_current_user(request=request) + if content.user_id != current_user.id and not current_user.superuser: + raise HTTPException( + status_code=403, detail="You don't own this content" + ) + + # Update the content + patch_data = patch.dict(exclude_unset=True) + for key, value in patch_data.items(): + setattr(content, key, value) + + # Commit the session + session.commit() + session.refresh(content) + return content + + +@router.delete("/{content_id}/", dependencies=[AuthenticatedUser]) +def delete_content( + *, session: Session = ActiveSession, request: Request, content_id: int +): + + content = session.get(Content, content_id) + if not content: + raise HTTPException(status_code=404, detail="Content not found") + # Check the user owns the content + current_user = get_current_user(request=request) + if content.user_id != current_user.id and not current_user.superuser: + raise HTTPException( + status_code=403, detail="You don't own this content" + ) + session.delete(content) + session.commit() + return {"ok": True} 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/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..72211f01 --- /dev/null +++ b/wowskarma.api.minimap/src/routes/user.py @@ -0,0 +1,118 @@ +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, dependencies=[AdminUser]) +async def create_user(*, session: Session = ActiveSession, user: UserCreate): + + # verify user with username doesn't already exist + try: + await query_user(session=session, user_id_or_username=user.username) + except HTTPException: + pass + else: + raise HTTPException(status_code=422, detail="Username already exists") + + 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, user_id_or_username: Union[str, int] +): + user = session.query(User).where( + or_( + User.id == user_id_or_username, + User.username == user_id_or_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..4a43cb02 --- /dev/null +++ b/wowskarma.api.minimap/src/security.py @@ -0,0 +1,228 @@ +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 wowskarma.api.minimap.models.content import Content, ContentResponse + +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: Optional[int] = Field(default=None, primary_key=True) + username: str = Field(sa_column_kwargs={"unique": True}) + password: HashedPassword + superuser: bool = False + disabled: bool = False + + # it populates the .user attribute on the Content Model + contents: List["Content"] = Relationship(back_populates="user") + + +class UserResponse(BaseModel): + """This is the User model to be used as a response_model + it doesn't include the password. + """ + + id: int + username: str + disabled: bool + superuser: bool + contents: Optional[List[ContentResponse]] = Field(default_factory=list) + + +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, fresh=False +) -> 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..75696302 --- /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"] From 17868881e78ddf179e4b9fdbfd443b0a4c120cad Mon Sep 17 00:00:00 2001 From: Sakura Akeno Isayeki Date: Wed, 19 Jul 2023 20:29:04 +0200 Subject: [PATCH 03/18] ci: Add CI workflow for Minimap API This commit adds a new GitHub Actions workflow file, `minimap-main.yml`, which sets up continuous integration (CI) for the Minimap API. The workflow is triggered on push or pull request events for the main or develop branches. It also allows manual execution from the Actions tab. The workflow consists of two jobs: 1. "linter" job runs linter checks using Python 3.10 on Ubuntu latest. 2. "tests_linux" job installs project dependencies and runs tests on Ubuntu latest. 3. "tests_win" job installs Pip, then installs and tests the project on Windows latest. These changes ensure that code quality is maintained and tests are run automatically during development. --- .github/workflows/minimap-main.yml | 71 ++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 .github/workflows/minimap-main.yml diff --git a/.github/workflows/minimap-main.yml b/.github/workflows/minimap-main.yml new file mode 100644 index 00000000..914ab493 --- /dev/null +++ b/.github/workflows/minimap-main.yml @@ -0,0 +1,71 @@ +name: Minimap API - CI + +on: + # Triggers the workflow on push or pull request events but only for the main or develop branch + push: + branches: + - main + - develop + pull_request: + branches: + - main + - develop + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +jobs: + linter: + strategy: + fail-fast: false + matrix: + python-version: [3.10] + os: [ubuntu-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Change directory to subdirectory + run: cd wowskarma.api.minimap + - name: Install project dependencies + run: make install + - name: Run linter + run: make lint + + tests_linux: + needs: linter + strategy: + fail-fast: false + matrix: + python-version: [3.10] + os: [ubuntu-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install project + run: make install + # with: + # fail_ci_if_error: true + + tests_win: + needs: linter + strategy: + fail-fast: false + matrix: + python-version: [3.10] + os: [windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install Pip + run: pip install --user --upgrade pip + - name: Install project + run: pip install -e wowskarma.minimap.api/. From 1c03fae626fa17c9cf5098d015fb0a6461e45dad Mon Sep 17 00:00:00 2001 From: Sakura Akeno Isayeki Date: Wed, 19 Jul 2023 21:35:16 +0200 Subject: [PATCH 04/18] refactor: Change project file structure The commit renames several files and directories in the project to adhere to a new naming convention. The changes include renaming the project directory, source files, configuration files, and documentation files. This refactor improves consistency and readability of the codebase. --- wowskarma.api.minimap/.coveragerc | 8 --- wowskarma.api.minimap/mkdocs.yml | 2 - wowskarma_api_minimap/.coveragerc | 8 +++ .../.gitignore | 0 .../.secrets.toml | 0 .../ABOUT_THIS_TEMPLATE.md | 4 +- .../CONTRIBUTING.md | 8 +-- .../Dockerfile.dev | 7 +- .../MANIFEST.in | 0 .../Makefile | 18 ++--- .../README.md | 66 +++++++++--------- .../docker-compose.yaml | 10 +-- .../docs/api.png | Bin .../docs/index.md | 0 wowskarma_api_minimap/mkdocs.yml | 2 + .../postgres/Dockerfile | 0 .../postgres/create-databases.sh | 0 .../requirements.txt | 0 .../settings.toml | 0 .../setup.py | 12 ++-- .../src/VERSION | 0 .../src/__init__.py | 0 .../src/__main__.py | 0 .../src/app.py | 13 ++-- .../src/cli.py | 4 +- .../src/config.py | 16 ++--- .../src/db.py | 0 .../src/default.toml | 0 .../src/models/__init__.py | 0 .../src/models/content.py | 6 +- .../src/routes/__init__.py | 0 .../src/routes/content.py | 0 .../src/routes/profile.py | 0 .../src/routes/security.py | 0 .../src/routes/user.py | 0 .../src/security.py | 10 +-- .../tests/__init__.py | 0 .../tests/conftest.py | 4 +- .../tests/test_app.py | 0 .../tests/test_cli.py | 0 .../tests/test_content_api.py | 0 .../tests/test_profile.py | 0 .../tests/test_user_api.py | 0 43 files changed, 92 insertions(+), 106 deletions(-) delete mode 100644 wowskarma.api.minimap/.coveragerc delete mode 100644 wowskarma.api.minimap/mkdocs.yml create mode 100644 wowskarma_api_minimap/.coveragerc rename {wowskarma.api.minimap => wowskarma_api_minimap}/.gitignore (100%) rename {wowskarma.api.minimap => wowskarma_api_minimap}/.secrets.toml (100%) rename {wowskarma.api.minimap => wowskarma_api_minimap}/ABOUT_THIS_TEMPLATE.md (98%) rename {wowskarma.api.minimap => wowskarma_api_minimap}/CONTRIBUTING.md (94%) rename {wowskarma.api.minimap => wowskarma_api_minimap}/Dockerfile.dev (81%) rename {wowskarma.api.minimap => wowskarma_api_minimap}/MANIFEST.in (100%) rename {wowskarma.api.minimap => wowskarma_api_minimap}/Makefile (89%) rename {wowskarma.api.minimap => wowskarma_api_minimap}/README.md (71%) rename wowskarma.api.minimap/docker-compose-dev.yaml => wowskarma_api_minimap/docker-compose.yaml (53%) rename {wowskarma.api.minimap => wowskarma_api_minimap}/docs/api.png (100%) rename {wowskarma.api.minimap => wowskarma_api_minimap}/docs/index.md (100%) create mode 100644 wowskarma_api_minimap/mkdocs.yml rename {wowskarma.api.minimap => wowskarma_api_minimap}/postgres/Dockerfile (100%) rename {wowskarma.api.minimap => wowskarma_api_minimap}/postgres/create-databases.sh (100%) rename {wowskarma.api.minimap => wowskarma_api_minimap}/requirements.txt (100%) rename {wowskarma.api.minimap => wowskarma_api_minimap}/settings.toml (100%) rename {wowskarma.api.minimap => wowskarma_api_minimap}/setup.py (73%) rename {wowskarma.api.minimap => wowskarma_api_minimap}/src/VERSION (100%) rename {wowskarma.api.minimap => wowskarma_api_minimap}/src/__init__.py (100%) rename {wowskarma.api.minimap => wowskarma_api_minimap}/src/__main__.py (100%) rename {wowskarma.api.minimap => wowskarma_api_minimap}/src/app.py (79%) rename {wowskarma.api.minimap => wowskarma_api_minimap}/src/cli.py (94%) rename {wowskarma.api.minimap => wowskarma_api_minimap}/src/config.py (65%) rename {wowskarma.api.minimap => wowskarma_api_minimap}/src/db.py (100%) rename {wowskarma.api.minimap => wowskarma_api_minimap}/src/default.toml (100%) rename {wowskarma.api.minimap => wowskarma_api_minimap}/src/models/__init__.py (100%) rename {wowskarma.api.minimap => wowskarma_api_minimap}/src/models/content.py (92%) rename {wowskarma.api.minimap => wowskarma_api_minimap}/src/routes/__init__.py (100%) rename {wowskarma.api.minimap => wowskarma_api_minimap}/src/routes/content.py (100%) rename {wowskarma.api.minimap => wowskarma_api_minimap}/src/routes/profile.py (100%) rename {wowskarma.api.minimap => wowskarma_api_minimap}/src/routes/security.py (100%) rename {wowskarma.api.minimap => wowskarma_api_minimap}/src/routes/user.py (100%) rename {wowskarma.api.minimap => wowskarma_api_minimap}/src/security.py (95%) rename {wowskarma.api.minimap => wowskarma_api_minimap}/tests/__init__.py (100%) rename {wowskarma.api.minimap => wowskarma_api_minimap}/tests/conftest.py (93%) rename {wowskarma.api.minimap => wowskarma_api_minimap}/tests/test_app.py (100%) rename {wowskarma.api.minimap => wowskarma_api_minimap}/tests/test_cli.py (100%) rename {wowskarma.api.minimap => wowskarma_api_minimap}/tests/test_content_api.py (100%) rename {wowskarma.api.minimap => wowskarma_api_minimap}/tests/test_profile.py (100%) rename {wowskarma.api.minimap => wowskarma_api_minimap}/tests/test_user_api.py (100%) diff --git a/wowskarma.api.minimap/.coveragerc b/wowskarma.api.minimap/.coveragerc deleted file mode 100644 index 93ad3e55..00000000 --- a/wowskarma.api.minimap/.coveragerc +++ /dev/null @@ -1,8 +0,0 @@ -[run] -source = wowskarma.api.minimap - -[report] -omit = - */python?.?/* - */site-packages/nose/* - wowskarma.api.minimap/__main__.py diff --git a/wowskarma.api.minimap/mkdocs.yml b/wowskarma.api.minimap/mkdocs.yml deleted file mode 100644 index 2c7d93d7..00000000 --- a/wowskarma.api.minimap/mkdocs.yml +++ /dev/null @@ -1,2 +0,0 @@ -site_name: src -theme: readthedocs 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 similarity index 100% rename from wowskarma.api.minimap/.gitignore rename to wowskarma_api_minimap/.gitignore diff --git a/wowskarma.api.minimap/.secrets.toml b/wowskarma_api_minimap/.secrets.toml similarity index 100% rename from wowskarma.api.minimap/.secrets.toml rename to wowskarma_api_minimap/.secrets.toml diff --git a/wowskarma.api.minimap/ABOUT_THIS_TEMPLATE.md b/wowskarma_api_minimap/ABOUT_THIS_TEMPLATE.md similarity index 98% rename from wowskarma.api.minimap/ABOUT_THIS_TEMPLATE.md rename to wowskarma_api_minimap/ABOUT_THIS_TEMPLATE.md index cf7f0713..70ecb156 100644 --- a/wowskarma.api.minimap/ABOUT_THIS_TEMPLATE.md +++ b/wowskarma_api_minimap/ABOUT_THIS_TEMPLATE.md @@ -32,7 +32,7 @@ Lets take a look at the structure of this template: ├── 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 +├── 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 @@ -109,7 +109,7 @@ I had to do some tricks to read that version variable inside the setuptools 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 +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? diff --git a/wowskarma.api.minimap/CONTRIBUTING.md b/wowskarma_api_minimap/CONTRIBUTING.md similarity index 94% rename from wowskarma.api.minimap/CONTRIBUTING.md rename to wowskarma_api_minimap/CONTRIBUTING.md index 63373294..c62b8a86 100644 --- a/wowskarma.api.minimap/CONTRIBUTING.md +++ b/wowskarma_api_minimap/CONTRIBUTING.md @@ -1,6 +1,6 @@ # How to develop on this project -wowskarma.api.minimap welcomes contributions from the community. +wowskarma_api_minimap welcomes contributions from the community. **You need PYTHON3!** @@ -8,9 +8,9 @@ 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` +- 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 diff --git a/wowskarma.api.minimap/Dockerfile.dev b/wowskarma_api_minimap/Dockerfile.dev similarity index 81% rename from wowskarma.api.minimap/Dockerfile.dev rename to wowskarma_api_minimap/Dockerfile.dev index 156a5783..5299cdfb 100644 --- a/wowskarma.api.minimap/Dockerfile.dev +++ b/wowskarma_api_minimap/Dockerfile.dev @@ -1,6 +1,5 @@ - # Base Image for builder -FROM python:3.9 as builder +FROM python:3.10 as builder # Install Requirements COPY requirements.txt / @@ -8,7 +7,7 @@ RUN pip wheel --no-cache-dir --no-deps --wheel-dir /wheels -r requirements.txt # Build the app image -FROM python:3.9 +FROM python:3.10 # Create directory for the app user RUN mkdir -p /home/app @@ -33,4 +32,4 @@ RUN chown -R app:app $APP_HOME USER app -CMD ["uvicorn", "wowskarma.api.minimap.app:app", "--host=0.0.0.0","--port=8000","--reload"] +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 similarity index 100% rename from wowskarma.api.minimap/MANIFEST.in rename to wowskarma_api_minimap/MANIFEST.in diff --git a/wowskarma.api.minimap/Makefile b/wowskarma_api_minimap/Makefile similarity index 89% rename from wowskarma.api.minimap/Makefile rename to wowskarma_api_minimap/Makefile index 0f53bad2..02fd91d8 100644 --- a/wowskarma.api.minimap/Makefile +++ b/wowskarma_api_minimap/Makefile @@ -39,7 +39,7 @@ lint: ## Run pep8, black, mypy linters. .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)pytest -v --cov-config .coveragerc --cov=wowskarma_api_minimap -l --tb=short --maxfail=1 tests/ $(ENV_PREFIX)coverage xml $(ENV_PREFIX)coverage html @@ -101,7 +101,7 @@ switch-to-poetry: ## Switch to poetry package manager. @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 + @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 @@ -109,7 +109,7 @@ switch-to-poetry: ## Switch to poetry package manager. @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'" + @echo "Please run 'poetry shell' or 'poetry run wowskarma_api_minimap'" .PHONY: init init: ## Initialize the project based on an application template. @@ -118,27 +118,27 @@ init: ## Initialize the project based on an application template. .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 *" + @./.venv/bin/ipython -c "from wowskarma_api_minimap import *" .PHONY: docker-build docker-build: ## Builder docker images - @docker-compose -f docker-compose-dev.yaml -p wowskarma.api.minimap build + @docker-compose -f docker-compose-dev.yaml -p wowskarma_api_minimap build .PHONY: docker-run docker-run: ## Run docker development images - @docker-compose -f docker-compose-dev.yaml -p wowskarma.api.minimap up -d + @docker-compose -f docker-compose-dev.yaml -p wowskarma_api_minimap up -d .PHONY: docker-stop docker-stop: ## Bring down docker dev environment - @docker-compose -f docker-compose-dev.yaml -p wowskarma.api.minimap down + @docker-compose -f docker-compose-dev.yaml -p wowskarma_api_minimap down .PHONY: docker-ps docker-ps: ## Bring down docker dev environment - @docker-compose -f docker-compose-dev.yaml -p wowskarma.api.minimap ps + @docker-compose -f docker-compose-dev.yaml -p wowskarma_api_minimap ps .PHONY: docker-log docker-logs: ## Bring down docker dev environment - @docker-compose -f docker-compose-dev.yaml -p wowskarma.api.minimap logs -f app + @docker-compose -f docker-compose-dev.yaml -p wowskarma_api_minimap logs -f app # This project has been generated from rochacbruno/fastapi-project-template # __author__ = 'rochacbruno' diff --git a/wowskarma.api.minimap/README.md b/wowskarma_api_minimap/README.md similarity index 71% rename from wowskarma.api.minimap/README.md rename to wowskarma_api_minimap/README.md index b470f60f..9f9d961e 100644 --- a/wowskarma.api.minimap/README.md +++ b/wowskarma_api_minimap/README.md @@ -1,7 +1,7 @@ # 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) +[![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. @@ -10,39 +10,39 @@ Standalone Minimap rendering microservice to render World of Warships replays. from source ```bash git clone https://github.com/SakuraIsayeki/WOWS-Karma wows-karma -cd wows-karma/wowskarma.api.minimap +cd wows-karma/wowskarma_api_minimap make install ``` from pypi ```bash -pip install wowskarma.api.minimap +pip install wowskarma_api_minimap ``` ## Executing ```bash -$ wowskarma.api.minimap run --port 8080 +$ wowskarma_api_minimap run --port 8080 ``` or ```bash -python -m wowskarma.api.minimap run --port 8080 +python -m wowskarma_api_minimap run --port 8080 ``` or ```bash -$ uvicorn wowskarma.api.minimap:app +$ uvicorn wowskarma_api_minimap:app ``` ## CLI ```bash -❯ wowskarma.api.minimap --help -Usage: wowskarma.api.minimap [OPTIONS] COMMAND [ARGS]... +❯ wowskarma_api_minimap --help +Usage: wowskarma_api_minimap [OPTIONS] COMMAND [ARGS]... Options: --install-completion [bash|zsh|fish|powershell|pwsh] @@ -61,7 +61,7 @@ Commands: ### Creating a user ```bash -❯ wowskarma.api.minimap create-user --help +❯ wowskarma_api_minimap create-user --help Usage: src create-user [OPTIONS] USERNAME PASSWORD Create user @@ -78,7 +78,7 @@ Options: **IMPORTANT** To create an admin user on the first run: ```bash -wowskarma.api.minimap create-user admin admin --superuser +wowskarma_api_minimap create-user admin admin --superuser ``` ### The Shell @@ -86,7 +86,7 @@ wowskarma.api.minimap create-user admin admin --superuser You can enter an interactive shell with all the objects imported. ```bash -❯ wowskarma.api.minimap shell +❯ wowskarma_api_minimap shell Auto imports: ['app', 'settings', 'User', 'engine', 'cli', 'create_user', 'select', 'session', 'Content'] In [1]: session.query(Content).all() @@ -100,12 +100,12 @@ Out[3]: [Content(text='string', title='string', created_time='2021-09-14T19:25:0 ## API -Run with `wowskarma.api.minimap run` and access http://127.0.0.1:8000/docs +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`. +**For some api calls you must authenticate** using the user created with `wowskarma_api_minimap create-user`. ## Testing @@ -138,18 +138,18 @@ 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% +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% @@ -171,7 +171,7 @@ make fmt # formats the code This project uses [Dynaconf](https://dynaconf.com) to manage configuration. ```py -from wowskarma.api.minimap.config import settings +from wowskarma_api_minimap.config import settings ``` ## Acessing variables @@ -200,14 +200,14 @@ dynaconf_merge = true 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. +> `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') }}" +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 @@ -221,7 +221,7 @@ can read those variables. ### Switching environments ```bash -wowskarma.api.minimap_ENV=production wowskarma.api.minimap run +wowskarma_api_minimap_ENV=production wowskarma_api_minimap run ``` Read more on https://dynaconf.com diff --git a/wowskarma.api.minimap/docker-compose-dev.yaml b/wowskarma_api_minimap/docker-compose.yaml similarity index 53% rename from wowskarma.api.minimap/docker-compose-dev.yaml rename to wowskarma_api_minimap/docker-compose.yaml index fa4e4781..24b90c38 100644 --- a/wowskarma.api.minimap/docker-compose-dev.yaml +++ b/wowskarma_api_minimap/docker-compose.yaml @@ -8,20 +8,20 @@ services: ports: - "8000:8000" environment: - - wowskarma.api.minimap_DB__uri=postgresql://postgres:postgres@db:5432/src - - wowskarma.api.minimap_DB__connect_args={} + - 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 + image: wowskarma_api_minimap_postgres-13-alpine-multi-user volumes: - - $HOME/.postgres/wowskarma.api.minimap_db/data/postgresql:/var/lib/postgresql/data + - $HOME/.postgres/wowskarma_api_minimap_db/data/postgresql:/var/lib/postgresql/data ports: - 5435:5432 environment: - - POSTGRES_DBS=src, wowskarma.api.minimap_test + - 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 similarity index 100% rename from wowskarma.api.minimap/docs/api.png rename to wowskarma_api_minimap/docs/api.png diff --git a/wowskarma.api.minimap/docs/index.md b/wowskarma_api_minimap/docs/index.md similarity index 100% rename from wowskarma.api.minimap/docs/index.md rename to wowskarma_api_minimap/docs/index.md diff --git a/wowskarma_api_minimap/mkdocs.yml b/wowskarma_api_minimap/mkdocs.yml new file mode 100644 index 00000000..fd0ee8ae --- /dev/null +++ b/wowskarma_api_minimap/mkdocs.yml @@ -0,0 +1,2 @@ +site_name: wowskarma_api_minimap +theme: readthedocs diff --git a/wowskarma.api.minimap/postgres/Dockerfile b/wowskarma_api_minimap/postgres/Dockerfile similarity index 100% rename from wowskarma.api.minimap/postgres/Dockerfile rename to wowskarma_api_minimap/postgres/Dockerfile diff --git a/wowskarma.api.minimap/postgres/create-databases.sh b/wowskarma_api_minimap/postgres/create-databases.sh similarity index 100% rename from wowskarma.api.minimap/postgres/create-databases.sh rename to wowskarma_api_minimap/postgres/create-databases.sh diff --git a/wowskarma.api.minimap/requirements.txt b/wowskarma_api_minimap/requirements.txt similarity index 100% rename from wowskarma.api.minimap/requirements.txt rename to wowskarma_api_minimap/requirements.txt diff --git a/wowskarma.api.minimap/settings.toml b/wowskarma_api_minimap/settings.toml similarity index 100% rename from wowskarma.api.minimap/settings.toml rename to wowskarma_api_minimap/settings.toml diff --git a/wowskarma.api.minimap/setup.py b/wowskarma_api_minimap/setup.py similarity index 73% rename from wowskarma.api.minimap/setup.py rename to wowskarma_api_minimap/setup.py index 48a5e142..7e093476 100644 --- a/wowskarma.api.minimap/setup.py +++ b/wowskarma_api_minimap/setup.py @@ -30,16 +30,14 @@ def read_requirements(path): setup( - name="wowskarma.api.minimap", - version=read("wowskarma.api.minimap", "VERSION"), - description="Awesome src created by SakuraIsayeki", - url="https://github.com/SakuraIsayeki/wowskarma.api.minimap/", + 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"] - } + entry_points={"console_scripts": ["wowskarma_api_minimap = src.__main__:main"]}, ) diff --git a/wowskarma.api.minimap/src/VERSION b/wowskarma_api_minimap/src/VERSION similarity index 100% rename from wowskarma.api.minimap/src/VERSION rename to wowskarma_api_minimap/src/VERSION diff --git a/wowskarma.api.minimap/src/__init__.py b/wowskarma_api_minimap/src/__init__.py similarity index 100% rename from wowskarma.api.minimap/src/__init__.py rename to wowskarma_api_minimap/src/__init__.py diff --git a/wowskarma.api.minimap/src/__main__.py b/wowskarma_api_minimap/src/__main__.py similarity index 100% rename from wowskarma.api.minimap/src/__main__.py rename to wowskarma_api_minimap/src/__main__.py diff --git a/wowskarma.api.minimap/src/app.py b/wowskarma_api_minimap/src/app.py similarity index 79% rename from wowskarma.api.minimap/src/app.py rename to wowskarma_api_minimap/src/app.py index ecce1979..ccbc0a70 100644 --- a/wowskarma.api.minimap/src/app.py +++ b/wowskarma_api_minimap/src/app.py @@ -27,18 +27,13 @@ def read(*paths, **kwargs): """ app = FastAPI( - title="src", + title="wowskarma_api_minimap", description=description, version=read("VERSION"), - terms_of_service="http://wowskarma.api.minimap.com/terms/", contact={ - "name": "SakuraIsayeki", - "url": "http://wowskarma.api.minimap.com/contact/", - "email": "SakuraIsayeki@src.com", - }, - license_info={ - "name": "The Unlicense", - "url": "https://unlicense.org", + "name": "Sakura Isayeki", + "url": "https://github.com/SakuraIsayeki", + "email": "sakura.isayeki@nodsoft.net", }, ) diff --git a/wowskarma.api.minimap/src/cli.py b/wowskarma_api_minimap/src/cli.py similarity index 94% rename from wowskarma.api.minimap/src/cli.py rename to wowskarma_api_minimap/src/cli.py index 58a567c4..c1715418 100644 --- a/wowskarma.api.minimap/src/cli.py +++ b/wowskarma_api_minimap/src/cli.py @@ -8,7 +8,7 @@ from .models.content import Content from .security import User -cli = typer.Typer(name="src API") +cli = typer.Typer(name="WOWS Karma - Minimap API") @cli.command() @@ -20,7 +20,7 @@ def run( ): # pragma: no cover """Run the API server.""" uvicorn.run( - "src.app:app", + "wowskarma_api_minimap.app:app", host=host, port=port, log_level=log_level, diff --git a/wowskarma.api.minimap/src/config.py b/wowskarma_api_minimap/src/config.py similarity index 65% rename from wowskarma.api.minimap/src/config.py rename to wowskarma_api_minimap/src/config.py index 64f8b1a4..65b493d8 100644 --- a/wowskarma.api.minimap/src/config.py +++ b/wowskarma_api_minimap/src/config.py @@ -5,11 +5,11 @@ HERE = os.path.dirname(os.path.abspath(__file__)) settings = Dynaconf( - envvar_prefix="src", + 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", + env_switcher="wowskarma_api_minimap_env", load_dotenv=False, ) @@ -18,7 +18,7 @@ # How to use this application settings ``` -from src.config import settings +from wowskarma_api_minimap.config import settings ``` ## Acessing variables @@ -45,15 +45,15 @@ ### 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') }}" +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 src run +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 similarity index 100% rename from wowskarma.api.minimap/src/db.py rename to wowskarma_api_minimap/src/db.py diff --git a/wowskarma.api.minimap/src/default.toml b/wowskarma_api_minimap/src/default.toml similarity index 100% rename from wowskarma.api.minimap/src/default.toml rename to wowskarma_api_minimap/src/default.toml diff --git a/wowskarma.api.minimap/src/models/__init__.py b/wowskarma_api_minimap/src/models/__init__.py similarity index 100% rename from wowskarma.api.minimap/src/models/__init__.py rename to wowskarma_api_minimap/src/models/__init__.py diff --git a/wowskarma.api.minimap/src/models/content.py b/wowskarma_api_minimap/src/models/content.py similarity index 92% rename from wowskarma.api.minimap/src/models/content.py rename to wowskarma_api_minimap/src/models/content.py index 6cb57085..a9844bc9 100644 --- a/wowskarma.api.minimap/src/models/content.py +++ b/wowskarma_api_minimap/src/models/content.py @@ -5,7 +5,7 @@ from sqlmodel import Field, Relationship, SQLModel if TYPE_CHECKING: - from wowskarma.api.minimap.security import User + from wowskarma_api_minimap.security import User class Content(SQLModel, table=True): @@ -19,9 +19,7 @@ class Content(SQLModel, table=True): slug: str = Field(default=None) text: str published: bool = False - created_time: str = Field( - default_factory=lambda: datetime.now().isoformat() - ) + created_time: str = Field(default_factory=lambda: datetime.now().isoformat()) tags: str = Field(default="") user_id: Optional[int] = Field(foreign_key="user.id") diff --git a/wowskarma.api.minimap/src/routes/__init__.py b/wowskarma_api_minimap/src/routes/__init__.py similarity index 100% rename from wowskarma.api.minimap/src/routes/__init__.py rename to wowskarma_api_minimap/src/routes/__init__.py diff --git a/wowskarma.api.minimap/src/routes/content.py b/wowskarma_api_minimap/src/routes/content.py similarity index 100% rename from wowskarma.api.minimap/src/routes/content.py rename to wowskarma_api_minimap/src/routes/content.py diff --git a/wowskarma.api.minimap/src/routes/profile.py b/wowskarma_api_minimap/src/routes/profile.py similarity index 100% rename from wowskarma.api.minimap/src/routes/profile.py rename to wowskarma_api_minimap/src/routes/profile.py diff --git a/wowskarma.api.minimap/src/routes/security.py b/wowskarma_api_minimap/src/routes/security.py similarity index 100% rename from wowskarma.api.minimap/src/routes/security.py rename to wowskarma_api_minimap/src/routes/security.py diff --git a/wowskarma.api.minimap/src/routes/user.py b/wowskarma_api_minimap/src/routes/user.py similarity index 100% rename from wowskarma.api.minimap/src/routes/user.py rename to wowskarma_api_minimap/src/routes/user.py diff --git a/wowskarma.api.minimap/src/security.py b/wowskarma_api_minimap/src/security.py similarity index 95% rename from wowskarma.api.minimap/src/security.py rename to wowskarma_api_minimap/src/security.py index 4a43cb02..3ea5b646 100644 --- a/wowskarma.api.minimap/src/security.py +++ b/wowskarma_api_minimap/src/security.py @@ -8,7 +8,7 @@ from pydantic import BaseModel from sqlmodel import Field, Relationship, Session, SQLModel -from wowskarma.api.minimap.models.content import Content, ContentResponse +from .models.content import Content, ContentResponse from .config import settings from .db import engine @@ -113,9 +113,7 @@ def get_password_hash(password) -> str: return pwd_context.hash(password) -def create_access_token( - data: dict, expires_delta: Optional[timedelta] = None -) -> str: +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: to_encode = data.copy() if expires_delta: expire = datetime.utcnow() + expires_delta @@ -126,9 +124,7 @@ def create_access_token( return encoded_jwt -def create_refresh_token( - data: dict, expires_delta: Optional[timedelta] = None -) -> str: +def create_refresh_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: to_encode = data.copy() if expires_delta: expire = datetime.utcnow() + expires_delta diff --git a/wowskarma.api.minimap/tests/__init__.py b/wowskarma_api_minimap/tests/__init__.py similarity index 100% rename from wowskarma.api.minimap/tests/__init__.py rename to wowskarma_api_minimap/tests/__init__.py diff --git a/wowskarma.api.minimap/tests/conftest.py b/wowskarma_api_minimap/tests/conftest.py similarity index 93% rename from wowskarma.api.minimap/tests/conftest.py rename to wowskarma_api_minimap/tests/conftest.py index 75696302..cafb5679 100644 --- a/wowskarma.api.minimap/tests/conftest.py +++ b/wowskarma_api_minimap/tests/conftest.py @@ -8,8 +8,8 @@ # 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 +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 diff --git a/wowskarma.api.minimap/tests/test_app.py b/wowskarma_api_minimap/tests/test_app.py similarity index 100% rename from wowskarma.api.minimap/tests/test_app.py rename to wowskarma_api_minimap/tests/test_app.py diff --git a/wowskarma.api.minimap/tests/test_cli.py b/wowskarma_api_minimap/tests/test_cli.py similarity index 100% rename from wowskarma.api.minimap/tests/test_cli.py rename to wowskarma_api_minimap/tests/test_cli.py diff --git a/wowskarma.api.minimap/tests/test_content_api.py b/wowskarma_api_minimap/tests/test_content_api.py similarity index 100% rename from wowskarma.api.minimap/tests/test_content_api.py rename to wowskarma_api_minimap/tests/test_content_api.py diff --git a/wowskarma.api.minimap/tests/test_profile.py b/wowskarma_api_minimap/tests/test_profile.py similarity index 100% rename from wowskarma.api.minimap/tests/test_profile.py rename to wowskarma_api_minimap/tests/test_profile.py diff --git a/wowskarma.api.minimap/tests/test_user_api.py b/wowskarma_api_minimap/tests/test_user_api.py similarity index 100% rename from wowskarma.api.minimap/tests/test_user_api.py rename to wowskarma_api_minimap/tests/test_user_api.py From fa5668ab3abc89fb28bd4a9fc36d3359ad714971 Mon Sep 17 00:00:00 2001 From: Sakura Akeno Isayeki Date: Thu, 20 Jul 2023 00:07:13 +0200 Subject: [PATCH 05/18] feat: Update docker-compose and app configuration - Updated the `docker-compose.yaml` file to use environment variables with a new prefix for dynamic configuration. - Modified the `app.py` file to change the title of the FastAPI application. This commit improves the configuration and naming conventions in the project. --- wowskarma_api_minimap/Makefile | 10 +++++----- wowskarma_api_minimap/docker-compose.yaml | 6 ++++-- wowskarma_api_minimap/mkdocs.yml | 2 +- wowskarma_api_minimap/setup.py | 6 +++++- wowskarma_api_minimap/src/app.py | 4 ++-- 5 files changed, 17 insertions(+), 11 deletions(-) diff --git a/wowskarma_api_minimap/Makefile b/wowskarma_api_minimap/Makefile index 02fd91d8..df0a18ba 100644 --- a/wowskarma_api_minimap/Makefile +++ b/wowskarma_api_minimap/Makefile @@ -122,23 +122,23 @@ shell: ## Open a shell in the project. .PHONY: docker-build docker-build: ## Builder docker images - @docker-compose -f docker-compose-dev.yaml -p wowskarma_api_minimap build + @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-dev.yaml -p wowskarma_api_minimap up -d + @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-dev.yaml -p wowskarma_api_minimap down + @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-dev.yaml -p wowskarma_api_minimap ps + @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-dev.yaml -p wowskarma_api_minimap logs -f app + @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' diff --git a/wowskarma_api_minimap/docker-compose.yaml b/wowskarma_api_minimap/docker-compose.yaml index 24b90c38..4c505c1d 100644 --- a/wowskarma_api_minimap/docker-compose.yaml +++ b/wowskarma_api_minimap/docker-compose.yaml @@ -8,8 +8,10 @@ services: ports: - "8000:8000" environment: - - wowskarma_api_minimap_DB__uri=postgresql://postgres:postgres@db:5432/wowskarma_api_minimap_dev - - wowskarma_api_minimap_DB__connect_args={} + - 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: diff --git a/wowskarma_api_minimap/mkdocs.yml b/wowskarma_api_minimap/mkdocs.yml index fd0ee8ae..09e266bf 100644 --- a/wowskarma_api_minimap/mkdocs.yml +++ b/wowskarma_api_minimap/mkdocs.yml @@ -1,2 +1,2 @@ -site_name: wowskarma_api_minimap +site_name: WOWS Karma - Minimap API theme: readthedocs diff --git a/wowskarma_api_minimap/setup.py b/wowskarma_api_minimap/setup.py index 7e093476..fed3eeeb 100644 --- a/wowskarma_api_minimap/setup.py +++ b/wowskarma_api_minimap/setup.py @@ -39,5 +39,9 @@ def read_requirements(path): author="SakuraIsayeki", packages=find_packages(exclude=["tests", ".github"]), install_requires=read_requirements("requirements.txt"), - entry_points={"console_scripts": ["wowskarma_api_minimap = src.__main__:main"]}, + entry_points={ + "console_scripts": [ + "wowskarma_api_minimap = wowskarma_api_minimap.__main__:main" + ] + }, ) diff --git a/wowskarma_api_minimap/src/app.py b/wowskarma_api_minimap/src/app.py index ccbc0a70..47b0e4ae 100644 --- a/wowskarma_api_minimap/src/app.py +++ b/wowskarma_api_minimap/src/app.py @@ -23,11 +23,11 @@ def read(*paths, **kwargs): description = """ -src API helps you do awesome stuff. 🚀 +Standalone Minimap rendering microservice to render World of Warships replays. """ app = FastAPI( - title="wowskarma_api_minimap", + title="WOWS Karma - Minimap API", description=description, version=read("VERSION"), contact={ From a6fc27dd4ead4f51564d2e14b748c9d220758023 Mon Sep 17 00:00:00 2001 From: Sakura Akeno Isayeki Date: Thu, 20 Jul 2023 01:33:13 +0200 Subject: [PATCH 06/18] feat: Update app description and security - Updated the description of the app in the FastAPI configuration. - Changed the algorithm used for security from HS256 to HS384. --- wowskarma_api_minimap/src/app.py | 6 +----- wowskarma_api_minimap/src/default.toml | 2 +- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/wowskarma_api_minimap/src/app.py b/wowskarma_api_minimap/src/app.py index 47b0e4ae..7e30c701 100644 --- a/wowskarma_api_minimap/src/app.py +++ b/wowskarma_api_minimap/src/app.py @@ -22,13 +22,9 @@ def read(*paths, **kwargs): return content -description = """ -Standalone Minimap rendering microservice to render World of Warships replays. -""" - app = FastAPI( title="WOWS Karma - Minimap API", - description=description, + description="Standalone Minimap rendering microservice to render World of Warships replays.", version=read("VERSION"), contact={ "name": "Sakura Isayeki", diff --git a/wowskarma_api_minimap/src/default.toml b/wowskarma_api_minimap/src/default.toml index 6dedb980..d59e167b 100644 --- a/wowskarma_api_minimap/src/default.toml +++ b/wowskarma_api_minimap/src/default.toml @@ -3,7 +3,7 @@ [default.security] # Set secret key in .secrets.toml # SECRET_KEY = "" -ALGORITHM = "HS256" +ALGORITHM = "HS384" ACCESS_TOKEN_EXPIRE_MINUTES = 30 REFRESH_TOKEN_EXPIRE_MINUTES = 600 From 30f71459b7ddc7c6c2a0c9321de0a59e7da7c82f Mon Sep 17 00:00:00 2001 From: Sakura Akeno Isayeki Date: Thu, 20 Jul 2023 01:33:44 +0200 Subject: [PATCH 07/18] feat(routes): Remove content routes & update user creation functionality This commit removes the content routes from the application and adds functionality to create new users. The `content.py` file has been deleted, along with its associated model classes and route handlers. The `user.py` file now includes a new route handler for creating users, which checks if the username already exists and only allows superusers to create other superuser accounts. Additionally, if this is the first user being created in the database, they are automatically assigned as a superuser. The changes made in this commit simplify the codebase by removing unused functionality and enhance user management capabilities by allowing for easy creation of new accounts. --- wowskarma_api_minimap/src/models/content.py | 73 ------------- wowskarma_api_minimap/src/routes/__init__.py | 2 - wowskarma_api_minimap/src/routes/content.py | 105 ------------------- wowskarma_api_minimap/src/routes/user.py | 29 +++-- wowskarma_api_minimap/src/security.py | 14 +-- 5 files changed, 23 insertions(+), 200 deletions(-) delete mode 100644 wowskarma_api_minimap/src/models/content.py delete mode 100644 wowskarma_api_minimap/src/routes/content.py diff --git a/wowskarma_api_minimap/src/models/content.py b/wowskarma_api_minimap/src/models/content.py deleted file mode 100644 index a9844bc9..00000000 --- a/wowskarma_api_minimap/src/models/content.py +++ /dev/null @@ -1,73 +0,0 @@ -from datetime import datetime -from typing import TYPE_CHECKING, List, Optional, Union - -from pydantic import BaseModel, Extra -from sqlmodel import Field, Relationship, SQLModel - -if TYPE_CHECKING: - from wowskarma_api_minimap.security import User - - -class Content(SQLModel, table=True): - """This is an example model for your application. - - Replace with the *things* you do in your application. - """ - - id: Optional[int] = Field(default=None, primary_key=True) - title: str - slug: str = Field(default=None) - text: str - published: bool = False - created_time: str = Field(default_factory=lambda: datetime.now().isoformat()) - tags: str = Field(default="") - user_id: Optional[int] = Field(foreign_key="user.id") - - # It populates a `.contents` attribute to the `User` model. - user: Optional["User"] = Relationship(back_populates="contents") - - -class ContentResponse(BaseModel): - """This the serializer exposed on the API""" - - id: int - title: str - slug: str - text: str - published: bool - created_time: str - tags: List[str] - user_id: int - - def __init__(self, *args, **kwargs): - # tags to model representation - tags = kwargs.pop("tags", None) - if tags and isinstance(tags, str): - kwargs["tags"] = tags.split(",") - super().__init__(*args, **kwargs) - - -class ContentIncoming(BaseModel): - """This is the serializer used for POST/PATCH requests""" - - title: Optional[str] - text: Optional[str] - published: Optional[bool] = False - tags: Optional[Union[List[str], str]] - - class Config: - extra = Extra.allow - arbitrary_types_allowed = True - - def __init__(self, *args, **kwargs): - # tags to database representation - tags = kwargs.pop("tags", None) - if tags and isinstance(tags, list): - kwargs["tags"] = ",".join(tags) - super().__init__(*args, **kwargs) - self.generate_slug() - - def generate_slug(self): - """Generate a slug from the title.""" - if self.title: - self.slug = self.title.lower().replace(" ", "-") diff --git a/wowskarma_api_minimap/src/routes/__init__.py b/wowskarma_api_minimap/src/routes/__init__.py index 95224be4..959df4be 100644 --- a/wowskarma_api_minimap/src/routes/__init__.py +++ b/wowskarma_api_minimap/src/routes/__init__.py @@ -1,13 +1,11 @@ from fastapi import APIRouter -from .content import router as content_router from .profile import router as profile_router from .security import router as security_router from .user import router as user_router main_router = APIRouter() -main_router.include_router(content_router, prefix="/content", tags=["content"]) 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"]) diff --git a/wowskarma_api_minimap/src/routes/content.py b/wowskarma_api_minimap/src/routes/content.py deleted file mode 100644 index 7ee33ec6..00000000 --- a/wowskarma_api_minimap/src/routes/content.py +++ /dev/null @@ -1,105 +0,0 @@ -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 ..models.content import Content, ContentIncoming, ContentResponse -from ..security import AuthenticatedUser, User, get_current_user - -router = APIRouter() - - -@router.get("/", response_model=List[ContentResponse]) -async def list_contents(*, session: Session = ActiveSession): - contents = session.exec(select(Content)).all() - return contents - - -@router.get("/{id_or_slug}/", response_model=ContentResponse) -async def query_content( - *, id_or_slug: Union[str, int], session: Session = ActiveSession -): - content = session.query(Content).where( - or_( - Content.id == id_or_slug, - Content.slug == id_or_slug, - ) - ) - if not content: - raise HTTPException(status_code=404, detail="Content not found") - return content.first() - - -@router.post( - "/", response_model=ContentResponse, dependencies=[AuthenticatedUser] -) -async def create_content( - *, - session: Session = ActiveSession, - request: Request, - content: ContentIncoming, -): - # set the ownsership of the content to the current user - db_content = Content.from_orm(content) - user: User = get_current_user(request=request) - db_content.user_id = user.id - session.add(db_content) - session.commit() - session.refresh(db_content) - return db_content - - -@router.patch( - "/{content_id}/", - response_model=ContentResponse, - dependencies=[AuthenticatedUser], -) -async def update_content( - *, - content_id: int, - session: Session = ActiveSession, - request: Request, - patch: ContentIncoming, -): - # Query the content - content = session.get(Content, content_id) - if not content: - raise HTTPException(status_code=404, detail="Content not found") - - # Check the user owns the content - current_user: User = get_current_user(request=request) - if content.user_id != current_user.id and not current_user.superuser: - raise HTTPException( - status_code=403, detail="You don't own this content" - ) - - # Update the content - patch_data = patch.dict(exclude_unset=True) - for key, value in patch_data.items(): - setattr(content, key, value) - - # Commit the session - session.commit() - session.refresh(content) - return content - - -@router.delete("/{content_id}/", dependencies=[AuthenticatedUser]) -def delete_content( - *, session: Session = ActiveSession, request: Request, content_id: int -): - - content = session.get(Content, content_id) - if not content: - raise HTTPException(status_code=404, detail="Content not found") - # Check the user owns the content - current_user = get_current_user(request=request) - if content.user_id != current_user.id and not current_user.superuser: - raise HTTPException( - status_code=403, detail="You don't own this content" - ) - session.delete(content) - session.commit() - return {"ok": True} diff --git a/wowskarma_api_minimap/src/routes/user.py b/wowskarma_api_minimap/src/routes/user.py index 72211f01..3ffedb2b 100644 --- a/wowskarma_api_minimap/src/routes/user.py +++ b/wowskarma_api_minimap/src/routes/user.py @@ -26,17 +26,31 @@ async def list_users(*, session: Session = ActiveSession): return users -@router.post("/", response_model=UserResponse, dependencies=[AdminUser]) +@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, user_id_or_username=user.username) + await query_user(session=session, username=user.username) except HTTPException: pass else: - raise HTTPException(status_code=422, detail="Username already exists") + 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() @@ -86,14 +100,9 @@ async def update_user_password( dependencies=[AuthenticatedUser], ) async def query_user( - *, session: Session = ActiveSession, user_id_or_username: Union[str, int] + *, session: Session = ActiveSession, username: Union[str, int] ): - user = session.query(User).where( - or_( - User.id == user_id_or_username, - User.username == user_id_or_username, - ) - ) + user = session.query(User).where(User.username == username) if not user.first(): raise HTTPException(status_code=404, detail="User not found") diff --git a/wowskarma_api_minimap/src/security.py b/wowskarma_api_minimap/src/security.py index 3ea5b646..35750b30 100644 --- a/wowskarma_api_minimap/src/security.py +++ b/wowskarma_api_minimap/src/security.py @@ -1,3 +1,4 @@ +import uuid as uuid_pkg from datetime import datetime, timedelta from typing import Callable, List, Optional, Union @@ -8,8 +9,6 @@ from pydantic import BaseModel from sqlmodel import Field, Relationship, Session, SQLModel -from .models.content import Content, ContentResponse - from .config import settings from .db import engine @@ -67,26 +66,21 @@ def validate(cls, v): class User(SQLModel, table=True): - id: Optional[int] = Field(default=None, primary_key=True) - username: str = Field(sa_column_kwargs={"unique": 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 - # it populates the .user attribute on the Content Model - contents: List["Content"] = Relationship(back_populates="user") - - class UserResponse(BaseModel): """This is the User model to be used as a response_model it doesn't include the password. """ - id: int + id: uuid_pkg.UUID username: str disabled: bool superuser: bool - contents: Optional[List[ContentResponse]] = Field(default_factory=list) class UserCreate(BaseModel): From 170c13534a7cb93dc4a5e231f433e3b3bf4f43cc Mon Sep 17 00:00:00 2001 From: Sakura Akeno Isayeki Date: Thu, 20 Jul 2023 03:34:13 +0200 Subject: [PATCH 08/18] feat(render): Add render route for replay rendering - Added a new route `/render` in the `render.py` file to handle replay rendering. - The route accepts a replay file and an optional replay ID. - Checks if the uploaded file is a valid replay file with the `.wowsreplay` extension. - Validates the size of the uploaded file against the maximum allowed size defined in the configuration. - Writes the replay data to a temporary file in `/tmp/wows-karma/minimap/`. - Uses the `Renderer` class from `renderer.render` module to start rendering based on provided options (enable chat, team tracers). - Returns the rendered video file as a response with media type "video/mp4" and filename based on either provided or default replay ID. --- wowskarma_api_minimap/requirements.txt | 2 + wowskarma_api_minimap/src/default.toml | 3 ++ wowskarma_api_minimap/src/routes/__init__.py | 2 + wowskarma_api_minimap/src/routes/render.py | 48 ++++++++++++++++++++ 4 files changed, 55 insertions(+) create mode 100644 wowskarma_api_minimap/src/routes/render.py diff --git a/wowskarma_api_minimap/requirements.txt b/wowskarma_api_minimap/requirements.txt index 99e05e36..f705c4fe 100644 --- a/wowskarma_api_minimap/requirements.txt +++ b/wowskarma_api_minimap/requirements.txt @@ -13,3 +13,5 @@ python-jose[cryptography] passlib[bcrypt] python-multipart psycopg2-binary + +git+https://github.com/WoWs-Builder-Team/minimap_renderer.git \ No newline at end of file diff --git a/wowskarma_api_minimap/src/default.toml b/wowskarma_api_minimap/src/default.toml index d59e167b..9ed4cd8c 100644 --- a/wowskarma_api_minimap/src/default.toml +++ b/wowskarma_api_minimap/src/default.toml @@ -17,3 +17,6 @@ reload = false 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 \ No newline at end of file diff --git a/wowskarma_api_minimap/src/routes/__init__.py b/wowskarma_api_minimap/src/routes/__init__.py index 959df4be..3296746a 100644 --- a/wowskarma_api_minimap/src/routes/__init__.py +++ b/wowskarma_api_minimap/src/routes/__init__.py @@ -3,9 +3,11 @@ 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"]) diff --git a/wowskarma_api_minimap/src/routes/render.py b/wowskarma_api_minimap/src/routes/render.py new file mode 100644 index 00000000..672eda0a --- /dev/null +++ b/wowskarma_api_minimap/src/routes/render.py @@ -0,0 +1,48 @@ +import base64 +import binascii +import random +import tempfile +import os + +from fastapi import APIRouter, HTTPException, status, UploadFile +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() + + +@router.post("/render", dependencies=[AuthenticatedUser]) +async def render_replay(file: UploadFile, replay_id: str | 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", + ) + + # We're gonna write the replay data to a temporary file, in the following folder: + # /tmp/wows-karma/minimap/ + + # Ensure the folder exists, if not, create it. + os.makedirs("/tmp/wows-karma/minimap/", exist_ok=True) + + # Render time! + replay_info = ReplayParser(file.file, True).get_info() + replay_data: ReplayData = replay_info["hidden"]["replay_data"] + + filepath = f"/tmp/wows-karma/minimap/{binascii.b2a_hex(os.urandom(7))}.mp4" + Renderer(replay_data, enable_chat=True, team_tracers=True).start(filepath) + + return FileResponse(filepath, media_type="video/mp4", filename=f"{replay_id or 'replay'}.mp4") \ No newline at end of file From fb5e850c64a5826b14b91e1849a68f10fb373e08 Mon Sep 17 00:00:00 2001 From: Sakura Akeno Isayeki Date: Thu, 20 Jul 2023 17:45:30 +0200 Subject: [PATCH 09/18] feat(render): Update requirements and add target player ID support - Updated the `requirements.txt` file to use an in-house repository for the minimap renderer. - Added support for specifying a target player ID in the `render_replay` function. --- wowskarma_api_minimap/requirements.txt | 2 +- wowskarma_api_minimap/src/routes/render.py | 16 +++++++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/wowskarma_api_minimap/requirements.txt b/wowskarma_api_minimap/requirements.txt index f705c4fe..79935fc3 100644 --- a/wowskarma_api_minimap/requirements.txt +++ b/wowskarma_api_minimap/requirements.txt @@ -14,4 +14,4 @@ passlib[bcrypt] python-multipart psycopg2-binary -git+https://github.com/WoWs-Builder-Team/minimap_renderer.git \ No newline at end of file +git+https://github.com/SakuraIsayeki/wowskarma_minimap_renderer.git \ No newline at end of file diff --git a/wowskarma_api_minimap/src/routes/render.py b/wowskarma_api_minimap/src/routes/render.py index 672eda0a..892cb86c 100644 --- a/wowskarma_api_minimap/src/routes/render.py +++ b/wowskarma_api_minimap/src/routes/render.py @@ -17,7 +17,11 @@ @router.post("/render", dependencies=[AuthenticatedUser]) -async def render_replay(file: UploadFile, replay_id: str | None = None): +async def render_replay( + file: UploadFile, + 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( @@ -29,7 +33,7 @@ async def render_replay(file: UploadFile, replay_id: str | None = None): if file.size > settings.replay.max_file_size: raise HTTPException( status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, - detail="File is too large", + detail="File is too large" ) # We're gonna write the replay data to a temporary file, in the following folder: @@ -43,6 +47,12 @@ async def render_replay(file: UploadFile, replay_id: str | None = None): replay_data: ReplayData = replay_info["hidden"]["replay_data"] filepath = f"/tmp/wows-karma/minimap/{binascii.b2a_hex(os.urandom(7))}.mp4" - Renderer(replay_data, enable_chat=True, team_tracers=True).start(filepath) + renderer = Renderer( + replay_data, + enable_chat=True, + team_tracers=True, + target_player_id=target_player_id + ) + renderer.start(filepath) return FileResponse(filepath, media_type="video/mp4", filename=f"{replay_id or 'replay'}.mp4") \ No newline at end of file From 65e95d19cb3d4a18e6f1a9e241bc43b24e405158 Mon Sep 17 00:00:00 2001 From: Sakura Akeno Isayeki Date: Thu, 20 Jul 2023 17:48:40 +0200 Subject: [PATCH 10/18] fix(ci): Update python-version matrix in minimap-main.yml The python-version matrix in the minimap-main.yml file has been updated to use the string representation of the Python version "3.10" instead of the previous array representation [3.10]. This change ensures compatibility with the CI workflow configuration. --- .github/workflows/minimap-main.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/minimap-main.yml b/.github/workflows/minimap-main.yml index 914ab493..e9160246 100644 --- a/.github/workflows/minimap-main.yml +++ b/.github/workflows/minimap-main.yml @@ -19,7 +19,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: [3.10] + python-version: ["3.10"] os: [ubuntu-latest] runs-on: ${{ matrix.os }} steps: @@ -39,7 +39,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: [3.10] + python-version: ["3.10"] os: [ubuntu-latest] runs-on: ${{ matrix.os }} steps: From b26ea12da93780eb45975ad0ddde45b62a124b27 Mon Sep 17 00:00:00 2001 From: Sakura Akeno Isayeki Date: Thu, 20 Jul 2023 17:49:33 +0200 Subject: [PATCH 11/18] fix(ci): Update directory name in minimap-main.yml The commit updates the directory name from "wowskarma.api.minimap" to "wowskarma_api_minimap" in the minimap-main.yml file. This change ensures consistency and improves readability. --- .github/workflows/minimap-main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/minimap-main.yml b/.github/workflows/minimap-main.yml index e9160246..b0d43535 100644 --- a/.github/workflows/minimap-main.yml +++ b/.github/workflows/minimap-main.yml @@ -28,7 +28,7 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: Change directory to subdirectory - run: cd wowskarma.api.minimap + run: cd wowskarma_api_minimap - name: Install project dependencies run: make install - name: Run linter From a2f1df6c610ead84efd35d055a7e89bfbe108b9c Mon Sep 17 00:00:00 2001 From: Sakura Akeno Isayeki Date: Thu, 20 Jul 2023 18:00:58 +0200 Subject: [PATCH 12/18] ci: Add Docker build and push workflows for Minimap API - Added a new workflow file `minimap-api-build.yml` to build the Minimap API Docker image. - Added a new workflow file `minimap-api-push.yml` to push the Minimap API Docker image to DockerHub and GHCR. - Deleted the old workflow file `minimap-main.yml` which was used for CI testing. These changes enable automated building and pushing of the Minimap API Docker image. --- .github/workflows/minimap-api-build.yml | 18 +++++++ .github/workflows/minimap-api-push.yml | 34 ++++++++++++ .github/workflows/minimap-main.yml | 71 ------------------------- 3 files changed, 52 insertions(+), 71 deletions(-) create mode 100644 .github/workflows/minimap-api-build.yml create mode 100644 .github/workflows/minimap-api-push.yml delete mode 100644 .github/workflows/minimap-main.yml diff --git a/.github/workflows/minimap-api-build.yml b/.github/workflows/minimap-api-build.yml new file mode 100644 index 00000000..88cfddda --- /dev/null +++ b/.github/workflows/minimap-api-build.yml @@ -0,0 +1,18 @@ +name: Build Minimap API Docker Image + +on: + push: + paths: + - 'wowskarma_api_minimap/**' + 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 . \ 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..f2327888 --- /dev/null +++ b/.github/workflows/minimap-api-push.yml @@ -0,0 +1,34 @@ +name: Push Minimap API Docker Image + +on: + workflow_dispatch: + push: + branches: [ main ] + +jobs: + release_dockerhub: + runs-on: ubuntu-latest + steps: + - name: Login to DockerHub + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + + - name: Push to DockerHub + run: docker push wowskarma_api_minimap:tag + + release_ghcr: + runs-on: ubuntu-latest + steps: + - name: Login to GHCR + uses: docker/login-action@v1 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Push to GHCR + run: | + docker tag wowskarma_api_minimap:tag ghcr.io/${{ github.repository }}/wowskarma_api_minimap:tag + docker push ghcr.io/${{ github.repository }}/wowskarma_api_minimap:tag \ No newline at end of file diff --git a/.github/workflows/minimap-main.yml b/.github/workflows/minimap-main.yml deleted file mode 100644 index b0d43535..00000000 --- a/.github/workflows/minimap-main.yml +++ /dev/null @@ -1,71 +0,0 @@ -name: Minimap API - CI - -on: - # Triggers the workflow on push or pull request events but only for the main or develop branch - push: - branches: - - main - - develop - pull_request: - branches: - - main - - develop - - # Allows you to run this workflow manually from the Actions tab - workflow_dispatch: - -jobs: - linter: - strategy: - fail-fast: false - matrix: - python-version: ["3.10"] - os: [ubuntu-latest] - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - name: Change directory to subdirectory - run: cd wowskarma_api_minimap - - name: Install project dependencies - run: make install - - name: Run linter - run: make lint - - tests_linux: - needs: linter - strategy: - fail-fast: false - matrix: - python-version: ["3.10"] - os: [ubuntu-latest] - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - name: Install project - run: make install - # with: - # fail_ci_if_error: true - - tests_win: - needs: linter - strategy: - fail-fast: false - matrix: - python-version: [3.10] - os: [windows-latest] - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - name: Install Pip - run: pip install --user --upgrade pip - - name: Install project - run: pip install -e wowskarma.minimap.api/. From 310e51276d03593e8438418c8b2df6167442ae39 Mon Sep 17 00:00:00 2001 From: Sakura Akeno Isayeki Date: Thu, 20 Jul 2023 23:19:19 +0200 Subject: [PATCH 13/18] ci: Refactor Minimap API build and push workflows - Removed unnecessary path restriction in the build workflow - Updated DockerHub login action to v2 - Added setup for QEMU and Docker Buildx in the build workflow - Replaced GHCR login action with DockerHub login action in the push workflow - Modified push step to use docker/build-push-action@v4 for building and pushing images with updated tags --- .github/workflows/minimap-api-build.yml | 4 +-- .github/workflows/minimap-api-push.yml | 47 ++++++++++++++----------- 2 files changed, 27 insertions(+), 24 deletions(-) diff --git a/.github/workflows/minimap-api-build.yml b/.github/workflows/minimap-api-build.yml index 88cfddda..073e93a4 100644 --- a/.github/workflows/minimap-api-build.yml +++ b/.github/workflows/minimap-api-build.yml @@ -2,8 +2,6 @@ name: Build Minimap API Docker Image on: push: - paths: - - 'wowskarma_api_minimap/**' workflow_dispatch: @@ -15,4 +13,4 @@ jobs: - uses: actions/checkout@v2 - name: Build Docker Image - run: docker build -f wowskarma_api_minimap/Dockerfile.dev -t wowskarma_api_minimap:tag . \ No newline at end of file + 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 index f2327888..6f2ca1a3 100644 --- a/.github/workflows/minimap-api-push.yml +++ b/.github/workflows/minimap-api-push.yml @@ -6,29 +6,34 @@ on: branches: [ main ] jobs: - release_dockerhub: + 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: - - name: Login to DockerHub - uses: docker/login-action@v1 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_PASSWORD }} + - uses: actions/checkout@v2 - - name: Push to DockerHub - run: docker push wowskarma_api_minimap:tag + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 - release_ghcr: - runs-on: ubuntu-latest - steps: - - name: Login to GHCR - uses: docker/login-action@v1 + - name: Login to DockerHub + uses: docker/login-action@v2 with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Push to GHCR - run: | - docker tag wowskarma_api_minimap:tag ghcr.io/${{ github.repository }}/wowskarma_api_minimap:tag - docker push ghcr.io/${{ github.repository }}/wowskarma_api_minimap:tag \ No newline at end of file + 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 From bd9b553d2c688c867c364dd5397c9f1b62770aaf Mon Sep 17 00:00:00 2001 From: Sakura Akeno Isayeki Date: Fri, 21 Jul 2023 01:26:20 +0200 Subject: [PATCH 14/18] feat(minimap_api_security): Improve get_current_user function The `get_current_user` function in the `security.py` file has been improved. The `fresh` parameter has been removed from the function signature, as it was not being used. Additionally, a commented out block of code that raised a credentials exception based on the freshness of the token and user's superuser status has been temporarily disabled. --- wowskarma_api_minimap/src/security.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/wowskarma_api_minimap/src/security.py b/wowskarma_api_minimap/src/security.py index 35750b30..756683e8 100644 --- a/wowskarma_api_minimap/src/security.py +++ b/wowskarma_api_minimap/src/security.py @@ -146,7 +146,7 @@ def get_user(username) -> Optional[User]: def get_current_user( - token: str = Depends(oauth2_scheme), request: Request = None, fresh=False + token: str = Depends(oauth2_scheme), request: Request = None ) -> User: credentials_exception = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -173,8 +173,8 @@ def get_current_user( 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 + # if fresh and (not payload["fresh"] and not user.superuser): + # raise credentials_exception return user From 90b3e5abfeaa03ecdbc3422a0138f9f5360c5935 Mon Sep 17 00:00:00 2001 From: Sakura Akeno Isayeki Date: Fri, 21 Jul 2023 11:53:57 +0200 Subject: [PATCH 15/18] feat(render): Add background task for removing temporary file This commit adds a new parameter `background_tasks` to the `render_replay` function in the `render.py` file. The purpose of this parameter is to allow for the addition of a background task that removes the temporary file after it has been rendered and returned as a response. This helps to clean up any leftover files and improve overall system efficiency. --- wowskarma_api_minimap/src/routes/render.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/wowskarma_api_minimap/src/routes/render.py b/wowskarma_api_minimap/src/routes/render.py index 892cb86c..4da5c9bb 100644 --- a/wowskarma_api_minimap/src/routes/render.py +++ b/wowskarma_api_minimap/src/routes/render.py @@ -4,7 +4,7 @@ import tempfile import os -from fastapi import APIRouter, HTTPException, status, UploadFile +from fastapi import APIRouter, HTTPException, status, UploadFile, BackgroundTasks from fastapi.responses import FileResponse from renderer.data import ReplayData from renderer.render import Renderer @@ -16,9 +16,12 @@ router = APIRouter() + + @router.post("/render", dependencies=[AuthenticatedUser]) async def render_replay( file: UploadFile, + background_tasks: BackgroundTasks, replay_id: str | None = None, target_player_id: int | None = None ): @@ -55,4 +58,5 @@ async def render_replay( ) renderer.start(filepath) + background_tasks.add_task(os.remove, filepath) return FileResponse(filepath, media_type="video/mp4", filename=f"{replay_id or 'replay'}.mp4") \ No newline at end of file From 192acb1479b7bb4989864d1a707e60ebf8e4003d Mon Sep 17 00:00:00 2001 From: Sakura Akeno Isayeki Date: Fri, 21 Jul 2023 12:41:42 +0200 Subject: [PATCH 16/18] feat(cli): Remove unused import and variable The commit removes an unused import and variable in the `cli.py` file. The import for `Content` from the models module is removed, as it is not being used in the code. fix(config): Add temporary work directory for replay processing This commit adds a new configuration option to the `default.toml` file. The `temp_workdir` setting specifies the temporary directory path for replay processing. The default value is set to "/tmp/wowskarma/minimap". refactor(routes): Update filepath handling in render_replay function In this commit, the filepath handling in the `render_replay` function of the `render.py` file is updated. Instead of using a hardcoded path, it now uses the configured temporary work directory from settings. Additionally, pathlib.Path is used to concatenate filepaths and ensure platform compatibility. --- wowskarma_api_minimap/src/cli.py | 2 -- wowskarma_api_minimap/src/default.toml | 3 ++- wowskarma_api_minimap/src/routes/render.py | 8 +++++--- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/wowskarma_api_minimap/src/cli.py b/wowskarma_api_minimap/src/cli.py index c1715418..f5403433 100644 --- a/wowskarma_api_minimap/src/cli.py +++ b/wowskarma_api_minimap/src/cli.py @@ -5,7 +5,6 @@ from .app import app from .config import settings from .db import create_db_and_tables, engine -from .models.content import Content from .security import User cli = typer.Typer(name="WOWS Karma - Minimap API") @@ -53,7 +52,6 @@ def shell(): # pragma: no cover "create_user": create_user, "select": select, "session": Session(engine), - "Content": Content, } typer.echo(f"Auto imports: {list(_vars.keys())}") try: diff --git a/wowskarma_api_minimap/src/default.toml b/wowskarma_api_minimap/src/default.toml index 9ed4cd8c..c7c4a479 100644 --- a/wowskarma_api_minimap/src/default.toml +++ b/wowskarma_api_minimap/src/default.toml @@ -19,4 +19,5 @@ connect_args = {check_same_thread=false} echo = false [default.replay] -max_file_size = 5242880 # Max replay file size in bytes. Default is 5MB \ No newline at end of file +max_file_size = 5242880 # Max replay file size in bytes. Default is 5MB +temp_workdir = "/tmp/wowskarma/minimap" # Temporary directory for replay processing diff --git a/wowskarma_api_minimap/src/routes/render.py b/wowskarma_api_minimap/src/routes/render.py index 4da5c9bb..7005cca7 100644 --- a/wowskarma_api_minimap/src/routes/render.py +++ b/wowskarma_api_minimap/src/routes/render.py @@ -4,6 +4,7 @@ import tempfile import os +import pathlib from fastapi import APIRouter, HTTPException, status, UploadFile, BackgroundTasks from fastapi.responses import FileResponse from renderer.data import ReplayData @@ -43,20 +44,21 @@ async def render_replay( # /tmp/wows-karma/minimap/ # Ensure the folder exists, if not, create it. - os.makedirs("/tmp/wows-karma/minimap/", exist_ok=True) + os.makedirs(settings.replay.temp_workdir, exist_ok=True) # Render time! replay_info = ReplayParser(file.file, True).get_info() replay_data: ReplayData = replay_info["hidden"]["replay_data"] - filepath = f"/tmp/wows-karma/minimap/{binascii.b2a_hex(os.urandom(7))}.mp4" + # Concat filepaths to get the full path to the replay file. + filepath = pathlib.Path(settings.replay.temp_workdir, f"{binascii.b2a_hex(os.urandom(7))}.mp4") renderer = Renderer( replay_data, enable_chat=True, team_tracers=True, target_player_id=target_player_id ) - renderer.start(filepath) + renderer.start(str(filepath)) background_tasks.add_task(os.remove, filepath) return FileResponse(filepath, media_type="video/mp4", filename=f"{replay_id or 'replay'}.mp4") \ No newline at end of file From 52ded6b92d16894aaf348d01e57888d840a02a55 Mon Sep 17 00:00:00 2001 From: Sakura Akeno Isayeki Date: Fri, 21 Jul 2023 14:13:54 +0200 Subject: [PATCH 17/18] feat(render): Add logging for new render job This commit adds logging functionality to the render_replay function in the `render.py` file. Now, when a new render job is started, a log message will be generated with details such as the Job ID, Replay ID (if available), and Filename. This helps in tracking and monitoring render jobs. Additionally, the commit also updates the filepath generation logic to use the newly generated Job ID instead of generating a random filename each time. This improves consistency and makes it easier to identify rendered files. The rendering process has also been updated to include quality settings (quality=9) and frame rate settings (fps=30) for better output results. Closes #123 --- wowskarma_api_minimap/src/routes/render.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/wowskarma_api_minimap/src/routes/render.py b/wowskarma_api_minimap/src/routes/render.py index 7005cca7..541e974a 100644 --- a/wowskarma_api_minimap/src/routes/render.py +++ b/wowskarma_api_minimap/src/routes/render.py @@ -5,6 +5,8 @@ import os import pathlib +from asyncio.log import logger + from fastapi import APIRouter, HTTPException, status, UploadFile, BackgroundTasks from fastapi.responses import FileResponse from renderer.data import ReplayData @@ -40,10 +42,12 @@ async def render_replay( detail="File is too large" ) - # We're gonna write the replay data to a temporary file, in the following folder: - # /tmp/wows-karma/minimap/ + # Log the new job. + job_id = binascii.b2a_hex(os.urandom(7)) + logger.info(f"Started new render job: Job ID: {job_id}, Replay ID: {replay_id or 'None'}, " + f"Filename: {file.filename}.") - # Ensure the folder exists, if not, create it. + # Ensure the work folder exists, if not, create it. os.makedirs(settings.replay.temp_workdir, exist_ok=True) # Render time! @@ -51,14 +55,16 @@ async def render_replay( replay_data: ReplayData = replay_info["hidden"]["replay_data"] # Concat filepaths to get the full path to the replay file. - filepath = pathlib.Path(settings.replay.temp_workdir, f"{binascii.b2a_hex(os.urandom(7))}.mp4") + + + filepath = pathlib.Path(settings.replay.temp_workdir, f"{job_id}.mp4") renderer = Renderer( replay_data, enable_chat=True, team_tracers=True, target_player_id=target_player_id ) - renderer.start(str(filepath)) + renderer.start(str(filepath), quality=9, fps=30) background_tasks.add_task(os.remove, filepath) return FileResponse(filepath, media_type="video/mp4", filename=f"{replay_id or 'replay'}.mp4") \ No newline at end of file From 8b0bef4b5c9fa8d5832c86f083ac392315670cef Mon Sep 17 00:00:00 2001 From: Sakura Akeno Isayeki Date: Fri, 21 Jul 2023 14:14:09 +0200 Subject: [PATCH 18/18] feat(render): Add fps and quality options to replay rendering - Added `fps` option to control the frames per second for replay processing. - Added `quality` option to specify the quality of the output video. - Updated temporary work directory path for replay processing. --- wowskarma_api_minimap/src/default.toml | 4 ++- wowskarma_api_minimap/src/routes/render.py | 39 ++++++++++++++-------- 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/wowskarma_api_minimap/src/default.toml b/wowskarma_api_minimap/src/default.toml index c7c4a479..8706ab1d 100644 --- a/wowskarma_api_minimap/src/default.toml +++ b/wowskarma_api_minimap/src/default.toml @@ -20,4 +20,6 @@ echo = false [default.replay] max_file_size = 5242880 # Max replay file size in bytes. Default is 5MB -temp_workdir = "/tmp/wowskarma/minimap" # Temporary directory for replay processing +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/routes/render.py b/wowskarma_api_minimap/src/routes/render.py index 541e974a..e8183387 100644 --- a/wowskarma_api_minimap/src/routes/render.py +++ b/wowskarma_api_minimap/src/routes/render.py @@ -5,9 +5,9 @@ import os import pathlib -from asyncio.log import logger 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 @@ -18,11 +18,13 @@ 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]) -async def render_replay( +def render_replay( file: UploadFile, background_tasks: BackgroundTasks, replay_id: str | None = None, @@ -43,7 +45,7 @@ async def render_replay( ) # Log the new job. - job_id = binascii.b2a_hex(os.urandom(7)) + 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}.") @@ -51,20 +53,29 @@ async def render_replay( 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"] - - # Concat filepaths to get the full path to the replay file. - - - filepath = pathlib.Path(settings.replay.temp_workdir, f"{job_id}.mp4") renderer = Renderer( - replay_data, + replay_data=replay_data, enable_chat=True, team_tracers=True, - target_player_id=target_player_id + 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" ) - renderer.start(str(filepath), quality=9, fps=30) - background_tasks.add_task(os.remove, filepath) - return FileResponse(filepath, media_type="video/mp4", filename=f"{replay_id or 'replay'}.mp4") \ No newline at end of file